迭代 ONE-OS 多原型:租赁/自营台账增强、租赁合同与条线说明,统一台账壳层与侧栏导航
- 新增业务部台账、收款记录、业务条线说明及 ledger-shared / vm-shared 公共壳层 - 租赁业务台账:字段计算、宽表交互、标注 PRD 与表头说明修复 - 租赁合同:列表/表单增强、客户资质与审批规则 - 侧栏调整:工作台移至 OneOS 根目录,台账与数据分析分组更新 - 补充 S3 批量发布脚本与弹窗明细表规范 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -33,8 +33,8 @@
|
|||||||
"region": "cn-hangzhou",
|
"region": "cn-hangzhou",
|
||||||
"bucket": "model-lnoneos",
|
"bucket": "model-lnoneos",
|
||||||
"prefix": "",
|
"prefix": "",
|
||||||
"baseUrl": "http://prototype.lnoneos.com/",
|
"baseUrl": "https://prototype.lnoneos.com",
|
||||||
"endpoint": ""
|
"endpoint": "https://model-lnoneos.oss-cn-hangzhou.aliyuncs.com"
|
||||||
},
|
},
|
||||||
"githubPages": {
|
"githubPages": {
|
||||||
"repository": "",
|
"repository": "",
|
||||||
@@ -42,11 +42,7 @@
|
|||||||
"sourceDirectory": "/"
|
"sourceDirectory": "/"
|
||||||
},
|
},
|
||||||
"publishSettings": {
|
"publishSettings": {
|
||||||
"includeSource": true,
|
"includeSource": true
|
||||||
"visibleTargets": [
|
|
||||||
"axhub",
|
|
||||||
"s3"
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,18 @@
|
|||||||
{
|
{
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"updatedAt": "2026-07-01T09:58:50.712Z",
|
"updatedAt": "2026-07-06T05:10:19.174Z",
|
||||||
"prototypes": [
|
"prototypes": [
|
||||||
{
|
{
|
||||||
"id": "folder-1782874576229-r8atbu",
|
"id": "folder-1782874576229-r8atbu",
|
||||||
"kind": "folder",
|
"kind": "folder",
|
||||||
"title": "OneOS",
|
"title": "OneOS",
|
||||||
"children": [
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "item:prototypes:oneos-web-workbench",
|
||||||
|
"kind": "item",
|
||||||
|
"title": "工作台",
|
||||||
|
"itemKey": "prototypes/oneos-web-workbench"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "folder-1782874599304-roj8fd",
|
"id": "folder-1782874599304-roj8fd",
|
||||||
"kind": "folder",
|
"kind": "folder",
|
||||||
@@ -76,38 +82,71 @@
|
|||||||
"itemKey": "prototypes/oneos-web-h2-station-site"
|
"itemKey": "prototypes/oneos-web-h2-station-site"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "folder-oneos-data-analysis",
|
||||||
|
"kind": "folder",
|
||||||
|
"title": "数据分析",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "item:prototypes:business-dept-ledger",
|
||||||
|
"kind": "item",
|
||||||
|
"title": "业务部台账",
|
||||||
|
"itemKey": "prototypes/business-dept-ledger"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "folder-1783314569299-4ejj2g",
|
||||||
|
"kind": "folder",
|
||||||
|
"title": "台账管理",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "item:prototypes:lease-business-ledger",
|
||||||
|
"kind": "item",
|
||||||
|
"title": "租赁业务台账",
|
||||||
|
"itemKey": "prototypes/lease-business-ledger"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "item:prototypes:self-operated-business-ledger",
|
||||||
|
"kind": "item",
|
||||||
|
"title": "自营业务台账",
|
||||||
|
"itemKey": "prototypes/self-operated-business-ledger"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "item:prototypes:vehicle-h2-fee-ledger",
|
||||||
|
"kind": "item",
|
||||||
|
"title": "车辆氢费明细",
|
||||||
|
"itemKey": "prototypes/vehicle-h2-fee-ledger"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "folder-1783314610587-rwspc1",
|
||||||
|
"kind": "folder",
|
||||||
|
"title": "财务管理",
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"id": "item:prototypes:payment-records",
|
||||||
|
"kind": "item",
|
||||||
|
"title": "收款记录",
|
||||||
|
"itemKey": "prototypes/payment-records"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "item:prototypes:lease-business-ledger",
|
"id": "item:prototypes:lease-business-line-overview",
|
||||||
"kind": "item",
|
"kind": "item",
|
||||||
"title": "租赁业务台账",
|
"title": "业务条线说明",
|
||||||
"itemKey": "prototypes/lease-business-ledger"
|
"itemKey": "prototypes/lease-business-line-overview"
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "item:prototypes:vehicle-h2-fee-ledger",
|
|
||||||
"kind": "item",
|
|
||||||
"title": "车辆氢费明细",
|
|
||||||
"itemKey": "prototypes/vehicle-h2-fee-ledger"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": "item:prototypes:self-operated-business-ledger",
|
|
||||||
"kind": "item",
|
|
||||||
"title": "自营业务台账",
|
|
||||||
"itemKey": "prototypes/self-operated-business-ledger"
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "folder-prototypes-oneos-web",
|
"id": "folder-prototypes-oneos-web",
|
||||||
"kind": "folder",
|
"kind": "folder",
|
||||||
"title": "ONE-OS Web 端",
|
"title": "ONE-OS Web 端",
|
||||||
"children": [
|
"children": [
|
||||||
{
|
|
||||||
"id": "item:prototypes:oneos-web-workbench",
|
|
||||||
"kind": "item",
|
|
||||||
"title": "工作台",
|
|
||||||
"itemKey": "prototypes/oneos-web-workbench"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"id": "item:prototypes:oneos-web-login",
|
"id": "item:prototypes:oneos-web-login",
|
||||||
"kind": "item",
|
"kind": "item",
|
||||||
|
|||||||
40
.cursor/rules/modal-detail-table.mdc
Normal file
40
.cursor/rules/modal-detail-table.mdc
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
---
|
||||||
|
description: 弹窗/抽屉内 Ant Design Table 列对齐规范;完成含明细列表的弹窗后必须检查
|
||||||
|
globs: src/prototypes/**/*.{tsx,jsx,ts,js}
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# 弹窗明细表列对齐(防错行)
|
||||||
|
|
||||||
|
## 适用场景
|
||||||
|
|
||||||
|
Modal、Drawer 内展示明细列表的 `Table`,尤其启用 `scroll.y` 或 `scroll.x` 时。
|
||||||
|
|
||||||
|
## 必须遵守
|
||||||
|
|
||||||
|
1. **Table 属性**
|
||||||
|
- `className={LN_MODAL_DETAIL_TABLE_CLASS}`(来自 `src/common/modalDetailTable.ts`)
|
||||||
|
- `tableLayout="fixed"`
|
||||||
|
- `scroll.x` 使用 `sumColumnScrollX(columns)`,值为各列 `width` 之和(可 +8~16 预留滚动条)
|
||||||
|
|
||||||
|
2. **列定义**
|
||||||
|
- **每一列**都必须设置数字 `width`,禁止仅 `ellipsis: true` 而不设宽度
|
||||||
|
- 金额列:`align: 'right'` + `className: 'tabular-nums'`
|
||||||
|
- 长文本列:`ellipsis: true` + 明确 `width`
|
||||||
|
|
||||||
|
3. **样式**
|
||||||
|
- 项目已提供 `src/common/modal-detail-table.css`(经 `vehicle-management/style.css` 全局引入)
|
||||||
|
- 不要覆盖 `.ant-table-header` / `.ant-table-body` 的 margin 或隐藏 measure-row 的局部 hack,统一用 `ln-modal-detail-table`
|
||||||
|
|
||||||
|
## 完成前自检
|
||||||
|
|
||||||
|
实现或修改弹窗明细表后,在浏览器中确认:
|
||||||
|
|
||||||
|
- [ ] 表头文字与每列首行数据**垂直对齐**(不错行、不整体右偏)
|
||||||
|
- [ ] 纵向滚动时表头与表体列宽仍一致
|
||||||
|
- [ ] 金额列右对齐、长文本省略号正常
|
||||||
|
- [ ] 开发控制台无 `弹窗表格列缺少 width` 警告
|
||||||
|
|
||||||
|
## 参考实现
|
||||||
|
|
||||||
|
`src/prototypes/payment-records/components/PaymentKpiDetailModal.tsx`
|
||||||
@@ -16,6 +16,7 @@
|
|||||||
|
|
||||||
| 产物/场景 | 位置 | 参考文档 |
|
| 产物/场景 | 位置 | 参考文档 |
|
||||||
|-----------|------|----------|
|
|-----------|------|----------|
|
||||||
|
| vm-page 列表分页 | `src/common/TablePagination.tsx` + `vm-pagination.css` | `src/prototypes/vm-shared/DESIGN.md` |
|
||||||
| 主题 | `src/themes/<theme-key>/` | `rules/theme-guide.md` |
|
| 主题 | `src/themes/<theme-key>/` | `rules/theme-guide.md` |
|
||||||
| 项目资料和文档 | `src/resources/` | `rules/resource-management-guide.md` |
|
| 项目资料和文档 | `src/resources/` | `rules/resource-management-guide.md` |
|
||||||
| 画布 | `src/prototypes/<prototype-name>/canvas.excalidraw`、`canvas-assets/` | 原型画布和画布素材 |
|
| 画布 | `src/prototypes/<prototype-name>/canvas.excalidraw`、`canvas-assets/` | 原型画布和画布素材 |
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
| 产物/场景 | 位置 | 参考文档 |
|
| 产物/场景 | 位置 | 参考文档 |
|
||||||
|-----------|------|----------|
|
|-----------|------|----------|
|
||||||
|
| vm-page 列表分页 | `src/common/TablePagination.tsx` + `vm-pagination.css` | `src/prototypes/vm-shared/DESIGN.md` |
|
||||||
| 主题 | `src/themes/<theme-key>/` | `rules/theme-guide.md` |
|
| 主题 | `src/themes/<theme-key>/` | `rules/theme-guide.md` |
|
||||||
| 项目资料和文档 | `src/resources/` | `rules/resource-management-guide.md` |
|
| 项目资料和文档 | `src/resources/` | `rules/resource-management-guide.md` |
|
||||||
| UI Review 结论 | `src/prototypes/<prototype-name>/.spec/ui-review.md` | `rules/ui-review-guide.md` |
|
| UI Review 结论 | `src/prototypes/<prototype-name>/.spec/ui-review.md` | `rules/ui-review-guide.md` |
|
||||||
|
|||||||
@@ -86,6 +86,16 @@ export default function MyApp() {
|
|||||||
|
|
||||||
- 使用主题 CSS Variables 时,按所选 `DESIGN.md` 和主题规则引入,不复制另一套 token。
|
- 使用主题 CSS Variables 时,按所选 `DESIGN.md` 和主题规则引入,不复制另一套 token。
|
||||||
|
|
||||||
|
## 列表分页(vm-page 强制)
|
||||||
|
|
||||||
|
凡 `vm-page` 下的数据列表页,表格底部 **必须** 使用全局分页组件与样式,规范见 `src/prototypes/vm-shared/DESIGN.md`:
|
||||||
|
|
||||||
|
- 组件:`import { TablePagination, DEFAULT_PAGE_SIZE } from '../../common/TablePagination'`
|
||||||
|
- 样式:引入 `vehicle-management/style.css`(已包含 `src/common/vm-pagination.css`)
|
||||||
|
- 禁止:下拉加载更多、自建分页 UI、与 `vm-pagination*` 不一致的第三方分页
|
||||||
|
|
||||||
|
参考:`vehicle-management/index.tsx`、`lease-business-ledger/index.tsx`。
|
||||||
|
|
||||||
## 验收流程
|
## 验收流程
|
||||||
|
|
||||||
运行原型验收脚本:
|
运行原型验收脚本:
|
||||||
|
|||||||
289
scripts/publish-all-to-s3.mjs
Normal file
289
scripts/publish-all-to-s3.mjs
Normal file
@@ -0,0 +1,289 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* 将项目原型页发布到 S3(阿里云 OSS)。
|
||||||
|
* 流程:Make Admin export-html 构建静态包 → 按页面独立前缀上传到 OSS。
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* node scripts/publish-all-to-s3.mjs
|
||||||
|
* node scripts/publish-all-to-s3.mjs --dry-run
|
||||||
|
* node scripts/publish-all-to-s3.mjs --group prototypes
|
||||||
|
*/
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
|
||||||
|
import extract from 'extract-zip';
|
||||||
|
import { readServerInfo } from './utils/serverInfo.mjs';
|
||||||
|
|
||||||
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
|
const projectRoot = path.resolve(__dirname, '..');
|
||||||
|
const CONFIG_PATH = path.join(projectRoot, '.axhub/make/axhub.config.json');
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
const args = {
|
||||||
|
projectId: 'oneos1.2',
|
||||||
|
adminOrigin: '',
|
||||||
|
dryRun: false,
|
||||||
|
group: 'all',
|
||||||
|
includeSource: true,
|
||||||
|
};
|
||||||
|
for (let i = 0; i < argv.length; i += 1) {
|
||||||
|
const arg = argv[i];
|
||||||
|
if (arg === '--project-id' && argv[i + 1]) args.projectId = argv[++i].trim();
|
||||||
|
else if (arg === '--admin-origin' && argv[i + 1]) args.adminOrigin = argv[++i].trim();
|
||||||
|
else if (arg === '--group' && argv[i + 1]) args.group = argv[++i].trim();
|
||||||
|
else if (arg === '--dry-run') args.dryRun = true;
|
||||||
|
else if (arg === '--no-source') args.includeSource = false;
|
||||||
|
}
|
||||||
|
return args;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAdminOrigin(explicit) {
|
||||||
|
if (explicit) return explicit.replace(/\/+$/u, '');
|
||||||
|
const info = readServerInfo(projectRoot, 'admin');
|
||||||
|
if (info?.origin) return info.origin.replace(/\/+$/u, '');
|
||||||
|
return 'http://localhost:53817';
|
||||||
|
}
|
||||||
|
|
||||||
|
function readS3Config() {
|
||||||
|
const raw = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
||||||
|
const s3 = raw?.cloudPublishing?.s3;
|
||||||
|
if (!s3?.accessKeyId || !s3?.secretAccessKey || !s3?.bucket) {
|
||||||
|
throw new Error('请在 .axhub/make/axhub.config.json 中配置 cloudPublishing.s3');
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...s3,
|
||||||
|
includeSource: raw?.cloudPublishing?.publishSettings?.includeSource !== false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function toPublishPath(resource) {
|
||||||
|
const raw = resource.filePath || resource.sourcePath || '';
|
||||||
|
return String(raw).replace(/\\/gu, '/').replace(/\/index\.tsx?$/iu, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveObjectPrefix(entry) {
|
||||||
|
const normalized = entry.path.replace(/^\/+|\/+$/gu, '');
|
||||||
|
const prototypeMatch = normalized.match(/^(?:src\/)?prototypes\/(.+)$/u);
|
||||||
|
if (prototypeMatch?.[1]) return prototypeMatch[1];
|
||||||
|
const themeMatch = normalized.match(/^(?:src\/)?themes\/(.+)$/u);
|
||||||
|
if (themeMatch?.[1]) return `themes/${themeMatch[1]}`;
|
||||||
|
return entry.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolvePublicUrl(baseUrl, prefix) {
|
||||||
|
const base = String(baseUrl || '').replace(/\/?$/u, '/');
|
||||||
|
const key = `${prefix.replace(/^\/+|\/+$/gu, '')}/index.html`;
|
||||||
|
return `${base}${key.split('/').map(encodeURIComponent).join('/')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function guessContentType(filePath) {
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
const map = {
|
||||||
|
'.html': 'text/html; charset=utf-8',
|
||||||
|
'.js': 'application/javascript; charset=utf-8',
|
||||||
|
'.css': 'text/css; charset=utf-8',
|
||||||
|
'.json': 'application/json; charset=utf-8',
|
||||||
|
'.webp': 'image/webp',
|
||||||
|
'.png': 'image/png',
|
||||||
|
'.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg',
|
||||||
|
'.svg': 'image/svg+xml',
|
||||||
|
'.woff2': 'font/woff2',
|
||||||
|
'.woff': 'font/woff',
|
||||||
|
'.txt': 'text/plain; charset=utf-8',
|
||||||
|
};
|
||||||
|
return map[ext] || 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function collectPublishEntries(adminOrigin, projectId, groupFilter) {
|
||||||
|
const res = await fetch(`${adminOrigin}/api/projects/${encodeURIComponent(projectId)}/resources`);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`GET /api/projects/${projectId}/resources failed: ${res.status}`);
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
const groups = groupFilter === 'all' ? ['prototypes', 'themes'] : [groupFilter];
|
||||||
|
const entries = [];
|
||||||
|
for (const group of groups) {
|
||||||
|
for (const item of data.resources?.[group] || []) {
|
||||||
|
const publishPath = toPublishPath(item);
|
||||||
|
if (!publishPath) continue;
|
||||||
|
entries.push({
|
||||||
|
group,
|
||||||
|
id: item.id,
|
||||||
|
title: item.title || item.id,
|
||||||
|
path: publishPath,
|
||||||
|
prefix: resolveObjectPrefix({ path: publishPath, id: item.id }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return entries;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportHtmlZip(adminOrigin, projectId, entry, includeSource) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
path: entry.path,
|
||||||
|
projectId,
|
||||||
|
});
|
||||||
|
if (includeSource) params.set('includeSource', 'true');
|
||||||
|
const res = await fetch(`${adminOrigin}/api/export-html?${params.toString()}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => '');
|
||||||
|
let message = text;
|
||||||
|
try {
|
||||||
|
message = JSON.parse(text).error || text;
|
||||||
|
} catch {
|
||||||
|
// keep raw text
|
||||||
|
}
|
||||||
|
throw new Error(message || `export-html failed (${res.status})`);
|
||||||
|
}
|
||||||
|
const buffer = Buffer.from(await res.arrayBuffer());
|
||||||
|
if (buffer.length < 4 || buffer[0] !== 0x50 || buffer[1] !== 0x4b) {
|
||||||
|
throw new Error('export-html 未返回有效 ZIP');
|
||||||
|
}
|
||||||
|
return buffer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveS3Endpoint(s3Config) {
|
||||||
|
const bucket = String(s3Config.bucket || '').trim();
|
||||||
|
const endpoint = String(s3Config.endpoint || '').trim().replace(/\/+$/u, '');
|
||||||
|
const region = String(s3Config.region || 'cn-hangzhou').trim();
|
||||||
|
|
||||||
|
if (endpoint) {
|
||||||
|
// 配置里可能是 bucket 级域名(model-lnoneos.oss-...),AWS SDK 只需区域 endpoint。
|
||||||
|
const bucketHostPrefix = bucket ? `${bucket}.` : '';
|
||||||
|
if (bucketHostPrefix && endpoint.includes(bucketHostPrefix)) {
|
||||||
|
return endpoint.replace(bucketHostPrefix, '');
|
||||||
|
}
|
||||||
|
return endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `https://oss-${region}.aliyuncs.com`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createS3Client(s3Config) {
|
||||||
|
return new S3Client({
|
||||||
|
region: s3Config.region || 'cn-hangzhou',
|
||||||
|
endpoint: resolveS3Endpoint(s3Config),
|
||||||
|
forcePathStyle: false,
|
||||||
|
credentials: {
|
||||||
|
accessKeyId: s3Config.accessKeyId,
|
||||||
|
secretAccessKey: s3Config.secretAccessKey,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uploadDirectory(client, bucket, prefix, dir) {
|
||||||
|
const uploaded = [];
|
||||||
|
const walk = async (currentDir, relative = '') => {
|
||||||
|
for (const name of fs.readdirSync(currentDir)) {
|
||||||
|
const abs = path.join(currentDir, name);
|
||||||
|
const rel = relative ? `${relative}/${name}` : name;
|
||||||
|
if (fs.statSync(abs).isDirectory()) {
|
||||||
|
await walk(abs, rel);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = [prefix.replace(/^\/+|\/+$/gu, ''), rel].filter(Boolean).join('/');
|
||||||
|
await client.send(new PutObjectCommand({
|
||||||
|
Bucket: bucket,
|
||||||
|
Key: key,
|
||||||
|
Body: fs.readFileSync(abs),
|
||||||
|
ContentType: guessContentType(abs),
|
||||||
|
}));
|
||||||
|
uploaded.push(key);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
await walk(dir);
|
||||||
|
return uploaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishEntry(client, s3Config, adminOrigin, projectId, entry, includeSource) {
|
||||||
|
const zipBuffer = await exportHtmlZip(adminOrigin, projectId, entry, includeSource);
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'axhub-publish-'));
|
||||||
|
const zipPath = path.join(tempDir, 'bundle.zip');
|
||||||
|
const extractDir = path.join(tempDir, 'site');
|
||||||
|
fs.writeFileSync(zipPath, zipBuffer);
|
||||||
|
fs.mkdirSync(extractDir, { recursive: true });
|
||||||
|
await extract(zipPath, { dir: extractDir });
|
||||||
|
const objectPrefix = s3Config.prefix
|
||||||
|
? `${String(s3Config.prefix).replace(/^\/+|\/+$/gu, '')}/${entry.prefix}`
|
||||||
|
: entry.prefix;
|
||||||
|
const keys = await uploadDirectory(client, s3Config.bucket, objectPrefix, extractDir);
|
||||||
|
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||||
|
return {
|
||||||
|
url: resolvePublicUrl(s3Config.baseUrl, objectPrefix),
|
||||||
|
fileCount: keys.length,
|
||||||
|
keys,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const args = parseArgs(process.argv.slice(2));
|
||||||
|
const adminOrigin = resolveAdminOrigin(args.adminOrigin);
|
||||||
|
const s3Config = readS3Config();
|
||||||
|
const includeSource = args.includeSource && s3Config.includeSource;
|
||||||
|
const entries = await collectPublishEntries(adminOrigin, args.projectId, args.group);
|
||||||
|
|
||||||
|
console.log(`Admin: ${adminOrigin}`);
|
||||||
|
console.log(`Bucket: ${s3Config.bucket}`);
|
||||||
|
console.log(`Base URL: ${s3Config.baseUrl}`);
|
||||||
|
console.log(`Pages to publish: ${entries.length}`);
|
||||||
|
|
||||||
|
if (args.dryRun) {
|
||||||
|
for (const entry of entries) {
|
||||||
|
console.log(`[dry-run] ${entry.group}/${entry.id} -> ${entry.prefix}/index.html`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const client = createS3Client(s3Config);
|
||||||
|
const results = [];
|
||||||
|
let failed = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < entries.length; i += 1) {
|
||||||
|
const entry = entries[i];
|
||||||
|
const label = `[${i + 1}/${entries.length}] ${entry.group}/${entry.id}`;
|
||||||
|
process.stdout.write(`${label} ... `);
|
||||||
|
try {
|
||||||
|
const result = await publishEntry(client, s3Config, adminOrigin, args.projectId, entry, includeSource);
|
||||||
|
console.log(`OK ${result.url} (${result.fileCount} files)`);
|
||||||
|
results.push({ ...entry, status: 'success', ...result });
|
||||||
|
} catch (error) {
|
||||||
|
failed += 1;
|
||||||
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
|
console.log(`FAIL ${message}`);
|
||||||
|
results.push({ ...entry, status: 'failed', error: message });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const reportPath = path.join(
|
||||||
|
projectRoot,
|
||||||
|
'.axhub/make/exports',
|
||||||
|
`cloud.publish.s3-all-${new Date().toISOString().replace(/[:.]/gu, '-')}.json`,
|
||||||
|
);
|
||||||
|
fs.mkdirSync(path.dirname(reportPath), { recursive: true });
|
||||||
|
fs.writeFileSync(reportPath, `${JSON.stringify({
|
||||||
|
schemaVersion: 2,
|
||||||
|
projectId: args.projectId,
|
||||||
|
target: 's3',
|
||||||
|
method: 'export-html+s3-upload',
|
||||||
|
total: entries.length,
|
||||||
|
success: entries.length - failed,
|
||||||
|
failed,
|
||||||
|
completedAt: new Date().toISOString(),
|
||||||
|
results: results.map(({ keys, ...rest }) => ({
|
||||||
|
...rest,
|
||||||
|
fileCount: rest.fileCount ?? keys?.length ?? 0,
|
||||||
|
})),
|
||||||
|
}, null, 2)}\n`, 'utf8');
|
||||||
|
|
||||||
|
console.log(`\nDone: ${entries.length - failed}/${entries.length} succeeded`);
|
||||||
|
if (failed > 0) process.exitCode = 1;
|
||||||
|
console.log(`Report: ${reportPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((error) => {
|
||||||
|
console.error(error instanceof Error ? error.message : error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -20,6 +20,12 @@ const XLL_MODULES = [
|
|||||||
{ slug: 'oneos-web-safety-training', title: '业务-司机安全培训', skipCodegen: true },
|
{ slug: 'oneos-web-safety-training', title: '业务-司机安全培训', skipCodegen: true },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/** 从 ONE-OS Web 合包拆出、由项目内手写 index 维护的独立原型 */
|
||||||
|
const STANDALONE_MODULES = [
|
||||||
|
{ slug: 'business-dept-ledger', title: '业务部台账', skipCodegen: true },
|
||||||
|
{ slug: 'oneos-web-h2-station-site', title: '站点信息', skipCodegen: true },
|
||||||
|
];
|
||||||
|
|
||||||
const MODULES = [
|
const MODULES = [
|
||||||
{ slug: 'oneos-web-workbench', title: '工作台', files: ['工作台.jsx'] },
|
{ slug: 'oneos-web-workbench', title: '工作台', files: ['工作台.jsx'] },
|
||||||
{ slug: 'oneos-web-login', title: '登录', files: ['登录.jsx'] },
|
{ slug: 'oneos-web-login', title: '登录', files: ['登录.jsx'] },
|
||||||
@@ -29,8 +35,8 @@ const MODULES = [
|
|||||||
{ slug: 'oneos-web-finance', title: '财务管理', dir: '财务管理', excludeDirs: ['文档'] },
|
{ slug: 'oneos-web-finance', title: '财务管理', dir: '财务管理', excludeDirs: ['文档'] },
|
||||||
{ slug: 'oneos-web-procurement', title: '采购管理', dir: '采购管理' },
|
{ slug: 'oneos-web-procurement', title: '采购管理', dir: '采购管理' },
|
||||||
{ slug: 'oneos-web-lease-contract', title: '车辆租赁合同', dir: '车辆租赁合同' },
|
{ slug: 'oneos-web-lease-contract', title: '车辆租赁合同', dir: '车辆租赁合同' },
|
||||||
{ slug: 'oneos-web-h2-station', title: '加氢站管理', dir: '加氢站管理', excludeDirs: ['export-tools', 'AI-加氢站站点信息-complete'] },
|
{ slug: 'oneos-web-h2-station', title: '加氢站管理', dir: '加氢站管理', excludeDirs: ['export-tools', 'AI-加氢站站点信息-complete'], excludeFiles: ['站点信息.jsx'] },
|
||||||
{ slug: 'oneos-web-data-analysis', title: '数据分析', dir: '数据分析' },
|
{ slug: 'oneos-web-data-analysis', title: '数据分析', dir: '数据分析', excludeFiles: ['业务部台账.jsx'] },
|
||||||
{ slug: 'oneos-web-ledger-data', title: '台账数据', dir: '台账数据', excludeDirs: ['docs'] },
|
{ slug: 'oneos-web-ledger-data', title: '台账数据', dir: '台账数据', excludeDirs: ['docs'] },
|
||||||
{ slug: 'oneos-web-business', title: '业务管理', dir: '业务管理', excludeDirs: ['文档', 'export-tools', 'AI-保险采购-complete'] },
|
{ slug: 'oneos-web-business', title: '业务管理', dir: '业务管理', excludeDirs: ['文档', 'export-tools', 'AI-保险采购-complete'] },
|
||||||
{ slug: 'oneos-web-ops', title: '运维管理', dir: '运维管理' },
|
{ slug: 'oneos-web-ops', title: '运维管理', dir: '运维管理' },
|
||||||
@@ -81,6 +87,7 @@ function collectJsxFiles(module) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (!entry.name.endsWith('.jsx')) continue;
|
if (!entry.name.endsWith('.jsx')) continue;
|
||||||
|
if (module.excludeFiles?.includes(entry.name)) continue;
|
||||||
results.push({
|
results.push({
|
||||||
rel,
|
rel,
|
||||||
abs: full,
|
abs: full,
|
||||||
@@ -223,6 +230,15 @@ function toComponentName(slug) {
|
|||||||
.join('');
|
.join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const WORKBENCH_SIDEBAR_ITEM = {
|
||||||
|
id: 'item:prototypes:oneos-web-workbench',
|
||||||
|
kind: 'item',
|
||||||
|
title: '工作台',
|
||||||
|
itemKey: 'prototypes/oneos-web-workbench',
|
||||||
|
};
|
||||||
|
|
||||||
|
const ONEOS_NAV_FOLDER_ID = 'folder-1782874576229-r8atbu';
|
||||||
|
|
||||||
function toSidebarItem(module) {
|
function toSidebarItem(module) {
|
||||||
return {
|
return {
|
||||||
id: `item:prototypes:${module.slug}`,
|
id: `item:prototypes:${module.slug}`,
|
||||||
@@ -232,6 +248,17 @@ function toSidebarItem(module) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureWorkbenchInOneOsRoot(prototypes) {
|
||||||
|
for (const item of prototypes) {
|
||||||
|
if (item?.id !== ONEOS_NAV_FOLDER_ID || item.kind !== 'folder' || !Array.isArray(item.children)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const rest = item.children.filter((child) => child.itemKey !== WORKBENCH_SIDEBAR_ITEM.itemKey);
|
||||||
|
item.children = [WORKBENCH_SIDEBAR_ITEM, ...rest];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function updateSidebar(webModules, xllModules) {
|
function updateSidebar(webModules, xllModules) {
|
||||||
const sidebar = JSON.parse(fs.readFileSync(sidebarPath, 'utf8'));
|
const sidebar = JSON.parse(fs.readFileSync(sidebarPath, 'utf8'));
|
||||||
const prefix = 'item:prototypes:oneos-web-';
|
const prefix = 'item:prototypes:oneos-web-';
|
||||||
@@ -244,6 +271,10 @@ function updateSidebar(webModules, xllModules) {
|
|||||||
&& !String(item.itemKey).startsWith('prototypes/oneos-web-'),
|
&& !String(item.itemKey).startsWith('prototypes/oneos-web-'),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
ensureWorkbenchInOneOsRoot(existing);
|
||||||
|
|
||||||
|
const webModulesForFolder = webModules.filter((module) => module.slug !== 'oneos-web-workbench');
|
||||||
|
|
||||||
const xllFolder = {
|
const xllFolder = {
|
||||||
id: 'folder-prototypes-xll-miniapp',
|
id: 'folder-prototypes-xll-miniapp',
|
||||||
kind: 'folder',
|
kind: 'folder',
|
||||||
@@ -256,7 +287,7 @@ function updateSidebar(webModules, xllModules) {
|
|||||||
kind: 'folder',
|
kind: 'folder',
|
||||||
title: 'ONE-OS Web 端',
|
title: 'ONE-OS Web 端',
|
||||||
defaultExpanded: true,
|
defaultExpanded: true,
|
||||||
children: webModules.map(toSidebarItem),
|
children: webModulesForFolder.map(toSidebarItem),
|
||||||
};
|
};
|
||||||
|
|
||||||
sidebar.prototypes = [...existing, xllFolder, oneosFolder];
|
sidebar.prototypes = [...existing, xllFolder, oneosFolder];
|
||||||
@@ -270,7 +301,7 @@ function main() {
|
|||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
const allModules = [...XLL_MODULES, ...MODULES];
|
const allModules = [...XLL_MODULES, ...STANDALONE_MODULES, ...MODULES];
|
||||||
|
|
||||||
for (const module of allModules) {
|
for (const module of allModules) {
|
||||||
if (module.skipCodegen) {
|
if (module.skipCodegen) {
|
||||||
|
|||||||
105
src/common/TablePagination.tsx
Normal file
105
src/common/TablePagination.tsx
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
/** 全局默认每页条数,见 vm-shared/DESIGN.md */
|
||||||
|
export const DEFAULT_PAGE_SIZE = 20;
|
||||||
|
|
||||||
|
/** 全局可选每页条数 */
|
||||||
|
export const PAGE_SIZE_OPTIONS = [10, 20, 50, 100] as const;
|
||||||
|
|
||||||
|
export interface TablePaginationProps {
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
total: number;
|
||||||
|
onPageChange: (page: number) => void;
|
||||||
|
onPageSizeChange: (size: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPageItems(current: number, totalPages: number): Array<number | 'ellipsis'> {
|
||||||
|
if (totalPages <= 7) {
|
||||||
|
return Array.from({ length: totalPages }, (_, index) => index + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: Array<number | 'ellipsis'> = [1];
|
||||||
|
if (current > 3) items.push('ellipsis');
|
||||||
|
|
||||||
|
const start = Math.max(2, current - 1);
|
||||||
|
const end = Math.min(totalPages - 1, current + 1);
|
||||||
|
for (let page = start; page <= end; page += 1) {
|
||||||
|
items.push(page);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (current < totalPages - 2) items.push('ellipsis');
|
||||||
|
if (totalPages > 1) items.push(totalPages);
|
||||||
|
return items;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TablePagination({
|
||||||
|
page,
|
||||||
|
pageSize,
|
||||||
|
total,
|
||||||
|
onPageChange,
|
||||||
|
onPageSizeChange,
|
||||||
|
}: TablePaginationProps) {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||||||
|
const pageItems = useMemo(() => buildPageItems(page, totalPages), [page, totalPages]);
|
||||||
|
|
||||||
|
if (total === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="vm-pagination" role="navigation" aria-label="表格分页">
|
||||||
|
<label className="vm-pagination-size">
|
||||||
|
<span className="vm-pagination-size-label">每页</span>
|
||||||
|
<select
|
||||||
|
className="vm-pagination-select"
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(event) => onPageSizeChange(Number(event.target.value))}
|
||||||
|
aria-label="每页显示条数"
|
||||||
|
>
|
||||||
|
{PAGE_SIZE_OPTIONS.map((size) => (
|
||||||
|
<option key={size} value={size}>{size}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<span className="vm-pagination-size-label">条</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="vm-pagination-pages">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="vm-pagination-nav"
|
||||||
|
disabled={page <= 1}
|
||||||
|
onClick={() => onPageChange(page - 1)}
|
||||||
|
aria-label="上一页"
|
||||||
|
>
|
||||||
|
<ChevronLeft size={16} aria-hidden />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{pageItems.map((item, index) => (
|
||||||
|
item === 'ellipsis' ? (
|
||||||
|
<span key={`ellipsis-${index}`} className="vm-pagination-ellipsis" aria-hidden>…</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
key={item}
|
||||||
|
type="button"
|
||||||
|
className={`vm-pagination-page ${page === item ? 'active' : ''}`}
|
||||||
|
onClick={() => onPageChange(item)}
|
||||||
|
aria-current={page === item ? 'page' : undefined}
|
||||||
|
>
|
||||||
|
{item}
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
))}
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="vm-pagination-nav"
|
||||||
|
disabled={page >= totalPages}
|
||||||
|
onClick={() => onPageChange(page + 1)}
|
||||||
|
aria-label="下一页"
|
||||||
|
>
|
||||||
|
<ChevronRight size={16} aria-hidden />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
172
src/common/modal-detail-table.css
Normal file
172
src/common/modal-detail-table.css
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* 弹窗 / 抽屉内 Ant Design Table 通用样式
|
||||||
|
* 防止 scroll.y 场景下表头与表体列宽错行、measure-row 撑开列宽等问题。
|
||||||
|
* 使用方式:Table className="ln-modal-detail-table" tableLayout="fixed" + 每列显式 width
|
||||||
|
*/
|
||||||
|
.ln-modal-detail-table.ant-table-wrapper .ant-table {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ln-modal-detail-table.ant-table-wrapper .ant-table-container {
|
||||||
|
border-inline-start: none;
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ln-modal-detail-table.ant-table-wrapper .ant-table-header {
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ln-modal-detail-table.ant-table-wrapper .ant-table-body {
|
||||||
|
margin-top: 0 !important;
|
||||||
|
overflow: auto !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ln-modal-detail-table.ant-table-wrapper .ant-table-body > table,
|
||||||
|
.ln-modal-detail-table.ant-table-wrapper .ant-table-header > table {
|
||||||
|
margin-top: 0 !important;
|
||||||
|
table-layout: fixed !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ln-modal-detail-table .ant-table-tbody > tr.ant-table-measure-row,
|
||||||
|
.ln-modal-detail-table .ant-table-tbody > tr.ant-table-measure-row > td,
|
||||||
|
.ln-modal-detail-table .ant-table-tbody > tr.ant-table-measure-row > th {
|
||||||
|
display: none !important;
|
||||||
|
height: 0 !important;
|
||||||
|
max-height: 0 !important;
|
||||||
|
min-height: 0 !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
border: none !important;
|
||||||
|
line-height: 0 !important;
|
||||||
|
font-size: 0 !important;
|
||||||
|
overflow: hidden !important;
|
||||||
|
visibility: hidden !important;
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ln-modal-detail-table .ant-table-thead > tr > th {
|
||||||
|
background: var(--ln-canvas-soft, #f8fafc) !important;
|
||||||
|
font-size: 12px !important;
|
||||||
|
font-weight: 600 !important;
|
||||||
|
color: var(--ln-muted, #64748b) !important;
|
||||||
|
padding: 8px 12px !important;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ln-modal-detail-table .ant-table-tbody > tr:not(.ant-table-measure-row) > td {
|
||||||
|
padding: 8px 12px !important;
|
||||||
|
font-size: 13px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ln-modal-detail-table .ant-table-tbody > tr:not(.ant-table-measure-row) > td.ant-table-cell-ellipsis {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ln-modal-detail-table .ant-table-tbody > tr:hover > td {
|
||||||
|
background: var(--ln-canvas-soft, #f8fafc) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ln-modal-detail-table .ant-table-cell-scrollbar {
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ln-modal-detail-table .ant-table-thead > tr > th.ln-modal-detail-table__money,
|
||||||
|
.ln-modal-detail-table .ant-table-tbody > tr > td.ln-modal-detail-table__money {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 左侧冻结列(收款时间等) */
|
||||||
|
.ln-modal-detail-table .ant-table-cell-fix-left,
|
||||||
|
.ln-modal-detail-table .ant-table-cell-fix-start {
|
||||||
|
z-index: 2;
|
||||||
|
background: var(--ln-surface-card, #fff) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ln-modal-detail-table .ant-table-thead > tr > th.ant-table-cell-fix-left,
|
||||||
|
.ln-modal-detail-table .ant-table-thead > tr > th.ant-table-cell-fix-start {
|
||||||
|
z-index: 3;
|
||||||
|
background: var(--ln-canvas-soft, #f8fafc) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ln-modal-detail-table .ant-table-tbody > tr:hover > td.ant-table-cell-fix-left,
|
||||||
|
.ln-modal-detail-table .ant-table-tbody > tr:hover > td.ant-table-cell-fix-start {
|
||||||
|
background: var(--ln-canvas-soft, #f8fafc) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ln-modal-detail-table .ant-table-cell-fix-left-last::after,
|
||||||
|
.ln-modal-detail-table .ant-table-cell-fix-start-last::after {
|
||||||
|
box-shadow: inset 10px 0 8px -8px rgba(15, 23, 42, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ln-modal-detail-table .ln-modal-detail-table__sticky-time {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 弹窗明细列表面板容器 */
|
||||||
|
.ln-modal-detail-panel {
|
||||||
|
border: 1px solid var(--ln-hairline, #e2e8f0);
|
||||||
|
border-radius: var(--ln-radius-card, 12px);
|
||||||
|
background: var(--ln-surface-card, #fff);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--ln-shadow-soft, 0 1px 2px rgba(24, 24, 27, 0.06));
|
||||||
|
}
|
||||||
|
|
||||||
|
.ln-modal-detail-panel__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-bottom: 1px solid var(--ln-hairline, #e2e8f0);
|
||||||
|
background: linear-gradient(180deg, #fafbfc 0%, #f6f7f7 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ln-modal-detail-panel__title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ln-body-strong, #18181b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ln-modal-detail-panel__meta {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--ln-muted, #71717a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ln-modal-detail-panel .ln-modal-detail-table.ant-table-wrapper {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ln-modal-detail-panel .ln-modal-detail-table .ant-table-pagination {
|
||||||
|
margin: 0 !important;
|
||||||
|
padding: 10px 14px !important;
|
||||||
|
border-top: 1px solid var(--ln-hairline, #e2e8f0);
|
||||||
|
background: #fafbfc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ln-modal-detail-empty {
|
||||||
|
padding: 28px 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--ln-muted, #71717a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ln-modal-detail-empty p {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--ln-body, #52525b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ln-modal-detail-empty__hint {
|
||||||
|
margin-top: 6px !important;
|
||||||
|
font-size: 12px !important;
|
||||||
|
color: var(--ln-muted, #94a3b8) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.pr-kpi-detail-modal .ant-modal {
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
25
src/common/modalDetailTable.ts
Normal file
25
src/common/modalDetailTable.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
|
||||||
|
/** 弹窗明细表统一 className,配合 src/common/modal-detail-table.css */
|
||||||
|
export const LN_MODAL_DETAIL_TABLE_CLASS = 'ln-modal-detail-table';
|
||||||
|
|
||||||
|
/** 计算 Ant Table scroll.x;expand 默认预留滚动条/展开列余量 */
|
||||||
|
export function sumColumnScrollX<T>(columns: ColumnsType<T>, expand = 0): number {
|
||||||
|
const base = columns.reduce((sum, col) => sum + (Number(col.width) || 0), 0);
|
||||||
|
return base + expand;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 开发环境检查:弹窗表每列必须设置 width,避免表头表体错行 */
|
||||||
|
export function warnMissingModalColumnWidths<T>(
|
||||||
|
columns: ColumnsType<T>,
|
||||||
|
context: string,
|
||||||
|
): void {
|
||||||
|
if (typeof import.meta !== 'undefined' && !import.meta.env?.DEV) return;
|
||||||
|
const missing = columns.filter((col) => col.width == null);
|
||||||
|
if (missing.length > 0) {
|
||||||
|
console.warn(
|
||||||
|
`[${context}] 弹窗表格列缺少 width,可能导致表头与内容错行:`,
|
||||||
|
missing.map((col) => col.key ?? col.title),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
138
src/common/vm-pagination.css
Normal file
138
src/common/vm-pagination.css
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
/**
|
||||||
|
* ONE-OS vm-page 列表分页 — 全局样式
|
||||||
|
* 规范:src/prototypes/vm-shared/DESIGN.md
|
||||||
|
* 组件:src/common/TablePagination.tsx
|
||||||
|
*/
|
||||||
|
|
||||||
|
.vm-pagination {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px 16px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-pagination-size {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--ln-muted);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-pagination-size-label {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-pagination-select {
|
||||||
|
min-width: 72px;
|
||||||
|
height: var(--vm-control-height);
|
||||||
|
padding: 0 28px 0 10px;
|
||||||
|
border: 1px solid var(--ln-hairline-strong);
|
||||||
|
border-radius: var(--ln-radius-control);
|
||||||
|
background: var(--ln-surface-card);
|
||||||
|
color: var(--ln-ink);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-pagination-select:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--vm-focus-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-pagination-pages {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-pagination-nav,
|
||||||
|
.vm-pagination-page {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: var(--vm-control-height);
|
||||||
|
height: var(--vm-control-height);
|
||||||
|
padding: 0 8px;
|
||||||
|
border: 1px solid var(--ln-hairline-strong);
|
||||||
|
border-radius: var(--ln-radius-control);
|
||||||
|
background: var(--ln-surface-card);
|
||||||
|
color: var(--ln-ink);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s, border-color 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-pagination-nav:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-pagination-nav:hover:not(:disabled),
|
||||||
|
.vm-pagination-page:hover:not(.active) {
|
||||||
|
background: var(--ln-canvas-soft);
|
||||||
|
border-color: var(--ln-hairline-strong);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-pagination-page.active {
|
||||||
|
background: var(--ln-primary);
|
||||||
|
border-color: var(--ln-primary);
|
||||||
|
color: var(--ln-on-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-pagination-nav:focus-visible,
|
||||||
|
.vm-pagination-page:focus-visible {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--vm-focus-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-pagination-ellipsis {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 28px;
|
||||||
|
height: var(--vm-control-height);
|
||||||
|
color: var(--ln-muted);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 详情 Tab 等紧凑场景:vm-table-footer--split vm-table-footer--compact */
|
||||||
|
.vm-table-footer--compact .vm-pagination {
|
||||||
|
width: auto;
|
||||||
|
flex: 0 1 auto;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-table-footer--compact .vm-pagination-size {
|
||||||
|
gap: 4px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-table-footer--compact .vm-pagination-select {
|
||||||
|
min-width: 52px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 22px 0 8px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-table-footer--compact .vm-pagination-nav,
|
||||||
|
.vm-table-footer--compact .vm-pagination-page {
|
||||||
|
min-width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 6px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-table-footer--compact .vm-pagination-ellipsis {
|
||||||
|
min-width: 20px;
|
||||||
|
height: 28px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
26
src/prototypes/business-dept-ledger/index.tsx
Normal file
26
src/prototypes/business-dept-ledger/index.tsx
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
/**
|
||||||
|
* @name 业务部台账
|
||||||
|
* 自 ONE-OS Web 端 / 数据分析 / 业务部台账 独立预览(OneOS 菜单入口)
|
||||||
|
*/
|
||||||
|
import '../../common/oneosWebLegacy/legacyGlobals';
|
||||||
|
import React, { useEffect } from 'react';
|
||||||
|
import * as antd from 'antd';
|
||||||
|
import { ConfigProvider } from 'antd';
|
||||||
|
import 'antd/dist/reset.css';
|
||||||
|
import { clearHostPrototypeRouteInfo } from '../../common/useHashPage';
|
||||||
|
import BusinessDeptLedgerPage from '../oneos-web-data-analysis/pages/05-业务部台账.jsx';
|
||||||
|
|
||||||
|
window.React = React;
|
||||||
|
window.antd = antd;
|
||||||
|
|
||||||
|
export default function BusinessDeptLedger() {
|
||||||
|
useEffect(() => {
|
||||||
|
clearHostPrototypeRouteInfo();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfigProvider>
|
||||||
|
<BusinessDeptLedgerPage />
|
||||||
|
</ConfigProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
"version": 2,
|
"version": 2,
|
||||||
"prototypeName": "contract-template-management",
|
"prototypeName": "contract-template-management",
|
||||||
"pageId": "list",
|
"pageId": "list",
|
||||||
"updatedAt": 1782871095265,
|
"updatedAt": 1783328981482,
|
||||||
"nodes": [
|
"nodes": [
|
||||||
{
|
{
|
||||||
"id": "ct-list-filter",
|
"id": "ct-list-filter",
|
||||||
@@ -332,66 +332,10 @@
|
|||||||
"title": "ONE-OS 原型导航",
|
"title": "ONE-OS 原型导航",
|
||||||
"defaultExpanded": true,
|
"defaultExpanded": true,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"id": "nav-link-contract-template-management",
|
|
||||||
"title": "合同模板管理(当前)",
|
|
||||||
"href": "/prototypes/contract-template-management",
|
|
||||||
"target": "self"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"id": "nav-link-insurance-procurement",
|
|
||||||
"title": "保险采购",
|
|
||||||
"href": "/prototypes/insurance-procurement",
|
|
||||||
"target": "self"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"id": "nav-link-vehicle-management",
|
|
||||||
"title": "车辆管理",
|
|
||||||
"href": "/prototypes/vehicle-management",
|
|
||||||
"target": "self"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"id": "nav-link-lease-contract-management",
|
|
||||||
"title": "租赁合同管理",
|
|
||||||
"href": "/prototypes/lease-contract-management",
|
|
||||||
"target": "self"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"id": "nav-link-customer-management",
|
|
||||||
"title": "客户管理",
|
|
||||||
"href": "/prototypes/customer-management",
|
|
||||||
"target": "self"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"id": "nav-link-lease-business-ledger",
|
|
||||||
"title": "租赁业务台账",
|
|
||||||
"href": "/prototypes/lease-business-ledger",
|
|
||||||
"target": "self"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"id": "nav-link-vehicle-h2-fee-ledger",
|
|
||||||
"title": "车辆氢费明细",
|
|
||||||
"href": "/prototypes/vehicle-h2-fee-ledger",
|
|
||||||
"target": "self"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"id": "nav-link-self-operated-business-ledger",
|
|
||||||
"title": "自营业务台账",
|
|
||||||
"href": "/prototypes/self-operated-business-ledger",
|
|
||||||
"target": "self"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "folder",
|
"type": "folder",
|
||||||
"id": "nav-folder-prototypes-oneos-web",
|
"id": "nav-folder-1782874576229-r8atbu",
|
||||||
"title": "ONE-OS Web 端",
|
"title": "OneOS",
|
||||||
"defaultExpanded": true,
|
"defaultExpanded": true,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
@@ -400,7 +344,22 @@
|
|||||||
"title": "工作台",
|
"title": "工作台",
|
||||||
"href": "/prototypes/oneos-web-workbench",
|
"href": "/prototypes/oneos-web-workbench",
|
||||||
"target": "self"
|
"target": "self"
|
||||||
},
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-lease-business-line-overview",
|
||||||
|
"title": "业务条线说明",
|
||||||
|
"href": "/prototypes/lease-business-line-overview",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "folder",
|
||||||
|
"id": "nav-folder-prototypes-oneos-web",
|
||||||
|
"title": "ONE-OS Web 端",
|
||||||
|
"defaultExpanded": true,
|
||||||
|
"children": [
|
||||||
{
|
{
|
||||||
"type": "link",
|
"type": "link",
|
||||||
"id": "nav-link-oneos-web-login",
|
"id": "nav-link-oneos-web-login",
|
||||||
|
|||||||
@@ -93,3 +93,24 @@ export function applyAttachmentSectionVehicleFilter(html, sectionVehicleBindings
|
|||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 附件章节删减后,按当前保留顺序重排「附件 N」序号 */
|
||||||
|
export function renumberAttachmentSections(html) {
|
||||||
|
var source = String(html || '');
|
||||||
|
var ranges = collectSectionRanges(source);
|
||||||
|
if (!ranges.length) return source;
|
||||||
|
|
||||||
|
var chunks = [];
|
||||||
|
var cursor = 0;
|
||||||
|
ranges.forEach(function (range, idx) {
|
||||||
|
var newNo = idx + 1;
|
||||||
|
chunks.push(source.slice(cursor, range.start));
|
||||||
|
var slice = source.slice(range.start, range.end);
|
||||||
|
slice = slice.replace(/附件\s*\d+\s*[::]/, '附件' + newNo + ':');
|
||||||
|
slice = slice.replace(/<b>\s*(\d+):租赁订单\s*<\/b>/, '<b>' + newNo + ':租赁订单 </b>');
|
||||||
|
chunks.push(slice);
|
||||||
|
cursor = range.end;
|
||||||
|
});
|
||||||
|
chunks.push(source.slice(cursor));
|
||||||
|
return chunks.join('');
|
||||||
|
}
|
||||||
|
|||||||
@@ -68,10 +68,10 @@ export var MOCK_LEASE_CUSTOMERS = [
|
|||||||
bankAccount: '6222021234567890123',
|
bankAccount: '6222021234567890123',
|
||||||
taxId: '91330400MA2XXXXX1',
|
taxId: '91330400MA2XXXXX1',
|
||||||
attachments: {
|
attachments: {
|
||||||
businessLicense: { name: '嘉兴某某物流-营业执照.pdf' },
|
businessLicense: { name: '嘉兴某某物流-营业执照.pdf', ocrVerified: true, expiryDate: '2028-06-30' },
|
||||||
legalIdFront: { name: '法人身份证-正面.jpg' },
|
legalIdFront: { name: '法人身份证-正面.jpg', ocrVerified: true, expiryDate: '2032-08-15' },
|
||||||
legalIdBack: { name: '法人身份证-反面.jpg' },
|
legalIdBack: { name: '法人身份证-反面.jpg', ocrVerified: true, expiryDate: '2032-08-15' },
|
||||||
roadTransportPermit: { name: '道路运输经营许可证.pdf' },
|
roadTransportPermit: { name: '道路运输经营许可证.pdf', ocrVerified: true, expiryDate: '2027-12-31' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -91,10 +91,10 @@ export var MOCK_LEASE_CUSTOMERS = [
|
|||||||
bankAccount: '6217001234567890123',
|
bankAccount: '6217001234567890123',
|
||||||
taxId: '91310000MA2XXXXX2',
|
taxId: '91310000MA2XXXXX2',
|
||||||
attachments: {
|
attachments: {
|
||||||
businessLicense: { name: '上海某某运输-营业执照.pdf' },
|
businessLicense: { name: '上海某某运输-营业执照.pdf', ocrVerified: true, expiryDate: '2027-03-01' },
|
||||||
legalIdFront: { name: '法人身份证-正面.jpg' },
|
legalIdFront: { name: '法人身份证-正面.jpg', ocrVerified: true, expiryDate: '2029-11-20' },
|
||||||
legalIdBack: { name: '法人身份证-反面.jpg' },
|
legalIdBack: { name: '法人身份证-反面.jpg', ocrVerified: true, expiryDate: '2029-11-20' },
|
||||||
roadTransportPermit: { name: '道路运输经营许可证.pdf' },
|
roadTransportPermit: { name: '道路运输经营许可证.pdf', ocrVerified: true, expiryDate: '2026-01-10' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -114,10 +114,10 @@ export var MOCK_LEASE_CUSTOMERS = [
|
|||||||
bankAccount: '6228481234567890123',
|
bankAccount: '6228481234567890123',
|
||||||
taxId: '91330100MA2XXXXX3',
|
taxId: '91330100MA2XXXXX3',
|
||||||
attachments: {
|
attachments: {
|
||||||
businessLicense: { name: '杭州某某租赁-营业执照.pdf' },
|
businessLicense: { name: '杭州某某租赁-营业执照.pdf', ocrVerified: true, expiryDate: '2026-09-20' },
|
||||||
legalIdFront: { name: '法人身份证-正面.jpg' },
|
legalIdFront: { name: '法人身份证-正面.jpg', ocrVerified: true, expiryDate: '2030-05-08' },
|
||||||
legalIdBack: { name: '法人身份证-反面.jpg' },
|
legalIdBack: { name: '法人身份证-反面.jpg', ocrVerified: true, expiryDate: '2030-05-08' },
|
||||||
roadTransportPermit: { name: '道路运输经营许可证.pdf' },
|
roadTransportPermit: { name: '道路运输经营许可证.pdf', ocrVerified: true, expiryDate: '2026-02-01' },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -214,6 +214,16 @@ export var DEFAULT_FEE_INFO = {
|
|||||||
returnHydrogenDiffUnitPrice: null,
|
returnHydrogenDiffUnitPrice: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 新建合同时系统自动生成的编号后缀(不含 LNZLHT 前缀) */
|
||||||
|
export function generateAutoContractCodeSuffix() {
|
||||||
|
var now = new Date();
|
||||||
|
var y = now.getFullYear();
|
||||||
|
var m = String(now.getMonth() + 1).padStart(2, '0');
|
||||||
|
var d = String(now.getDate()).padStart(2, '0');
|
||||||
|
var seq = String(Math.floor(Math.random() * 9000) + 1000);
|
||||||
|
return y + m + d + seq;
|
||||||
|
}
|
||||||
|
|
||||||
export var PAYMENT_PERIOD_OPTIONS = [
|
export var PAYMENT_PERIOD_OPTIONS = [
|
||||||
{ value: 1, label: '1个月' },
|
{ value: 1, label: '1个月' },
|
||||||
{ value: 3, label: '3个月' },
|
{ value: 3, label: '3个月' },
|
||||||
@@ -224,6 +234,7 @@ export var PAYMENT_PERIOD_OPTIONS = [
|
|||||||
export var HYDROGEN_PAYMENT_METHOD_OPTIONS = [
|
export var HYDROGEN_PAYMENT_METHOD_OPTIONS = [
|
||||||
{ value: 'self', label: '自行解决' },
|
{ value: 'self', label: '自行解决' },
|
||||||
{ value: 'prepay', label: '预付款' },
|
{ value: 'prepay', label: '预付款' },
|
||||||
|
{ value: 'month', label: '按月结算(原型占位,条款待法务确认)' },
|
||||||
];
|
];
|
||||||
|
|
||||||
export function getLessorCompanies() {
|
export function getLessorCompanies() {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"version": 2,
|
"version": 2,
|
||||||
"prototypeName": "customer-management",
|
"prototypeName": "customer-management",
|
||||||
"pageId": "list",
|
"pageId": "list",
|
||||||
"updatedAt": 1782896901748,
|
"updatedAt": 1783328981485,
|
||||||
"nodes": [
|
"nodes": [
|
||||||
{
|
{
|
||||||
"id": "cm-list-filter",
|
"id": "cm-list-filter",
|
||||||
@@ -245,6 +245,135 @@
|
|||||||
"assetMap": {},
|
"assetMap": {},
|
||||||
"directory": {
|
"directory": {
|
||||||
"nodes": [
|
"nodes": [
|
||||||
|
{
|
||||||
|
"type": "folder",
|
||||||
|
"id": "oneos-project-nav",
|
||||||
|
"title": "ONE-OS 原型导航",
|
||||||
|
"defaultExpanded": true,
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "folder",
|
||||||
|
"id": "nav-folder-1782874576229-r8atbu",
|
||||||
|
"title": "OneOS",
|
||||||
|
"defaultExpanded": true,
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-workbench",
|
||||||
|
"title": "工作台",
|
||||||
|
"href": "/prototypes/oneos-web-workbench",
|
||||||
|
"target": "self"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-lease-business-line-overview",
|
||||||
|
"title": "业务条线说明",
|
||||||
|
"href": "/prototypes/lease-business-line-overview",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "folder",
|
||||||
|
"id": "nav-folder-prototypes-oneos-web",
|
||||||
|
"title": "ONE-OS Web 端",
|
||||||
|
"defaultExpanded": true,
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-login",
|
||||||
|
"title": "登录",
|
||||||
|
"href": "/prototypes/oneos-web-login",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-vehicle-asset",
|
||||||
|
"title": "车辆管理",
|
||||||
|
"href": "/prototypes/oneos-web-vehicle-asset",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-contract-template",
|
||||||
|
"title": "合同模板管理",
|
||||||
|
"href": "/prototypes/oneos-web-contract-template",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-help-center",
|
||||||
|
"title": "帮助中心",
|
||||||
|
"href": "/prototypes/oneos-web-help-center",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-finance",
|
||||||
|
"title": "财务管理",
|
||||||
|
"href": "/prototypes/oneos-web-finance",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-procurement",
|
||||||
|
"title": "采购管理",
|
||||||
|
"href": "/prototypes/oneos-web-procurement",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-lease-contract",
|
||||||
|
"title": "车辆租赁合同",
|
||||||
|
"href": "/prototypes/oneos-web-lease-contract",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-h2-station",
|
||||||
|
"title": "加氢站管理",
|
||||||
|
"href": "/prototypes/oneos-web-h2-station",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-data-analysis",
|
||||||
|
"title": "数据分析",
|
||||||
|
"href": "/prototypes/oneos-web-data-analysis",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-ledger-data",
|
||||||
|
"title": "台账数据",
|
||||||
|
"href": "/prototypes/oneos-web-ledger-data",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-business",
|
||||||
|
"title": "业务管理",
|
||||||
|
"href": "/prototypes/oneos-web-business",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-ops",
|
||||||
|
"title": "运维管理",
|
||||||
|
"href": "/prototypes/oneos-web-ops",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-requirements",
|
||||||
|
"title": "需求说明",
|
||||||
|
"href": "/prototypes/oneos-web-requirements",
|
||||||
|
"target": "self"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "folder",
|
"type": "folder",
|
||||||
"id": "cm-doc-root",
|
"id": "cm-doc-root",
|
||||||
@@ -261,7 +390,7 @@
|
|||||||
"type": "link",
|
"type": "link",
|
||||||
"id": "cm-link-self",
|
"id": "cm-link-self",
|
||||||
"title": "客户管理原型",
|
"title": "客户管理原型",
|
||||||
"href": "/prototypes/customer-management",
|
"href": "./index.html",
|
||||||
"target": "self"
|
"target": "self"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"version": 2,
|
"version": 2,
|
||||||
"prototypeName": "insurance-procurement",
|
"prototypeName": "insurance-procurement",
|
||||||
"pageId": "list",
|
"pageId": "list",
|
||||||
"updatedAt": 1782871095269,
|
"updatedAt": 1783328981486,
|
||||||
"nodes": [
|
"nodes": [
|
||||||
{
|
{
|
||||||
"id": "ipc-header",
|
"id": "ipc-header",
|
||||||
@@ -502,66 +502,10 @@
|
|||||||
"title": "ONE-OS 原型导航",
|
"title": "ONE-OS 原型导航",
|
||||||
"defaultExpanded": true,
|
"defaultExpanded": true,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"id": "nav-link-contract-template-management",
|
|
||||||
"title": "合同模板管理",
|
|
||||||
"href": "/prototypes/contract-template-management",
|
|
||||||
"target": "self"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"id": "nav-link-insurance-procurement",
|
|
||||||
"title": "保险采购(当前)",
|
|
||||||
"href": "/prototypes/insurance-procurement",
|
|
||||||
"target": "self"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"id": "nav-link-vehicle-management",
|
|
||||||
"title": "车辆管理",
|
|
||||||
"href": "/prototypes/vehicle-management",
|
|
||||||
"target": "self"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"id": "nav-link-lease-contract-management",
|
|
||||||
"title": "租赁合同管理",
|
|
||||||
"href": "/prototypes/lease-contract-management",
|
|
||||||
"target": "self"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"id": "nav-link-customer-management",
|
|
||||||
"title": "客户管理",
|
|
||||||
"href": "/prototypes/customer-management",
|
|
||||||
"target": "self"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"id": "nav-link-lease-business-ledger",
|
|
||||||
"title": "租赁业务台账",
|
|
||||||
"href": "/prototypes/lease-business-ledger",
|
|
||||||
"target": "self"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"id": "nav-link-vehicle-h2-fee-ledger",
|
|
||||||
"title": "车辆氢费明细",
|
|
||||||
"href": "/prototypes/vehicle-h2-fee-ledger",
|
|
||||||
"target": "self"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"id": "nav-link-self-operated-business-ledger",
|
|
||||||
"title": "自营业务台账",
|
|
||||||
"href": "/prototypes/self-operated-business-ledger",
|
|
||||||
"target": "self"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "folder",
|
"type": "folder",
|
||||||
"id": "nav-folder-prototypes-oneos-web",
|
"id": "nav-folder-1782874576229-r8atbu",
|
||||||
"title": "ONE-OS Web 端",
|
"title": "OneOS",
|
||||||
"defaultExpanded": true,
|
"defaultExpanded": true,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
@@ -570,7 +514,22 @@
|
|||||||
"title": "工作台",
|
"title": "工作台",
|
||||||
"href": "/prototypes/oneos-web-workbench",
|
"href": "/prototypes/oneos-web-workbench",
|
||||||
"target": "self"
|
"target": "self"
|
||||||
},
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-lease-business-line-overview",
|
||||||
|
"title": "业务条线说明",
|
||||||
|
"href": "/prototypes/lease-business-line-overview",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "folder",
|
||||||
|
"id": "nav-folder-prototypes-oneos-web",
|
||||||
|
"title": "ONE-OS Web 端",
|
||||||
|
"defaultExpanded": true,
|
||||||
|
"children": [
|
||||||
{
|
{
|
||||||
"type": "link",
|
"type": "link",
|
||||||
"id": "nav-link-oneos-web-login",
|
"id": "nav-link-oneos-web-login",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 租赁业务台账 · 产品需求说明(PRD)
|
# 租赁业务台账 · 产品需求说明(PRD)
|
||||||
|
|
||||||
> 对齐《租赁业务运营明细台账模版》;业务服务组维护各自台账,标记已确认与收款关联为原型演示。
|
> 对齐《租赁业务运营明细台账模版》;业务服务组维护各自台账,收款关联与状态自动计算为原型演示。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -10,8 +10,8 @@
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 模块名称 | 台账数据 — 租赁业务台账 |
|
| 模块名称 | 台账数据 — 租赁业务台账 |
|
||||||
| 目标用户 | 租赁业务客服、业务服务组主管 |
|
| 目标用户 | 租赁业务客服、业务服务组主管 |
|
||||||
| 核心任务 | 维护租赁运营明细、汇总应收实收与盈亏、标记确认与关联收款 |
|
| 核心任务 | 维护租赁运营明细、汇总应收实收与盈亏、关联收款记录 |
|
||||||
| 页面结构 | 筛选区 + KPI 统计 + 工具栏 + 宽表 + 下拉加载更多(不做分页) |
|
| 页面结构 | 筛选区 + KPI 统计 + 工具栏 + 宽表 + 底部分页 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -25,8 +25,8 @@
|
|||||||
| 业务部门 / 业务员 | 精确匹配 |
|
| 业务部门 / 业务员 | 精确匹配 |
|
||||||
| 车牌号码 | 精确匹配 |
|
| 车牌号码 | 精确匹配 |
|
||||||
| 客户名称 | 模糊包含 |
|
| 客户名称 | 模糊包含 |
|
||||||
| 明细状态 | 草稿 / 已确认 / 部分收款 / 已收款 |
|
| 明细状态 | 未收款 / 部分收款 / 已收款 |
|
||||||
| 付款日期 | 日期区间 |
|
| 应付款日 | 单输入框双日历区间,精确至日 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -55,18 +55,18 @@
|
|||||||
|---|---|
|
|---|---|
|
||||||
| 批量导入 | 下载模板并上传 Excel(原型 Toast) |
|
| 批量导入 | 下载模板并上传 Excel(原型 Toast) |
|
||||||
| 导出 | 导出当前筛选结果 |
|
| 导出 | 导出当前筛选结果 |
|
||||||
| 标记为已确认 | 勾选草稿行后二次确认 |
|
| 保存 | 勾选记录后设为未归档,保存后不可编辑 |
|
||||||
| 提交收款审批 | 勾选行提交审批 |
|
| 设置为归档 | 勾选记录后锁定为已归档 |
|
||||||
|
|
||||||
添加行已移至表格底部;列表采用下拉加载更多,不做分页。
|
表格底部提供分页,可切换页码与每页条数。
|
||||||
|
|
||||||
### 标记为已确认
|
### 保存
|
||||||
|
|
||||||
勾选草稿记录后点击,弹出二次确认:「确认后的数据只有主管才能继续进行修改,是否确认数据准确无误?」确认后状态变更为已确认,业务服务组人员不可再删改。
|
勾选台账记录后点击,将账单状态设为**未归档**。未归档数据不可编辑,仅草稿可编辑。
|
||||||
|
|
||||||
### 添加行
|
### 设置为归档
|
||||||
|
|
||||||
位于表格底部;列表不做分页,采用下拉加载更多,避免多选受分页限制。
|
勾选台账记录后点击,将账单状态设为**已归档**并锁定编辑。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -78,132 +78,344 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 状态流转
|
## 6. 收款状态
|
||||||
|
|
||||||
1. 新增/导入 → **草稿**(业务服务组可删改)
|
## 状态
|
||||||
2. 多选 + 「标记为已确认」→ **已确认**(仅主管可改)
|
|
||||||
3. 已确认行可「关联收款记录」;到账合计 < 应收 → **部分收款**;≥ 应收 → **已收款**
|
**数据来源**:收款记录模块中关联到本账单的分配金额、账单应收合计。
|
||||||
|
**计算方式**:分配金额 ≥ 应收合计且应收 > 0 → 已收款;0 < 分配金额 < 应收 → 部分收款;未关联收款 → 未收款。
|
||||||
|
**录入方式**:系统自动计算,只读。
|
||||||
|
|
||||||
|
**业务说明**:当收款记录中分配金额完全覆盖账单的应收金额时,显示已收款。当收款记录中分配金额≤账单的应收金额时,显示部分收款。未分配收款记录时,显示为未收款。其他状态去除
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. 字段说明
|
## 7. 字段说明
|
||||||
|
|
||||||
### 业务部门列
|
### 应付款日
|
||||||
|
|
||||||
业务部门与业务负责人合并为一列:第一行显示为业务部门,第二行显示为业务负责人。
|
**数据来源**:账单起始日期(优先)或提车日期作为开始计费日;账单所属年月。
|
||||||
|
**计算方式**:首期账单应付款日 = 开始计费日;后续每期 = 账单所属自然月 5 日。
|
||||||
|
**录入方式**:系统自动计算,只读。
|
||||||
|
|
||||||
### 车牌号码列
|
**业务说明**:交车后按「开始计费日期」生成首期账单应付款日;后续每期账单在所属自然月 5 日生成下一期应付款日。
|
||||||
|
|
||||||
车牌与车型合并为一列:上方车牌号码,下方品牌与型号;导入/新增时按车牌在系统中自动匹配。
|
### 车牌号码
|
||||||
|
|
||||||
### 客户名称列
|
**数据来源**:车辆档案(按车牌匹配品牌、型号)。
|
||||||
|
**计算方式**:导入/新增台账时按车牌号反写车辆信息。
|
||||||
|
**录入方式**:只读展示(车牌 + 车型两行)。
|
||||||
|
|
||||||
客户名称与项目名称合并为一列:上方客户名称,下方项目名称;按账单日期与车牌号自动匹配。
|
**业务说明**:车牌与车型合并为一列:上方车牌号码,下方品牌与型号;导入/新增时按车牌在系统中自动匹配。
|
||||||
|
|
||||||
|
### 客户名称
|
||||||
|
|
||||||
|
**数据来源**:租赁合同(按账单日期 + 车牌匹配客户、项目)。
|
||||||
|
**计算方式**:匹配当前车辆在账期内的有效合同,带出客户名称与项目名称。
|
||||||
|
**录入方式**:只读展示(客户 + 项目两行)。
|
||||||
|
|
||||||
|
**业务说明**:客户名称与项目名称合并为一列:上方客户名称,下方项目名称;按账单日期与车牌号自动匹配。
|
||||||
|
|
||||||
|
### 增值服务
|
||||||
|
|
||||||
|
**数据来源**:租赁合同签订时勾选的增值服务项目。
|
||||||
|
**计算方式**:按车牌匹配合同增值服务配置,多项服务分行展示。
|
||||||
|
**录入方式**:只读。
|
||||||
|
|
||||||
|
**业务说明**:签订合同时勾选的增值服务项目,按车牌自动匹配合同数据;多项服务各占一行展示。
|
||||||
|
|
||||||
### 提车日期
|
### 提车日期
|
||||||
|
|
||||||
等于交车管理中该车辆运维 E 签宝签字完成的时间。
|
**数据来源**:交车管理 / 运维 E 签宝签字完成时间。
|
||||||
|
**计算方式**:取该车辆实际交车办结时间。
|
||||||
|
**录入方式**:只读。
|
||||||
|
|
||||||
|
**业务说明**:运维实际交车日期。
|
||||||
|
|
||||||
|
### 合同生效日期
|
||||||
|
|
||||||
|
**数据来源**:提车日期(交车办结时间)。
|
||||||
|
**计算方式**:合同生效日期 = 提车日期(YYYY-MM-DD),系统自动反写。
|
||||||
|
**录入方式**:系统自动反写,只读。
|
||||||
|
|
||||||
|
**业务说明**:等于提车日期,即租赁合同生效日期,系统自动反写,只读。
|
||||||
|
|
||||||
|
### 合同到期日期
|
||||||
|
|
||||||
|
**数据来源**:合同租期月数 + 提车日期,或合同约定的到期日。
|
||||||
|
**计算方式**:日期模式:取合同存储的 contractEndDate;租期模式:提车日 + leaseTermMonths 个月。
|
||||||
|
**录入方式**:系统自动计算,只读。
|
||||||
|
|
||||||
|
**业务说明**:合同到期日:租期模式按提车日期 + 租期月数计算;日期模式取合同中约定的到期日。
|
||||||
|
|
||||||
|
### 起始日期
|
||||||
|
|
||||||
|
**数据来源**:账单年月、提车日期。
|
||||||
|
**计算方式**:月前已提车 → 当月 1 日;月内提车 → 提车日;月后提车 → 空(calcPeriodStartDate)。
|
||||||
|
**录入方式**:系统自动计算,只读。
|
||||||
|
|
||||||
|
**业务说明**:账单所属月内的租赁起始日:月前已提车取当月 1 日,月内提车取提车日;系统自动计算,只读。
|
||||||
|
|
||||||
|
### 退车日期
|
||||||
|
|
||||||
|
**数据来源**:退车管理登记的退车日期。
|
||||||
|
**计算方式**:取该车牌退车办结日期;未退车为空。
|
||||||
|
**录入方式**:只读。
|
||||||
|
|
||||||
|
**业务说明**:退车管理中登记的退车日期;未退车时为空。
|
||||||
|
|
||||||
|
### 平均天数
|
||||||
|
|
||||||
|
**数据来源**:账单月内租赁天数、当月自然日天数。
|
||||||
|
**计算方式**:平均天数 = 账单月内租赁天数 ÷ 当月自然日天数,保留两位小数(calcAvgDays)。
|
||||||
|
**录入方式**:系统自动计算,只读。
|
||||||
|
|
||||||
|
**业务说明**:平均天数 = 账单月内租赁天数 ÷ 当月自然日天数,保留两位小数;用于成本分摊口径。
|
||||||
|
|
||||||
### 保证金
|
### 保证金
|
||||||
|
|
||||||
与创建合同时的保证金、提车应收款中的保证金等同;提车应收款有保证金时显示。
|
**数据来源**:提车应收款 / 合同保证金。
|
||||||
|
**计算方式**:与创建合同时保证金、提车应收款中该车辆保证金一致。
|
||||||
|
**录入方式**:只读。
|
||||||
|
|
||||||
|
**业务说明**:显示该车辆实付保证金金额,与提车应收款时该车辆收取的提车应收款相同。
|
||||||
|
|
||||||
### 合同标的租金
|
### 合同标的租金
|
||||||
|
|
||||||
等于该车辆在签订该合同时合同中的租金(月租金标准)。
|
**数据来源**:租赁合同月租金标准。
|
||||||
|
**计算方式**:签订合同时该车辆的月租金(合同标的租金)。
|
||||||
|
**录入方式**:合同带入,只读。
|
||||||
|
|
||||||
|
**业务说明**:等于该车辆在签订该合同时合同中的租金(月租金标准)。
|
||||||
|
|
||||||
|
### 应收合计
|
||||||
|
|
||||||
|
**数据来源**:应收租金、保险上浮费、运维费(收入侧)、其他收入、里程/其他减免。
|
||||||
|
**计算方式**:应收合计 = 应收租金 + 保险上浮费 + 运维费(收入侧)+ 其他收入 − 里程减免 − 其他减免(calcReceivableTotal)。
|
||||||
|
**录入方式**:系统自动汇总,只读。
|
||||||
|
|
||||||
|
**业务说明**:应收合计 = 应收租金 + 保险上浮费 + 运维费(收入侧)+ 其他收入 − 里程减免金额 − 其他减免金额。
|
||||||
|
|
||||||
### 应收租金
|
### 应收租金
|
||||||
|
|
||||||
按合同付款周期(1/3/6/12 个月)计算的单期账单租金总金额 = 合同标的租金 × 付款周期月数。
|
**数据来源**:合同标的租金、合同付款周期月数。
|
||||||
|
**计算方式**:应收租金 = 合同标的租金 × 付款周期月数(1/3/6/12)(calcReceivableRent)。
|
||||||
|
**录入方式**:系统自动计算,只读。
|
||||||
|
|
||||||
|
**业务说明**:按合同付款周期(1/3/6/12 个月)计算的单期账单租金总金额 = 合同标的租金 × 付款周期月数。
|
||||||
|
|
||||||
### 月度收入
|
### 月度收入
|
||||||
|
|
||||||
租金 + 服务费 − 里程减免金额 − 其他减免金额(后两项为减项)。
|
**数据来源**:月度租金、维保包干、保险上浮费、运维费(收入侧)、其他收入、减免项。
|
||||||
|
**计算方式**:月度收入 = 月度租金 + 维保包干收入 + 保险上浮费 + 运维费(收入侧)+ 其他收入 − 里程减免 − 其他减免(calcMonthlyIncome)。
|
||||||
|
**录入方式**:系统自动计算,只读。
|
||||||
|
|
||||||
|
**业务说明**:月度收入 = 月度租金 + 维保包干收入 + 保险上浮费 + 运维费(收入侧)+ 其他收入 − 里程减免金额 − 其他减免金额。
|
||||||
|
|
||||||
### 月度租金
|
### 月度租金
|
||||||
|
|
||||||
根据客户付款周期间隔,计算每个月需收取的租金,等于合同标的租金。
|
**数据来源**:合同标的租金。
|
||||||
|
**计算方式**:月度租金 = 合同标的租金(calcMonthlyRent)。
|
||||||
|
**录入方式**:只读。
|
||||||
|
|
||||||
|
**业务说明**:根据客户付款周期间隔,计算每个月需收取的租金,等于合同标的租金。
|
||||||
|
|
||||||
### 维保包干收入
|
### 维保包干收入
|
||||||
|
|
||||||
合同勾选维保包干时:单价(元/km)× 里程数,必填;未勾选时默认 0 且不可编辑。
|
**数据来源**:合同维保包干配置、账单里程。
|
||||||
|
**计算方式**:勾选维保包干时:单价(元/km)× 里程数;未勾选为 0(calcMaintenanceIncome)。
|
||||||
|
**录入方式**:包干未勾选时不可编辑。
|
||||||
|
|
||||||
|
**业务说明**:合同勾选维保包干时:单价(元/km)× 里程数,必填;未勾选时默认 0 且不可编辑。
|
||||||
|
|
||||||
### 保险上浮费
|
### 保险上浮费
|
||||||
|
|
||||||
事故中有保险上浮费时,最新账单显示警示标志;关联事故单后自动汇总上浮费,支持多选。
|
**数据来源**:事故台账关联的保险上浮费。
|
||||||
|
**计算方式**:关联事故单后汇总 linkedAccidentIds 对应上浮费;最新账单未关联时显示警示。
|
||||||
|
**录入方式**:关联后自动汇总,支持多选事故单。
|
||||||
|
|
||||||
### 运维费(收入侧)
|
**业务说明**:事故中有保险上浮费时,最新账单显示警示标志;关联事故单后自动汇总上浮费,支持多选。
|
||||||
|
|
||||||
维修/保养台账为「客户承担」时,最新一期账单显示警示图标,提醒关联维修/保养记录;关联后自动汇总运维费,支持多选。
|
### 运维费
|
||||||
|
|
||||||
|
**数据来源**:维修/保养台账(客户承担)、账单月内该车牌完工记录。
|
||||||
|
**计算方式**:汇总 bearBy=客户承担 的维保金额;可关联 linkedMaintenanceIds(sumMaintenanceForBill)。
|
||||||
|
**录入方式**:关联后自动汇总;最新账单可关联记录。
|
||||||
|
|
||||||
|
**业务说明**:收入侧运维费:自动汇总该账单月内、该车牌维修/保养台账中「客户承担」的金额;最新一期账单可关联记录。
|
||||||
|
|
||||||
### 其他收入
|
### 其他收入
|
||||||
|
|
||||||
手工填写,预留作为特殊情况记录使用。
|
**数据来源**:行内其他收入明细(事由、金额、备注)。
|
||||||
|
**计算方式**:多条明细金额合计计入应收与月度收入(sumLineItems)。
|
||||||
|
**录入方式**:草稿状态可编辑明细。
|
||||||
|
|
||||||
|
**业务说明**:支持多条明细录入(事由、金额、备注);合计自动计入应收与月度收入。
|
||||||
|
|
||||||
|
### 里程减免金额
|
||||||
|
|
||||||
|
**数据来源**:BI 里程(GPS)、里程减免单价。
|
||||||
|
**计算方式**:里程减免 = GPS 里程 × 单价;有 GPS 时默认 0.5 元/km,无 GPS 为 0(calcMileageDeduction)。
|
||||||
|
**录入方式**:系统自动计算,只读。
|
||||||
|
|
||||||
|
**业务说明**:里程减免金额 = GPS 里程 × 里程减免单价;有 GPS 里程时默认单价 0.5 元/km,无 GPS 时为 0。
|
||||||
|
|
||||||
|
### 其他减免金额
|
||||||
|
|
||||||
|
**数据来源**:行内其他减免明细。
|
||||||
|
**计算方式**:多条明细金额合计,从应收与月度收入扣减(sumLineItems)。
|
||||||
|
**录入方式**:草稿状态可编辑明细。
|
||||||
|
|
||||||
|
**业务说明**:支持多条明细录入(事由、金额、备注);合计自动从应收与月度收入中扣减。
|
||||||
|
|
||||||
### 实收金额
|
### 实收金额
|
||||||
|
|
||||||
根据财务导入收款明细归集;支持关联多条并分配计入金额;超出应收部分在单据下自行分配。
|
**数据来源**:收款记录模块关联分配金额。
|
||||||
|
**计算方式**:关联收款单后按分配计入金额汇总,不超过应收合计(calcReceivedAmountFromReceipts)。
|
||||||
|
**录入方式**:通过「关联收款」操作维护。
|
||||||
|
|
||||||
|
**业务说明**:从「收款记录」功能关联该账单后分配的实收金额;点击可查看收款明细列表。
|
||||||
|
|
||||||
### 未收金额
|
### 未收金额
|
||||||
|
|
||||||
未收金额 = 应收合计 − 实收金额,系统自动计算,不可手工录入。
|
**数据来源**:应收合计、实收金额。
|
||||||
|
**计算方式**:未收金额 = 应收合计 − 实收金额(calcUnreceived)。
|
||||||
|
**录入方式**:系统自动计算,不可手工录入。
|
||||||
|
|
||||||
|
**业务说明**:未收金额 = 应收合计 − 实收金额,系统自动计算,不可手工录入。
|
||||||
|
|
||||||
### 开票日期
|
### 开票日期
|
||||||
|
|
||||||
账单开票后由财务维护,自动反写,只读。
|
**数据来源**:关联收款记录中的开票信息。
|
||||||
|
**计算方式**:resolveInvoiceDateFromReceipts 取关联收款单开票日期。
|
||||||
|
**录入方式**:只读。
|
||||||
|
|
||||||
|
**业务说明**:从「收款记录」功能关联开票记录,显示开票日期。
|
||||||
|
|
||||||
### 实际付款日期
|
### 实际付款日期
|
||||||
|
|
||||||
关联收款记录时取对应到账记录的付款日期;未关联时为空。
|
**数据来源**:关联收款记录的到账日期。
|
||||||
|
**计算方式**:有关联收款时取到账记录付款日期;未关联为空。
|
||||||
|
**录入方式**:只读。
|
||||||
|
|
||||||
|
**业务说明**:关联收款记录时取对应到账记录的付款日期;未关联时为空。
|
||||||
|
|
||||||
### 付款方式
|
### 付款方式
|
||||||
|
|
||||||
由合同付款周期决定:1 个月→月度预付,3 个月→季度预付,6 个月→半年预付,12 个月→年度预付。
|
**数据来源**:合同付款周期月数。
|
||||||
|
**计算方式**:1→月度预付,3→季度预付,6→半年预付,12→年度预付(resolvePaymentMethod)。
|
||||||
|
**录入方式**:只读。
|
||||||
|
|
||||||
|
**业务说明**:由合同付款周期决定:1 个月→月度预付,3 个月→季度预付,6 个月→半年预付,12 个月→年度预付。
|
||||||
|
|
||||||
### 车辆标准成本
|
### 车辆标准成本
|
||||||
|
|
||||||
固定成本,按车型明细维护,只读。
|
**数据来源**:车型成本明细 / 车牌标准成本配置(PLATE_STANDARD_COST)。
|
||||||
|
**计算方式**:按车型维护的固定月标准成本。
|
||||||
|
**录入方式**:只读。
|
||||||
|
|
||||||
|
**业务说明**:固定成本,按车型明细维护,只读。
|
||||||
|
|
||||||
### 车辆实际成本
|
### 车辆实际成本
|
||||||
|
|
||||||
车辆标准成本 ×(实际天数 / 30),自动计算。
|
**数据来源**:车辆标准成本、账单月内租赁天数。
|
||||||
|
**计算方式**:车辆实际成本 = 标准成本 × 租赁天数 / 30(calcVehicleActualCost)。
|
||||||
|
**录入方式**:系统自动计算,只读。
|
||||||
|
|
||||||
|
**业务说明**:车辆实际成本 = 车辆标准成本 × 账单月内租赁天数 / 30,自动计算。
|
||||||
|
|
||||||
### 保险费
|
### 保险费
|
||||||
|
|
||||||
暂保留,不可编辑和导入;后续可能自动计算。
|
**数据来源**:保险采购金额、保险有效天数、账单月租赁天数。
|
||||||
|
**计算方式**:保险费 =(采购金额 / 有效天数)× 租赁天数,四舍五入两位(calcInsuranceFee)。
|
||||||
|
**录入方式**:只读。
|
||||||
|
|
||||||
|
**业务说明**:保险费 =(保险采购金额 / 保险有效天数)× 账单月内租赁天数,四舍五入保留两位小数。
|
||||||
|
|
||||||
### 氢费
|
### 氢费
|
||||||
|
|
||||||
表头提示:仅显示合同氢费承担方式为「我司承担」时,该账单期内氢费总和(含未对账/已对账)。
|
**数据来源**:氢费明细台账、合同氢费承担方式。
|
||||||
|
**计算方式**:合同为「我司承担」时,汇总账单月内该车牌氢费(sumHydrogenFeeForBill);客户承担不计入。
|
||||||
|
**录入方式**:只读。
|
||||||
|
|
||||||
### 运维费(成本侧)
|
**业务说明**:仅汇总该账单月内、氢费明细中「我司承担」的氢费(含未对账/已对账);合同为客户承担时不计入。
|
||||||
|
|
||||||
与收入侧运维费平进平出,自动同步。
|
### 运维费
|
||||||
|
|
||||||
|
**数据来源**:维修/保养台账(我司承担)、账单月内该车牌。
|
||||||
|
**计算方式**:汇总 bearBy=我司承担 的维保金额(sumMaintenanceForBill)。
|
||||||
|
**录入方式**:只读。
|
||||||
|
|
||||||
|
**业务说明**:成本侧运维费:自动汇总该账单月内、该车牌维修/保养台账中「我司承担」的金额。
|
||||||
|
|
||||||
### 居间费
|
### 居间费
|
||||||
|
|
||||||
手动输入。
|
**数据来源**:行内手工录入。
|
||||||
|
**计算方式**:直接存储 brokerageFee 字段。
|
||||||
|
**录入方式**:草稿可编辑。
|
||||||
|
|
||||||
### 其他(成本侧)
|
**业务说明**:手动输入。
|
||||||
|
|
||||||
前方其他收入有填写时,此处为必填项;与收入侧其他费用区分。
|
### 其他
|
||||||
|
|
||||||
|
**数据来源**:行内手工录入。
|
||||||
|
**计算方式**:直接存储 otherCost;前方有其他收入时本项必填。
|
||||||
|
**录入方式**:草稿可编辑。
|
||||||
|
|
||||||
|
**业务说明**:前方其他收入有填写时,此处为必填项;与收入侧其他费用区分。
|
||||||
|
|
||||||
### GPS里程
|
### GPS里程
|
||||||
|
|
||||||
从 BI 里程取账单时间段的数据,自动展示。
|
**数据来源**:BI 里程数据(bi-mileage.json / 车机 GPS)。
|
||||||
|
**计算方式**:lookupBiMileage(车牌, 年, 月) 取账单期里程。
|
||||||
|
**录入方式**:只读。
|
||||||
|
|
||||||
|
**业务说明**:从 BI 里程取账单时间段的数据,自动展示。
|
||||||
|
|
||||||
### 其他减免金额明细内容
|
### 其他减免金额明细内容
|
||||||
|
|
||||||
手动输入,备注明细内容。
|
**数据来源**:行内手工录入。
|
||||||
|
**计算方式**:存储 otherDeductionDetail 文本。
|
||||||
|
**录入方式**:草稿可编辑。
|
||||||
|
|
||||||
|
**业务说明**:手动输入,备注明细内容。
|
||||||
|
|
||||||
### 资产归属
|
### 资产归属
|
||||||
|
|
||||||
显示车辆实际归属公司名称,按车辆档案自动获取。
|
**数据来源**:车辆档案资产归属。
|
||||||
|
**计算方式**:按车牌从 PLATE_ASSET_OWNER / 车辆档案反写。
|
||||||
|
**录入方式**:只读。
|
||||||
|
|
||||||
|
**业务说明**:显示车辆实际归属公司名称,按车辆档案自动获取。
|
||||||
|
|
||||||
### 签约公司
|
### 签约公司
|
||||||
|
|
||||||
租赁合同甲方,签订合同时自动获取;用于租赁总账利润归集。
|
**数据来源**:租赁合同甲方。
|
||||||
|
**计算方式**:按车牌匹配签订合同时甲方名称(PLATE_SIGNING_COMPANY)。
|
||||||
|
**录入方式**:只读。
|
||||||
|
|
||||||
|
**业务说明**:显示租赁合同签订时甲方名称。
|
||||||
|
|
||||||
### 备注
|
### 备注
|
||||||
|
|
||||||
手动输入。
|
**数据来源**:行内手工录入。
|
||||||
|
**计算方式**:存储 remark 文本。
|
||||||
|
**录入方式**:草稿可编辑。
|
||||||
|
|
||||||
|
**业务说明**:本列为人工手动填写。
|
||||||
|
|
||||||
|
### 账单状态
|
||||||
|
|
||||||
|
**数据来源**:账单归档状态字段 archiveStatus。
|
||||||
|
**计算方式**:草稿 / 未归档 / 已归档;工具栏「保存」「设置为归档」批量变更。
|
||||||
|
**录入方式**:仅草稿可编辑业务字段。
|
||||||
|
|
||||||
|
**业务说明**:状态分为已归档、未归档、草稿。仅草稿可编辑;工具栏「保存」将勾选记录设为未归档(不可编辑);「设置为归档」将勾选记录锁定为已归档。
|
||||||
|
|
||||||
|
### 维护时间
|
||||||
|
|
||||||
|
**数据来源**:台账行维护记录。
|
||||||
|
**计算方式**:展示最近一次保存/维护时间(maintainerTime)。
|
||||||
|
**录入方式**:系统记录,只读。
|
||||||
|
|
||||||
|
**业务说明**:展示最近一次维护时间(YYYY-MM-DD HH:MM);维护人显示最后一次维护用户姓名。
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
66
src/prototypes/lease-business-ledger/columnHeaderTips.ts
Normal file
66
src/prototypes/lease-business-ledger/columnHeaderTips.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
/** 表头悬浮说明 — 与 annotation-source 中 lbl-col-* 标注对齐 */
|
||||||
|
export const COLUMN_HEADER_TIPS: Record<string, string> = {
|
||||||
|
'lbl-col-status':
|
||||||
|
'当收款记录中分配金额完全覆盖账单的应收金额时,显示已收款。当收款记录中分配金额≤账单的应收金额时,显示部分收款。未分配收款记录时,显示为未收款。其他状态去除',
|
||||||
|
'lbl-col-archive-status':
|
||||||
|
'状态分为已归档、未归档、草稿。仅草稿可编辑;工具栏「保存」将勾选记录设为未归档(不可编辑);「设置为归档」将勾选记录锁定为已归档。',
|
||||||
|
'lbl-col-payment-due':
|
||||||
|
'交车后按「开始计费日期」生成首期账单应付款日;后续每期账单在所属自然月 5 日生成下一期应付款日。',
|
||||||
|
'lbl-col-plate': '车牌与车型合并为一列:上方车牌号码,下方品牌与型号;导入/新增时按车牌在系统中自动匹配。',
|
||||||
|
'lbl-col-customer': '客户名称与项目名称合并为一列:上方客户名称,下方项目名称;按账单日期与车牌号自动匹配。',
|
||||||
|
'lbl-col-value-added-service':
|
||||||
|
'签订合同时勾选的增值服务项目,按车牌自动匹配合同数据;多项服务各占一行展示。',
|
||||||
|
'lbl-col-pickup-date': '运维实际交车日期。',
|
||||||
|
'lbl-col-contract-start-date': '等于提车日期,即租赁合同生效日期,系统自动反写,只读。',
|
||||||
|
'lbl-col-contract-end-date':
|
||||||
|
'合同到期日:租期模式按提车日期 + 租期月数计算;日期模式取合同中约定的到期日。',
|
||||||
|
'lbl-col-period-start-date':
|
||||||
|
'账单所属月内的租赁起始日:月前已提车取当月 1 日,月内提车取提车日;系统自动计算,只读。',
|
||||||
|
'lbl-col-return-date': '退车管理中登记的退车日期;未退车时为空。',
|
||||||
|
'lbl-col-avg-days':
|
||||||
|
'平均天数 = 账单月内租赁天数 ÷ 当月自然日天数,保留两位小数;用于成本分摊口径。',
|
||||||
|
'lbl-col-deposit': '显示该车辆实付保证金金额,与提车应收款时该车辆收取的提车应收款相同。',
|
||||||
|
'lbl-col-contract-rent': '等于该车辆在签订该合同时合同中的租金(月租金标准)。',
|
||||||
|
'lbl-col-receivable-total':
|
||||||
|
'应收合计 = 应收租金 + 保险上浮费 + 运维费(收入侧)+ 其他收入 − 里程减免金额 − 其他减免金额。',
|
||||||
|
'lbl-col-receivable-rent': '按合同付款周期(1/3/6/12 个月)计算的单期账单租金总金额 = 合同标的租金 × 付款周期月数。',
|
||||||
|
'lbl-col-monthly-income':
|
||||||
|
'月度收入 = 月度租金 + 维保包干收入 + 保险上浮费 + 运维费(收入侧)+ 其他收入 − 里程减免金额 − 其他减免金额。',
|
||||||
|
'lbl-col-monthly-rent': '根据客户付款周期间隔,计算每个月需收取的租金,等于合同标的租金。',
|
||||||
|
'lbl-col-maintenance-income': '合同勾选维保包干时:单价(元/km)× 里程数,必填;未勾选时默认 0 且不可编辑。',
|
||||||
|
'lbl-col-insurance-surcharge': '事故中有保险上浮费时,最新账单显示警示标志;关联事故单后自动汇总上浮费,支持多选。',
|
||||||
|
'lbl-col-ops-income':
|
||||||
|
'收入侧运维费:自动汇总该账单月内、该车牌维修/保养台账中「客户承担」的金额;最新一期账单可关联记录。',
|
||||||
|
'lbl-col-other-income': '支持多条明细录入(事由、金额、备注);合计自动计入应收与月度收入。',
|
||||||
|
'lbl-col-mileage-deduction':
|
||||||
|
'里程减免金额 = GPS 里程 × 里程减免单价;有 GPS 里程时默认单价 0.5 元/km,无 GPS 时为 0。',
|
||||||
|
'lbl-col-other-deduction': '支持多条明细录入(事由、金额、备注);合计自动从应收与月度收入中扣减。',
|
||||||
|
'lbl-col-received-amount': '从「收款记录」功能关联该账单后分配的实收金额;点击可查看收款明细列表。',
|
||||||
|
'lbl-col-unreceived': '未收金额 = 应收合计 − 实收金额,系统自动计算,不可手工录入。',
|
||||||
|
'lbl-col-invoice-date': '从「收款记录」功能关联开票记录,显示开票日期。',
|
||||||
|
'lbl-col-actual-payment': '关联收款记录时取对应到账记录的付款日期;未关联时为空。',
|
||||||
|
'lbl-col-payment-method': '由合同付款周期决定:1 个月→月度预付,3 个月→季度预付,6 个月→半年预付,12 个月→年度预付。',
|
||||||
|
'lbl-col-vehicle-standard-cost': '固定成本,按车型明细维护,只读。',
|
||||||
|
'lbl-col-vehicle-actual-cost':
|
||||||
|
'车辆实际成本 = 车辆标准成本 × 账单月内租赁天数 / 30,自动计算。',
|
||||||
|
'lbl-col-insurance-fee':
|
||||||
|
'保险费 =(保险采购金额 / 保险有效天数)× 账单月内租赁天数,四舍五入保留两位小数。',
|
||||||
|
'lbl-col-hydrogen-fee':
|
||||||
|
'仅汇总该账单月内、氢费明细中「我司承担」的氢费(含未对账/已对账);合同为客户承担时不计入。',
|
||||||
|
'lbl-col-ops-cost':
|
||||||
|
'成本侧运维费:自动汇总该账单月内、该车牌维修/保养台账中「我司承担」的金额。',
|
||||||
|
'lbl-col-brokerage-fee': '手动输入。',
|
||||||
|
'lbl-col-other-cost': '前方其他收入有填写时,此处为必填项;与收入侧其他费用区分。',
|
||||||
|
'lbl-col-gps-mileage': '从 BI 里程取账单时间段的数据,自动展示。',
|
||||||
|
'lbl-col-deduction-detail': '手动输入,备注明细内容。',
|
||||||
|
'lbl-col-asset-ownership': '显示车辆实际归属公司名称,按车辆档案自动获取。',
|
||||||
|
'lbl-col-signing-company': '显示租赁合同签订时甲方名称。',
|
||||||
|
'lbl-col-remark': '本列为人工手动填写。',
|
||||||
|
'lbl-col-maintainer-time': '展示最近一次维护时间(YYYY-MM-DD HH:MM);维护人显示最后一次维护用户姓名。',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function resolveColumnHeaderTip(annotationId?: string, headerTip?: string): string | undefined {
|
||||||
|
if (headerTip) return headerTip;
|
||||||
|
if (annotationId && COLUMN_HEADER_TIPS[annotationId]) return COLUMN_HEADER_TIPS[annotationId];
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
@@ -3,12 +3,13 @@ import { X } from 'lucide-react';
|
|||||||
|
|
||||||
interface DeleteConfirmModalProps {
|
interface DeleteConfirmModalProps {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
plateNo: string;
|
plateNo?: string;
|
||||||
|
meta?: string;
|
||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function DeleteConfirmModal({ open, plateNo, onCancel, onConfirm }: DeleteConfirmModalProps) {
|
export function DeleteConfirmModal({ open, plateNo = '', meta, onCancel, onConfirm }: DeleteConfirmModalProps) {
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -30,7 +31,7 @@ export function DeleteConfirmModal({ open, plateNo, onCancel, onConfirm }: Delet
|
|||||||
<p className="ldb-confirm-text">
|
<p className="ldb-confirm-text">
|
||||||
删除后该条台账记录将无法恢复,是否确认删除?
|
删除后该条台账记录将无法恢复,是否确认删除?
|
||||||
</p>
|
</p>
|
||||||
<p className="ldb-confirm-meta">{plateNo ? `车牌:${plateNo}` : ''}</p>
|
<p className="ldb-confirm-meta">{meta ?? (plateNo ? `车牌:${plateNo}` : '')}</p>
|
||||||
</div>
|
</div>
|
||||||
<footer className="vm-modal-footer">
|
<footer className="vm-modal-footer">
|
||||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={onCancel}>取消</button>
|
<button type="button" className="vm-btn vm-btn-ghost" onClick={onCancel}>取消</button>
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ import React, { useMemo, useState } from 'react';
|
|||||||
import { ChevronDown, Search } from 'lucide-react';
|
import { ChevronDown, Search } from 'lucide-react';
|
||||||
import type { LeaseLedgerFilters, LeaseLedgerRow } from '../types';
|
import type { LeaseLedgerFilters, LeaseLedgerRow } from '../types';
|
||||||
import { FilterPickerField } from '../../vehicle-management/components/FilterPickerField';
|
import { FilterPickerField } from '../../vehicle-management/components/FilterPickerField';
|
||||||
|
import { DateRangeFilterField } from '../../vehicle-management/components/DateRangeFilterField';
|
||||||
import { buildLeaseOptions } from '../utils/ledger';
|
import { buildLeaseOptions } from '../utils/ledger';
|
||||||
|
|
||||||
const STATUS_OPTIONS = ['草稿', '已确认', '部分收款', '已收款'];
|
const STATUS_OPTIONS = ['未收款', '部分收款', '已收款'];
|
||||||
const DEFAULT_VISIBLE = 4;
|
const DEFAULT_VISIBLE = 4;
|
||||||
|
|
||||||
interface FilterPanelProps {
|
interface FilterPanelProps {
|
||||||
@@ -32,12 +33,15 @@ export function FilterPanel({ records, filters, onChange, onReset, onSearch }: F
|
|||||||
{ label: '车牌号码', control: <FilterPickerField value={filters.plateNo} options={plateOptions} placeholder="全部车牌" ariaLabel="车牌号码" onChange={(plateNo) => onChange({ plateNo })} /> },
|
{ label: '车牌号码', control: <FilterPickerField value={filters.plateNo} options={plateOptions} placeholder="全部车牌" ariaLabel="车牌号码" onChange={(plateNo) => onChange({ plateNo })} /> },
|
||||||
{ label: '客户名称', control: <input className="vm-input" value={filters.customerName} placeholder="输入客户名称" aria-label="客户名称" onChange={(e) => onChange({ customerName: e.target.value })} /> },
|
{ label: '客户名称', control: <input className="vm-input" value={filters.customerName} placeholder="输入客户名称" aria-label="客户名称" onChange={(e) => onChange({ customerName: e.target.value })} /> },
|
||||||
{ label: '明细状态', control: <FilterPickerField value={filters.status} options={STATUS_OPTIONS} placeholder="全部状态" ariaLabel="明细状态" onChange={(status) => onChange({ status })} /> },
|
{ label: '明细状态', control: <FilterPickerField value={filters.status} options={STATUS_OPTIONS} placeholder="全部状态" ariaLabel="明细状态" onChange={(status) => onChange({ status })} /> },
|
||||||
{ label: '付款日期', control: (
|
{ label: '应付款日', control: (
|
||||||
<div className="ldb-date-range">
|
<DateRangeFilterField
|
||||||
<input type="date" className="vm-input" value={filters.paymentFrom} aria-label="付款开始日期" onChange={(e) => onChange({ paymentFrom: e.target.value })} />
|
startDate={filters.paymentFrom}
|
||||||
<span className="ldb-date-range-sep" aria-hidden>至</span>
|
endDate={filters.paymentTo}
|
||||||
<input type="date" className="vm-input" value={filters.paymentTo} aria-label="付款结束日期" onChange={(e) => onChange({ paymentTo: e.target.value })} />
|
ariaLabel="应付款日"
|
||||||
</div>
|
startPlaceholder="开始日期"
|
||||||
|
endPlaceholder="结束日期"
|
||||||
|
onChange={({ startDate, endDate }) => onChange({ paymentFrom: startDate, paymentTo: endDate })}
|
||||||
|
/>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -46,7 +50,7 @@ export function FilterPanel({ records, filters, onChange, onReset, onSearch }: F
|
|||||||
const extraFields = fields.slice(DEFAULT_VISIBLE);
|
const extraFields = fields.slice(DEFAULT_VISIBLE);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="vm-filter-card ldb-filter-card" data-annotation-id="lbl-filter">
|
<section className="vm-filter-card ldb-filter-card" data-annotation-id="lbl-filter" aria-label="筛选条件">
|
||||||
<header className="vm-filter-header">
|
<header className="vm-filter-header">
|
||||||
<h1 className="ldb-page-title">租赁业务台账</h1>
|
<h1 className="ldb-page-title">租赁业务台账</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,34 +1,41 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { ArrowDown, ArrowUp, ArrowUpDown, AlertTriangle, CircleHelp } from 'lucide-react';
|
import { createPortal } from 'react-dom';
|
||||||
import type { AccidentRecord, LeaseLedgerRow, MaintenanceOrder } from '../types';
|
import { ArrowDown, ArrowUp, ArrowUpDown, CircleHelp, History } from 'lucide-react';
|
||||||
|
import type { HydrogenFeeRecord, LeaseLedgerRow, MaintenanceOrder, ReceiptRecord } from '../types';
|
||||||
import {
|
import {
|
||||||
calcMonthlyIncome,
|
calcMonthlyIncome,
|
||||||
calcUnreceived,
|
calcUnreceived,
|
||||||
canStaffDeleteRow,
|
|
||||||
canStaffEditRow,
|
canStaffEditRow,
|
||||||
displayMoney,
|
displayMoney,
|
||||||
displayText,
|
displayText,
|
||||||
|
formatBillingYearMonth,
|
||||||
formatMoney,
|
formatMoney,
|
||||||
|
getHydrogenFeesForBill,
|
||||||
|
getLinkedReceiptsForRow,
|
||||||
|
getMaintenanceOrdersForBill,
|
||||||
isBlankDisplayValue,
|
isBlankDisplayValue,
|
||||||
needsAccidentLinkWarning,
|
|
||||||
needsMaintenanceLinkWarning,
|
|
||||||
} from '../utils/ledger';
|
} from '../utils/ledger';
|
||||||
import { LEASE_TABLE_COLUMNS, STATUS_CLASS, type LeaseTableColumn } from './tableColumns';
|
import { LEASE_TABLE_COLUMNS, STATUS_CLASS, ARCHIVE_STATUS_CLASS, type LeaseTableColumn } from './tableColumns';
|
||||||
|
import { resolveColumnHeaderTip } from '../columnHeaderTips';
|
||||||
|
import { LedgerDetailPopover, LedgerDetailTable } from './LedgerDetailPopover';
|
||||||
|
import { LedgerLineItemsCell } from './LedgerLineItemsCell';
|
||||||
|
import { OperationHistoryModal } from './OperationHistoryModal';
|
||||||
|
|
||||||
interface LeaseLedgerTableProps {
|
interface LeaseLedgerTableProps {
|
||||||
records: LeaseLedgerRow[];
|
records: LeaseLedgerRow[];
|
||||||
selectedIds: Set<string>;
|
selectedIds: Set<string>;
|
||||||
loading?: boolean;
|
loading?: boolean;
|
||||||
maintenanceOrders: MaintenanceOrder[];
|
maintenanceOrders: MaintenanceOrder[];
|
||||||
accidentRecords: AccidentRecord[];
|
receipts: ReceiptRecord[];
|
||||||
|
hydrogenFees: HydrogenFeeRecord[];
|
||||||
onToggleRow: (id: string, checked: boolean) => void;
|
onToggleRow: (id: string, checked: boolean) => void;
|
||||||
onToggleAll: (checked: boolean) => void;
|
onToggleAll: (checked: boolean) => void;
|
||||||
onEdit: (record: LeaseLedgerRow) => void;
|
onEdit: (record: LeaseLedgerRow) => void;
|
||||||
onDelete: (record: LeaseLedgerRow) => void;
|
onSave: (record: LeaseLedgerRow) => void;
|
||||||
|
editingRowId: string | null;
|
||||||
onLinkReceipt: (record: LeaseLedgerRow) => void;
|
onLinkReceipt: (record: LeaseLedgerRow) => void;
|
||||||
onLinkMaintenance: (record: LeaseLedgerRow) => void;
|
onOtherIncomeItemsChange: (recordId: string, items: LeaseLedgerRow['otherIncomeItems']) => void;
|
||||||
onLinkAccident: (record: LeaseLedgerRow) => void;
|
onOtherDeductionItemsChange: (recordId: string, items: LeaseLedgerRow['otherDeductionItems']) => void;
|
||||||
onOtherIncomeChange: (recordId: string, value: number) => void;
|
|
||||||
onOtherCostChange: (recordId: string, value: number) => void;
|
onOtherCostChange: (recordId: string, value: number) => void;
|
||||||
onBrokerageFeeChange: (recordId: string, value: number) => void;
|
onBrokerageFeeChange: (recordId: string, value: number) => void;
|
||||||
onRemarkChange: (recordId: string, value: string) => void;
|
onRemarkChange: (recordId: string, value: string) => void;
|
||||||
@@ -38,16 +45,16 @@ interface LeaseLedgerTableProps {
|
|||||||
type SortDir = 'asc' | 'desc';
|
type SortDir = 'asc' | 'desc';
|
||||||
|
|
||||||
const CHECK_COL_WIDTH = 48;
|
const CHECK_COL_WIDTH = 48;
|
||||||
const ACTION_COL_WIDTH = 168;
|
const ACTION_COL_WIDTH = 208;
|
||||||
|
|
||||||
const DEFAULT_COLUMN_WIDTHS: Record<string, number> = {
|
const DEFAULT_COLUMN_WIDTHS: Record<string, number> = {
|
||||||
_index: 56,
|
|
||||||
status: 88,
|
status: 88,
|
||||||
|
billingYearMonth: 88,
|
||||||
maintainerName: 88,
|
maintainerName: 88,
|
||||||
year: 72,
|
archiveStatus: 88,
|
||||||
month: 64,
|
maintainerTime: 140,
|
||||||
dept: 120,
|
dept: 120,
|
||||||
paymentDate: 108,
|
paymentDueDate: 108,
|
||||||
plateNo: 120,
|
plateNo: 120,
|
||||||
customerName: 160,
|
customerName: 160,
|
||||||
valueAddedService: 96,
|
valueAddedService: 96,
|
||||||
@@ -91,11 +98,70 @@ const DEFAULT_COLUMN_WIDTHS: Record<string, number> = {
|
|||||||
remark: 140,
|
remark: 140,
|
||||||
};
|
};
|
||||||
|
|
||||||
function IncomeWarning({ title, onClick }: { title: string; onClick: () => void }) {
|
const HEADER_TIP_MAX_WIDTH = 280;
|
||||||
|
const HEADER_TIP_VIEWPORT_MARGIN = 12;
|
||||||
|
|
||||||
|
function TableHeaderTip({ tip, title }: { tip: string; title: string }) {
|
||||||
|
const anchorRef = useRef<HTMLSpanElement>(null);
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [style, setStyle] = useState<React.CSSProperties>({});
|
||||||
|
|
||||||
|
const reposition = useCallback(() => {
|
||||||
|
const el = anchorRef.current;
|
||||||
|
if (!el) return;
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
const maxWidth = Math.min(HEADER_TIP_MAX_WIDTH, window.innerWidth - HEADER_TIP_VIEWPORT_MARGIN * 2);
|
||||||
|
let left = rect.left;
|
||||||
|
left = Math.max(
|
||||||
|
HEADER_TIP_VIEWPORT_MARGIN,
|
||||||
|
Math.min(left, window.innerWidth - HEADER_TIP_VIEWPORT_MARGIN - maxWidth),
|
||||||
|
);
|
||||||
|
setStyle({
|
||||||
|
top: rect.bottom + 6,
|
||||||
|
left,
|
||||||
|
maxWidth,
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!visible) return undefined;
|
||||||
|
reposition();
|
||||||
|
window.addEventListener('scroll', reposition, true);
|
||||||
|
window.addEventListener('resize', reposition);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', reposition, true);
|
||||||
|
window.removeEventListener('resize', reposition);
|
||||||
|
};
|
||||||
|
}, [visible, reposition]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button type="button" className="ldb-income-warn" title={title} aria-label={title} onClick={onClick}>
|
<>
|
||||||
<AlertTriangle size={14} aria-hidden />
|
<span
|
||||||
</button>
|
ref={anchorRef}
|
||||||
|
className="ldb-th-tip"
|
||||||
|
tabIndex={0}
|
||||||
|
aria-label={`${title}说明`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
onMouseEnter={() => {
|
||||||
|
reposition();
|
||||||
|
setVisible(true);
|
||||||
|
}}
|
||||||
|
onMouseLeave={() => setVisible(false)}
|
||||||
|
onFocus={() => {
|
||||||
|
reposition();
|
||||||
|
setVisible(true);
|
||||||
|
}}
|
||||||
|
onBlur={() => setVisible(false)}
|
||||||
|
>
|
||||||
|
<CircleHelp size={14} aria-hidden />
|
||||||
|
</span>
|
||||||
|
{visible && createPortal(
|
||||||
|
<span className="ldb-kpi-tooltip ldb-kpi-tooltip--portal" role="tooltip" style={style}>
|
||||||
|
{tip}
|
||||||
|
</span>,
|
||||||
|
document.body,
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,12 +183,7 @@ function HeaderWithTip({
|
|||||||
const label = (
|
const label = (
|
||||||
<span className="ldb-th-label">
|
<span className="ldb-th-label">
|
||||||
{title}
|
{title}
|
||||||
{tip && (
|
{tip && <TableHeaderTip tip={tip} title={title} />}
|
||||||
<span className="ldb-th-tip" tabIndex={0} aria-label={`${title}说明`} onClick={(e) => e.stopPropagation()}>
|
|
||||||
<CircleHelp size={14} aria-hidden />
|
|
||||||
<span className="ldb-kpi-tooltip" role="tooltip">{tip}</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -205,6 +266,8 @@ function getSortValue(row: LeaseLedgerRow, col: LeaseTableColumn): string | numb
|
|||||||
return row.customerName;
|
return row.customerName;
|
||||||
case 'status':
|
case 'status':
|
||||||
return row.status;
|
return row.status;
|
||||||
|
case 'year-month':
|
||||||
|
return row.year * 100 + row.month;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -222,11 +285,12 @@ function renderCell(
|
|||||||
index: number,
|
index: number,
|
||||||
ctx: {
|
ctx: {
|
||||||
maintenanceOrders: MaintenanceOrder[];
|
maintenanceOrders: MaintenanceOrder[];
|
||||||
accidentRecords: AccidentRecord[];
|
receipts: ReceiptRecord[];
|
||||||
|
hydrogenFees: HydrogenFeeRecord[];
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
onLinkMaintenance: (record: LeaseLedgerRow) => void;
|
onLinkReceipt: (record: LeaseLedgerRow) => void;
|
||||||
onLinkAccident: (record: LeaseLedgerRow) => void;
|
onOtherIncomeItemsChange: (recordId: string, items: LeaseLedgerRow['otherIncomeItems']) => void;
|
||||||
onOtherIncomeChange: (recordId: string, value: number) => void;
|
onOtherDeductionItemsChange: (recordId: string, items: LeaseLedgerRow['otherDeductionItems']) => void;
|
||||||
onOtherCostChange: (recordId: string, value: number) => void;
|
onOtherCostChange: (recordId: string, value: number) => void;
|
||||||
onBrokerageFeeChange: (recordId: string, value: number) => void;
|
onBrokerageFeeChange: (recordId: string, value: number) => void;
|
||||||
onRemarkChange: (recordId: string, value: string) => void;
|
onRemarkChange: (recordId: string, value: string) => void;
|
||||||
@@ -236,6 +300,22 @@ function renderCell(
|
|||||||
switch (col.render) {
|
switch (col.render) {
|
||||||
case 'status':
|
case 'status':
|
||||||
return <span className={`ldb-status-tag ${STATUS_CLASS[row.status]}`}>{row.status}</span>;
|
return <span className={`ldb-status-tag ${STATUS_CLASS[row.status]}`}>{row.status}</span>;
|
||||||
|
case 'archive-status':
|
||||||
|
return (
|
||||||
|
<span className={`ldb-status-tag ${ARCHIVE_STATUS_CLASS[row.archiveStatus]}`}>
|
||||||
|
{row.archiveStatus}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
case 'year-month':
|
||||||
|
return (
|
||||||
|
<span className="tabular-nums">{formatBillingYearMonth(row.year, row.month)}</span>
|
||||||
|
);
|
||||||
|
case 'value-added-service':
|
||||||
|
return (
|
||||||
|
<span className="ldb-cell-multiline" title={row.valueAddedService}>
|
||||||
|
{displayText(row.valueAddedService)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
case 'stack-dept':
|
case 'stack-dept':
|
||||||
return (
|
return (
|
||||||
<div className="ldb-cell-stack">
|
<div className="ldb-cell-stack">
|
||||||
@@ -265,10 +345,10 @@ function renderCell(
|
|||||||
case 'asset-owner':
|
case 'asset-owner':
|
||||||
return <span title={row.assetOwner}>{displayText(row.assetOwner)}</span>;
|
return <span title={row.assetOwner}>{displayText(row.assetOwner)}</span>;
|
||||||
case 'signing-company':
|
case 'signing-company':
|
||||||
return <span className="ldb-cell-readonly" title="租赁合同甲方自动获取">{displayText(row.signingCompany)}</span>;
|
return <span className="ldb-cell-readonly" title="租赁合同签订时甲方名称">{displayText(row.signingCompany)}</span>;
|
||||||
case 'monthly-income':
|
case 'monthly-income':
|
||||||
return (
|
return (
|
||||||
<span className="ldb-computed-val" title="租金 + 服务费 − 里程减免 − 其他减免">
|
<span className="ldb-computed-val" title="月度租金+里程维保收入+保险上浮费+运维费+其他收入−里程减免−其他减免">
|
||||||
{formatMoney(calcMonthlyIncome(row))}
|
{formatMoney(calcMonthlyIncome(row))}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
@@ -285,42 +365,84 @@ function renderCell(
|
|||||||
{formatMoney(row.maintenanceIncome)}
|
{formatMoney(row.maintenanceIncome)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case 'insurance-surcharge': {
|
case 'insurance-surcharge':
|
||||||
const warn = needsAccidentLinkWarning(row, ctx.accidentRecords);
|
|
||||||
return (
|
return (
|
||||||
<div className="ldb-income-cell">
|
<span className="ldb-computed-val">{formatMoney(row.insuranceSurcharge)}</span>
|
||||||
{warn && (
|
);
|
||||||
<IncomeWarning title="有未关联的事故单据,请点击关联" onClick={() => ctx.onLinkAccident(row)} />
|
case 'detail-popover-maintenance-income': {
|
||||||
)}
|
const orders = getMaintenanceOrdersForBill(row, ctx.maintenanceOrders, '客户承担');
|
||||||
<span className={warn ? 'ldb-income-pending' : ''}>{formatMoney(row.insuranceSurcharge)}</span>
|
const tableRows = orders.map((item) => ({
|
||||||
</div>
|
date: item.completedAt.split(' ')[0],
|
||||||
|
plateNo: item.plateNo,
|
||||||
|
type: item.type,
|
||||||
|
amount: formatMoney(item.amount),
|
||||||
|
}));
|
||||||
|
return (
|
||||||
|
<LedgerDetailPopover
|
||||||
|
label={formatMoney(row.opsFeeIncome)}
|
||||||
|
title="客户承担维修保养明细"
|
||||||
|
triggerClassName="tabular-nums"
|
||||||
|
>
|
||||||
|
<LedgerDetailTable
|
||||||
|
columns={[
|
||||||
|
{ key: 'date', title: '日期' },
|
||||||
|
{ key: 'plateNo', title: '车牌号' },
|
||||||
|
{ key: 'type', title: '类型' },
|
||||||
|
{ key: 'amount', title: '费用总计', align: 'right' },
|
||||||
|
]}
|
||||||
|
rows={tableRows}
|
||||||
|
/>
|
||||||
|
</LedgerDetailPopover>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case 'ops-fee-income': {
|
case 'detail-popover-maintenance-cost': {
|
||||||
const warn = needsMaintenanceLinkWarning(row, ctx.maintenanceOrders);
|
const orders = getMaintenanceOrdersForBill(row, ctx.maintenanceOrders, '我司承担');
|
||||||
|
const tableRows = orders.map((item) => ({
|
||||||
|
date: item.completedAt.split(' ')[0],
|
||||||
|
plateNo: item.plateNo,
|
||||||
|
type: item.type,
|
||||||
|
amount: formatMoney(item.amount),
|
||||||
|
}));
|
||||||
return (
|
return (
|
||||||
<div className="ldb-income-cell">
|
<LedgerDetailPopover
|
||||||
{warn && (
|
label={formatMoney(row.opsFeeCost)}
|
||||||
<IncomeWarning title="维修/保养为客户承担,请关联维修保养单" onClick={() => ctx.onLinkMaintenance(row)} />
|
title="我司承担维修保养明细"
|
||||||
)}
|
triggerClassName="tabular-nums"
|
||||||
<span className={warn ? 'ldb-income-pending' : ''}>{formatMoney(row.opsFeeIncome)}</span>
|
>
|
||||||
</div>
|
<LedgerDetailTable
|
||||||
|
columns={[
|
||||||
|
{ key: 'date', title: '日期' },
|
||||||
|
{ key: 'plateNo', title: '车牌号' },
|
||||||
|
{ key: 'type', title: '类型' },
|
||||||
|
{ key: 'amount', title: '费用总计', align: 'right' },
|
||||||
|
]}
|
||||||
|
rows={tableRows}
|
||||||
|
/>
|
||||||
|
</LedgerDetailPopover>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
case 'ops-fee-cost':
|
case 'line-items-income':
|
||||||
return (
|
return (
|
||||||
<span className="ldb-computed-val" title="与收入侧运维费平进平出">
|
<LedgerLineItemsCell
|
||||||
{formatMoney(row.opsFeeCost)}
|
items={row.otherIncomeItems ?? []}
|
||||||
</span>
|
total={row.otherIncome}
|
||||||
);
|
canEdit={ctx.canEdit}
|
||||||
case 'other-income':
|
reasonLabel="收入来源"
|
||||||
if (!ctx.canEdit) return displayMoney(row.otherIncome, true);
|
title="其他收入明细"
|
||||||
return (
|
|
||||||
<MoneyInput
|
|
||||||
value={row.otherIncome}
|
|
||||||
placeholder="手工填写"
|
|
||||||
ariaLabel={`${row.plateNo} 其他收入`}
|
ariaLabel={`${row.plateNo} 其他收入`}
|
||||||
onChange={(v) => ctx.onOtherIncomeChange(row.id, v)}
|
onChange={(items) => ctx.onOtherIncomeItemsChange(row.id, items)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
case 'line-items-deduction':
|
||||||
|
return (
|
||||||
|
<LedgerLineItemsCell
|
||||||
|
items={row.otherDeductionItems ?? []}
|
||||||
|
total={row.otherDeduction}
|
||||||
|
canEdit={ctx.canEdit}
|
||||||
|
reasonLabel="减免原因"
|
||||||
|
title="其他减免金额明细"
|
||||||
|
ariaLabel={`${row.plateNo} 其他减免`}
|
||||||
|
onChange={(items) => ctx.onOtherDeductionItemsChange(row.id, items)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 'other-cost':
|
case 'other-cost':
|
||||||
@@ -344,15 +466,38 @@ function renderCell(
|
|||||||
onChange={(v) => ctx.onBrokerageFeeChange(row.id, v)}
|
onChange={(v) => ctx.onBrokerageFeeChange(row.id, v)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
case 'received-amount':
|
case 'received-amount': {
|
||||||
|
const linked = getLinkedReceiptsForRow(row, ctx.receipts);
|
||||||
|
const showReceiptLink = row.status !== '已收款';
|
||||||
|
const tableRows = linked.map((item) => ({
|
||||||
|
time: item.arrivalTime,
|
||||||
|
customer: item.customerName,
|
||||||
|
amount: formatMoney(item.amount),
|
||||||
|
remark: item.remark ?? '',
|
||||||
|
}));
|
||||||
return (
|
return (
|
||||||
<span title={row.linkedReceiptIds?.length ? `已关联 ${row.linkedReceiptIds.length} 条收款明细` : '关联收款记录后自动归集'}>
|
<LedgerDetailPopover
|
||||||
{formatMoney(row.receivedAmount)}
|
label={formatMoney(row.receivedAmount)}
|
||||||
{row.linkedReceiptIds?.length > 0 && (
|
title="收款明细"
|
||||||
<span className="ldb-linked-badge">{row.linkedReceiptIds.length}</span>
|
footer={showReceiptLink ? (
|
||||||
)}
|
<button type="button" className="vm-btn vm-btn-secondary" onClick={() => ctx.onLinkReceipt(row)}>
|
||||||
</span>
|
关联收款
|
||||||
|
</button>
|
||||||
|
) : undefined}
|
||||||
|
>
|
||||||
|
<LedgerDetailTable
|
||||||
|
columns={[
|
||||||
|
{ key: 'time', title: '收款时间' },
|
||||||
|
{ key: 'customer', title: '客户名称' },
|
||||||
|
{ key: 'amount', title: '收款金额', align: 'right' },
|
||||||
|
{ key: 'remark', title: '备注' },
|
||||||
|
]}
|
||||||
|
rows={tableRows}
|
||||||
|
emptyText="尚未关联收款记录"
|
||||||
|
/>
|
||||||
|
</LedgerDetailPopover>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
case 'invoice-date':
|
case 'invoice-date':
|
||||||
return <span className="ldb-cell-readonly tabular-nums">{displayText(row.invoiceDate)}</span>;
|
return <span className="ldb-cell-readonly tabular-nums">{displayText(row.invoiceDate)}</span>;
|
||||||
case 'vehicle-standard-cost':
|
case 'vehicle-standard-cost':
|
||||||
@@ -363,25 +508,48 @@ function renderCell(
|
|||||||
);
|
);
|
||||||
case 'vehicle-actual-cost':
|
case 'vehicle-actual-cost':
|
||||||
return (
|
return (
|
||||||
<span className="ldb-computed-val" title={`标准成本 × (${row.avgDays}/30)`}>
|
<span className="ldb-computed-val" title={`车辆标准成本 × 实际天数(${row.actualLeaseDays ?? 0} 天)`}>
|
||||||
{formatMoney(row.vehicleActualCost)}
|
{formatMoney(row.vehicleActualCost)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case 'insurance-readonly':
|
case 'insurance-readonly':
|
||||||
return (
|
return (
|
||||||
<span className="ldb-cell-readonly" title="暂不可编辑与导入,后续可能自动计算">
|
<span className="ldb-computed-val" title="保险采购金额/生效天数 × 当月实际使用天数">
|
||||||
{displayMoney(row.insuranceFee, true)}
|
{displayMoney(row.insuranceFee, true)}
|
||||||
</span>
|
</span>
|
||||||
);
|
);
|
||||||
case 'hydrogen-fee':
|
case 'detail-popover-hydrogen': {
|
||||||
|
const records = getHydrogenFeesForBill(row, ctx.hydrogenFees);
|
||||||
|
const tableRows = records.map((item) => ({
|
||||||
|
refuelDate: item.refuelDate ?? `${item.year}-${String(item.month).padStart(2, '0')}-01`,
|
||||||
|
plateNo: item.plateNo,
|
||||||
|
stationName: item.stationName ?? '—',
|
||||||
|
volume: item.volume != null ? String(item.volume) : '—',
|
||||||
|
unitCost: item.unitCost != null ? formatMoney(item.unitCost) : '—',
|
||||||
|
totalCost: formatMoney(item.totalCost ?? item.amount),
|
||||||
|
borneBy: item.borneBy ?? '我司承担',
|
||||||
|
}));
|
||||||
return (
|
return (
|
||||||
<span
|
<LedgerDetailPopover
|
||||||
className={row.hydrogenBorneBy === '我司承担' ? 'ldb-computed-val' : 'ldb-cell-readonly'}
|
label={displayMoney(row.hydrogenFee, true)}
|
||||||
title={row.hydrogenBorneBy === '我司承担' ? '我司承担氢费账单期汇总' : '客户承担或无氢费'}
|
title="车辆氢费明细"
|
||||||
|
triggerClassName="tabular-nums"
|
||||||
>
|
>
|
||||||
{displayMoney(row.hydrogenFee, true)}
|
<LedgerDetailTable
|
||||||
</span>
|
columns={[
|
||||||
|
{ key: 'refuelDate', title: '加氢日期' },
|
||||||
|
{ key: 'plateNo', title: '车牌号' },
|
||||||
|
{ key: 'stationName', title: '加氢站名称' },
|
||||||
|
{ key: 'volume', title: '加氢量', align: 'right' },
|
||||||
|
{ key: 'unitCost', title: '成本单价', align: 'right' },
|
||||||
|
{ key: 'totalCost', title: '成本总价', align: 'right' },
|
||||||
|
{ key: 'borneBy', title: '承担方式' },
|
||||||
|
]}
|
||||||
|
rows={tableRows}
|
||||||
|
/>
|
||||||
|
</LedgerDetailPopover>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
case 'gps-mileage':
|
case 'gps-mileage':
|
||||||
return (
|
return (
|
||||||
<span className="ldb-computed-val tabular-nums" title="BI 里程 · 账单时间段">
|
<span className="ldb-computed-val tabular-nums" title="BI 里程 · 账单时间段">
|
||||||
@@ -408,11 +576,33 @@ function renderCell(
|
|||||||
onChange={(v) => ctx.onRemarkChange(row.id, v)}
|
onChange={(v) => ctx.onRemarkChange(row.id, v)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
case 'maintainer-time':
|
||||||
|
return <span className="tabular-nums">{displayText(row.maintainerTime)}</span>;
|
||||||
default:
|
default:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (col.key === 'unreceived') return formatMoney(calcUnreceived(row));
|
if (col.key === 'unreceived') {
|
||||||
|
return (
|
||||||
|
<span className="ldb-col-unreceived-warn">{formatMoney(calcUnreceived(row))}</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (col.key === 'avgDays') {
|
||||||
|
return (
|
||||||
|
<span className="tabular-nums" title="本月在租天数/当月天数">
|
||||||
|
{row.avgDays.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (col.key === 'mileageDeduction') {
|
||||||
|
return (
|
||||||
|
<span className="ldb-computed-val" title="根据 GPS 里程自动计算">
|
||||||
|
{formatMoney(row.mileageDeduction)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (col.key === 'profitLoss') {
|
if (col.key === 'profitLoss') {
|
||||||
const val = row.profitLoss;
|
const val = row.profitLoss;
|
||||||
@@ -423,7 +613,7 @@ function renderCell(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const raw = col.key === '_index' ? index + 1 : row[col.key as keyof LeaseLedgerRow];
|
const raw = row[col.key as keyof LeaseLedgerRow];
|
||||||
if (col.money && typeof raw === 'number') return formatMoney(raw);
|
if (col.money && typeof raw === 'number') return formatMoney(raw);
|
||||||
if (isBlankDisplayValue(raw)) return '';
|
if (isBlankDisplayValue(raw)) return '';
|
||||||
return String(raw);
|
return String(raw);
|
||||||
@@ -444,15 +634,16 @@ export function LeaseLedgerTable({
|
|||||||
selectedIds,
|
selectedIds,
|
||||||
loading = false,
|
loading = false,
|
||||||
maintenanceOrders,
|
maintenanceOrders,
|
||||||
accidentRecords,
|
receipts,
|
||||||
|
hydrogenFees,
|
||||||
onToggleRow,
|
onToggleRow,
|
||||||
onToggleAll,
|
onToggleAll,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onSave,
|
||||||
|
editingRowId,
|
||||||
onLinkReceipt,
|
onLinkReceipt,
|
||||||
onLinkMaintenance,
|
onOtherIncomeItemsChange,
|
||||||
onLinkAccident,
|
onOtherDeductionItemsChange,
|
||||||
onOtherIncomeChange,
|
|
||||||
onOtherCostChange,
|
onOtherCostChange,
|
||||||
onBrokerageFeeChange,
|
onBrokerageFeeChange,
|
||||||
onRemarkChange,
|
onRemarkChange,
|
||||||
@@ -460,6 +651,7 @@ export function LeaseLedgerTable({
|
|||||||
}: LeaseLedgerTableProps) {
|
}: LeaseLedgerTableProps) {
|
||||||
const [sortKey, setSortKey] = useState<string | null>(null);
|
const [sortKey, setSortKey] = useState<string | null>(null);
|
||||||
const [sortDir, setSortDir] = useState<SortDir>('asc');
|
const [sortDir, setSortDir] = useState<SortDir>('asc');
|
||||||
|
const [historyRow, setHistoryRow] = useState<LeaseLedgerRow | null>(null);
|
||||||
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(() => ({ ...DEFAULT_COLUMN_WIDTHS }));
|
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(() => ({ ...DEFAULT_COLUMN_WIDTHS }));
|
||||||
const resizeRef = useRef<{ key: string; startX: number; startWidth: number } | null>(null);
|
const resizeRef = useRef<{ key: string; startX: number; startWidth: number } | null>(null);
|
||||||
|
|
||||||
@@ -546,11 +738,12 @@ export function LeaseLedgerTable({
|
|||||||
</colgroup>
|
</colgroup>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="ldb-col-check sticky-col-left">
|
<th className="ldb-col-check sticky-col-left" data-annotation-id="lbl-col-check">
|
||||||
<input type="checkbox" checked={allSelected} onChange={(e) => onToggleAll(e.target.checked)} aria-label="全选" />
|
<input type="checkbox" checked={allSelected} onChange={(e) => onToggleAll(e.target.checked)} aria-label="全选" />
|
||||||
</th>
|
</th>
|
||||||
{LEASE_TABLE_COLUMNS.map((col) => {
|
{LEASE_TABLE_COLUMNS.map((col) => {
|
||||||
const sortable = col.sortable !== false && col.key !== '_index';
|
const sortable = col.sortable !== false;
|
||||||
|
const headerTip = resolveColumnHeaderTip(col.annotationId, col.headerTip);
|
||||||
return (
|
return (
|
||||||
<th
|
<th
|
||||||
key={col.key}
|
key={col.key}
|
||||||
@@ -561,18 +754,11 @@ export function LeaseLedgerTable({
|
|||||||
'ldb-col-resizable',
|
'ldb-col-resizable',
|
||||||
].filter(Boolean).join(' ')}
|
].filter(Boolean).join(' ')}
|
||||||
data-annotation-id={col.annotationId}
|
data-annotation-id={col.annotationId}
|
||||||
title={
|
|
||||||
col.key === 'opsFeeIncome'
|
|
||||||
? '收入侧运维费'
|
|
||||||
: col.key === 'opsFeeCost'
|
|
||||||
? '成本侧运维费,与收入侧平进平出'
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<div className="ldb-th-inner">
|
<div className="ldb-th-inner">
|
||||||
<HeaderWithTip
|
<HeaderWithTip
|
||||||
title={col.title}
|
title={col.title}
|
||||||
tip={col.headerTip}
|
tip={headerTip}
|
||||||
sortable={sortable}
|
sortable={sortable}
|
||||||
sortActive={sortKey === col.key}
|
sortActive={sortKey === col.key}
|
||||||
sortDir={sortDir}
|
sortDir={sortDir}
|
||||||
@@ -589,22 +775,20 @@ export function LeaseLedgerTable({
|
|||||||
</th>
|
</th>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
<th className="sticky-col-right ldb-col-actions">操作</th>
|
<th className="sticky-col-right ldb-col-actions" data-annotation-id="lbl-col-actions">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{sortedRecords.map((row, index) => {
|
{sortedRecords.map((row, index) => {
|
||||||
const canEdit = canStaffEditRow(row);
|
const canEdit = canStaffEditRow(row);
|
||||||
const canDelete = canStaffDeleteRow(row);
|
|
||||||
const isDraft = row.status === '草稿';
|
|
||||||
const showReceiptLink = row.status === '已确认' || row.status === '部分收款';
|
|
||||||
const cellCtx = {
|
const cellCtx = {
|
||||||
maintenanceOrders,
|
maintenanceOrders,
|
||||||
accidentRecords,
|
receipts,
|
||||||
|
hydrogenFees,
|
||||||
canEdit,
|
canEdit,
|
||||||
onLinkMaintenance,
|
onLinkReceipt,
|
||||||
onLinkAccident,
|
onOtherIncomeItemsChange,
|
||||||
onOtherIncomeChange,
|
onOtherDeductionItemsChange,
|
||||||
onOtherCostChange,
|
onOtherCostChange,
|
||||||
onBrokerageFeeChange,
|
onBrokerageFeeChange,
|
||||||
onRemarkChange,
|
onRemarkChange,
|
||||||
@@ -635,19 +819,22 @@ export function LeaseLedgerTable({
|
|||||||
))}
|
))}
|
||||||
<td className="sticky-col-right ldb-col-actions">
|
<td className="sticky-col-right ldb-col-actions">
|
||||||
<div className="ldb-row-actions">
|
<div className="ldb-row-actions">
|
||||||
{showReceiptLink && (
|
<button
|
||||||
<button type="button" className="ldb-action-btn ldb-action-btn--link" onClick={() => onLinkReceipt(row)}>
|
type="button"
|
||||||
关联收款
|
className="ldb-action-btn ldb-action-btn--history ldb-cell-clickable"
|
||||||
</button>
|
onClick={() => setHistoryRow(row)}
|
||||||
)}
|
>
|
||||||
|
<History size={14} aria-hidden />
|
||||||
|
操作记录
|
||||||
|
</button>
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<button type="button" className="ldb-action-btn ldb-action-btn--edit" onClick={() => onEdit(row)}>
|
<button type="button" className="ldb-action-btn ldb-action-btn--edit" onClick={() => onEdit(row)}>
|
||||||
编辑
|
编辑
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
{isDraft && canDelete && (
|
{editingRowId === row.id && canEdit && (
|
||||||
<button type="button" className="ldb-action-btn ldb-action-btn--delete" onClick={() => onDelete(row)}>
|
<button type="button" className="ldb-action-btn ldb-action-btn--save" onClick={() => onSave(row)}>
|
||||||
删除
|
保存
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -657,6 +844,12 @@ export function LeaseLedgerTable({
|
|||||||
})}
|
})}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<OperationHistoryModal
|
||||||
|
open={Boolean(historyRow)}
|
||||||
|
plateNo={historyRow?.plateNo ?? ''}
|
||||||
|
entries={historyRow?.operationHistory ?? []}
|
||||||
|
onClose={() => setHistoryRow(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import React, { useEffect, useId, useRef, useState } from 'react';
|
||||||
|
|
||||||
|
interface LedgerDetailPopoverProps {
|
||||||
|
label: string;
|
||||||
|
title?: string;
|
||||||
|
triggerClassName?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
footer?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LedgerDetailPopover({
|
||||||
|
label,
|
||||||
|
title,
|
||||||
|
triggerClassName = '',
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
}: LedgerDetailPopoverProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const wrapRef = useRef<HTMLDivElement>(null);
|
||||||
|
const panelId = useId();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
const onDocClick = (event: MouseEvent) => {
|
||||||
|
if (!wrapRef.current?.contains(event.target as Node)) setOpen(false);
|
||||||
|
};
|
||||||
|
const onKey = (event: KeyboardEvent) => {
|
||||||
|
if (event.key === 'Escape') setOpen(false);
|
||||||
|
};
|
||||||
|
document.addEventListener('mousedown', onDocClick);
|
||||||
|
document.addEventListener('keydown', onKey);
|
||||||
|
return () => {
|
||||||
|
document.removeEventListener('mousedown', onDocClick);
|
||||||
|
document.removeEventListener('keydown', onKey);
|
||||||
|
};
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ldb-detail-popover-wrap" ref={wrapRef}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`ldb-detail-popover-trigger ldb-cell-clickable cursor-pointer ${triggerClassName}`.trim()}
|
||||||
|
aria-expanded={open}
|
||||||
|
aria-controls={panelId}
|
||||||
|
onClick={() => setOpen((prev) => !prev)}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
{open && (
|
||||||
|
<div id={panelId} className="ldb-detail-popover-card" role="dialog" aria-label={title ?? label}>
|
||||||
|
{title && <div className="ldb-detail-popover-title">{title}</div>}
|
||||||
|
<div className="ldb-detail-popover-body">{children}</div>
|
||||||
|
{footer && <div className="ldb-detail-popover-footer">{footer}</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LedgerDetailTableProps {
|
||||||
|
columns: { key: string; title: string; align?: 'left' | 'right' }[];
|
||||||
|
rows: Record<string, React.ReactNode>[];
|
||||||
|
emptyText?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LedgerDetailTable({ columns, rows, emptyText = '暂无明细' }: LedgerDetailTableProps) {
|
||||||
|
if (!rows.length) {
|
||||||
|
return <p className="ldb-detail-popover-empty">{emptyText}</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="ldb-detail-popover-table-wrap">
|
||||||
|
<table className="ldb-detail-popover-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<th key={col.key} className={col.align === 'right' ? 'text-right' : ''}>{col.title}</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row, index) => (
|
||||||
|
<tr key={index}>
|
||||||
|
{columns.map((col) => (
|
||||||
|
<td key={col.key} className={col.align === 'right' ? 'text-right tabular-nums' : ''}>
|
||||||
|
{row[col.key] ?? ''}
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import type { LedgerLineItem } from '../types';
|
||||||
|
import { formatMoney } from '../utils/ledger';
|
||||||
|
import { LedgerDetailPopover, LedgerDetailTable } from './LedgerDetailPopover';
|
||||||
|
|
||||||
|
interface LedgerLineItemsCellProps {
|
||||||
|
items: LedgerLineItem[];
|
||||||
|
total: number;
|
||||||
|
canEdit: boolean;
|
||||||
|
reasonLabel: string;
|
||||||
|
title: string;
|
||||||
|
ariaLabel: string;
|
||||||
|
onChange: (items: LedgerLineItem[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_ITEM: LedgerLineItem = { reason: '', amount: 0, remark: '' };
|
||||||
|
|
||||||
|
export function LedgerLineItemsCell({
|
||||||
|
items,
|
||||||
|
total,
|
||||||
|
canEdit,
|
||||||
|
reasonLabel,
|
||||||
|
title,
|
||||||
|
ariaLabel,
|
||||||
|
onChange,
|
||||||
|
}: LedgerLineItemsCellProps) {
|
||||||
|
const displayItems = items.length > 0 ? items : [];
|
||||||
|
const sum = total;
|
||||||
|
|
||||||
|
const tableRows = useMemo(
|
||||||
|
() => displayItems.map((item) => ({
|
||||||
|
reason: item.reason,
|
||||||
|
amount: formatMoney(item.amount),
|
||||||
|
remark: item.remark,
|
||||||
|
})),
|
||||||
|
[displayItems],
|
||||||
|
);
|
||||||
|
|
||||||
|
const editor = canEdit ? (
|
||||||
|
<div className="ldb-line-items-editor">
|
||||||
|
{displayItems.map((item, index) => (
|
||||||
|
<div key={index} className="ldb-line-items-row">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="vm-input ldb-line-items-input"
|
||||||
|
value={item.reason}
|
||||||
|
placeholder={reasonLabel}
|
||||||
|
aria-label={`${ariaLabel} ${reasonLabel}`}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...displayItems];
|
||||||
|
next[index] = { ...next[index], reason: e.target.value };
|
||||||
|
onChange(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
className="vm-input ldb-line-items-input tabular-nums"
|
||||||
|
value={item.amount || ''}
|
||||||
|
min={0}
|
||||||
|
step={0.01}
|
||||||
|
placeholder="金额"
|
||||||
|
aria-label={`${ariaLabel} 金额`}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...displayItems];
|
||||||
|
next[index] = { ...next[index], amount: Number(e.target.value) || 0 };
|
||||||
|
onChange(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="vm-input ldb-line-items-input"
|
||||||
|
value={item.remark}
|
||||||
|
placeholder="备注"
|
||||||
|
aria-label={`${ariaLabel} 备注`}
|
||||||
|
onChange={(e) => {
|
||||||
|
const next = [...displayItems];
|
||||||
|
next[index] = { ...next[index], remark: e.target.value };
|
||||||
|
onChange(next);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="vm-btn vm-btn-ghost ldb-line-items-add"
|
||||||
|
onClick={() => onChange([...displayItems, { ...EMPTY_ITEM }])}
|
||||||
|
>
|
||||||
|
添加一行
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<LedgerDetailTable
|
||||||
|
columns={[
|
||||||
|
{ key: 'reason', title: reasonLabel },
|
||||||
|
{ key: 'amount', title: '金额', align: 'right' },
|
||||||
|
{ key: 'remark', title: '备注' },
|
||||||
|
]}
|
||||||
|
rows={tableRows}
|
||||||
|
emptyText="暂无明细"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<LedgerDetailPopover
|
||||||
|
label={formatMoney(sum)}
|
||||||
|
title={title}
|
||||||
|
triggerClassName={canEdit ? 'ldb-line-items-trigger--editable' : ''}
|
||||||
|
>
|
||||||
|
{editor}
|
||||||
|
</LedgerDetailPopover>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
import type { OperationHistoryEntry } from '../types';
|
||||||
|
import { displayText } from '../utils/ledger';
|
||||||
|
|
||||||
|
interface OperationHistoryModalProps {
|
||||||
|
open: boolean;
|
||||||
|
plateNo: string;
|
||||||
|
entries: OperationHistoryEntry[];
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function OperationHistoryModal({ open, plateNo, entries, onClose }: OperationHistoryModalProps) {
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="vm-modal-backdrop" role="presentation" onClick={onClose}>
|
||||||
|
<div
|
||||||
|
className="vm-modal vm-modal-md"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-labelledby="operation-history-title"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
<header className="vm-modal-header">
|
||||||
|
<h2 id="operation-history-title" className="vm-modal-title">操作历史 · {plateNo}</h2>
|
||||||
|
<button type="button" className="vm-modal-close" onClick={onClose} aria-label="关闭">
|
||||||
|
<X size={18} aria-hidden />
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
<div className="vm-modal-body">
|
||||||
|
{entries.length === 0 ? (
|
||||||
|
<p className="ldb-confirm-text">暂无操作记录</p>
|
||||||
|
) : (
|
||||||
|
<div className="ldb-detail-popover-table-wrap">
|
||||||
|
<table className="ldb-detail-popover-table ldb-history-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>操作时间</th>
|
||||||
|
<th>操作人</th>
|
||||||
|
<th>修改项</th>
|
||||||
|
<th>修改前</th>
|
||||||
|
<th>修改后</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{entries.map((entry, index) => (
|
||||||
|
<tr key={`${entry.operatedAt}-${index}`}>
|
||||||
|
<td className="tabular-nums">{displayText(entry.operatedAt)}</td>
|
||||||
|
<td>{displayText(entry.operatorName)}</td>
|
||||||
|
<td>{displayText(entry.field)}</td>
|
||||||
|
<td>{displayText(entry.before)}</td>
|
||||||
|
<td>{displayText(entry.after)}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<footer className="vm-modal-footer">
|
||||||
|
<button type="button" className="vm-btn vm-btn-primary" onClick={onClose}>关闭</button>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -72,7 +72,7 @@ export function ReceiptLinkModal({
|
|||||||
? '已收款'
|
? '已收款'
|
||||||
: linkedAmount > 0
|
: linkedAmount > 0
|
||||||
? '部分收款'
|
? '部分收款'
|
||||||
: '已确认';
|
: '未收款';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="vm-modal-backdrop" role="presentation" onClick={onClose}>
|
<div className="vm-modal-backdrop" role="presentation" onClick={onClose}>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import type { LeaseDetailStatus } from './types';
|
import type { BillArchiveStatus, LeaseDetailStatus } from '../types';
|
||||||
|
|
||||||
/** 宽表列定义(对齐 Excel「租赁车辆收入明细表」) */
|
/** 宽表列定义(对齐 Excel「租赁车辆收入明细表」) */
|
||||||
export interface LeaseTableColumn {
|
export interface LeaseTableColumn {
|
||||||
@@ -15,74 +15,172 @@ export interface LeaseTableColumn {
|
|||||||
render?:
|
render?:
|
||||||
| 'stack-dept' | 'stack-plate' | 'stack-customer' | 'status' | 'deposit'
|
| 'stack-dept' | 'stack-plate' | 'stack-customer' | 'status' | 'deposit'
|
||||||
| 'asset-owner' | 'signing-company'
|
| 'asset-owner' | 'signing-company'
|
||||||
| 'monthly-income' | 'maintenance-income' | 'insurance-surcharge' | 'ops-fee-income' | 'other-income'
|
| 'monthly-income' | 'maintenance-income' | 'insurance-surcharge'
|
||||||
| 'received-amount' | 'invoice-date' | 'vehicle-standard-cost' | 'vehicle-actual-cost'
|
| 'detail-popover-maintenance-income' | 'line-items-income' | 'line-items-deduction'
|
||||||
| 'insurance-readonly' | 'hydrogen-fee' | 'ops-fee-cost'
|
| 'received-amount' | 'detail-popover-receipts'
|
||||||
|
| 'invoice-date' | 'vehicle-standard-cost' | 'vehicle-actual-cost'
|
||||||
|
| 'insurance-readonly' | 'detail-popover-hydrogen' | 'detail-popover-maintenance-cost'
|
||||||
| 'brokerage-fee' | 'other-cost' | 'gps-mileage'
|
| 'brokerage-fee' | 'other-cost' | 'gps-mileage'
|
||||||
| 'deduction-detail' | 'remark-text' | 'text';
|
| 'deduction-detail' | 'remark-text' | 'year-month' | 'text' | 'maintainer-time' | 'value-added-service' | 'archive-status';
|
||||||
}
|
}
|
||||||
|
|
||||||
export const LEASE_TABLE_COLUMNS: LeaseTableColumn[] = [
|
export const LEASE_TABLE_COLUMNS: LeaseTableColumn[] = [
|
||||||
{ key: '_index', title: '序号', sticky: 'left', className: 'tabular-nums' },
|
{ key: 'status', title: '状态', render: 'status', annotationId: 'lbl-col-status', sortable: false },
|
||||||
{ key: 'status', title: '状态', render: 'status', annotationId: 'lbl-col-status' },
|
{ key: 'billingYearMonth', title: '年月', className: 'tabular-nums', render: 'year-month', sortable: false },
|
||||||
{ key: 'maintainerName', title: '维护人' },
|
{ key: 'dept', title: '业务部门', render: 'stack-dept', sortable: false },
|
||||||
{ key: 'year', title: '年份', className: 'tabular-nums' },
|
{
|
||||||
{ key: 'month', title: '月份', className: 'tabular-nums' },
|
key: 'paymentDueDate',
|
||||||
{ key: 'dept', title: '业务部门', render: 'stack-dept', annotationId: 'lbl-col-dept' },
|
title: '应付款日',
|
||||||
{ key: 'paymentDate', title: '付款日期', date: true },
|
date: true,
|
||||||
|
annotationId: 'lbl-col-payment-due',
|
||||||
|
},
|
||||||
{ key: 'plateNo', title: '车牌号码', render: 'stack-plate', annotationId: 'lbl-col-plate' },
|
{ key: 'plateNo', title: '车牌号码', render: 'stack-plate', annotationId: 'lbl-col-plate' },
|
||||||
{ key: 'customerName', title: '客户名称', render: 'stack-customer', annotationId: 'lbl-col-customer' },
|
{ key: 'customerName', title: '客户名称', render: 'stack-customer', annotationId: 'lbl-col-customer', sortable: false },
|
||||||
{ key: 'valueAddedService', title: '增值服务' },
|
{
|
||||||
{ key: 'exemptionPolicy', title: '享免政策' },
|
key: 'valueAddedService',
|
||||||
|
title: '增值服务',
|
||||||
|
render: 'value-added-service',
|
||||||
|
annotationId: 'lbl-col-value-added-service',
|
||||||
|
sortable: false,
|
||||||
|
},
|
||||||
|
{ key: 'exemptionPolicy', title: '享免政策', sortable: false },
|
||||||
{ key: 'pickupDate', title: '提车日期', date: true, annotationId: 'lbl-col-pickup-date' },
|
{ key: 'pickupDate', title: '提车日期', date: true, annotationId: 'lbl-col-pickup-date' },
|
||||||
{ key: 'contractStartDate', title: '合同生效日期', date: true },
|
{
|
||||||
{ key: 'contractEndDate', title: '合同到期日期', date: true },
|
key: 'contractStartDate',
|
||||||
{ key: 'periodStartDate', title: '起始日期', date: true },
|
title: '合同生效日期',
|
||||||
{ key: 'returnDate', title: '退车日期', date: true },
|
date: true,
|
||||||
{ key: 'avgDays', title: '平均天数', className: 'tabular-nums' },
|
annotationId: 'lbl-col-contract-start-date',
|
||||||
{ key: 'deposit', title: '保证金', render: 'deposit', annotationId: 'lbl-col-deposit' },
|
},
|
||||||
{ key: 'contractRent', title: '合同标的租金', money: true, annotationId: 'lbl-col-contract-rent' },
|
{
|
||||||
{ key: 'receivableTotal', title: '应收合计', money: true },
|
key: 'contractEndDate',
|
||||||
{ key: 'receivableRent', title: '应收租金', money: true, annotationId: 'lbl-col-receivable-rent' },
|
title: '合同到期日期',
|
||||||
{ key: 'monthlyIncome', title: '月度收入', money: true, render: 'monthly-income', annotationId: 'lbl-col-monthly-income' },
|
date: true,
|
||||||
{ key: 'monthlyRent', title: '月度租金', money: true, annotationId: 'lbl-col-monthly-rent' },
|
annotationId: 'lbl-col-contract-end-date',
|
||||||
{ key: 'maintenanceIncome', title: '维保包干收入', money: true, render: 'maintenance-income', annotationId: 'lbl-col-maintenance-income' },
|
},
|
||||||
{ key: 'insuranceSurcharge', title: '保险上浮费', money: true, render: 'insurance-surcharge', annotationId: 'lbl-col-insurance-surcharge' },
|
{
|
||||||
{ key: 'opsFeeIncome', title: '运维费', money: true, className: 'ldb-col-ops-income', render: 'ops-fee-income', annotationId: 'lbl-col-ops-income' },
|
key: 'periodStartDate',
|
||||||
{ key: 'otherIncome', title: '其他收入', money: true, render: 'other-income', annotationId: 'lbl-col-other-income' },
|
title: '起始日期',
|
||||||
{ key: 'mileageDeduction', title: '里程减免金额', money: true },
|
date: true,
|
||||||
{ key: 'otherDeduction', title: '其他减免金额', money: true },
|
annotationId: 'lbl-col-period-start-date',
|
||||||
{ key: 'receivedAmount', title: '实收金额', money: true, render: 'received-amount', annotationId: 'lbl-col-received-amount' },
|
},
|
||||||
{ key: 'unreceived', title: '未收金额', money: true, annotationId: 'lbl-col-unreceived' },
|
{
|
||||||
|
key: 'returnDate',
|
||||||
|
title: '退车日期',
|
||||||
|
date: true,
|
||||||
|
annotationId: 'lbl-col-return-date',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'avgDays',
|
||||||
|
title: '平均天数',
|
||||||
|
className: 'tabular-nums',
|
||||||
|
annotationId: 'lbl-col-avg-days',
|
||||||
|
sortable: false,
|
||||||
|
},
|
||||||
|
{ key: 'deposit', title: '保证金', render: 'deposit', annotationId: 'lbl-col-deposit', sortable: false },
|
||||||
|
{ key: 'contractRent', title: '合同标的租金', money: true, annotationId: 'lbl-col-contract-rent', sortable: false },
|
||||||
|
{
|
||||||
|
key: 'receivableTotal',
|
||||||
|
title: '应收合计',
|
||||||
|
money: true,
|
||||||
|
annotationId: 'lbl-col-receivable-total',
|
||||||
|
sortable: false,
|
||||||
|
},
|
||||||
|
{ key: 'receivableRent', title: '应收租金', money: true, annotationId: 'lbl-col-receivable-rent', sortable: false },
|
||||||
|
{ key: 'monthlyIncome', title: '月度收入', money: true, render: 'monthly-income', annotationId: 'lbl-col-monthly-income', sortable: false },
|
||||||
|
{ key: 'monthlyRent', title: '月度租金', money: true, annotationId: 'lbl-col-monthly-rent', sortable: false },
|
||||||
|
{ key: 'maintenanceIncome', title: '维保包干收入', money: true, render: 'maintenance-income', annotationId: 'lbl-col-maintenance-income', sortable: false },
|
||||||
|
{ key: 'insuranceSurcharge', title: '保险上浮费', money: true, render: 'insurance-surcharge', annotationId: 'lbl-col-insurance-surcharge', sortable: false },
|
||||||
|
{
|
||||||
|
key: 'opsFeeIncome',
|
||||||
|
title: '运维费',
|
||||||
|
money: true,
|
||||||
|
className: 'ldb-col-ops-income',
|
||||||
|
render: 'detail-popover-maintenance-income',
|
||||||
|
annotationId: 'lbl-col-ops-income',
|
||||||
|
sortable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'otherIncome',
|
||||||
|
title: '其他收入',
|
||||||
|
money: true,
|
||||||
|
render: 'line-items-income',
|
||||||
|
annotationId: 'lbl-col-other-income',
|
||||||
|
sortable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'mileageDeduction',
|
||||||
|
title: '里程减免金额',
|
||||||
|
money: true,
|
||||||
|
annotationId: 'lbl-col-mileage-deduction',
|
||||||
|
sortable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'otherDeduction',
|
||||||
|
title: '其他减免金额',
|
||||||
|
money: true,
|
||||||
|
render: 'line-items-deduction',
|
||||||
|
annotationId: 'lbl-col-other-deduction',
|
||||||
|
sortable: false,
|
||||||
|
},
|
||||||
|
{ key: 'receivedAmount', title: '实收金额', money: true, render: 'received-amount', annotationId: 'lbl-col-received-amount', sortable: false },
|
||||||
|
{ key: 'unreceived', title: '未收金额', money: true, annotationId: 'lbl-col-unreceived', sortable: false },
|
||||||
{ key: 'invoiceDate', title: '开票日期', date: true, render: 'invoice-date', annotationId: 'lbl-col-invoice-date' },
|
{ key: 'invoiceDate', title: '开票日期', date: true, render: 'invoice-date', annotationId: 'lbl-col-invoice-date' },
|
||||||
{ key: 'actualPaymentDate', title: '实际付款日期', date: true, annotationId: 'lbl-col-actual-payment' },
|
{ key: 'actualPaymentDate', title: '实际付款日期', date: true, annotationId: 'lbl-col-actual-payment' },
|
||||||
{ key: 'paymentMethod', title: '付款方式', annotationId: 'lbl-col-payment-method' },
|
{ key: 'paymentMethod', title: '付款方式', annotationId: 'lbl-col-payment-method', sortable: false },
|
||||||
{ key: 'vehicleStandardCost', title: '车辆标准成本', money: true, render: 'vehicle-standard-cost', annotationId: 'lbl-col-vehicle-standard-cost' },
|
{ key: 'vehicleStandardCost', title: '车辆标准成本', money: true, render: 'vehicle-standard-cost', annotationId: 'lbl-col-vehicle-standard-cost', sortable: false },
|
||||||
{ key: 'vehicleActualCost', title: '车辆实际成本', money: true, render: 'vehicle-actual-cost', annotationId: 'lbl-col-vehicle-actual-cost' },
|
{ key: 'vehicleActualCost', title: '车辆实际成本', money: true, render: 'vehicle-actual-cost', annotationId: 'lbl-col-vehicle-actual-cost', sortable: false },
|
||||||
{ key: 'insuranceFee', title: '保险费', money: true, render: 'insurance-readonly', annotationId: 'lbl-col-insurance-fee' },
|
{ key: 'insuranceFee', title: '保险费', money: true, render: 'insurance-readonly', annotationId: 'lbl-col-insurance-fee', sortable: false },
|
||||||
{
|
{
|
||||||
key: 'hydrogenFee',
|
key: 'hydrogenFee',
|
||||||
title: '氢费',
|
title: '氢费',
|
||||||
money: true,
|
money: true,
|
||||||
render: 'hydrogen-fee',
|
render: 'detail-popover-hydrogen',
|
||||||
annotationId: 'lbl-col-hydrogen-fee',
|
annotationId: 'lbl-col-hydrogen-fee',
|
||||||
headerTip: '仅显示签订合同时氢费承担方式为「我司承担」的氢费在该账单期内的总和,取自车辆氢费明细所有状态的数据(包含未对账和已对账)',
|
sortable: false,
|
||||||
},
|
},
|
||||||
{ key: 'opsFeeCost', title: '运维费', money: true, className: 'ldb-col-ops-cost', render: 'ops-fee-cost', annotationId: 'lbl-col-ops-cost' },
|
{
|
||||||
{ key: 'brokerageFee', title: '居间费', money: true, render: 'brokerage-fee', annotationId: 'lbl-col-brokerage-fee' },
|
key: 'opsFeeCost',
|
||||||
{ key: 'otherCost', title: '其他', money: true, render: 'other-cost', annotationId: 'lbl-col-other-cost' },
|
title: '运维费',
|
||||||
{ key: 'totalCost', title: '总成本', money: true },
|
money: true,
|
||||||
{ key: 'profitLoss', title: '盈亏', money: true },
|
className: 'ldb-col-ops-cost',
|
||||||
{ key: 'gpsMileage', title: 'GPS里程', render: 'gps-mileage', annotationId: 'lbl-col-gps-mileage' },
|
render: 'detail-popover-maintenance-cost',
|
||||||
|
annotationId: 'lbl-col-ops-cost',
|
||||||
|
sortable: false,
|
||||||
|
},
|
||||||
|
{ key: 'brokerageFee', title: '居间费', money: true, render: 'brokerage-fee', annotationId: 'lbl-col-brokerage-fee', sortable: false },
|
||||||
|
{ key: 'otherCost', title: '其他', money: true, render: 'other-cost', annotationId: 'lbl-col-other-cost', sortable: false },
|
||||||
|
{ key: 'totalCost', title: '总成本', money: true, sortable: false },
|
||||||
|
{ key: 'profitLoss', title: '盈亏', money: true, sortable: false },
|
||||||
|
{ key: 'gpsMileage', title: 'GPS里程', render: 'gps-mileage', annotationId: 'lbl-col-gps-mileage', sortable: false },
|
||||||
{ key: 'otherDeductionDetail', title: '其他减免金额明细内容', className: 'ldb-col-remark', render: 'deduction-detail', annotationId: 'lbl-col-deduction-detail' },
|
{ key: 'otherDeductionDetail', title: '其他减免金额明细内容', className: 'ldb-col-remark', render: 'deduction-detail', annotationId: 'lbl-col-deduction-detail' },
|
||||||
{ key: 'assetOwner', title: '资产归属', render: 'asset-owner', annotationId: 'lbl-col-asset-ownership' },
|
{ key: 'assetOwner', title: '资产归属', render: 'asset-owner', annotationId: 'lbl-col-asset-ownership', sortable: false },
|
||||||
{ key: 'signingCompany', title: '签约公司', className: 'ldb-col-name', render: 'signing-company', annotationId: 'lbl-col-signing-company' },
|
{ key: 'signingCompany', title: '签约公司', className: 'ldb-col-name', render: 'signing-company', annotationId: 'lbl-col-signing-company', sortable: false },
|
||||||
{ key: 'remark', title: '备注', className: 'ldb-col-remark', render: 'remark-text', annotationId: 'lbl-col-remark' },
|
{ key: 'remark', title: '备注', className: 'ldb-col-remark', render: 'remark-text', annotationId: 'lbl-col-remark', sortable: false },
|
||||||
|
{
|
||||||
|
key: 'archiveStatus',
|
||||||
|
title: '账单状态',
|
||||||
|
render: 'archive-status',
|
||||||
|
annotationId: 'lbl-col-archive-status',
|
||||||
|
sortable: false,
|
||||||
|
},
|
||||||
|
{ key: 'maintainerName', title: '维护人', sortable: false },
|
||||||
|
{
|
||||||
|
key: 'maintainerTime',
|
||||||
|
title: '维护时间',
|
||||||
|
className: 'tabular-nums',
|
||||||
|
annotationId: 'lbl-col-maintainer-time',
|
||||||
|
render: 'maintainer-time',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export const STATUS_CLASS: Record<LeaseDetailStatus, string> = {
|
export const STATUS_CLASS: Record<LeaseDetailStatus, string> = {
|
||||||
草稿: 'ldb-status--draft',
|
未收款: 'ldb-status--unpaid',
|
||||||
已确认: 'ldb-status--confirmed',
|
|
||||||
部分收款: 'ldb-status--partial',
|
部分收款: 'ldb-status--partial',
|
||||||
已收款: 'ldb-status--paid',
|
已收款: 'ldb-status--paid',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const ARCHIVE_STATUS_CLASS: Record<BillArchiveStatus, string> = {
|
||||||
|
已归档: 'ldb-status--archived',
|
||||||
|
未归档: 'ldb-status--unarchived',
|
||||||
|
草稿: 'ldb-status--draft',
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,5 +1,54 @@
|
|||||||
[
|
[
|
||||||
{ "plateNo": "沪A32896F", "year": 2026, "month": 1, "amount": 1280.5, "status": "已对账" },
|
{
|
||||||
{ "plateNo": "沪A65889F", "year": 2026, "month": 1, "amount": 960, "status": "未对账" },
|
"plateNo": "沪A32896F",
|
||||||
{ "plateNo": "浙F08991F", "year": 2026, "month": 1, "amount": 720, "status": "已对账" }
|
"year": 2026,
|
||||||
|
"month": 1,
|
||||||
|
"amount": 1280.5,
|
||||||
|
"status": "已对账",
|
||||||
|
"refuelDate": "2026-01-08",
|
||||||
|
"stationName": "上海嘉定氢站",
|
||||||
|
"volume": 42.5,
|
||||||
|
"unitCost": 30.13,
|
||||||
|
"totalCost": 1280.5,
|
||||||
|
"borneBy": "我司承担"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"plateNo": "沪A32896F",
|
||||||
|
"year": 2026,
|
||||||
|
"month": 1,
|
||||||
|
"amount": 620,
|
||||||
|
"status": "未对账",
|
||||||
|
"refuelDate": "2026-01-22",
|
||||||
|
"stationName": "上海嘉定氢站",
|
||||||
|
"volume": 20,
|
||||||
|
"unitCost": 31,
|
||||||
|
"totalCost": 620,
|
||||||
|
"borneBy": "我司承担"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"plateNo": "沪A65889F",
|
||||||
|
"year": 2026,
|
||||||
|
"month": 1,
|
||||||
|
"amount": 960,
|
||||||
|
"status": "未对账",
|
||||||
|
"refuelDate": "2026-01-15",
|
||||||
|
"stationName": "嘉兴港区加氢站",
|
||||||
|
"volume": 32,
|
||||||
|
"unitCost": 30,
|
||||||
|
"totalCost": 960,
|
||||||
|
"borneBy": "客户承担"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"plateNo": "浙F08991F",
|
||||||
|
"year": 2026,
|
||||||
|
"month": 1,
|
||||||
|
"amount": 720,
|
||||||
|
"status": "已对账",
|
||||||
|
"refuelDate": "2026-01-11",
|
||||||
|
"stationName": "嘉兴南湖氢站",
|
||||||
|
"volume": 24,
|
||||||
|
"unitCost": 30,
|
||||||
|
"totalCost": 720,
|
||||||
|
"borneBy": "我司承担"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,6 +1,65 @@
|
|||||||
[
|
[
|
||||||
{ "id": "mo1", "plateNo": "浙F05580F", "orderNo": "WX202601001", "type": "维修", "bearBy": "客户承担", "amount": 860, "completedAt": "2026-01-12" },
|
{
|
||||||
{ "id": "mo2", "plateNo": "浙F05580F", "orderNo": "BY202601008", "type": "保养", "bearBy": "客户承担", "amount": 420, "completedAt": "2026-01-18" },
|
"id": "mo1",
|
||||||
{ "id": "mo3", "plateNo": "沪A50990F", "orderNo": "WX202601015", "type": "维修", "bearBy": "客户承担", "amount": 1500, "completedAt": "2026-01-20" },
|
"plateNo": "浙F05580F",
|
||||||
{ "id": "mo4", "plateNo": "沪A65889F", "orderNo": "BY202512020", "type": "保养", "bearBy": "公司承担", "amount": 380, "completedAt": "2025-12-20" }
|
"orderNo": "WX202601001",
|
||||||
|
"type": "维修",
|
||||||
|
"bearBy": "客户承担",
|
||||||
|
"amount": 860,
|
||||||
|
"completedAt": "2026-01-12"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mo2",
|
||||||
|
"plateNo": "浙F05580F",
|
||||||
|
"orderNo": "BY202601008",
|
||||||
|
"type": "保养",
|
||||||
|
"bearBy": "客户承担",
|
||||||
|
"amount": 420,
|
||||||
|
"completedAt": "2026-01-18"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mo3",
|
||||||
|
"plateNo": "沪A50990F",
|
||||||
|
"orderNo": "WX202601015",
|
||||||
|
"type": "维修",
|
||||||
|
"bearBy": "客户承担",
|
||||||
|
"amount": 1500,
|
||||||
|
"completedAt": "2026-01-20"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mo4",
|
||||||
|
"plateNo": "沪A65889F",
|
||||||
|
"orderNo": "BY202512020",
|
||||||
|
"type": "保养",
|
||||||
|
"bearBy": "我司承担",
|
||||||
|
"amount": 380,
|
||||||
|
"completedAt": "2025-12-20"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mo5",
|
||||||
|
"plateNo": "沪A65889F",
|
||||||
|
"orderNo": "WX202601022",
|
||||||
|
"type": "维修",
|
||||||
|
"bearBy": "我司承担",
|
||||||
|
"amount": 1250,
|
||||||
|
"completedAt": "2026-01-16"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mo6",
|
||||||
|
"plateNo": "浙F08991F",
|
||||||
|
"orderNo": "BY202601011",
|
||||||
|
"type": "保养",
|
||||||
|
"bearBy": "我司承担",
|
||||||
|
"amount": 560,
|
||||||
|
"completedAt": "2026-01-09"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "mo7",
|
||||||
|
"plateNo": "沪A32896F",
|
||||||
|
"orderNo": "BY202601003",
|
||||||
|
"type": "保养",
|
||||||
|
"bearBy": "我司承担",
|
||||||
|
"amount": 450,
|
||||||
|
"completedAt": "2026-01-07"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,8 +1,50 @@
|
|||||||
[
|
[
|
||||||
{ "id": "rc1", "arrivalTime": "2026-01-19 14:30", "customerName": "安徽驰远供应链管理有限公司", "amount": 4500, "remark": "银行转账" },
|
{
|
||||||
{ "id": "rc2", "arrivalTime": "2026-02-02 10:00", "customerName": "嘉兴港区韵达快递有限公司", "amount": 2000, "remark": "第一笔到账" },
|
"id": "rc1",
|
||||||
{ "id": "rc3", "arrivalTime": "2026-02-02 16:20", "customerName": "嘉兴港区韵达快递有限公司", "amount": 1500, "remark": "第二笔到账" },
|
"arrivalTime": "2026-01-19 14:30",
|
||||||
{ "id": "rc4", "arrivalTime": "2025-12-25 09:15", "customerName": "上海朔宇物流有限公司", "amount": 1500, "remark": "部分到账" },
|
"customerName": "安徽驰远供应链管理有限公司",
|
||||||
{ "id": "rc5", "arrivalTime": "2025-12-25 11:00", "customerName": "上海朔宇物流有限公司", "amount": 1000, "remark": "第二笔部分到账" },
|
"amount": 4500,
|
||||||
{ "id": "rc6", "arrivalTime": "2025-12-26 15:40", "customerName": "上海利合供应链管理有限公司", "amount": 4000, "remark": "全额到账" }
|
"remark": "银行转账",
|
||||||
|
"invoiceDate": "2026-01-05"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rc2",
|
||||||
|
"arrivalTime": "2026-02-02 10:00",
|
||||||
|
"customerName": "嘉兴港区韵达快递有限公司",
|
||||||
|
"amount": 2000,
|
||||||
|
"remark": "第一笔到账",
|
||||||
|
"invoiceDate": "2026-01-27"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rc3",
|
||||||
|
"arrivalTime": "2026-02-02 16:20",
|
||||||
|
"customerName": "嘉兴港区韵达快递有限公司",
|
||||||
|
"amount": 1500,
|
||||||
|
"remark": "第二笔到账",
|
||||||
|
"invoiceDate": "2026-01-28"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rc4",
|
||||||
|
"arrivalTime": "2025-12-25 09:15",
|
||||||
|
"customerName": "上海朔宇物流有限公司",
|
||||||
|
"amount": 1500,
|
||||||
|
"remark": "部分到账",
|
||||||
|
"invoiceDate": "2025-12-29"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rc5",
|
||||||
|
"arrivalTime": "2025-12-25 11:00",
|
||||||
|
"customerName": "上海朔宇物流有限公司",
|
||||||
|
"amount": 1000,
|
||||||
|
"remark": "第二笔部分到账",
|
||||||
|
"invoiceDate": "2025-12-29"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "rc6",
|
||||||
|
"arrivalTime": "2025-12-26 15:40",
|
||||||
|
"customerName": "上海利合供应链管理有限公司",
|
||||||
|
"amount": 4000,
|
||||||
|
"remark": "全额到账",
|
||||||
|
"invoiceDate": "2025-12-29"
|
||||||
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -3,11 +3,11 @@
|
|||||||
"id": "l1",
|
"id": "l1",
|
||||||
"status": "已收款",
|
"status": "已收款",
|
||||||
"maintainerName": "刘念念",
|
"maintainerName": "刘念念",
|
||||||
|
"maintainerTime": "2026-01-10 14:30",
|
||||||
"year": 2026,
|
"year": 2026,
|
||||||
"month": 1,
|
"month": 1,
|
||||||
"dept": "业务二部",
|
"dept": "业务二部",
|
||||||
"salesman": "刘念念",
|
"salesman": "刘念念",
|
||||||
"paymentDate": "2026-01-01",
|
|
||||||
"plateNo": "沪A32896F",
|
"plateNo": "沪A32896F",
|
||||||
"vehicleBrand": "苏龙",
|
"vehicleBrand": "苏龙",
|
||||||
"vehicleModel": "18T",
|
"vehicleModel": "18T",
|
||||||
@@ -19,34 +19,38 @@
|
|||||||
"pickupDate": "2024-11-30",
|
"pickupDate": "2024-11-30",
|
||||||
"contractStartDate": "2025-12-01",
|
"contractStartDate": "2025-12-01",
|
||||||
"contractEndDate": "2026-11-30",
|
"contractEndDate": "2026-11-30",
|
||||||
|
"contractEndMode": "term",
|
||||||
|
"leaseTermMonths": 24,
|
||||||
"periodStartDate": "",
|
"periodStartDate": "",
|
||||||
"returnDate": "",
|
"returnDate": "",
|
||||||
"avgDays": 1,
|
"avgDays": 1,
|
||||||
"deposit": "/",
|
"deposit": "/",
|
||||||
"contractRent": 4500.0,
|
"contractRent": 4500,
|
||||||
"receivableTotal": 4500.0,
|
"receivableTotal": 4500,
|
||||||
"receivableRent": 4500.0,
|
"receivableRent": 4500,
|
||||||
"monthlyIncome": 4500.0,
|
"monthlyIncome": 4500,
|
||||||
"monthlyRent": 4500.0,
|
"monthlyRent": 4500,
|
||||||
"maintenanceIncome": 0.0,
|
"maintenanceIncome": 0,
|
||||||
"insuranceSurcharge": 0.0,
|
"insuranceSurcharge": 0,
|
||||||
"opsFeeIncome": 0.0,
|
"opsFeeIncome": 0,
|
||||||
"otherIncome": 0.0,
|
"otherIncome": 0,
|
||||||
"mileageDeduction": 0.0,
|
"mileageDeduction": 0,
|
||||||
"otherDeduction": 0.0,
|
"otherDeduction": 0,
|
||||||
"receivedAmount": 4500.0,
|
"receivedAmount": 4500,
|
||||||
"invoiceDate": "2026-01-05",
|
"invoiceDate": "2026-01-05",
|
||||||
"actualPaymentDate": "2026-01-19",
|
"actualPaymentDate": "2026-01-19",
|
||||||
"paymentMethod": "月度预付",
|
"paymentMethod": "月度预付",
|
||||||
"vehicleStandardCost": 3900.0,
|
"vehicleStandardCost": 3900,
|
||||||
"vehicleActualCost": 3900.0,
|
"vehicleActualCost": 3900,
|
||||||
"insuranceFee": 0.0,
|
"insuranceFee": 0,
|
||||||
|
"insurancePurchaseAmount": 12000,
|
||||||
|
"insuranceEffectiveDays": 365,
|
||||||
"hydrogenFee": 0,
|
"hydrogenFee": 0,
|
||||||
"opsFeeCost": 0.0,
|
"opsFeeCost": 0,
|
||||||
"brokerageFee": 500.0,
|
"brokerageFee": 500,
|
||||||
"otherCost": 0.0,
|
"otherCost": 0,
|
||||||
"totalCost": 4400.0,
|
"totalCost": 4400,
|
||||||
"profitLoss": 100.0,
|
"profitLoss": 100,
|
||||||
"gpsMileage": "",
|
"gpsMileage": "",
|
||||||
"otherDeductionDetail": "",
|
"otherDeductionDetail": "",
|
||||||
"assetOwner": "安吉天地物流科技有限公司",
|
"assetOwner": "安吉天地物流科技有限公司",
|
||||||
@@ -65,17 +69,18 @@
|
|||||||
"linkedReceiptIds": [
|
"linkedReceiptIds": [
|
||||||
"rc1"
|
"rc1"
|
||||||
],
|
],
|
||||||
"hydrogenBorneBy": "我司承担"
|
"hydrogenBorneBy": "我司承担",
|
||||||
|
"archiveStatus": "已归档"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "l2",
|
"id": "l2",
|
||||||
"status": "已收款",
|
"status": "已收款",
|
||||||
"maintainerName": "刘念念",
|
"maintainerName": "刘念念",
|
||||||
|
"maintainerTime": "2026-01-11 09:20",
|
||||||
"year": 2026,
|
"year": 2026,
|
||||||
"month": 1,
|
"month": 1,
|
||||||
"dept": "业务二部",
|
"dept": "业务二部",
|
||||||
"salesman": "刘念念",
|
"salesman": "刘念念",
|
||||||
"paymentDate": "2026-01-01",
|
|
||||||
"plateNo": "沪A61121F",
|
"plateNo": "沪A61121F",
|
||||||
"vehicleBrand": "苏龙",
|
"vehicleBrand": "苏龙",
|
||||||
"vehicleModel": "18T",
|
"vehicleModel": "18T",
|
||||||
@@ -87,34 +92,44 @@
|
|||||||
"pickupDate": "2024-11-30",
|
"pickupDate": "2024-11-30",
|
||||||
"contractStartDate": "2025-12-01",
|
"contractStartDate": "2025-12-01",
|
||||||
"contractEndDate": "2026-11-30",
|
"contractEndDate": "2026-11-30",
|
||||||
|
"contractEndMode": "term",
|
||||||
|
"leaseTermMonths": 24,
|
||||||
"periodStartDate": "",
|
"periodStartDate": "",
|
||||||
"returnDate": "",
|
"returnDate": "",
|
||||||
"avgDays": 1,
|
"avgDays": 1,
|
||||||
"deposit": "/",
|
"deposit": "/",
|
||||||
"contractRent": 4500.0,
|
"contractRent": 4500,
|
||||||
"receivableTotal": 4500.0,
|
"receivableTotal": 4500,
|
||||||
"receivableRent": 4500.0,
|
"receivableRent": 4500,
|
||||||
"monthlyIncome": 4500.0,
|
"monthlyIncome": 4500,
|
||||||
"monthlyRent": 4500.0,
|
"monthlyRent": 4500,
|
||||||
"maintenanceIncome": 0.0,
|
"maintenanceIncome": 0,
|
||||||
"insuranceSurcharge": 0.0,
|
"insuranceSurcharge": 0,
|
||||||
"opsFeeIncome": 0.0,
|
"opsFeeIncome": 0,
|
||||||
"otherIncome": 0.0,
|
"otherIncome": 0,
|
||||||
"mileageDeduction": 50,
|
"mileageDeduction": 0,
|
||||||
"otherDeduction": 30,
|
"otherDeduction": 0,
|
||||||
"receivedAmount": 4500.0,
|
"otherDeductionItems": [
|
||||||
|
{
|
||||||
|
"reason": "超里程扣减",
|
||||||
|
"amount": 30,
|
||||||
|
"remark": "按协议扣减"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"mileageDeductionRate": 0.5,
|
||||||
|
"receivedAmount": 4500,
|
||||||
"invoiceDate": "2026-01-05",
|
"invoiceDate": "2026-01-05",
|
||||||
"actualPaymentDate": "2026-01-19",
|
"actualPaymentDate": "2026-01-19",
|
||||||
"paymentMethod": "月度预付",
|
"paymentMethod": "月度预付",
|
||||||
"vehicleStandardCost": 3900.0,
|
"vehicleStandardCost": 3900,
|
||||||
"vehicleActualCost": 3900.0,
|
"vehicleActualCost": 3900,
|
||||||
"insuranceFee": 0.0,
|
"insuranceFee": 0,
|
||||||
"hydrogenFee": 0,
|
"hydrogenFee": 0,
|
||||||
"opsFeeCost": 0.0,
|
"opsFeeCost": 0,
|
||||||
"brokerageFee": 500.0,
|
"brokerageFee": 500,
|
||||||
"otherCost": 0.0,
|
"otherCost": 0,
|
||||||
"totalCost": 4400.0,
|
"totalCost": 4400,
|
||||||
"profitLoss": 100.0,
|
"profitLoss": 100,
|
||||||
"gpsMileage": "",
|
"gpsMileage": "",
|
||||||
"otherDeductionDetail": "",
|
"otherDeductionDetail": "",
|
||||||
"assetOwner": "安吉天地物流科技有限公司",
|
"assetOwner": "安吉天地物流科技有限公司",
|
||||||
@@ -131,7 +146,8 @@
|
|||||||
"linkedAccidentIds": [],
|
"linkedAccidentIds": [],
|
||||||
"isLatestBill": false,
|
"isLatestBill": false,
|
||||||
"linkedReceiptIds": [],
|
"linkedReceiptIds": [],
|
||||||
"hydrogenBorneBy": "客户承担"
|
"hydrogenBorneBy": "客户承担",
|
||||||
|
"archiveStatus": "已归档"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "l3",
|
"id": "l3",
|
||||||
@@ -141,7 +157,6 @@
|
|||||||
"month": 1,
|
"month": 1,
|
||||||
"dept": "业务二部",
|
"dept": "业务二部",
|
||||||
"salesman": "刘念念",
|
"salesman": "刘念念",
|
||||||
"paymentDate": "2026-01-01",
|
|
||||||
"plateNo": "沪A69695F",
|
"plateNo": "沪A69695F",
|
||||||
"vehicleBrand": "苏龙",
|
"vehicleBrand": "苏龙",
|
||||||
"vehicleModel": "18T",
|
"vehicleModel": "18T",
|
||||||
@@ -153,34 +168,35 @@
|
|||||||
"pickupDate": "2024-08-20",
|
"pickupDate": "2024-08-20",
|
||||||
"contractStartDate": "2025-09-01",
|
"contractStartDate": "2025-09-01",
|
||||||
"contractEndDate": "2026-08-31",
|
"contractEndDate": "2026-08-31",
|
||||||
|
"contractEndMode": "date",
|
||||||
"periodStartDate": "",
|
"periodStartDate": "",
|
||||||
"returnDate": "",
|
"returnDate": "",
|
||||||
"avgDays": 1,
|
"avgDays": 1,
|
||||||
"deposit": "/",
|
"deposit": "/",
|
||||||
"contractRent": 4500.0,
|
"contractRent": 4500,
|
||||||
"receivableTotal": 4500.0,
|
"receivableTotal": 4500,
|
||||||
"receivableRent": 4500.0,
|
"receivableRent": 4500,
|
||||||
"monthlyIncome": 4500.0,
|
"monthlyIncome": 4500,
|
||||||
"monthlyRent": 4500.0,
|
"monthlyRent": 4500,
|
||||||
"maintenanceIncome": 0.0,
|
"maintenanceIncome": 0,
|
||||||
"insuranceSurcharge": 0.0,
|
"insuranceSurcharge": 0,
|
||||||
"opsFeeIncome": 0.0,
|
"opsFeeIncome": 0,
|
||||||
"otherIncome": 0.0,
|
"otherIncome": 0,
|
||||||
"mileageDeduction": 0.0,
|
"mileageDeduction": 0,
|
||||||
"otherDeduction": 0.0,
|
"otherDeduction": 0,
|
||||||
"receivedAmount": 4500.0,
|
"receivedAmount": 4500,
|
||||||
"invoiceDate": "2026-01-05",
|
"invoiceDate": "2026-01-05",
|
||||||
"actualPaymentDate": "2026-01-19",
|
"actualPaymentDate": "2026-01-19",
|
||||||
"paymentMethod": "月度预付",
|
"paymentMethod": "月度预付",
|
||||||
"vehicleStandardCost": 3900.0,
|
"vehicleStandardCost": 3900,
|
||||||
"vehicleActualCost": 3900.0,
|
"vehicleActualCost": 3900,
|
||||||
"insuranceFee": 0.0,
|
"insuranceFee": 0,
|
||||||
"hydrogenFee": 0,
|
"hydrogenFee": 0,
|
||||||
"opsFeeCost": 0.0,
|
"opsFeeCost": 0,
|
||||||
"brokerageFee": 500.0,
|
"brokerageFee": 500,
|
||||||
"otherCost": 0.0,
|
"otherCost": 0,
|
||||||
"totalCost": 4400.0,
|
"totalCost": 4400,
|
||||||
"profitLoss": 100.0,
|
"profitLoss": 100,
|
||||||
"gpsMileage": "",
|
"gpsMileage": "",
|
||||||
"otherDeductionDetail": "",
|
"otherDeductionDetail": "",
|
||||||
"assetOwner": "安吉天地物流科技有限公司",
|
"assetOwner": "安吉天地物流科技有限公司",
|
||||||
@@ -197,7 +213,8 @@
|
|||||||
"linkedAccidentIds": [],
|
"linkedAccidentIds": [],
|
||||||
"isLatestBill": false,
|
"isLatestBill": false,
|
||||||
"linkedReceiptIds": [],
|
"linkedReceiptIds": [],
|
||||||
"hydrogenBorneBy": "客户承担"
|
"hydrogenBorneBy": "客户承担",
|
||||||
|
"archiveStatus": "已归档"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "l4",
|
"id": "l4",
|
||||||
@@ -207,7 +224,6 @@
|
|||||||
"month": 1,
|
"month": 1,
|
||||||
"dept": "业务二部",
|
"dept": "业务二部",
|
||||||
"salesman": "刘念念",
|
"salesman": "刘念念",
|
||||||
"paymentDate": "2026-01-01",
|
|
||||||
"plateNo": "粤AGF4535",
|
"plateNo": "粤AGF4535",
|
||||||
"vehicleBrand": "现代",
|
"vehicleBrand": "现代",
|
||||||
"vehicleModel": "4.5T/交投",
|
"vehicleModel": "4.5T/交投",
|
||||||
@@ -219,34 +235,35 @@
|
|||||||
"pickupDate": "2024-10-29",
|
"pickupDate": "2024-10-29",
|
||||||
"contractStartDate": "2025-11-01",
|
"contractStartDate": "2025-11-01",
|
||||||
"contractEndDate": "2026-10-31",
|
"contractEndDate": "2026-10-31",
|
||||||
|
"contractEndMode": "date",
|
||||||
"periodStartDate": "",
|
"periodStartDate": "",
|
||||||
"returnDate": "",
|
"returnDate": "",
|
||||||
"avgDays": 1,
|
"avgDays": 1,
|
||||||
"deposit": "/",
|
"deposit": "/",
|
||||||
"contractRent": 2000.0,
|
"contractRent": 2000,
|
||||||
"receivableTotal": 2000.0,
|
"receivableTotal": 2000,
|
||||||
"receivableRent": 2000.0,
|
"receivableRent": 2000,
|
||||||
"monthlyIncome": 2000.0,
|
"monthlyIncome": 2000,
|
||||||
"monthlyRent": 2000.0,
|
"monthlyRent": 2000,
|
||||||
"maintenanceIncome": 0.0,
|
"maintenanceIncome": 0,
|
||||||
"insuranceSurcharge": 0.0,
|
"insuranceSurcharge": 0,
|
||||||
"opsFeeIncome": 0.0,
|
"opsFeeIncome": 0,
|
||||||
"otherIncome": 0.0,
|
"otherIncome": 0,
|
||||||
"mileageDeduction": 0.0,
|
"mileageDeduction": 0,
|
||||||
"otherDeduction": 0.0,
|
"otherDeduction": 0,
|
||||||
"receivedAmount": 2000.0,
|
"receivedAmount": 2000,
|
||||||
"invoiceDate": "2026-01-27",
|
"invoiceDate": "2026-01-27",
|
||||||
"actualPaymentDate": "2026-02-02",
|
"actualPaymentDate": "2026-02-02",
|
||||||
"paymentMethod": "月度预付",
|
"paymentMethod": "月度预付",
|
||||||
"vehicleStandardCost": 1500.0,
|
"vehicleStandardCost": 1500,
|
||||||
"vehicleActualCost": 1500.0,
|
"vehicleActualCost": 1500,
|
||||||
"insuranceFee": 0.0,
|
"insuranceFee": 0,
|
||||||
"hydrogenFee": 0,
|
"hydrogenFee": 0,
|
||||||
"opsFeeCost": 0.0,
|
"opsFeeCost": 0,
|
||||||
"brokerageFee": 0.0,
|
"brokerageFee": 0,
|
||||||
"otherCost": 0.0,
|
"otherCost": 0,
|
||||||
"totalCost": 1500.0,
|
"totalCost": 1500,
|
||||||
"profitLoss": 500.0,
|
"profitLoss": 500,
|
||||||
"gpsMileage": "",
|
"gpsMileage": "",
|
||||||
"otherDeductionDetail": "",
|
"otherDeductionDetail": "",
|
||||||
"assetOwner": "广州开发区交投氢能运营管理有限公司",
|
"assetOwner": "广州开发区交投氢能运营管理有限公司",
|
||||||
@@ -263,7 +280,8 @@
|
|||||||
"linkedAccidentIds": [],
|
"linkedAccidentIds": [],
|
||||||
"isLatestBill": false,
|
"isLatestBill": false,
|
||||||
"linkedReceiptIds": [],
|
"linkedReceiptIds": [],
|
||||||
"hydrogenBorneBy": "客户承担"
|
"hydrogenBorneBy": "客户承担",
|
||||||
|
"archiveStatus": "已归档"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "l5",
|
"id": "l5",
|
||||||
@@ -273,7 +291,6 @@
|
|||||||
"month": 1,
|
"month": 1,
|
||||||
"dept": "业务二部",
|
"dept": "业务二部",
|
||||||
"salesman": "刘念念",
|
"salesman": "刘念念",
|
||||||
"paymentDate": "2026-01-01",
|
|
||||||
"plateNo": "沪A65889F",
|
"plateNo": "沪A65889F",
|
||||||
"vehicleBrand": "苏龙",
|
"vehicleBrand": "苏龙",
|
||||||
"vehicleModel": "18T",
|
"vehicleModel": "18T",
|
||||||
@@ -285,34 +302,36 @@
|
|||||||
"pickupDate": "2024-12-03",
|
"pickupDate": "2024-12-03",
|
||||||
"contractStartDate": "2025-12-03",
|
"contractStartDate": "2025-12-03",
|
||||||
"contractEndDate": "2026-12-02",
|
"contractEndDate": "2026-12-02",
|
||||||
|
"contractEndMode": "term",
|
||||||
|
"leaseTermMonths": 12,
|
||||||
"periodStartDate": "",
|
"periodStartDate": "",
|
||||||
"returnDate": "",
|
"returnDate": "",
|
||||||
"avgDays": 1,
|
"avgDays": 1,
|
||||||
"deposit": "/",
|
"deposit": "/",
|
||||||
"contractRent": 3500.0,
|
"contractRent": 3500,
|
||||||
"receivableTotal": 3500.0,
|
"receivableTotal": 3500,
|
||||||
"receivableRent": 3500.0,
|
"receivableRent": 3500,
|
||||||
"monthlyIncome": 3500.0,
|
"monthlyIncome": 3500,
|
||||||
"monthlyRent": 3500.0,
|
"monthlyRent": 3500,
|
||||||
"maintenanceIncome": 0.0,
|
"maintenanceIncome": 0,
|
||||||
"insuranceSurcharge": 0.0,
|
"insuranceSurcharge": 0,
|
||||||
"opsFeeIncome": 0.0,
|
"opsFeeIncome": 0,
|
||||||
"otherIncome": 0.0,
|
"otherIncome": 0,
|
||||||
"mileageDeduction": 0.0,
|
"mileageDeduction": 0,
|
||||||
"otherDeduction": 0.0,
|
"otherDeduction": 0,
|
||||||
"receivedAmount": 3500.0,
|
"receivedAmount": 3500,
|
||||||
"invoiceDate": "2026-01-27",
|
"invoiceDate": "2026-01-27",
|
||||||
"actualPaymentDate": "2026-02-02",
|
"actualPaymentDate": "2026-02-02",
|
||||||
"paymentMethod": "月度预付",
|
"paymentMethod": "月度预付",
|
||||||
"vehicleStandardCost": 3900.0,
|
"vehicleStandardCost": 3900,
|
||||||
"vehicleActualCost": 3900.0,
|
"vehicleActualCost": 3900,
|
||||||
"insuranceFee": 0.0,
|
"insuranceFee": 0,
|
||||||
"hydrogenFee": 0,
|
"hydrogenFee": 0,
|
||||||
"opsFeeCost": 0.0,
|
"opsFeeCost": 0,
|
||||||
"brokerageFee": 0.0,
|
"brokerageFee": 0,
|
||||||
"otherCost": 0.0,
|
"otherCost": 0,
|
||||||
"totalCost": 3900.0,
|
"totalCost": 3900,
|
||||||
"profitLoss": -400.0,
|
"profitLoss": -400,
|
||||||
"gpsMileage": "",
|
"gpsMileage": "",
|
||||||
"otherDeductionDetail": "",
|
"otherDeductionDetail": "",
|
||||||
"assetOwner": "羚牛新能源科技(上海)有限公司",
|
"assetOwner": "羚牛新能源科技(上海)有限公司",
|
||||||
@@ -329,17 +348,18 @@
|
|||||||
"linkedAccidentIds": [],
|
"linkedAccidentIds": [],
|
||||||
"isLatestBill": true,
|
"isLatestBill": true,
|
||||||
"linkedReceiptIds": [],
|
"linkedReceiptIds": [],
|
||||||
"hydrogenBorneBy": "客户承担"
|
"hydrogenBorneBy": "客户承担",
|
||||||
|
"archiveStatus": "已归档"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "l6",
|
"id": "l6",
|
||||||
"status": "已确认",
|
"status": "未收款",
|
||||||
"maintainerName": "刘念念",
|
"maintainerName": "刘念念",
|
||||||
|
"maintainerTime": "2026-01-10 14:30",
|
||||||
"year": 2026,
|
"year": 2026,
|
||||||
"month": 1,
|
"month": 1,
|
||||||
"dept": "业务二部",
|
"dept": "业务二部",
|
||||||
"salesman": "刘念念",
|
"salesman": "刘念念",
|
||||||
"paymentDate": "2026-01-01",
|
|
||||||
"plateNo": "浙F08991F",
|
"plateNo": "浙F08991F",
|
||||||
"vehicleBrand": "飞驰",
|
"vehicleBrand": "飞驰",
|
||||||
"vehicleModel": "18T",
|
"vehicleModel": "18T",
|
||||||
@@ -351,34 +371,35 @@
|
|||||||
"pickupDate": "2024-03-01",
|
"pickupDate": "2024-03-01",
|
||||||
"contractStartDate": "2025-03-01",
|
"contractStartDate": "2025-03-01",
|
||||||
"contractEndDate": "2026-02-28",
|
"contractEndDate": "2026-02-28",
|
||||||
|
"contractEndMode": "date",
|
||||||
"periodStartDate": "",
|
"periodStartDate": "",
|
||||||
"returnDate": "2026-02-04",
|
"returnDate": "2026-02-04",
|
||||||
"avgDays": 1,
|
"avgDays": 1,
|
||||||
"deposit": "/",
|
"deposit": "/",
|
||||||
"contractRent": 4000.0,
|
"contractRent": 4000,
|
||||||
"receivableTotal": 4000.0,
|
"receivableTotal": 4000,
|
||||||
"receivableRent": 4000.0,
|
"receivableRent": 12000,
|
||||||
"monthlyIncome": 4000.0,
|
"monthlyIncome": 4000,
|
||||||
"monthlyRent": 4000.0,
|
"monthlyRent": 4000,
|
||||||
"maintenanceIncome": 0.0,
|
"maintenanceIncome": 0,
|
||||||
"insuranceSurcharge": 0.0,
|
"insuranceSurcharge": 0,
|
||||||
"opsFeeIncome": 0.0,
|
"opsFeeIncome": 0,
|
||||||
"otherIncome": 0.0,
|
"otherIncome": 0,
|
||||||
"mileageDeduction": 0.0,
|
"mileageDeduction": 0,
|
||||||
"otherDeduction": 0.0,
|
"otherDeduction": 0,
|
||||||
"receivedAmount": 0,
|
"receivedAmount": 0,
|
||||||
"invoiceDate": "2025-12-29",
|
"invoiceDate": "2025-12-29",
|
||||||
"actualPaymentDate": "",
|
"actualPaymentDate": "",
|
||||||
"paymentMethod": "月度预付",
|
"paymentMethod": "月度预付",
|
||||||
"vehicleStandardCost": 3000.0,
|
"vehicleStandardCost": 3000,
|
||||||
"vehicleActualCost": 3000.0,
|
"vehicleActualCost": 3000,
|
||||||
"insuranceFee": 0.0,
|
"insuranceFee": 0,
|
||||||
"hydrogenFee": 0,
|
"hydrogenFee": 0,
|
||||||
"opsFeeCost": 0.0,
|
"opsFeeCost": 0,
|
||||||
"brokerageFee": 0.0,
|
"brokerageFee": 0,
|
||||||
"otherCost": 0.0,
|
"otherCost": 0,
|
||||||
"totalCost": 3000.0,
|
"totalCost": 3000,
|
||||||
"profitLoss": 1000.0,
|
"profitLoss": 1000,
|
||||||
"gpsMileage": "",
|
"gpsMileage": "",
|
||||||
"otherDeductionDetail": "",
|
"otherDeductionDetail": "",
|
||||||
"assetOwner": "浙江羚牛氢能科技有限公司",
|
"assetOwner": "浙江羚牛氢能科技有限公司",
|
||||||
@@ -395,17 +416,18 @@
|
|||||||
"linkedAccidentIds": [],
|
"linkedAccidentIds": [],
|
||||||
"isLatestBill": true,
|
"isLatestBill": true,
|
||||||
"linkedReceiptIds": [],
|
"linkedReceiptIds": [],
|
||||||
"hydrogenBorneBy": "我司承担"
|
"hydrogenBorneBy": "我司承担",
|
||||||
|
"archiveStatus": "未归档"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "l7",
|
"id": "l7",
|
||||||
"status": "部分收款",
|
"status": "部分收款",
|
||||||
"maintainerName": "刘念念",
|
"maintainerName": "刘念念",
|
||||||
|
"maintainerTime": "2026-01-12 16:45",
|
||||||
"year": 2026,
|
"year": 2026,
|
||||||
"month": 1,
|
"month": 1,
|
||||||
"dept": "业务二部",
|
"dept": "业务二部",
|
||||||
"salesman": "刘念念",
|
"salesman": "刘念念",
|
||||||
"paymentDate": "2026-01-01",
|
|
||||||
"plateNo": "浙F05580F",
|
"plateNo": "浙F05580F",
|
||||||
"vehicleBrand": "苏龙",
|
"vehicleBrand": "苏龙",
|
||||||
"vehicleModel": "18T",
|
"vehicleModel": "18T",
|
||||||
@@ -417,34 +439,36 @@
|
|||||||
"pickupDate": "2025-09-26",
|
"pickupDate": "2025-09-26",
|
||||||
"contractStartDate": "2025-10-01",
|
"contractStartDate": "2025-10-01",
|
||||||
"contractEndDate": "2026-09-30",
|
"contractEndDate": "2026-09-30",
|
||||||
|
"contractEndMode": "term",
|
||||||
|
"leaseTermMonths": 12,
|
||||||
"periodStartDate": "",
|
"periodStartDate": "",
|
||||||
"returnDate": "",
|
"returnDate": "",
|
||||||
"avgDays": 1,
|
"avgDays": 1,
|
||||||
"deposit": "/",
|
"deposit": "/",
|
||||||
"contractRent": 4000.0,
|
"contractRent": 4000,
|
||||||
"receivableTotal": 4000.0,
|
"receivableTotal": 4000,
|
||||||
"receivableRent": 4000.0,
|
"receivableRent": 4000,
|
||||||
"monthlyIncome": 4000.0,
|
"monthlyIncome": 4000,
|
||||||
"monthlyRent": 4000.0,
|
"monthlyRent": 4000,
|
||||||
"maintenanceIncome": 0.0,
|
"maintenanceIncome": 0,
|
||||||
"insuranceSurcharge": 0.0,
|
"insuranceSurcharge": 0,
|
||||||
"opsFeeIncome": 0.0,
|
"opsFeeIncome": 0,
|
||||||
"otherIncome": 0.0,
|
"otherIncome": 0,
|
||||||
"mileageDeduction": 0,
|
"mileageDeduction": 0,
|
||||||
"otherDeduction": 0,
|
"otherDeduction": 0,
|
||||||
"receivedAmount": 2500.0,
|
"receivedAmount": 2500,
|
||||||
"invoiceDate": "2025-12-29",
|
"invoiceDate": "2025-12-29",
|
||||||
"actualPaymentDate": "2025-12-25",
|
"actualPaymentDate": "2025-12-25",
|
||||||
"paymentMethod": "月度预付",
|
"paymentMethod": "月度预付",
|
||||||
"vehicleStandardCost": 3900.0,
|
"vehicleStandardCost": 3900,
|
||||||
"vehicleActualCost": 3900.0,
|
"vehicleActualCost": 3900,
|
||||||
"insuranceFee": 0.0,
|
"insuranceFee": 0,
|
||||||
"hydrogenFee": 0,
|
"hydrogenFee": 0,
|
||||||
"opsFeeCost": 0.0,
|
"opsFeeCost": 0,
|
||||||
"brokerageFee": 0.0,
|
"brokerageFee": 0,
|
||||||
"otherCost": 0.0,
|
"otherCost": 0,
|
||||||
"totalCost": 3900.0,
|
"totalCost": 3900,
|
||||||
"profitLoss": 100.0,
|
"profitLoss": 100,
|
||||||
"gpsMileage": "",
|
"gpsMileage": "",
|
||||||
"otherDeductionDetail": "",
|
"otherDeductionDetail": "",
|
||||||
"assetOwner": "上海宇速物流有限公司",
|
"assetOwner": "上海宇速物流有限公司",
|
||||||
@@ -460,18 +484,22 @@
|
|||||||
"linkedMaintenanceIds": [],
|
"linkedMaintenanceIds": [],
|
||||||
"linkedAccidentIds": [],
|
"linkedAccidentIds": [],
|
||||||
"isLatestBill": true,
|
"isLatestBill": true,
|
||||||
"linkedReceiptIds": [],
|
"linkedReceiptIds": [
|
||||||
"hydrogenBorneBy": "客户承担"
|
"rc4",
|
||||||
|
"rc5"
|
||||||
|
],
|
||||||
|
"hydrogenBorneBy": "客户承担",
|
||||||
|
"archiveStatus": "未归档"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "l8",
|
"id": "l8",
|
||||||
"status": "草稿",
|
"status": "未收款",
|
||||||
"maintainerName": "王芳",
|
"maintainerName": "王芳",
|
||||||
|
"maintainerTime": "2026-01-09 11:30",
|
||||||
"year": 2026,
|
"year": 2026,
|
||||||
"month": 1,
|
"month": 1,
|
||||||
"dept": "业务二部",
|
"dept": "业务二部",
|
||||||
"salesman": "刘念念",
|
"salesman": "刘念念",
|
||||||
"paymentDate": "2026-01-01",
|
|
||||||
"plateNo": "沪A50990F",
|
"plateNo": "沪A50990F",
|
||||||
"vehicleBrand": "苏龙",
|
"vehicleBrand": "苏龙",
|
||||||
"vehicleModel": "18T",
|
"vehicleModel": "18T",
|
||||||
@@ -483,34 +511,45 @@
|
|||||||
"pickupDate": "2025-08-21",
|
"pickupDate": "2025-08-21",
|
||||||
"contractStartDate": "2025-09-01",
|
"contractStartDate": "2025-09-01",
|
||||||
"contractEndDate": "2026-08-31",
|
"contractEndDate": "2026-08-31",
|
||||||
|
"contractEndMode": "date",
|
||||||
|
"leaseTermMonths": 12,
|
||||||
"periodStartDate": "",
|
"periodStartDate": "",
|
||||||
"returnDate": "",
|
"returnDate": "",
|
||||||
"avgDays": 1,
|
"avgDays": 1,
|
||||||
"deposit": "/",
|
"deposit": "/",
|
||||||
"contractRent": 5500.0,
|
"contractRent": 5500,
|
||||||
"receivableTotal": 5500.0,
|
"receivableTotal": 5500,
|
||||||
"receivableRent": 5500.0,
|
"receivableRent": 33000,
|
||||||
"monthlyIncome": 5500.0,
|
"monthlyIncome": 5500,
|
||||||
"monthlyRent": 5500.0,
|
"monthlyRent": 5500,
|
||||||
"maintenanceIncome": 0.0,
|
"maintenanceIncome": 0,
|
||||||
"insuranceSurcharge": 0.0,
|
"insuranceSurcharge": 0,
|
||||||
"opsFeeIncome": 0.0,
|
"opsFeeIncome": 0,
|
||||||
"otherIncome": 120,
|
"otherIncome": 0,
|
||||||
"mileageDeduction": 0.0,
|
"otherIncomeItems": [
|
||||||
"otherDeduction": 0.0,
|
{
|
||||||
|
"reason": "迟还补偿",
|
||||||
|
"amount": 120,
|
||||||
|
"remark": "客户延迟还车"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"mileageDeduction": 0,
|
||||||
|
"otherDeduction": 0,
|
||||||
"receivedAmount": 0,
|
"receivedAmount": 0,
|
||||||
"invoiceDate": "2025-12-02",
|
"invoiceDate": "2025-12-02",
|
||||||
"actualPaymentDate": "",
|
"actualPaymentDate": "",
|
||||||
"paymentMethod": "月度预付",
|
"paymentMethod": "月度预付",
|
||||||
"vehicleStandardCost": 3900.0,
|
"vehicleStandardCost": 3900,
|
||||||
"vehicleActualCost": 3900.0,
|
"vehicleActualCost": 3900,
|
||||||
"insuranceFee": 0.0,
|
"insuranceFee": 0,
|
||||||
|
"insurancePurchaseAmount": 9800,
|
||||||
|
"insuranceEffectiveDays": 365,
|
||||||
"hydrogenFee": 0,
|
"hydrogenFee": 0,
|
||||||
"opsFeeCost": 0.0,
|
"opsFeeCost": 0,
|
||||||
"brokerageFee": 1500.0,
|
"brokerageFee": 1500,
|
||||||
"otherCost": 0.0,
|
"otherCost": 0,
|
||||||
"totalCost": 5400.0,
|
"totalCost": 5400,
|
||||||
"profitLoss": 100.0,
|
"profitLoss": 100,
|
||||||
"gpsMileage": "",
|
"gpsMileage": "",
|
||||||
"otherDeductionDetail": "",
|
"otherDeductionDetail": "",
|
||||||
"assetOwner": "上海宇速物流有限公司",
|
"assetOwner": "上海宇速物流有限公司",
|
||||||
@@ -527,6 +566,23 @@
|
|||||||
"linkedAccidentIds": [],
|
"linkedAccidentIds": [],
|
||||||
"isLatestBill": true,
|
"isLatestBill": true,
|
||||||
"linkedReceiptIds": [],
|
"linkedReceiptIds": [],
|
||||||
"hydrogenBorneBy": "客户承担"
|
"hydrogenBorneBy": "客户承担",
|
||||||
|
"operationHistory": [
|
||||||
|
{
|
||||||
|
"operatedAt": "2026-01-08 10:15",
|
||||||
|
"operatorName": "王芳",
|
||||||
|
"field": "合同标的租金",
|
||||||
|
"before": "5400",
|
||||||
|
"after": "5500"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"operatedAt": "2026-01-09 11:30",
|
||||||
|
"operatorName": "王芳",
|
||||||
|
"field": "居间费",
|
||||||
|
"before": "1400",
|
||||||
|
"after": "1500"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"archiveStatus": "草稿"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* @name 租赁业务台账
|
* @name 租赁业务台账
|
||||||
|
* 设计规范:ledger-shared/DESIGN.md · vm-page + ldb-page
|
||||||
*/
|
*/
|
||||||
import '../vehicle-management/style.css';
|
import '../vehicle-management/style.css';
|
||||||
import './styles/index.css';
|
import './styles/index.css';
|
||||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
import { CheckCircle2, Download, FileUp, Plus, Send } from 'lucide-react';
|
import { Archive, Download, FileUp, Save } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
AnnotationViewer,
|
AnnotationViewer,
|
||||||
type AnnotationDirectoryRouteNode,
|
type AnnotationDirectoryRouteNode,
|
||||||
@@ -14,20 +15,15 @@ import {
|
|||||||
import seedRows from './data/rows.json';
|
import seedRows from './data/rows.json';
|
||||||
import seedReceipts from './data/receipts.json';
|
import seedReceipts from './data/receipts.json';
|
||||||
import seedMaintenance from './data/maintenance-orders.json';
|
import seedMaintenance from './data/maintenance-orders.json';
|
||||||
import seedAccidents from './data/accident-records.json';
|
|
||||||
import seedBiMileage from './data/bi-mileage.json';
|
import seedBiMileage from './data/bi-mileage.json';
|
||||||
import seedHydrogenFees from './data/hydrogen-fees.json';
|
import seedHydrogenFees from './data/hydrogen-fees.json';
|
||||||
import { FilterPanel } from './components/FilterPanel';
|
import { FilterPanel } from './components/FilterPanel';
|
||||||
import { LedgerKpiRow } from './components/LedgerKpiRow';
|
import { LedgerKpiRow } from './components/LedgerKpiRow';
|
||||||
import { LeaseLedgerTable } from './components/LeaseLedgerTable';
|
import { LeaseLedgerTable } from './components/LeaseLedgerTable';
|
||||||
import { ConfirmMarkModal } from './components/ConfirmMarkModal';
|
|
||||||
import { DeleteConfirmModal } from './components/DeleteConfirmModal';
|
|
||||||
import { ReceiptLinkModal } from './components/ReceiptLinkModal';
|
import { ReceiptLinkModal } from './components/ReceiptLinkModal';
|
||||||
import { MaintenanceLinkModal } from './components/MaintenanceLinkModal';
|
import { TablePagination, DEFAULT_PAGE_SIZE } from '../../common/TablePagination';
|
||||||
import { AccidentLinkModal } from './components/AccidentLinkModal';
|
|
||||||
import {
|
import {
|
||||||
EMPTY_LEASE_FILTERS,
|
EMPTY_LEASE_FILTERS,
|
||||||
type AccidentRecord,
|
|
||||||
type BiMileageRecord,
|
type BiMileageRecord,
|
||||||
type HydrogenFeeRecord,
|
type HydrogenFeeRecord,
|
||||||
type LeaseLedgerFilters,
|
type LeaseLedgerFilters,
|
||||||
@@ -39,48 +35,47 @@ import {
|
|||||||
import {
|
import {
|
||||||
applyLeaseFilters,
|
applyLeaseFilters,
|
||||||
applyListViewFilter,
|
applyListViewFilter,
|
||||||
canStaffDeleteRow,
|
|
||||||
canStaffEditRow,
|
canStaffEditRow,
|
||||||
enrichLeaseRow,
|
enrichLeaseRow,
|
||||||
filterRecordsByRole,
|
filterRecordsByRole,
|
||||||
latestReceiptPaymentDate,
|
latestReceiptPaymentDate,
|
||||||
LOAD_BATCH_SIZE,
|
CURRENT_STAFF_USER,
|
||||||
resolveStatusAfterReceipt,
|
|
||||||
sumLeaseKpi,
|
sumLeaseKpi,
|
||||||
} from './utils/ledger';
|
} from './utils/ledger';
|
||||||
import annotationSourceDocument from './annotation-source.json';
|
import annotationSourceDocument from './annotation-source.json';
|
||||||
|
|
||||||
const MAINTENANCE_ORDERS = seedMaintenance as MaintenanceOrder[];
|
const MAINTENANCE_ORDERS = seedMaintenance as MaintenanceOrder[];
|
||||||
const ACCIDENT_RECORDS = seedAccidents as AccidentRecord[];
|
|
||||||
const BI_MILEAGE = seedBiMileage as BiMileageRecord[];
|
const BI_MILEAGE = seedBiMileage as BiMileageRecord[];
|
||||||
const HYDROGEN_FEES = seedHydrogenFees as HydrogenFeeRecord[];
|
const HYDROGEN_FEES = seedHydrogenFees as HydrogenFeeRecord[];
|
||||||
|
|
||||||
function mapRows(rows: LeaseLedgerRow[]): LeaseLedgerRow[] {
|
function formatMaintainerTime(date = new Date()): string {
|
||||||
|
const pad = (value: number) => String(value).padStart(2, '0');
|
||||||
|
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapRows(rows: LeaseLedgerRow[], receipts: ReceiptRecord[]): LeaseLedgerRow[] {
|
||||||
return rows.map((row) => enrichLeaseRow(row, {
|
return rows.map((row) => enrichLeaseRow(row, {
|
||||||
maintenanceOrders: MAINTENANCE_ORDERS,
|
maintenanceOrders: MAINTENANCE_ORDERS,
|
||||||
accidentRecords: ACCIDENT_RECORDS,
|
|
||||||
biMileage: BI_MILEAGE,
|
biMileage: BI_MILEAGE,
|
||||||
hydrogenFees: HYDROGEN_FEES,
|
hydrogenFees: HYDROGEN_FEES,
|
||||||
|
receipts,
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function LeaseBusinessLedgerApp() {
|
export default function LeaseBusinessLedgerApp() {
|
||||||
const [records, setRecords] = useState<LeaseLedgerRow[]>(() => mapRows(seedRows as LeaseLedgerRow[]));
|
const [records, setRecords] = useState<LeaseLedgerRow[]>(() => mapRows(seedRows as LeaseLedgerRow[], seedReceipts as ReceiptRecord[]));
|
||||||
const [receipts] = useState<ReceiptRecord[]>(seedReceipts as ReceiptRecord[]);
|
const [receipts] = useState<ReceiptRecord[]>(seedReceipts as ReceiptRecord[]);
|
||||||
const [pendingFilters, setPendingFilters] = useState<LeaseLedgerFilters>(EMPTY_LEASE_FILTERS);
|
const [pendingFilters, setPendingFilters] = useState<LeaseLedgerFilters>(EMPTY_LEASE_FILTERS);
|
||||||
const [appliedFilters, setAppliedFilters] = useState<LeaseLedgerFilters>(EMPTY_LEASE_FILTERS);
|
const [appliedFilters, setAppliedFilters] = useState<LeaseLedgerFilters>(EMPTY_LEASE_FILTERS);
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
const [toast, setToast] = useState('');
|
const [toast, setToast] = useState('');
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
|
||||||
const [receiptRow, setReceiptRow] = useState<LeaseLedgerRow | null>(null);
|
const [receiptRow, setReceiptRow] = useState<LeaseLedgerRow | null>(null);
|
||||||
const [maintenanceRow, setMaintenanceRow] = useState<LeaseLedgerRow | null>(null);
|
|
||||||
const [accidentRow, setAccidentRow] = useState<LeaseLedgerRow | null>(null);
|
|
||||||
const [showUnpaidOnly, setShowUnpaidOnly] = useState(false);
|
const [showUnpaidOnly, setShowUnpaidOnly] = useState(false);
|
||||||
const [listViewMode, setListViewMode] = useState<ListViewMode>('all');
|
const [listViewMode, setListViewMode] = useState<ListViewMode>('all');
|
||||||
const [visibleCount, setVisibleCount] = useState(LOAD_BATCH_SIZE);
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||||
const [tableLoading, setTableLoading] = useState(false);
|
const [tableLoading, setTableLoading] = useState(false);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<LeaseLedgerRow | null>(null);
|
const [editingRowId, setEditingRowId] = useState<string | null>(null);
|
||||||
const [submitApprovalOpen, setSubmitApprovalOpen] = useState(false);
|
|
||||||
|
|
||||||
const showToast = useCallback((msg: string) => {
|
const showToast = useCallback((msg: string) => {
|
||||||
setToast(msg);
|
setToast(msg);
|
||||||
@@ -97,25 +92,17 @@ export default function LeaseBusinessLedgerApp() {
|
|||||||
[filtered, showUnpaidOnly, listViewMode],
|
[filtered, showUnpaidOnly, listViewMode],
|
||||||
);
|
);
|
||||||
const kpi = useMemo(() => sumLeaseKpi(applyLeaseFilters(roleScopedRecords, appliedFilters)), [roleScopedRecords, appliedFilters]);
|
const kpi = useMemo(() => sumLeaseKpi(applyLeaseFilters(roleScopedRecords, appliedFilters)), [roleScopedRecords, appliedFilters]);
|
||||||
const visibleRecords = useMemo(
|
const totalPages = Math.max(1, Math.ceil(listFiltered.length / pageSize));
|
||||||
() => listFiltered.slice(0, visibleCount),
|
const pagedRecords = useMemo(() => {
|
||||||
[listFiltered, visibleCount],
|
const start = (page - 1) * pageSize;
|
||||||
);
|
return listFiltered.slice(start, start + pageSize);
|
||||||
const hasMore = visibleCount < listFiltered.length;
|
}, [listFiltered, page, pageSize]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setVisibleCount(LOAD_BATCH_SIZE);
|
|
||||||
}, [appliedFilters, showUnpaidOnly, listViewMode]);
|
|
||||||
|
|
||||||
const draftSelectedCount = useMemo(
|
|
||||||
() => Array.from(selectedIds).filter((id) => records.find((r) => r.id === id)?.status === '草稿').length,
|
|
||||||
[selectedIds, records],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
setTableLoading(true);
|
setTableLoading(true);
|
||||||
window.setTimeout(() => {
|
window.setTimeout(() => {
|
||||||
setAppliedFilters(pendingFilters);
|
setAppliedFilters(pendingFilters);
|
||||||
|
setPage(1);
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
setTableLoading(false);
|
setTableLoading(false);
|
||||||
}, 320);
|
}, 320);
|
||||||
@@ -124,29 +111,78 @@ export default function LeaseBusinessLedgerApp() {
|
|||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
setPendingFilters(EMPTY_LEASE_FILTERS);
|
setPendingFilters(EMPTY_LEASE_FILTERS);
|
||||||
setAppliedFilters(EMPTY_LEASE_FILTERS);
|
setAppliedFilters(EMPTY_LEASE_FILTERS);
|
||||||
|
setPage(1);
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
setShowUnpaidOnly(false);
|
setShowUnpaidOnly(false);
|
||||||
setListViewMode('all');
|
setListViewMode('all');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMarkConfirmedClick = () => {
|
const handleSetArchived = () => {
|
||||||
if (draftSelectedCount === 0) {
|
if (selectedIds.size === 0) {
|
||||||
showToast('请先勾选草稿状态的记录');
|
showToast('请先勾选需要归档的记录');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setConfirmOpen(true);
|
const ids = new Set(selectedIds);
|
||||||
|
const now = formatMaintainerTime();
|
||||||
|
setRecords((prev) => mapRows(
|
||||||
|
prev.map((row) => {
|
||||||
|
if (!ids.has(row.id)) return row;
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
archiveStatus: '已归档' as const,
|
||||||
|
maintainerName: CURRENT_STAFF_USER,
|
||||||
|
maintainerTime: now,
|
||||||
|
operationHistory: [
|
||||||
|
...(row.operationHistory ?? []),
|
||||||
|
{
|
||||||
|
operatedAt: now,
|
||||||
|
operatorName: CURRENT_STAFF_USER,
|
||||||
|
field: '账单状态',
|
||||||
|
before: row.archiveStatus,
|
||||||
|
after: '已归档',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
receipts,
|
||||||
|
));
|
||||||
|
setEditingRowId(null);
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
showToast(`已将 ${ids.size} 条记录设置为已归档`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirmMark = () => {
|
const handleSaveUnarchived = () => {
|
||||||
const draftIds = new Set(
|
if (selectedIds.size === 0) {
|
||||||
Array.from(selectedIds).filter((id) => records.find((r) => r.id === id)?.status === '草稿'),
|
showToast('请先勾选需要保存的记录');
|
||||||
);
|
return;
|
||||||
|
}
|
||||||
|
const ids = new Set(selectedIds);
|
||||||
|
const now = formatMaintainerTime();
|
||||||
setRecords((prev) => mapRows(
|
setRecords((prev) => mapRows(
|
||||||
prev.map((row) => (draftIds.has(row.id) ? { ...row, status: '已确认' as const } : row)),
|
prev.map((row) => {
|
||||||
|
if (!ids.has(row.id)) return row;
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
archiveStatus: '未归档' as const,
|
||||||
|
maintainerName: CURRENT_STAFF_USER,
|
||||||
|
maintainerTime: now,
|
||||||
|
operationHistory: [
|
||||||
|
...(row.operationHistory ?? []),
|
||||||
|
{
|
||||||
|
operatedAt: now,
|
||||||
|
operatorName: CURRENT_STAFF_USER,
|
||||||
|
field: '账单状态',
|
||||||
|
before: row.archiveStatus,
|
||||||
|
after: '未归档',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
receipts,
|
||||||
));
|
));
|
||||||
setConfirmOpen(false);
|
setEditingRowId(null);
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
showToast(`已确认 ${draftIds.size} 条记录,业务服务组人员不可再删改`);
|
showToast(`已将 ${ids.size} 条记录保存为未归档`);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReceiptConfirm = (
|
const handleReceiptConfirm = (
|
||||||
@@ -170,70 +206,59 @@ export default function LeaseBusinessLedgerApp() {
|
|||||||
receivedAmount,
|
receivedAmount,
|
||||||
linkedReceiptIds: [...new Set([...(row.linkedReceiptIds ?? []), ...selectedReceiptIds])],
|
linkedReceiptIds: [...new Set([...(row.linkedReceiptIds ?? []), ...selectedReceiptIds])],
|
||||||
actualPaymentDate: paymentDate,
|
actualPaymentDate: paymentDate,
|
||||||
status: resolveStatusAfterReceipt(row.receivableTotal, receivedAmount),
|
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
|
receipts,
|
||||||
));
|
));
|
||||||
setReceiptRow(null);
|
setReceiptRow(null);
|
||||||
showToast(linkedAmount > billAmount ? '收款关联成功,多出部分请在单据下自行分配' : '收款记录关联成功');
|
showToast(linkedAmount > billAmount ? '收款关联成功,多出部分请在单据下自行分配' : '收款记录关联成功');
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleMaintenanceConfirm = (recordId: string, selectedIds: string[]) => {
|
const handleOtherIncomeItemsChange = (recordId: string, items: LeaseLedgerRow['otherIncomeItems']) => {
|
||||||
setRecords((prev) => mapRows(
|
setRecords((prev) => mapRows(
|
||||||
prev.map((row) => (
|
prev.map((row) => (row.id === recordId ? { ...row, otherIncomeItems: items ?? [] } : row)),
|
||||||
row.id === recordId
|
receipts,
|
||||||
? { ...row, linkedMaintenanceIds: [...new Set([...row.linkedMaintenanceIds, ...selectedIds])] }
|
|
||||||
: row
|
|
||||||
)),
|
|
||||||
));
|
));
|
||||||
setMaintenanceRow(null);
|
|
||||||
showToast('维修/保养记录关联成功,运维费已自动计算');
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleAccidentConfirm = (recordId: string, selectedIds: string[]) => {
|
const handleOtherDeductionItemsChange = (recordId: string, items: LeaseLedgerRow['otherDeductionItems']) => {
|
||||||
setRecords((prev) => mapRows(
|
setRecords((prev) => mapRows(
|
||||||
prev.map((row) => (
|
prev.map((row) => (row.id === recordId ? { ...row, otherDeductionItems: items ?? [] } : row)),
|
||||||
row.id === recordId
|
receipts,
|
||||||
? { ...row, linkedAccidentIds: [...new Set([...row.linkedAccidentIds, ...selectedIds])] }
|
|
||||||
: row
|
|
||||||
)),
|
|
||||||
));
|
|
||||||
setAccidentRow(null);
|
|
||||||
showToast('事故单据关联成功,保险上浮费已自动汇总');
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleOtherIncomeChange = (recordId: string, value: number) => {
|
|
||||||
setRecords((prev) => mapRows(
|
|
||||||
prev.map((row) => (row.id === recordId ? { ...row, otherIncome: value } : row)),
|
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleOtherCostChange = (recordId: string, value: number) => {
|
const handleOtherCostChange = (recordId: string, value: number) => {
|
||||||
setRecords((prev) => mapRows(
|
setRecords((prev) => mapRows(
|
||||||
prev.map((row) => (row.id === recordId ? { ...row, otherCost: value } : row)),
|
prev.map((row) => (row.id === recordId ? { ...row, otherCost: value } : row)),
|
||||||
|
receipts,
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleBrokerageFeeChange = (recordId: string, value: number) => {
|
const handleBrokerageFeeChange = (recordId: string, value: number) => {
|
||||||
setRecords((prev) => mapRows(
|
setRecords((prev) => mapRows(
|
||||||
prev.map((row) => (row.id === recordId ? { ...row, brokerageFee: value } : row)),
|
prev.map((row) => (row.id === recordId ? { ...row, brokerageFee: value } : row)),
|
||||||
|
receipts,
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRemarkChange = (recordId: string, value: string) => {
|
const handleRemarkChange = (recordId: string, value: string) => {
|
||||||
setRecords((prev) => mapRows(
|
setRecords((prev) => mapRows(
|
||||||
prev.map((row) => (row.id === recordId ? { ...row, remark: value } : row)),
|
prev.map((row) => (row.id === recordId ? { ...row, remark: value } : row)),
|
||||||
|
receipts,
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeductionDetailChange = (recordId: string, value: string) => {
|
const handleDeductionDetailChange = (recordId: string, value: string) => {
|
||||||
setRecords((prev) => mapRows(
|
setRecords((prev) => mapRows(
|
||||||
prev.map((row) => (row.id === recordId ? { ...row, otherDeductionDetail: value } : row)),
|
prev.map((row) => (row.id === recordId ? { ...row, otherDeductionDetail: value } : row)),
|
||||||
|
receipts,
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleListViewChange = (mode: ListViewMode) => {
|
const handleListViewChange = (mode: ListViewMode) => {
|
||||||
setListViewMode(mode);
|
setListViewMode(mode);
|
||||||
|
setPage(1);
|
||||||
if (mode === 'unpaid') setShowUnpaidOnly(true);
|
if (mode === 'unpaid') setShowUnpaidOnly(true);
|
||||||
if (mode === 'all') setShowUnpaidOnly(false);
|
if (mode === 'all') setShowUnpaidOnly(false);
|
||||||
};
|
};
|
||||||
@@ -277,6 +302,7 @@ export default function LeaseBusinessLedgerApp() {
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
setShowUnpaidOnly(e.target.checked);
|
setShowUnpaidOnly(e.target.checked);
|
||||||
setListViewMode(e.target.checked ? 'unpaid' : 'all');
|
setListViewMode(e.target.checked ? 'unpaid' : 'all');
|
||||||
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
仅显示未收款数据
|
仅显示未收款数据
|
||||||
@@ -293,36 +319,33 @@ export default function LeaseBusinessLedgerApp() {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="vm-btn vm-btn-ghost ldb-toolbar-btn"
|
className="vm-btn vm-btn-ghost ldb-toolbar-btn"
|
||||||
data-annotation-id="lbl-btn-mark-confirmed"
|
data-annotation-id="lbl-btn-save-unarchived"
|
||||||
onClick={handleMarkConfirmedClick}
|
onClick={handleSaveUnarchived}
|
||||||
>
|
>
|
||||||
<CheckCircle2 size={16} aria-hidden />
|
<Save size={16} aria-hidden />
|
||||||
标记为已确认
|
保存
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="vm-btn vm-btn-primary ldb-toolbar-btn"
|
className="vm-btn vm-btn-ghost ldb-toolbar-btn"
|
||||||
onClick={() => {
|
data-annotation-id="lbl-btn-set-archived"
|
||||||
if (selectedIds.size === 0) {
|
onClick={handleSetArchived}
|
||||||
showToast('请先勾选需要提交审批的记录');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSubmitApprovalOpen(true);
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<Send size={16} aria-hidden />
|
<Archive size={16} aria-hidden />
|
||||||
提交收款审批
|
设置为归档
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="vm-table-card">
|
<div className="vm-table-card">
|
||||||
<LeaseLedgerTable
|
<LeaseLedgerTable
|
||||||
records={visibleRecords}
|
records={pagedRecords}
|
||||||
loading={tableLoading}
|
loading={tableLoading}
|
||||||
maintenanceOrders={MAINTENANCE_ORDERS}
|
maintenanceOrders={MAINTENANCE_ORDERS}
|
||||||
accidentRecords={ACCIDENT_RECORDS}
|
receipts={receipts}
|
||||||
|
hydrogenFees={HYDROGEN_FEES}
|
||||||
selectedIds={selectedIds}
|
selectedIds={selectedIds}
|
||||||
|
editingRowId={editingRowId}
|
||||||
onToggleRow={(id, checked) => {
|
onToggleRow={(id, checked) => {
|
||||||
setSelectedIds((prev) => {
|
setSelectedIds((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -336,47 +359,60 @@ export default function LeaseBusinessLedgerApp() {
|
|||||||
}}
|
}}
|
||||||
onEdit={(row) => {
|
onEdit={(row) => {
|
||||||
if (!canStaffEditRow(row)) {
|
if (!canStaffEditRow(row)) {
|
||||||
showToast('已确认记录仅业务服务组主管可修改');
|
showToast('仅草稿状态的数据可以编辑');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
showToast(`行内编辑:${row.plateNo}(原型演示)`);
|
setEditingRowId(row.id);
|
||||||
|
showToast(`正在编辑 ${row.plateNo},完成后请点击保存`);
|
||||||
}}
|
}}
|
||||||
onDelete={(row) => {
|
onSave={(row) => {
|
||||||
if (!canStaffDeleteRow(row)) return;
|
const now = formatMaintainerTime();
|
||||||
setDeleteTarget(row);
|
setRecords((prev) => mapRows(
|
||||||
|
prev.map((item) => {
|
||||||
|
if (item.id !== row.id) return item;
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
archiveStatus: '未归档' as const,
|
||||||
|
maintainerName: CURRENT_STAFF_USER,
|
||||||
|
maintainerTime: now,
|
||||||
|
operationHistory: [
|
||||||
|
...(item.operationHistory ?? []),
|
||||||
|
{
|
||||||
|
operatedAt: now,
|
||||||
|
operatorName: CURRENT_STAFF_USER,
|
||||||
|
field: '账单状态',
|
||||||
|
before: item.archiveStatus,
|
||||||
|
after: '未归档',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
receipts,
|
||||||
|
));
|
||||||
|
setEditingRowId(null);
|
||||||
|
showToast(`已保存 ${row.plateNo},账单状态已设为未归档`);
|
||||||
}}
|
}}
|
||||||
onLinkReceipt={(row) => setReceiptRow(row)}
|
onLinkReceipt={(row) => setReceiptRow(row)}
|
||||||
onLinkMaintenance={(row) => setMaintenanceRow(row)}
|
onOtherIncomeItemsChange={handleOtherIncomeItemsChange}
|
||||||
onLinkAccident={(row) => setAccidentRow(row)}
|
onOtherDeductionItemsChange={handleOtherDeductionItemsChange}
|
||||||
onOtherIncomeChange={handleOtherIncomeChange}
|
|
||||||
onOtherCostChange={handleOtherCostChange}
|
onOtherCostChange={handleOtherCostChange}
|
||||||
onBrokerageFeeChange={handleBrokerageFeeChange}
|
onBrokerageFeeChange={handleBrokerageFeeChange}
|
||||||
onRemarkChange={handleRemarkChange}
|
onRemarkChange={handleRemarkChange}
|
||||||
onDeductionDetailChange={handleDeductionDetailChange}
|
onDeductionDetailChange={handleDeductionDetailChange}
|
||||||
/>
|
/>
|
||||||
<div className="vm-table-footer ldb-table-footer">
|
<div className="vm-table-footer ldb-table-footer">
|
||||||
<span className="ldb-table-total">
|
<span className="ldb-table-total">共 {listFiltered.length} 条</span>
|
||||||
已显示 {visibleRecords.length} / 共 {listFiltered.length} 条
|
|
||||||
</span>
|
|
||||||
<div className="ldb-table-footer-actions">
|
<div className="ldb-table-footer-actions">
|
||||||
{hasMore && (
|
<TablePagination
|
||||||
<button
|
page={Math.min(page, totalPages)}
|
||||||
type="button"
|
pageSize={pageSize}
|
||||||
className="vm-btn vm-btn-ghost"
|
total={listFiltered.length}
|
||||||
onClick={() => setVisibleCount((n) => n + LOAD_BATCH_SIZE)}
|
onPageChange={setPage}
|
||||||
>
|
onPageSizeChange={(size) => {
|
||||||
下拉显示更多
|
setPageSize(size);
|
||||||
</button>
|
setPage(1);
|
||||||
)}
|
}}
|
||||||
<button
|
/>
|
||||||
type="button"
|
|
||||||
className="vm-btn vm-btn-secondary"
|
|
||||||
data-annotation-id="lbl-btn-add-row"
|
|
||||||
onClick={() => showToast('表格底部新增行(原型演示)')}
|
|
||||||
>
|
|
||||||
<Plus size={16} aria-hidden />
|
|
||||||
添加行
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -385,53 +421,6 @@ export default function LeaseBusinessLedgerApp() {
|
|||||||
{toast && <div className="vm-toast" role="status">{toast}</div>}
|
{toast && <div className="vm-toast" role="status">{toast}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ConfirmMarkModal
|
|
||||||
open={confirmOpen}
|
|
||||||
count={draftSelectedCount}
|
|
||||||
onCancel={() => setConfirmOpen(false)}
|
|
||||||
onConfirm={handleConfirmMark}
|
|
||||||
/>
|
|
||||||
<DeleteConfirmModal
|
|
||||||
open={Boolean(deleteTarget)}
|
|
||||||
plateNo={deleteTarget?.plateNo ?? ''}
|
|
||||||
onCancel={() => setDeleteTarget(null)}
|
|
||||||
onConfirm={() => {
|
|
||||||
if (!deleteTarget) return;
|
|
||||||
setRecords((prev) => prev.filter((row) => row.id !== deleteTarget.id));
|
|
||||||
setSelectedIds((prev) => {
|
|
||||||
const next = new Set(prev);
|
|
||||||
next.delete(deleteTarget.id);
|
|
||||||
return next;
|
|
||||||
});
|
|
||||||
setDeleteTarget(null);
|
|
||||||
showToast(`已删除 ${deleteTarget.plateNo}(原型)`);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
{submitApprovalOpen && (
|
|
||||||
<div className="vm-modal-backdrop" role="presentation" onClick={() => setSubmitApprovalOpen(false)}>
|
|
||||||
<div className="vm-modal vm-modal-sm" role="dialog" aria-modal="true" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<header className="vm-modal-header">
|
|
||||||
<h2 className="vm-modal-title">提交收款审批</h2>
|
|
||||||
</header>
|
|
||||||
<div className="vm-modal-body">
|
|
||||||
<p className="ldb-confirm-text">确认将所选 {selectedIds.size} 条记录提交收款审批?</p>
|
|
||||||
</div>
|
|
||||||
<footer className="vm-modal-footer">
|
|
||||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={() => setSubmitApprovalOpen(false)}>取消</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="vm-btn vm-btn-primary"
|
|
||||||
onClick={() => {
|
|
||||||
setSubmitApprovalOpen(false);
|
|
||||||
showToast(`已提交 ${selectedIds.size} 条收款审批(原型演示)`);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
确认提交
|
|
||||||
</button>
|
|
||||||
</footer>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<ReceiptLinkModal
|
<ReceiptLinkModal
|
||||||
key={receiptRow?.id ?? 'closed'}
|
key={receiptRow?.id ?? 'closed'}
|
||||||
open={Boolean(receiptRow)}
|
open={Boolean(receiptRow)}
|
||||||
@@ -440,23 +429,6 @@ export default function LeaseBusinessLedgerApp() {
|
|||||||
onClose={() => setReceiptRow(null)}
|
onClose={() => setReceiptRow(null)}
|
||||||
onConfirm={handleReceiptConfirm}
|
onConfirm={handleReceiptConfirm}
|
||||||
/>
|
/>
|
||||||
<MaintenanceLinkModal
|
|
||||||
key={maintenanceRow?.id ?? 'closed'}
|
|
||||||
open={Boolean(maintenanceRow)}
|
|
||||||
record={maintenanceRow}
|
|
||||||
orders={MAINTENANCE_ORDERS}
|
|
||||||
onClose={() => setMaintenanceRow(null)}
|
|
||||||
onConfirm={handleMaintenanceConfirm}
|
|
||||||
/>
|
|
||||||
<AccidentLinkModal
|
|
||||||
key={accidentRow?.id ?? 'closed'}
|
|
||||||
open={Boolean(accidentRow)}
|
|
||||||
record={accidentRow}
|
|
||||||
accidents={ACCIDENT_RECORDS}
|
|
||||||
onClose={() => setAccidentRow(null)}
|
|
||||||
onConfirm={handleAccidentConfirm}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<AnnotationViewer source={annotationSourceDocument as AnnotationSourceDocument} options={annotationOptions} />
|
<AnnotationViewer source={annotationSourceDocument as AnnotationSourceDocument} options={annotationOptions} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import fs from 'node:fs';
|
import fs from 'node:fs';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { FIELD_ANNOTATION_SPEC } from './field-annotation-spec.mjs';
|
||||||
|
|
||||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||||
const protoRoot = path.resolve(__dirname, '..');
|
const protoRoot = path.resolve(__dirname, '..');
|
||||||
const prdPath = path.join(protoRoot, '.spec/requirements-prd.md');
|
const prdPath = path.join(protoRoot, '.spec/requirements-prd.md');
|
||||||
const sourcePath = path.join(protoRoot, 'annotation-source.json');
|
const sourcePath = path.join(protoRoot, 'annotation-source.json');
|
||||||
const columnsPath = path.join(protoRoot, 'components/tableColumns.ts');
|
const columnsPath = path.join(protoRoot, 'components/tableColumns.ts');
|
||||||
|
const tipsPath = path.join(protoRoot, 'columnHeaderTips.ts');
|
||||||
|
|
||||||
const legacyPrd = fs.existsSync(prdPath) ? fs.readFileSync(prdPath, 'utf8') : '';
|
const legacyPrd = fs.existsSync(prdPath) ? fs.readFileSync(prdPath, 'utf8') : '';
|
||||||
const existing = fs.existsSync(sourcePath)
|
const existing = fs.existsSync(sourcePath)
|
||||||
@@ -15,18 +17,120 @@ const existing = fs.existsSync(sourcePath)
|
|||||||
|
|
||||||
/** 保留批注工具里已写入的 markdown,避免被 PRD 摘要覆盖 */
|
/** 保留批注工具里已写入的 markdown,避免被 PRD 摘要覆盖 */
|
||||||
const markdownMap = { ...(existing?.markdownMap ?? {}) };
|
const markdownMap = { ...(existing?.markdownMap ?? {}) };
|
||||||
|
delete markdownMap['lbl-btn-mark-confirmed'];
|
||||||
|
delete markdownMap['lbl-btn-add-row'];
|
||||||
|
markdownMap['lbl-col-status'] = [
|
||||||
|
'## 收款状态',
|
||||||
|
'',
|
||||||
|
'当收款记录中分配金额完全覆盖账单的应收金额时,显示**已收款**。',
|
||||||
|
'当收款记录中分配金额≤账单的应收金额时,显示**部分收款**。',
|
||||||
|
'未分配收款记录时,显示为**未收款**。其他状态去除。',
|
||||||
|
'',
|
||||||
|
'---',
|
||||||
|
].join('\n');
|
||||||
|
markdownMap['lbl-filter'] = [
|
||||||
|
'## 2. 筛选区',
|
||||||
|
'',
|
||||||
|
'默认展示 4 项(一行),点击「更多筛选」展开全部 8 项。',
|
||||||
|
'',
|
||||||
|
'| 筛选项 | 说明 |',
|
||||||
|
'|---|---|',
|
||||||
|
'| 年份 / 月份 | 账单所属年月 |',
|
||||||
|
'| 业务部门 / 业务员 | 精确匹配 |',
|
||||||
|
'| 车牌号码 | 精确匹配 |',
|
||||||
|
'| 客户名称 | 模糊包含 |',
|
||||||
|
'| 明细状态 | 未收款 / 部分收款 / 已收款 |',
|
||||||
|
'| 应付款日 | 单输入框双日历区间,精确至日 |',
|
||||||
|
'',
|
||||||
|
'---',
|
||||||
|
].join('\n');
|
||||||
|
markdownMap['lbl-toolbar'] = [
|
||||||
|
'## 4. 列表工具栏',
|
||||||
|
'',
|
||||||
|
'左侧「仅显示未收款数据」勾选后,列表仅显示状态不等于已收款的记录。',
|
||||||
|
'',
|
||||||
|
'| 按钮 | 行为 |',
|
||||||
|
'|---|---|',
|
||||||
|
'| 批量导入 | 下载模板并上传 Excel(原型 Toast) |',
|
||||||
|
'| 导出 | 导出当前筛选结果 |',
|
||||||
|
'| 保存 | 勾选记录后设为未归档,保存后不可编辑 |',
|
||||||
|
'| 设置为归档 | 勾选记录后锁定为已归档 |',
|
||||||
|
'',
|
||||||
|
'表格底部提供分页,可切换页码与每页条数。',
|
||||||
|
'',
|
||||||
|
'---',
|
||||||
|
].join('\n');
|
||||||
|
markdownMap['lbl-col-archive-status'] = [
|
||||||
|
'## 账单状态',
|
||||||
|
'',
|
||||||
|
'状态分为**已归档**、**未归档**、**草稿**。',
|
||||||
|
'',
|
||||||
|
'**仅草稿**状态的数据可以编辑;未归档、已归档均不可编辑。',
|
||||||
|
'工具栏「保存」:勾选记录后设为**未归档**。',
|
||||||
|
'工具栏「设置为归档」:勾选记录后设为**已归档**。',
|
||||||
|
'',
|
||||||
|
'---',
|
||||||
|
].join('\n');
|
||||||
|
markdownMap['lbl-btn-save-unarchived'] = [
|
||||||
|
'## 保存',
|
||||||
|
'',
|
||||||
|
'勾选台账记录后点击,将账单状态设为**未归档**。未归档数据不可编辑,仅草稿可编辑。',
|
||||||
|
'',
|
||||||
|
'---',
|
||||||
|
].join('\n');
|
||||||
|
markdownMap['lbl-btn-set-archived'] = [
|
||||||
|
'## 设置为归档',
|
||||||
|
'',
|
||||||
|
'勾选台账记录后点击,将账单状态设为**已归档**并锁定编辑。',
|
||||||
|
'',
|
||||||
|
'---',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
function parseColumnTitles(source) {
|
||||||
|
const titles = {};
|
||||||
|
for (const match of source.matchAll(/\{[^{}]*annotationId:\s*'(lbl-[^']+)'[^{}]*\}/gs)) {
|
||||||
|
const block = match[0];
|
||||||
|
const id = match[1];
|
||||||
|
const title = block.match(/title:\s*'([^']+)'/)?.[1];
|
||||||
|
if (title) titles[id] = title;
|
||||||
|
}
|
||||||
|
return titles;
|
||||||
|
}
|
||||||
|
|
||||||
const columnsSource = fs.readFileSync(columnsPath, 'utf8');
|
const columnsSource = fs.readFileSync(columnsPath, 'utf8');
|
||||||
const columnAnnotationIds = [...columnsSource.matchAll(/annotationId:\s*'(lbl-[^']+)'/g)].map((m) => m[1]);
|
const columnAnnotationIds = [...columnsSource.matchAll(/annotationId:\s*'(lbl-[^']+)'/g)].map((m) => m[1]);
|
||||||
|
const columnTitles = parseColumnTitles(columnsSource);
|
||||||
|
|
||||||
|
const tipsSource = fs.readFileSync(tipsPath, 'utf8');
|
||||||
|
const columnHeaderTips = Object.fromEntries(
|
||||||
|
[...tipsSource.matchAll(/'(lbl-col-[^']+)':\s*\n?\s*'([^']*(?:\\'[^']*)*)'/gs)].map((m) => [
|
||||||
|
m[1],
|
||||||
|
m[2].replace(/\\'/g, "'"),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
function buildColumnFieldMarkdown(id) {
|
||||||
|
const spec = FIELD_ANNOTATION_SPEC[id];
|
||||||
|
const title = columnTitles[id] ?? titleFromMarkdown(markdownMap[id]) ?? id.replace(/^lbl-col-/, '');
|
||||||
|
const tip = columnHeaderTips[id];
|
||||||
|
const lines = [`## ${title}`, ''];
|
||||||
|
if (spec?.source) lines.push(`**数据来源**:${spec.source}`);
|
||||||
|
if (spec?.formula) lines.push(`**计算方式**:${spec.formula}`);
|
||||||
|
if (spec?.editable) lines.push(`**录入方式**:${spec.editable}`);
|
||||||
|
if (tip) lines.push('', `**业务说明**:${tip}`);
|
||||||
|
lines.push('', '---');
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
const NODE_META = {
|
const NODE_META = {
|
||||||
'lbl-filter': { title: '筛选条件', color: '#2563eb', aiPrompt: '年份、月份、部门、业务员等筛选。' },
|
'lbl-filter': { title: '筛选条件', color: '#2563eb', aiPrompt: '年份、月份、部门、业务员等筛选。' },
|
||||||
'lbl-kpi': { title: 'KPI 统计', color: '#64748b', aiPrompt: '六张统计卡片口径。' },
|
'lbl-kpi': { title: 'KPI 统计', color: '#64748b', aiPrompt: '六张统计卡片口径。' },
|
||||||
'lbl-kpi-count': { title: '台账条数', color: '#64748b', aiPrompt: '台账条数 KPI 点击切换列表。' },
|
'lbl-kpi-count': { title: '台账条数', color: '#64748b', aiPrompt: '台账条数 KPI 点击切换列表。' },
|
||||||
'lbl-kpi-unreceived': { title: '未收总计', color: '#64748b', aiPrompt: '未收总计 KPI 筛选未收款。' },
|
'lbl-kpi-unreceived': { title: '未收总计', color: '#64748b', aiPrompt: '未收总计 KPI 筛选未收款。' },
|
||||||
'lbl-toolbar': { title: '列表工具栏', color: '#0f766e', aiPrompt: '导入、导出、标记确认、提交审批。' },
|
'lbl-toolbar': { title: '列表工具栏', color: '#0f766e', aiPrompt: '导入、导出、保存、设置为归档。' },
|
||||||
'lbl-btn-mark-confirmed': { title: '标记为已确认', color: '#0f766e', aiPrompt: '草稿批量确认与权限收口。' },
|
'lbl-btn-save-unarchived': { title: '保存', color: '#0f766e', aiPrompt: '勾选记录批量设为未归档。' },
|
||||||
'lbl-btn-add-row': { title: '添加行', color: '#0f766e', aiPrompt: '表格底部新增行。' },
|
'lbl-btn-set-archived': { title: '设置为归档', color: '#0f766e', aiPrompt: '勾选记录批量归档。' },
|
||||||
'lbl-table': { title: '租赁台账宽表', color: '#2563eb', aiPrompt: '运营明细台账列表与行操作。' },
|
'lbl-table': { title: '租赁台账宽表', color: '#2563eb', aiPrompt: '运营明细台账列表与行操作。' },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -58,6 +162,10 @@ function resolveMeta(id) {
|
|||||||
if (NODE_META[id]) return NODE_META[id];
|
if (NODE_META[id]) return NODE_META[id];
|
||||||
const md = markdownMap[id];
|
const md = markdownMap[id];
|
||||||
const fromMd = md ? titleFromMarkdown(md) : null;
|
const fromMd = md ? titleFromMarkdown(md) : null;
|
||||||
|
const fromColumn = columnTitles[id];
|
||||||
|
if (fromColumn) {
|
||||||
|
return { title: fromColumn, color: '#64748b', aiPrompt: `${fromColumn}字段的数据来源与计算口径。` };
|
||||||
|
}
|
||||||
if (fromMd) {
|
if (fromMd) {
|
||||||
return { title: fromMd, color: '#64748b', aiPrompt: `${fromMd}字段说明。` };
|
return { title: fromMd, color: '#64748b', aiPrompt: `${fromMd}字段说明。` };
|
||||||
}
|
}
|
||||||
@@ -87,7 +195,7 @@ function buildFullPrd(map, columnIds) {
|
|||||||
return [
|
return [
|
||||||
'# 租赁业务台账 · 产品需求说明(PRD)',
|
'# 租赁业务台账 · 产品需求说明(PRD)',
|
||||||
'',
|
'',
|
||||||
'> 对齐《租赁业务运营明细台账模版》;业务服务组维护各自台账,标记已确认与收款关联为原型演示。',
|
'> 对齐《租赁业务运营明细台账模版》;业务服务组维护各自台账,收款关联与状态自动计算为原型演示。',
|
||||||
'',
|
'',
|
||||||
'---',
|
'---',
|
||||||
'',
|
'',
|
||||||
@@ -98,8 +206,8 @@ function buildFullPrd(map, columnIds) {
|
|||||||
'|---|---|',
|
'|---|---|',
|
||||||
'| 模块名称 | 台账数据 — 租赁业务台账 |',
|
'| 模块名称 | 台账数据 — 租赁业务台账 |',
|
||||||
'| 目标用户 | 租赁业务客服、业务服务组主管 |',
|
'| 目标用户 | 租赁业务客服、业务服务组主管 |',
|
||||||
'| 核心任务 | 维护租赁运营明细、汇总应收实收与盈亏、标记确认与关联收款 |',
|
'| 核心任务 | 维护租赁运营明细、汇总应收实收与盈亏、关联收款记录 |',
|
||||||
'| 页面结构 | 筛选区 + KPI 统计 + 工具栏 + 宽表 + 下拉加载更多(不做分页) |',
|
'| 页面结构 | 筛选区 + KPI 统计 + 工具栏 + 宽表 + 底部分页 |',
|
||||||
].join('\n'),
|
].join('\n'),
|
||||||
),
|
),
|
||||||
'',
|
'',
|
||||||
@@ -119,9 +227,9 @@ function buildFullPrd(map, columnIds) {
|
|||||||
'',
|
'',
|
||||||
trimDoc(map['lbl-toolbar']),
|
trimDoc(map['lbl-toolbar']),
|
||||||
'',
|
'',
|
||||||
asSubsection(map['lbl-btn-mark-confirmed']),
|
asSubsection(map['lbl-btn-save-unarchived']),
|
||||||
'',
|
'',
|
||||||
asSubsection(map['lbl-btn-add-row']),
|
asSubsection(map['lbl-btn-set-archived']),
|
||||||
'',
|
'',
|
||||||
'---',
|
'---',
|
||||||
'',
|
'',
|
||||||
@@ -130,8 +238,8 @@ function buildFullPrd(map, columnIds) {
|
|||||||
'---',
|
'---',
|
||||||
'',
|
'',
|
||||||
(() => {
|
(() => {
|
||||||
const body = trimDoc(map['lbl-col-status']).replace(/^##\s+状态流转\s*\n+/, '');
|
const body = trimDoc(map['lbl-col-status']).replace(/^##\s+(状态流转|收款状态)\s*\n+/, '');
|
||||||
return prdSection('6. 状态流转', body);
|
return prdSection('6. 收款状态', body);
|
||||||
})(),
|
})(),
|
||||||
'',
|
'',
|
||||||
'---',
|
'---',
|
||||||
@@ -143,6 +251,9 @@ function buildFullPrd(map, columnIds) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function ensureMarkdown(id, meta) {
|
function ensureMarkdown(id, meta) {
|
||||||
|
if (id.startsWith('lbl-col-')) {
|
||||||
|
return buildColumnFieldMarkdown(id);
|
||||||
|
}
|
||||||
if (markdownMap[id]?.trim()) return markdownMap[id];
|
if (markdownMap[id]?.trim()) return markdownMap[id];
|
||||||
const prdFallback = {
|
const prdFallback = {
|
||||||
'lbl-filter': '## 2. 筛选区',
|
'lbl-filter': '## 2. 筛选区',
|
||||||
@@ -162,8 +273,8 @@ const pageNodeIds = [
|
|||||||
'lbl-kpi-count',
|
'lbl-kpi-count',
|
||||||
'lbl-kpi-unreceived',
|
'lbl-kpi-unreceived',
|
||||||
'lbl-toolbar',
|
'lbl-toolbar',
|
||||||
'lbl-btn-mark-confirmed',
|
'lbl-btn-save-unarchived',
|
||||||
'lbl-btn-add-row',
|
'lbl-btn-set-archived',
|
||||||
'lbl-table',
|
'lbl-table',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -290,8 +401,8 @@ const source = {
|
|||||||
defaultExpanded: false,
|
defaultExpanded: false,
|
||||||
children: [
|
children: [
|
||||||
docNode('lbl-toolbar'),
|
docNode('lbl-toolbar'),
|
||||||
docNode('lbl-btn-mark-confirmed'),
|
docNode('lbl-btn-save-unarchived'),
|
||||||
docNode('lbl-btn-add-row'),
|
docNode('lbl-btn-set-archived'),
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,226 @@
|
|||||||
|
/**
|
||||||
|
* 租赁业务台账 — 字段标注规格(数据来源 + 计算方式)
|
||||||
|
* 供 build-annotation-source.mjs 生成 annotation-source / PRD 字段说明。
|
||||||
|
*/
|
||||||
|
export const FIELD_ANNOTATION_SPEC = {
|
||||||
|
'lbl-col-status': {
|
||||||
|
source: '收款记录模块中关联到本账单的分配金额、账单应收合计。',
|
||||||
|
formula: '分配金额 ≥ 应收合计且应收 > 0 → 已收款;0 < 分配金额 < 应收 → 部分收款;未关联收款 → 未收款。',
|
||||||
|
editable: '系统自动计算,只读。',
|
||||||
|
},
|
||||||
|
'lbl-col-payment-due': {
|
||||||
|
source: '账单起始日期(优先)或提车日期作为开始计费日;账单所属年月。',
|
||||||
|
formula: '首期账单应付款日 = 开始计费日;后续每期 = 账单所属自然月 5 日。',
|
||||||
|
editable: '系统自动计算,只读。',
|
||||||
|
},
|
||||||
|
'lbl-col-plate': {
|
||||||
|
source: '车辆档案(按车牌匹配品牌、型号)。',
|
||||||
|
formula: '导入/新增台账时按车牌号反写车辆信息。',
|
||||||
|
editable: '只读展示(车牌 + 车型两行)。',
|
||||||
|
},
|
||||||
|
'lbl-col-customer': {
|
||||||
|
source: '租赁合同(按账单日期 + 车牌匹配客户、项目)。',
|
||||||
|
formula: '匹配当前车辆在账期内的有效合同,带出客户名称与项目名称。',
|
||||||
|
editable: '只读展示(客户 + 项目两行)。',
|
||||||
|
},
|
||||||
|
'lbl-col-value-added-service': {
|
||||||
|
source: '租赁合同签订时勾选的增值服务项目。',
|
||||||
|
formula: '按车牌匹配合同增值服务配置,多项服务分行展示。',
|
||||||
|
editable: '只读。',
|
||||||
|
},
|
||||||
|
'lbl-col-pickup-date': {
|
||||||
|
source: '交车管理 / 运维 E 签宝签字完成时间。',
|
||||||
|
formula: '取该车辆实际交车办结时间。',
|
||||||
|
editable: '只读。',
|
||||||
|
},
|
||||||
|
'lbl-col-contract-start-date': {
|
||||||
|
source: '提车日期(交车办结时间)。',
|
||||||
|
formula: '合同生效日期 = 提车日期(YYYY-MM-DD),系统自动反写。',
|
||||||
|
editable: '系统自动反写,只读。',
|
||||||
|
},
|
||||||
|
'lbl-col-contract-end-date': {
|
||||||
|
source: '合同租期月数 + 提车日期,或合同约定的到期日。',
|
||||||
|
formula: '日期模式:取合同存储的 contractEndDate;租期模式:提车日 + leaseTermMonths 个月。',
|
||||||
|
editable: '系统自动计算,只读。',
|
||||||
|
},
|
||||||
|
'lbl-col-period-start-date': {
|
||||||
|
source: '账单年月、提车日期。',
|
||||||
|
formula: '月前已提车 → 当月 1 日;月内提车 → 提车日;月后提车 → 空(calcPeriodStartDate)。',
|
||||||
|
editable: '系统自动计算,只读。',
|
||||||
|
},
|
||||||
|
'lbl-col-return-date': {
|
||||||
|
source: '退车管理登记的退车日期。',
|
||||||
|
formula: '取该车牌退车办结日期;未退车为空。',
|
||||||
|
editable: '只读。',
|
||||||
|
},
|
||||||
|
'lbl-col-avg-days': {
|
||||||
|
source: '账单月内租赁天数、当月自然日天数。',
|
||||||
|
formula: '平均天数 = 账单月内租赁天数 ÷ 当月自然日天数,保留两位小数(calcAvgDays)。',
|
||||||
|
editable: '系统自动计算,只读。',
|
||||||
|
},
|
||||||
|
'lbl-col-deposit': {
|
||||||
|
source: '提车应收款 / 合同保证金。',
|
||||||
|
formula: '与创建合同时保证金、提车应收款中该车辆保证金一致。',
|
||||||
|
editable: '只读。',
|
||||||
|
},
|
||||||
|
'lbl-col-contract-rent': {
|
||||||
|
source: '租赁合同月租金标准。',
|
||||||
|
formula: '签订合同时该车辆的月租金(合同标的租金)。',
|
||||||
|
editable: '合同带入,只读。',
|
||||||
|
},
|
||||||
|
'lbl-col-receivable-total': {
|
||||||
|
source: '应收租金、保险上浮费、运维费(收入侧)、其他收入、里程/其他减免。',
|
||||||
|
formula: '应收合计 = 应收租金 + 保险上浮费 + 运维费(收入侧)+ 其他收入 − 里程减免 − 其他减免(calcReceivableTotal)。',
|
||||||
|
editable: '系统自动汇总,只读。',
|
||||||
|
},
|
||||||
|
'lbl-col-receivable-rent': {
|
||||||
|
source: '合同标的租金、合同付款周期月数。',
|
||||||
|
formula: '应收租金 = 合同标的租金 × 付款周期月数(1/3/6/12)(calcReceivableRent)。',
|
||||||
|
editable: '系统自动计算,只读。',
|
||||||
|
},
|
||||||
|
'lbl-col-monthly-income': {
|
||||||
|
source: '月度租金、维保包干、保险上浮费、运维费(收入侧)、其他收入、减免项。',
|
||||||
|
formula: '月度收入 = 月度租金 + 维保包干收入 + 保险上浮费 + 运维费(收入侧)+ 其他收入 − 里程减免 − 其他减免(calcMonthlyIncome)。',
|
||||||
|
editable: '系统自动计算,只读。',
|
||||||
|
},
|
||||||
|
'lbl-col-monthly-rent': {
|
||||||
|
source: '合同标的租金。',
|
||||||
|
formula: '月度租金 = 合同标的租金(calcMonthlyRent)。',
|
||||||
|
editable: '只读。',
|
||||||
|
},
|
||||||
|
'lbl-col-maintenance-income': {
|
||||||
|
source: '合同维保包干配置、账单里程。',
|
||||||
|
formula: '勾选维保包干时:单价(元/km)× 里程数;未勾选为 0(calcMaintenanceIncome)。',
|
||||||
|
editable: '包干未勾选时不可编辑。',
|
||||||
|
},
|
||||||
|
'lbl-col-insurance-surcharge': {
|
||||||
|
source: '事故台账关联的保险上浮费。',
|
||||||
|
formula: '关联事故单后汇总 linkedAccidentIds 对应上浮费;最新账单未关联时显示警示。',
|
||||||
|
editable: '关联后自动汇总,支持多选事故单。',
|
||||||
|
},
|
||||||
|
'lbl-col-ops-income': {
|
||||||
|
source: '维修/保养台账(客户承担)、账单月内该车牌完工记录。',
|
||||||
|
formula: '汇总 bearBy=客户承担 的维保金额;可关联 linkedMaintenanceIds(sumMaintenanceForBill)。',
|
||||||
|
editable: '关联后自动汇总;最新账单可关联记录。',
|
||||||
|
},
|
||||||
|
'lbl-col-other-income': {
|
||||||
|
source: '行内其他收入明细(事由、金额、备注)。',
|
||||||
|
formula: '多条明细金额合计计入应收与月度收入(sumLineItems)。',
|
||||||
|
editable: '草稿状态可编辑明细。',
|
||||||
|
},
|
||||||
|
'lbl-col-mileage-deduction': {
|
||||||
|
source: 'BI 里程(GPS)、里程减免单价。',
|
||||||
|
formula: '里程减免 = GPS 里程 × 单价;有 GPS 时默认 0.5 元/km,无 GPS 为 0(calcMileageDeduction)。',
|
||||||
|
editable: '系统自动计算,只读。',
|
||||||
|
},
|
||||||
|
'lbl-col-other-deduction': {
|
||||||
|
source: '行内其他减免明细。',
|
||||||
|
formula: '多条明细金额合计,从应收与月度收入扣减(sumLineItems)。',
|
||||||
|
editable: '草稿状态可编辑明细。',
|
||||||
|
},
|
||||||
|
'lbl-col-received-amount': {
|
||||||
|
source: '收款记录模块关联分配金额。',
|
||||||
|
formula: '关联收款单后按分配计入金额汇总,不超过应收合计(calcReceivedAmountFromReceipts)。',
|
||||||
|
editable: '通过「关联收款」操作维护。',
|
||||||
|
},
|
||||||
|
'lbl-col-unreceived': {
|
||||||
|
source: '应收合计、实收金额。',
|
||||||
|
formula: '未收金额 = 应收合计 − 实收金额(calcUnreceived)。',
|
||||||
|
editable: '系统自动计算,不可手工录入。',
|
||||||
|
},
|
||||||
|
'lbl-col-invoice-date': {
|
||||||
|
source: '关联收款记录中的开票信息。',
|
||||||
|
formula: 'resolveInvoiceDateFromReceipts 取关联收款单开票日期。',
|
||||||
|
editable: '只读。',
|
||||||
|
},
|
||||||
|
'lbl-col-actual-payment': {
|
||||||
|
source: '关联收款记录的到账日期。',
|
||||||
|
formula: '有关联收款时取到账记录付款日期;未关联为空。',
|
||||||
|
editable: '只读。',
|
||||||
|
},
|
||||||
|
'lbl-col-payment-method': {
|
||||||
|
source: '合同付款周期月数。',
|
||||||
|
formula: '1→月度预付,3→季度预付,6→半年预付,12→年度预付(resolvePaymentMethod)。',
|
||||||
|
editable: '只读。',
|
||||||
|
},
|
||||||
|
'lbl-col-vehicle-standard-cost': {
|
||||||
|
source: '车型成本明细 / 车牌标准成本配置(PLATE_STANDARD_COST)。',
|
||||||
|
formula: '按车型维护的固定月标准成本。',
|
||||||
|
editable: '只读。',
|
||||||
|
},
|
||||||
|
'lbl-col-vehicle-actual-cost': {
|
||||||
|
source: '车辆标准成本、账单月内租赁天数。',
|
||||||
|
formula: '车辆实际成本 = 标准成本 × 租赁天数 / 30(calcVehicleActualCost)。',
|
||||||
|
editable: '系统自动计算,只读。',
|
||||||
|
},
|
||||||
|
'lbl-col-insurance-fee': {
|
||||||
|
source: '保险采购金额、保险有效天数、账单月租赁天数。',
|
||||||
|
formula: '保险费 =(采购金额 / 有效天数)× 租赁天数,四舍五入两位(calcInsuranceFee)。',
|
||||||
|
editable: '只读。',
|
||||||
|
},
|
||||||
|
'lbl-col-hydrogen-fee': {
|
||||||
|
source: '氢费明细台账、合同氢费承担方式。',
|
||||||
|
formula: '合同为「我司承担」时,汇总账单月内该车牌氢费(sumHydrogenFeeForBill);客户承担不计入。',
|
||||||
|
editable: '只读。',
|
||||||
|
},
|
||||||
|
'lbl-col-ops-cost': {
|
||||||
|
source: '维修/保养台账(我司承担)、账单月内该车牌。',
|
||||||
|
formula: '汇总 bearBy=我司承担 的维保金额(sumMaintenanceForBill)。',
|
||||||
|
editable: '只读。',
|
||||||
|
},
|
||||||
|
'lbl-col-brokerage-fee': {
|
||||||
|
source: '行内手工录入。',
|
||||||
|
formula: '直接存储 brokerageFee 字段。',
|
||||||
|
editable: '草稿可编辑。',
|
||||||
|
},
|
||||||
|
'lbl-col-other-cost': {
|
||||||
|
source: '行内手工录入。',
|
||||||
|
formula: '直接存储 otherCost;前方有其他收入时本项必填。',
|
||||||
|
editable: '草稿可编辑。',
|
||||||
|
},
|
||||||
|
'lbl-col-gps-mileage': {
|
||||||
|
source: 'BI 里程数据(bi-mileage.json / 车机 GPS)。',
|
||||||
|
formula: 'lookupBiMileage(车牌, 年, 月) 取账单期里程。',
|
||||||
|
editable: '只读。',
|
||||||
|
},
|
||||||
|
'lbl-col-deduction-detail': {
|
||||||
|
source: '行内手工录入。',
|
||||||
|
formula: '存储 otherDeductionDetail 文本。',
|
||||||
|
editable: '草稿可编辑。',
|
||||||
|
},
|
||||||
|
'lbl-col-asset-ownership': {
|
||||||
|
source: '车辆档案资产归属。',
|
||||||
|
formula: '按车牌从 PLATE_ASSET_OWNER / 车辆档案反写。',
|
||||||
|
editable: '只读。',
|
||||||
|
},
|
||||||
|
'lbl-col-signing-company': {
|
||||||
|
source: '租赁合同甲方。',
|
||||||
|
formula: '按车牌匹配签订合同时甲方名称(PLATE_SIGNING_COMPANY)。',
|
||||||
|
editable: '只读。',
|
||||||
|
},
|
||||||
|
'lbl-col-remark': {
|
||||||
|
source: '行内手工录入。',
|
||||||
|
formula: '存储 remark 文本。',
|
||||||
|
editable: '草稿可编辑。',
|
||||||
|
},
|
||||||
|
'lbl-col-archive-status': {
|
||||||
|
source: '账单归档状态字段 archiveStatus。',
|
||||||
|
formula: '草稿 / 未归档 / 已归档;工具栏「保存」「设置为归档」批量变更。',
|
||||||
|
editable: '仅草稿可编辑业务字段。',
|
||||||
|
},
|
||||||
|
'lbl-col-maintainer-time': {
|
||||||
|
source: '台账行维护记录。',
|
||||||
|
formula: '展示最近一次保存/维护时间(maintainerTime)。',
|
||||||
|
editable: '系统记录,只读。',
|
||||||
|
},
|
||||||
|
'lbl-col-dept': {
|
||||||
|
source: '台账行业务部门、业务员字段。',
|
||||||
|
formula: '导入或维护时写入,列表以「部门 + 业务员」两行展示。',
|
||||||
|
editable: '导入/草稿可维护。',
|
||||||
|
},
|
||||||
|
'lbl-col-payment-date': {
|
||||||
|
source: '收款记录关联反写。',
|
||||||
|
formula: '关联租赁订单收款后反显收款日期。',
|
||||||
|
editable: '只读。',
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -1,33 +1,11 @@
|
|||||||
/* 租赁业务台账 — 模块样式(基于 vehicle-management vm-*) */
|
/* 租赁业务台账 — 模块样式(基于 vehicle-management vm-* + ledger-shared 壳层) */
|
||||||
|
|
||||||
.ldb-page {
|
@import '../../ledger-shared/styles/ledger-page.css';
|
||||||
padding-bottom: 48px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ldb-page-title {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.125rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--ln-ink);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ldb-date-range {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ldb-date-range input {
|
|
||||||
flex: 1;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ldb-kpi-row {
|
.ldb-kpi-row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 1280px) {
|
@media (max-width: 1280px) {
|
||||||
@@ -75,6 +53,12 @@
|
|||||||
cursor: help;
|
cursor: help;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ldb-th-tip.ldb-kpi-tip {
|
||||||
|
position: relative;
|
||||||
|
top: auto;
|
||||||
|
right: auto;
|
||||||
|
}
|
||||||
|
|
||||||
.ldb-kpi-tooltip {
|
.ldb-kpi-tooltip {
|
||||||
display: none;
|
display: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -93,10 +77,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ldb-kpi-tip:hover .ldb-kpi-tooltip,
|
.ldb-kpi-tip:hover .ldb-kpi-tooltip,
|
||||||
.ldb-kpi-tip:focus-within .ldb-kpi-tooltip {
|
.ldb-kpi-tip:focus-within .ldb-kpi-tooltip,
|
||||||
|
.ldb-th-tip:hover .ldb-kpi-tooltip,
|
||||||
|
.ldb-th-tip:focus-within .ldb-kpi-tooltip {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ldb-kpi-tip .ldb-kpi-tooltip {
|
||||||
|
top: auto;
|
||||||
|
bottom: calc(100% + 6px);
|
||||||
|
right: 0;
|
||||||
|
left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-th-inner .ldb-kpi-tooltip {
|
||||||
|
max-width: 280px;
|
||||||
|
text-align: left;
|
||||||
|
white-space: normal;
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
.ldb-table-toolbar {
|
.ldb-table-toolbar {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -152,15 +152,8 @@
|
|||||||
.ldb-asset--leased { background: #fff7ed; color: #c2410c; }
|
.ldb-asset--leased { background: #fff7ed; color: #c2410c; }
|
||||||
.ldb-asset--affiliated { background: #f5f3ff; color: #6d28d9; }
|
.ldb-asset--affiliated { background: #f5f3ff; color: #6d28d9; }
|
||||||
|
|
||||||
.ldb-table-footer-actions {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ldb-table-wrap { overflow: auto; }
|
.ldb-table-wrap { overflow: auto; }
|
||||||
.ldb-table { min-width: 4200px; table-layout: fixed; }
|
.ldb-table { min-width: 4200px; }
|
||||||
|
|
||||||
.ldb-table .vm-table thead th,
|
.ldb-table .vm-table thead th,
|
||||||
.ldb-table .vm-table tbody td {
|
.ldb-table .vm-table tbody td {
|
||||||
@@ -176,11 +169,6 @@
|
|||||||
background: var(--ln-canvas-soft);
|
background: var(--ln-canvas-soft);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ldb-table .vm-table tbody tr:hover td.sticky-col-left,
|
|
||||||
.ldb-table .vm-table tbody tr:hover td.sticky-col-right {
|
|
||||||
background: var(--ln-canvas-soft);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ldb-cell-stack {
|
.ldb-cell-stack {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -324,52 +312,6 @@
|
|||||||
background: color-mix(in srgb, var(--ln-primary) 18%, transparent);
|
background: color-mix(in srgb, var(--ln-primary) 18%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ldb-toolbar-btn {
|
|
||||||
transition: transform 0.12s ease, box-shadow 0.15s ease, border-color 0.15s ease, background-color 0.15s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ldb-toolbar-btn:hover {
|
|
||||||
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ldb-toolbar-btn:active {
|
|
||||||
transform: translateY(1px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ldb-filter-expand {
|
|
||||||
display: grid;
|
|
||||||
grid-template-rows: 0fr;
|
|
||||||
transition: grid-template-rows 0.28s ease, opacity 0.22s ease;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ldb-filter-expand.is-expanded {
|
|
||||||
grid-template-rows: 1fr;
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ldb-filter-expand-inner {
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ldb-filter-expand-grid {
|
|
||||||
padding-top: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ldb-filter-toggle-icon {
|
|
||||||
transition: transform 0.25s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ldb-filter-toggle-icon.is-open {
|
|
||||||
transform: rotate(180deg);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ldb-date-range-sep {
|
|
||||||
flex-shrink: 0;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
color: var(--ln-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ldb-table-skeleton {
|
.ldb-table-skeleton {
|
||||||
padding: 12px 0;
|
padding: 12px 0;
|
||||||
}
|
}
|
||||||
@@ -400,20 +342,6 @@
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sticky-col-left {
|
|
||||||
position: sticky;
|
|
||||||
left: 0;
|
|
||||||
z-index: 2;
|
|
||||||
background: var(--ln-surface-card);
|
|
||||||
}
|
|
||||||
|
|
||||||
.sticky-col-right {
|
|
||||||
position: sticky;
|
|
||||||
right: 0;
|
|
||||||
z-index: 2;
|
|
||||||
background: var(--ln-surface-card);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ldb-status-tag {
|
.ldb-status-tag {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -425,10 +353,12 @@
|
|||||||
border: 1px solid transparent;
|
border: 1px solid transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ldb-status--draft { background: #ecfdf5; color: #047857; border-color: #a7f3d0; }
|
.ldb-status--unpaid { background: #fef2f2; color: #b91c1c; border-color: #fecaca; }
|
||||||
.ldb-status--confirmed { background: #eff6ff; color: #1d4ed8; border-color: #bfdbfe; }
|
|
||||||
.ldb-status--partial { background: #fff7ed; color: #c2410c; border-color: #fed7aa; }
|
.ldb-status--partial { background: #fff7ed; color: #c2410c; border-color: #fed7aa; }
|
||||||
.ldb-status--paid { background: #f0fdf4; color: #15803d; border-color: #bbf7d0; }
|
.ldb-status--paid { background: #f0fdf4; color: #15803d; border-color: #bbf7d0; }
|
||||||
|
.ldb-status--archived { background: #eff6ff; color: #1d4ed8; border-color: #bfdbfe; }
|
||||||
|
.ldb-status--unarchived { background: #ecfdf5; color: #047857; border-color: #a7f3d0; }
|
||||||
|
.ldb-status--draft { background: #f8fafc; color: #475569; border-color: #e2e8f0; }
|
||||||
|
|
||||||
.ldb-confirm-text {
|
.ldb-confirm-text {
|
||||||
margin: 0 0 8px;
|
margin: 0 0 8px;
|
||||||
@@ -486,19 +416,6 @@
|
|||||||
.ldb-action-sep { color: var(--ln-hairline); margin: 0 2px; }
|
.ldb-action-sep { color: var(--ln-hairline); margin: 0 2px; }
|
||||||
.ldb-link-danger { color: var(--ln-error) !important; }
|
.ldb-link-danger { color: var(--ln-error) !important; }
|
||||||
|
|
||||||
.ldb-table-footer {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: 16px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.ldb-table-total {
|
|
||||||
font-size: 0.875rem;
|
|
||||||
color: var(--ln-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.ldb-table .vm-table thead th.ldb-col-money,
|
.ldb-table .vm-table thead th.ldb-col-money,
|
||||||
.ldb-table .vm-table tbody td.ldb-col-money {
|
.ldb-table .vm-table tbody td.ldb-col-money {
|
||||||
text-align: right;
|
text-align: right;
|
||||||
@@ -571,8 +488,38 @@
|
|||||||
.ldb-th-tip {
|
.ldb-th-tip {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
color: var(--ln-muted);
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
color: var(--ln-muted-soft);
|
||||||
cursor: help;
|
cursor: help;
|
||||||
|
flex-shrink: 0;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-th-tip:focus-visible {
|
||||||
|
outline: 2px solid var(--ln-focus-ring, #32a06e);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-kpi-tooltip--portal {
|
||||||
|
display: block;
|
||||||
|
position: fixed;
|
||||||
|
z-index: 1000;
|
||||||
|
width: max-content;
|
||||||
|
max-width: min(280px, calc(100vw - 24px));
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: var(--ln-radius-control);
|
||||||
|
background: var(--ln-ink);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
box-shadow: var(--ln-shadow-hover);
|
||||||
|
text-align: left;
|
||||||
|
white-space: normal;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.ldb-text-input {
|
.ldb-text-input {
|
||||||
@@ -622,3 +569,167 @@
|
|||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.ldb-col-unreceived-warn {
|
||||||
|
color: #c2410c;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-cell-multiline {
|
||||||
|
white-space: pre-line;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-detail-popover-wrap {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-detail-popover-trigger {
|
||||||
|
border: none;
|
||||||
|
background: transparent;
|
||||||
|
padding: 2px 6px;
|
||||||
|
margin: -2px -6px;
|
||||||
|
font: inherit;
|
||||||
|
color: inherit;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-cell-clickable {
|
||||||
|
color: #1d4ed8;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-color: rgba(29, 78, 216, 0.35);
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: color 0.15s ease, background 0.15s ease, text-decoration-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-cell-clickable:hover,
|
||||||
|
.ldb-cell-clickable[aria-expanded='true'] {
|
||||||
|
color: #1e40af;
|
||||||
|
text-decoration-color: #1d4ed8;
|
||||||
|
background: rgba(37, 99, 235, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-detail-popover-trigger:hover,
|
||||||
|
.ldb-detail-popover-trigger[aria-expanded='true'] {
|
||||||
|
text-decoration-color: #1d4ed8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-line-items-trigger--editable {
|
||||||
|
color: var(--ln-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-detail-popover-card {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: calc(100% + 6px);
|
||||||
|
z-index: 30;
|
||||||
|
min-width: 320px;
|
||||||
|
max-width: min(520px, 80vw);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: var(--ln-radius-card);
|
||||||
|
border: 1px solid var(--ln-hairline);
|
||||||
|
background: var(--ln-surface-card);
|
||||||
|
box-shadow: var(--ln-shadow-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-detail-popover-title {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ln-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-detail-popover-body {
|
||||||
|
max-height: 280px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-detail-popover-footer {
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid var(--ln-hairline);
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-detail-popover-empty {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--ln-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-detail-popover-table-wrap {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-detail-popover-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-detail-popover-table th,
|
||||||
|
.ldb-detail-popover-table td {
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-bottom: 1px solid var(--ln-hairline);
|
||||||
|
text-align: left;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-detail-popover-table th {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ln-muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-detail-popover-table .text-right {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-line-items-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-line-items-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1.2fr 0.8fr 1fr;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-line-items-input {
|
||||||
|
padding: 4px 6px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-line-items-add {
|
||||||
|
align-self: flex-start;
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-action-btn--history {
|
||||||
|
color: #1d4ed8;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-underline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-action-btn--history:hover {
|
||||||
|
color: #1e40af;
|
||||||
|
background: rgba(37, 99, 235, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-action-btn--save {
|
||||||
|
color: var(--ln-success, #15803d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-action-btn--save:hover {
|
||||||
|
color: #166534;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,16 +1,36 @@
|
|||||||
export type LeaseDetailStatus = '草稿' | '已确认' | '部分收款' | '已收款';
|
export type LeaseDetailStatus = '未收款' | '部分收款' | '已收款';
|
||||||
|
export type BillArchiveStatus = '已归档' | '未归档' | '草稿';
|
||||||
export type AssetOwnership = '自有' | '租赁' | '挂靠';
|
export type AssetOwnership = '自有' | '租赁' | '挂靠';
|
||||||
export type ListViewMode = 'all' | 'unpaid';
|
export type ListViewMode = 'all' | 'unpaid';
|
||||||
|
export type MaintenanceBearBy = '客户承担' | '我司承担';
|
||||||
|
export type ContractEndMode = 'term' | 'date';
|
||||||
|
|
||||||
|
export interface LedgerLineItem {
|
||||||
|
reason: string;
|
||||||
|
amount: number;
|
||||||
|
remark: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface OperationHistoryEntry {
|
||||||
|
operatedAt: string;
|
||||||
|
operatorName: string;
|
||||||
|
field: string;
|
||||||
|
before: string;
|
||||||
|
after: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface LeaseLedgerRow {
|
export interface LeaseLedgerRow {
|
||||||
id: string;
|
id: string;
|
||||||
status: LeaseDetailStatus;
|
status: LeaseDetailStatus;
|
||||||
|
archiveStatus: BillArchiveStatus;
|
||||||
maintainerName: string;
|
maintainerName: string;
|
||||||
|
maintainerTime?: string;
|
||||||
year: number;
|
year: number;
|
||||||
month: number;
|
month: number;
|
||||||
dept: string;
|
dept: string;
|
||||||
salesman: string;
|
salesman: string;
|
||||||
paymentDate: string;
|
paymentDueDate?: string;
|
||||||
|
paymentDate?: string;
|
||||||
plateNo: string;
|
plateNo: string;
|
||||||
vehicleBrand: string;
|
vehicleBrand: string;
|
||||||
vehicleModel: string;
|
vehicleModel: string;
|
||||||
@@ -22,9 +42,12 @@ export interface LeaseLedgerRow {
|
|||||||
pickupDate: string;
|
pickupDate: string;
|
||||||
contractStartDate: string;
|
contractStartDate: string;
|
||||||
contractEndDate: string;
|
contractEndDate: string;
|
||||||
|
contractEndMode?: ContractEndMode;
|
||||||
|
leaseTermMonths?: number;
|
||||||
periodStartDate: string;
|
periodStartDate: string;
|
||||||
returnDate: string;
|
returnDate: string;
|
||||||
avgDays: number;
|
avgDays: number;
|
||||||
|
actualLeaseDays?: number;
|
||||||
deposit: string | number;
|
deposit: string | number;
|
||||||
contractRent: number;
|
contractRent: number;
|
||||||
paymentCycleMonths: number;
|
paymentCycleMonths: number;
|
||||||
@@ -32,12 +55,18 @@ export interface LeaseLedgerRow {
|
|||||||
maintenancePackageEnabled: boolean;
|
maintenancePackageEnabled: boolean;
|
||||||
maintenanceUnitPricePerKm: number;
|
maintenanceUnitPricePerKm: number;
|
||||||
mileage: number;
|
mileage: number;
|
||||||
|
mileageDeductionRate?: number;
|
||||||
customerBearsMaintenance: boolean;
|
customerBearsMaintenance: boolean;
|
||||||
linkedMaintenanceIds: string[];
|
linkedMaintenanceIds: string[];
|
||||||
linkedAccidentIds: string[];
|
linkedAccidentIds: string[];
|
||||||
linkedReceiptIds: string[];
|
linkedReceiptIds: string[];
|
||||||
hydrogenBorneBy: '我司承担' | '客户承担' | '';
|
hydrogenBorneBy: '我司承担' | '客户承担' | '';
|
||||||
isLatestBill: boolean;
|
isLatestBill: boolean;
|
||||||
|
insurancePurchaseAmount?: number;
|
||||||
|
insuranceEffectiveDays?: number;
|
||||||
|
otherIncomeItems?: LedgerLineItem[];
|
||||||
|
otherDeductionItems?: LedgerLineItem[];
|
||||||
|
operationHistory?: OperationHistoryEntry[];
|
||||||
receivableTotal: number;
|
receivableTotal: number;
|
||||||
receivableRent: number;
|
receivableRent: number;
|
||||||
monthlyIncome: number;
|
monthlyIncome: number;
|
||||||
@@ -108,6 +137,7 @@ export interface ReceiptRecord {
|
|||||||
customerName: string;
|
customerName: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
remark: string;
|
remark: string;
|
||||||
|
invoiceDate?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface BiMileageRecord {
|
export interface BiMileageRecord {
|
||||||
@@ -123,6 +153,12 @@ export interface HydrogenFeeRecord {
|
|||||||
month: number;
|
month: number;
|
||||||
amount: number;
|
amount: number;
|
||||||
status: string;
|
status: string;
|
||||||
|
refuelDate: string;
|
||||||
|
stationName: string;
|
||||||
|
volume: number;
|
||||||
|
unitCost: number;
|
||||||
|
totalCost: number;
|
||||||
|
borneBy: '我司承担' | '客户承担';
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MaintenanceOrder {
|
export interface MaintenanceOrder {
|
||||||
@@ -130,7 +166,7 @@ export interface MaintenanceOrder {
|
|||||||
plateNo: string;
|
plateNo: string;
|
||||||
orderNo: string;
|
orderNo: string;
|
||||||
type: string;
|
type: string;
|
||||||
bearBy: string;
|
bearBy: MaintenanceBearBy;
|
||||||
amount: number;
|
amount: number;
|
||||||
completedAt: string;
|
completedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,9 @@ import type {
|
|||||||
LeaseLedgerRow,
|
LeaseLedgerRow,
|
||||||
LeaseKpiSummary,
|
LeaseKpiSummary,
|
||||||
ListViewMode,
|
ListViewMode,
|
||||||
|
MaintenanceBearBy,
|
||||||
MaintenanceOrder,
|
MaintenanceOrder,
|
||||||
|
ReceiptRecord,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
/** 原型:车牌主数据(导入/新增时按车牌自动匹配品牌型号) */
|
/** 原型:车牌主数据(导入/新增时按车牌自动匹配品牌型号) */
|
||||||
@@ -71,6 +73,18 @@ export const PLATE_STANDARD_COST: Record<string, number> = {
|
|||||||
沪A50990F: 3900,
|
沪A50990F: 3900,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 原型:车牌增值服务(签订合同时勾选,一行一项) */
|
||||||
|
export const PLATE_VALUE_ADDED: Record<string, string[]> = {
|
||||||
|
沪A32896F: ['GPS 定位服务', '商业险代办'],
|
||||||
|
沪A61121F: ['ETC 办卡'],
|
||||||
|
沪A69695F: ['GPS 定位服务'],
|
||||||
|
粤AGF4535: ['氢气卡充值代办'],
|
||||||
|
沪A65889F: ['GPS 定位服务', '年检代办'],
|
||||||
|
浙F08991F: ['GPS 定位服务', 'ETC 办卡', '商业险代办'],
|
||||||
|
浙F05580F: ['GPS 定位服务'],
|
||||||
|
沪A50990F: ['ETC 办卡', '商业险代办'],
|
||||||
|
};
|
||||||
|
|
||||||
export const HYDROGEN_FEE_HEADER_TIP = '仅显示签订合同时氢费承担方式为「我司承担」的氢费在该账单期内的总和,取自车辆氢费明细所有状态的数据(包含未对账和已对账)';
|
export const HYDROGEN_FEE_HEADER_TIP = '仅显示签订合同时氢费承担方式为「我司承担」的氢费在该账单期内的总和,取自车辆氢费明细所有状态的数据(包含未对账和已对账)';
|
||||||
|
|
||||||
/** 资产归属类型(内部) */
|
/** 资产归属类型(内部) */
|
||||||
@@ -87,6 +101,8 @@ export const PLATE_ASSET_OWNERSHIP: Record<string, AssetOwnership> = {
|
|||||||
|
|
||||||
export const IS_SUPERVISOR = false;
|
export const IS_SUPERVISOR = false;
|
||||||
export const CURRENT_STAFF_USER = '刘念念';
|
export const CURRENT_STAFF_USER = '刘念念';
|
||||||
|
export const DEFAULT_MAINTAINER_TIME = '2026-01-10 14:30';
|
||||||
|
export const DEFAULT_MILEAGE_DEDUCTION_RATE = 0.5;
|
||||||
|
|
||||||
export const LOAD_BATCH_SIZE = 20;
|
export const LOAD_BATCH_SIZE = 20;
|
||||||
|
|
||||||
@@ -104,6 +120,150 @@ export function isBlankDisplayValue(value: unknown): boolean {
|
|||||||
|| value === '/';
|
|| value === '/';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function formatBillingYearMonth(
|
||||||
|
year: number | null | undefined,
|
||||||
|
month: number | null | undefined,
|
||||||
|
): string {
|
||||||
|
if (year == null || month == null) return '';
|
||||||
|
return `${year}-${String(month).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseDateOnly(raw: string): Date | null {
|
||||||
|
if (isBlankDisplayValue(raw)) return null;
|
||||||
|
const datePart = String(raw).trim().split(' ')[0];
|
||||||
|
const parts = datePart.split('-');
|
||||||
|
if (parts.length < 3) return null;
|
||||||
|
const date = new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]));
|
||||||
|
return Number.isNaN(date.getTime()) ? null : date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateOnly(date: Date): string {
|
||||||
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDaysInMonth(year: number, month: number): number {
|
||||||
|
return new Date(year, month, 0).getDate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMonths(date: Date, months: number): Date {
|
||||||
|
const next = new Date(date);
|
||||||
|
next.setMonth(next.getMonth() + months);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isInBillMonth(dateText: string, year: number, month: number): boolean {
|
||||||
|
const date = parseDateOnly(dateText);
|
||||||
|
if (!date) return false;
|
||||||
|
return date.getFullYear() === year && date.getMonth() + 1 === month;
|
||||||
|
}
|
||||||
|
|
||||||
|
function roundMoney(value: number): number {
|
||||||
|
return Math.round(value * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sumLineItems(items: { amount: number }[] | undefined): number {
|
||||||
|
return roundMoney((items ?? []).reduce((sum, item) => sum + item.amount, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 账单月内租赁起始日:月前已提车取当月 1 日,月内提车取提车日 */
|
||||||
|
export function calcPeriodStartDate(
|
||||||
|
row: Pick<LeaseLedgerRow, 'year' | 'month' | 'pickupDate'>,
|
||||||
|
): string {
|
||||||
|
if (!row.year || !row.month) return '';
|
||||||
|
const monthStart = `${row.year}-${String(row.month).padStart(2, '0')}-01`;
|
||||||
|
const pickupDate = parseDateOnly(row.pickupDate);
|
||||||
|
if (!pickupDate) return monthStart;
|
||||||
|
|
||||||
|
const billMonthStart = new Date(row.year, row.month - 1, 1);
|
||||||
|
const billMonthEnd = new Date(row.year, row.month, 0);
|
||||||
|
if (pickupDate > billMonthEnd) return '';
|
||||||
|
if (pickupDate <= billMonthStart) return monthStart;
|
||||||
|
return row.pickupDate.split(' ')[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 合同到期日:按租期月数自提车日起算,或取已存储到期日 */
|
||||||
|
export function calcContractEndDate(
|
||||||
|
row: Pick<LeaseLedgerRow, 'pickupDate' | 'contractEndDate' | 'contractEndMode' | 'leaseTermMonths'>,
|
||||||
|
): string {
|
||||||
|
if (row.contractEndMode === 'date' && !isBlankDisplayValue(row.contractEndDate)) {
|
||||||
|
return row.contractEndDate.split(' ')[0];
|
||||||
|
}
|
||||||
|
if (row.leaseTermMonths && !isBlankDisplayValue(row.pickupDate)) {
|
||||||
|
const pickupDate = parseDateOnly(row.pickupDate);
|
||||||
|
if (pickupDate) {
|
||||||
|
return formatDateOnly(addMonths(pickupDate, row.leaseTermMonths));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!isBlankDisplayValue(row.contractEndDate)) {
|
||||||
|
return row.contractEndDate.split(' ')[0];
|
||||||
|
}
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 账单月内租赁天数(用于成本分摊与平均天数) */
|
||||||
|
export function calcLeaseDaysInMonth(
|
||||||
|
row: Pick<LeaseLedgerRow, 'year' | 'month' | 'pickupDate' | 'returnDate' | 'periodStartDate'>,
|
||||||
|
): number {
|
||||||
|
if (!row.year || !row.month) return 0;
|
||||||
|
const daysInMonth = getDaysInMonth(row.year, row.month);
|
||||||
|
const periodStartText = row.periodStartDate || calcPeriodStartDate(row);
|
||||||
|
const periodStart = parseDateOnly(periodStartText);
|
||||||
|
if (!periodStart) return 0;
|
||||||
|
|
||||||
|
const monthEnd = new Date(row.year, row.month, 0);
|
||||||
|
let periodEnd = monthEnd;
|
||||||
|
const returnDate = parseDateOnly(row.returnDate);
|
||||||
|
if (returnDate) {
|
||||||
|
if (returnDate < periodStart) return 0;
|
||||||
|
if (returnDate < periodEnd) periodEnd = returnDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
const diffDays = Math.floor((periodEnd.getTime() - periodStart.getTime()) / 86400000) + 1;
|
||||||
|
return Math.max(0, Math.min(diffDays, daysInMonth));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 平均天数 = 账单月租赁天数 / 当月自然日天数 */
|
||||||
|
export function calcAvgDays(leaseDaysInMonth: number, year: number, month: number): number {
|
||||||
|
const daysInMonth = getDaysInMonth(year, month);
|
||||||
|
if (!daysInMonth) return 0;
|
||||||
|
return roundMoney(leaseDaysInMonth / daysInMonth);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 交车后设置的开始计费日期:优先取账单起始日期,否则取提车日期 */
|
||||||
|
export function resolveBillingStartDate(
|
||||||
|
row: Pick<LeaseLedgerRow, 'periodStartDate' | 'pickupDate'>,
|
||||||
|
): string {
|
||||||
|
if (row.periodStartDate && !isBlankDisplayValue(row.periodStartDate)) return row.periodStartDate;
|
||||||
|
if (row.pickupDate && !isBlankDisplayValue(row.pickupDate)) return row.pickupDate;
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 应付款日:首期取开始计费日,后续每期取账单所属自然月 5 日 */
|
||||||
|
export function calcPaymentDueDate(
|
||||||
|
row: Pick<LeaseLedgerRow, 'year' | 'month' | 'periodStartDate' | 'pickupDate'>,
|
||||||
|
): string {
|
||||||
|
if (!row.year || !row.month) return '';
|
||||||
|
const billingStart = resolveBillingStartDate(row);
|
||||||
|
const billYm = row.year * 100 + row.month;
|
||||||
|
const startDate = parseDateOnly(billingStart);
|
||||||
|
if (startDate) {
|
||||||
|
const startYm = startDate.getFullYear() * 100 + (startDate.getMonth() + 1);
|
||||||
|
if (billYm === startYm) {
|
||||||
|
return billingStart.split(' ')[0];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return `${row.year}-${String(row.month).padStart(2, '0')}-05`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveLinkedPaymentDate(
|
||||||
|
row: Pick<LeaseLedgerRow, 'actualPaymentDate' | 'linkedReceiptIds' | 'receivedAmount'>,
|
||||||
|
): string {
|
||||||
|
const hasReceipt = row.receivedAmount > 0
|
||||||
|
&& ((row.linkedReceiptIds?.length ?? 0) > 0 || !isBlankDisplayValue(row.actualPaymentDate));
|
||||||
|
if (!hasReceipt || isBlankDisplayValue(row.actualPaymentDate)) return '';
|
||||||
|
return extractReceiptPaymentDate(row.actualPaymentDate);
|
||||||
|
}
|
||||||
|
|
||||||
export function displayText(value: unknown): string {
|
export function displayText(value: unknown): string {
|
||||||
if (isBlankDisplayValue(value)) return '';
|
if (isBlankDisplayValue(value)) return '';
|
||||||
return String(value);
|
return String(value);
|
||||||
@@ -116,7 +276,7 @@ export function displayMoney(value: number | null | undefined, treatZeroAsEmpty
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function calcUnreceived(row: Pick<LeaseLedgerRow, 'receivableTotal' | 'receivedAmount'>): number {
|
export function calcUnreceived(row: Pick<LeaseLedgerRow, 'receivableTotal' | 'receivedAmount'>): number {
|
||||||
return Math.round((row.receivableTotal - row.receivedAmount) * 100) / 100;
|
return roundMoney(row.receivableTotal - row.receivedAmount);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function isUnpaidRow(row: LeaseLedgerRow): boolean {
|
export function isUnpaidRow(row: LeaseLedgerRow): boolean {
|
||||||
@@ -138,25 +298,49 @@ export function calcMonthlyRent(contractRent: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function calcReceivableRent(contractRent: number, cycleMonths: number): number {
|
export function calcReceivableRent(contractRent: number, cycleMonths: number): number {
|
||||||
return Math.round(contractRent * cycleMonths * 100) / 100;
|
return roundMoney(contractRent * cycleMonths);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveStatusAfterReceipt(
|
/** 根据收款分配金额与应收合计计算账单收款状态 */
|
||||||
|
export function resolvePaymentStatus(
|
||||||
receivableTotal: number,
|
receivableTotal: number,
|
||||||
receivedAmount: number,
|
receivedAmount: number,
|
||||||
): LeaseDetailStatus {
|
): LeaseDetailStatus {
|
||||||
if (receivedAmount >= receivableTotal) return '已收款';
|
if (receivedAmount >= receivableTotal && receivableTotal > 0) return '已收款';
|
||||||
if (receivedAmount > 0) return '部分收款';
|
if (receivedAmount > 0) return '部分收款';
|
||||||
return '已确认';
|
return '未收款';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated 使用 resolvePaymentStatus */
|
||||||
|
export const resolveStatusAfterReceipt = resolvePaymentStatus;
|
||||||
|
|
||||||
export function filterRecordsByRole(rows: LeaseLedgerRow[]): LeaseLedgerRow[] {
|
export function filterRecordsByRole(rows: LeaseLedgerRow[]): LeaseLedgerRow[] {
|
||||||
if (IS_SUPERVISOR) return rows;
|
if (IS_SUPERVISOR) return rows;
|
||||||
return rows.filter((row) => row.maintainerName === CURRENT_STAFF_USER);
|
return rows.filter((row) => row.maintainerName === CURRENT_STAFF_USER);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calcVehicleActualCost(standardCost: number, actualDays: number): number {
|
export function calcVehicleActualCost(standardCost: number, leaseDaysInMonth: number): number {
|
||||||
return Math.round(standardCost * (actualDays / 30) * 100) / 100;
|
return roundMoney(standardCost * (leaseDaysInMonth / 30));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calcInsuranceFee(
|
||||||
|
purchaseAmount: number | undefined,
|
||||||
|
effectiveDays: number | undefined,
|
||||||
|
leaseDaysInMonth: number,
|
||||||
|
): number {
|
||||||
|
if (!purchaseAmount || !effectiveDays || effectiveDays <= 0 || leaseDaysInMonth <= 0) return 0;
|
||||||
|
return roundMoney((purchaseAmount / effectiveDays) * leaseDaysInMonth);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calcMileageDeduction(
|
||||||
|
gpsMileage: string,
|
||||||
|
rate: number | undefined,
|
||||||
|
): number {
|
||||||
|
if (isBlankDisplayValue(gpsMileage)) return 0;
|
||||||
|
const mileage = Number(gpsMileage);
|
||||||
|
if (!Number.isFinite(mileage) || mileage <= 0) return 0;
|
||||||
|
const deductionRate = rate ?? DEFAULT_MILEAGE_DEDUCTION_RATE;
|
||||||
|
return roundMoney(mileage * deductionRate);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function lookupBiMileage(
|
export function lookupBiMileage(
|
||||||
@@ -169,14 +353,77 @@ export function lookupBiMileage(
|
|||||||
return hit ? String(hit.mileageKm) : '';
|
return hit ? String(hit.mileageKm) : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getMaintenanceOrdersForBill(
|
||||||
|
row: Pick<LeaseLedgerRow, 'plateNo' | 'year' | 'month'>,
|
||||||
|
orders: MaintenanceOrder[],
|
||||||
|
bearBy?: MaintenanceBearBy,
|
||||||
|
): MaintenanceOrder[] {
|
||||||
|
return orders.filter(
|
||||||
|
(item) => item.plateNo === row.plateNo
|
||||||
|
&& isInBillMonth(item.completedAt, row.year, row.month)
|
||||||
|
&& (!bearBy || item.bearBy === bearBy),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sumMaintenanceForBill(
|
||||||
|
row: Pick<LeaseLedgerRow, 'plateNo' | 'year' | 'month'>,
|
||||||
|
orders: MaintenanceOrder[],
|
||||||
|
bearBy: MaintenanceBearBy,
|
||||||
|
): number {
|
||||||
|
return roundMoney(
|
||||||
|
getMaintenanceOrdersForBill(row, orders, bearBy)
|
||||||
|
.reduce((sum, item) => sum + item.amount, 0),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getHydrogenFeesForBill(
|
||||||
|
row: Pick<LeaseLedgerRow, 'plateNo' | 'year' | 'month'>,
|
||||||
|
records: HydrogenFeeRecord[],
|
||||||
|
): HydrogenFeeRecord[] {
|
||||||
|
return records.filter(
|
||||||
|
(item) => item.plateNo === row.plateNo
|
||||||
|
&& item.year === row.year
|
||||||
|
&& item.month === row.month
|
||||||
|
&& item.borneBy === '我司承担',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function sumHydrogenFeeForBill(
|
export function sumHydrogenFeeForBill(
|
||||||
row: Pick<LeaseLedgerRow, 'plateNo' | 'year' | 'month' | 'hydrogenBorneBy'>,
|
row: Pick<LeaseLedgerRow, 'plateNo' | 'year' | 'month'>,
|
||||||
records: HydrogenFeeRecord[],
|
records: HydrogenFeeRecord[],
|
||||||
): number {
|
): number {
|
||||||
if (row.hydrogenBorneBy !== '我司承担') return 0;
|
return roundMoney(
|
||||||
return records
|
getHydrogenFeesForBill(row, records)
|
||||||
.filter((item) => item.plateNo === row.plateNo && item.year === row.year && item.month === row.month)
|
.reduce((sum, item) => sum + (item.totalCost ?? item.amount), 0),
|
||||||
.reduce((sum, item) => sum + item.amount, 0);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getLinkedReceiptsForRow(
|
||||||
|
row: Pick<LeaseLedgerRow, 'linkedReceiptIds'>,
|
||||||
|
receipts: ReceiptRecord[],
|
||||||
|
): ReceiptRecord[] {
|
||||||
|
const linkedIds = row.linkedReceiptIds ?? [];
|
||||||
|
return receipts.filter((item) => linkedIds.includes(item.id));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveInvoiceDateFromReceipts(
|
||||||
|
row: Pick<LeaseLedgerRow, 'invoiceDate' | 'linkedReceiptIds'>,
|
||||||
|
receipts: ReceiptRecord[],
|
||||||
|
): string {
|
||||||
|
const linked = getLinkedReceiptsForRow(row, receipts);
|
||||||
|
if (linked.length === 0) {
|
||||||
|
return isBlankDisplayValue(row.invoiceDate) ? '' : row.invoiceDate.split(' ')[0];
|
||||||
|
}
|
||||||
|
const dates = linked
|
||||||
|
.map((item) => item.invoiceDate || extractReceiptPaymentDate(item.arrivalTime))
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort();
|
||||||
|
return dates[dates.length - 1] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveValueAddedService(plateNo: string): string {
|
||||||
|
const services = PLATE_VALUE_ADDED[plateNo] ?? [];
|
||||||
|
return services.join('\n');
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calcReceivedAmountFromReceipts(
|
export function calcReceivedAmountFromReceipts(
|
||||||
@@ -187,18 +434,47 @@ export function calcReceivedAmountFromReceipts(
|
|||||||
const total = receipts
|
const total = receipts
|
||||||
.filter((item) => linkedIds.includes(item.id))
|
.filter((item) => linkedIds.includes(item.id))
|
||||||
.reduce((sum, item) => sum + item.amount, 0);
|
.reduce((sum, item) => sum + item.amount, 0);
|
||||||
return Math.min(Math.round(total * 100) / 100, receivableTotal);
|
return Math.min(roundMoney(total), receivableTotal);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calcMaintenanceIncome(row: Pick<LeaseLedgerRow, 'maintenancePackageEnabled' | 'maintenanceUnitPricePerKm' | 'mileage'>): number {
|
export function calcMaintenanceIncome(
|
||||||
|
row: Pick<LeaseLedgerRow, 'maintenancePackageEnabled' | 'maintenanceUnitPricePerKm' | 'mileage'>,
|
||||||
|
): number {
|
||||||
if (!row.maintenancePackageEnabled) return 0;
|
if (!row.maintenancePackageEnabled) return 0;
|
||||||
return Math.round(row.maintenanceUnitPricePerKm * row.mileage * 100) / 100;
|
return roundMoney(row.maintenanceUnitPricePerKm * row.mileage);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function calcMonthlyIncome(
|
export function calcMonthlyIncome(
|
||||||
row: Pick<LeaseLedgerRow, 'monthlyRent' | 'serviceFee' | 'mileageDeduction' | 'otherDeduction'>,
|
row: Pick<
|
||||||
|
LeaseLedgerRow,
|
||||||
|
'monthlyRent' | 'maintenanceIncome' | 'insuranceSurcharge' | 'opsFeeIncome' | 'otherIncome' | 'mileageDeduction' | 'otherDeduction'
|
||||||
|
>,
|
||||||
): number {
|
): number {
|
||||||
return Math.round((row.monthlyRent + row.serviceFee - row.mileageDeduction - row.otherDeduction) * 100) / 100;
|
return roundMoney(
|
||||||
|
row.monthlyRent
|
||||||
|
+ row.maintenanceIncome
|
||||||
|
+ row.insuranceSurcharge
|
||||||
|
+ row.opsFeeIncome
|
||||||
|
+ row.otherIncome
|
||||||
|
- row.mileageDeduction
|
||||||
|
- row.otherDeduction,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calcReceivableTotal(
|
||||||
|
row: Pick<
|
||||||
|
LeaseLedgerRow,
|
||||||
|
'receivableRent' | 'insuranceSurcharge' | 'opsFeeIncome' | 'otherIncome' | 'mileageDeduction' | 'otherDeduction'
|
||||||
|
>,
|
||||||
|
): number {
|
||||||
|
return roundMoney(
|
||||||
|
row.receivableRent
|
||||||
|
+ row.insuranceSurcharge
|
||||||
|
+ row.opsFeeIncome
|
||||||
|
+ row.otherIncome
|
||||||
|
- row.mileageDeduction
|
||||||
|
- row.otherDeduction,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sumLinkedMaintenanceAmount(
|
export function sumLinkedMaintenanceAmount(
|
||||||
@@ -225,7 +501,7 @@ export function needsMaintenanceLinkWarning(
|
|||||||
): boolean {
|
): boolean {
|
||||||
if (!row.isLatestBill || !row.customerBearsMaintenance) return false;
|
if (!row.isLatestBill || !row.customerBearsMaintenance) return false;
|
||||||
if (row.linkedMaintenanceIds.length > 0) return false;
|
if (row.linkedMaintenanceIds.length > 0) return false;
|
||||||
return orders.some((item) => item.plateNo === row.plateNo && item.bearBy === '客户承担');
|
return getMaintenanceOrdersForBill(row, orders, '客户承担').length > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function needsAccidentLinkWarning(
|
export function needsAccidentLinkWarning(
|
||||||
@@ -244,6 +520,7 @@ export function enrichLeaseRow(
|
|||||||
accidentRecords?: AccidentRecord[];
|
accidentRecords?: AccidentRecord[];
|
||||||
biMileage?: BiMileageRecord[];
|
biMileage?: BiMileageRecord[];
|
||||||
hydrogenFees?: HydrogenFeeRecord[];
|
hydrogenFees?: HydrogenFeeRecord[];
|
||||||
|
receipts?: ReceiptRecord[];
|
||||||
},
|
},
|
||||||
): LeaseLedgerRow {
|
): LeaseLedgerRow {
|
||||||
const vehicle = PLATE_VEHICLE_MAP[row.plateNo];
|
const vehicle = PLATE_VEHICLE_MAP[row.plateNo];
|
||||||
@@ -266,35 +543,79 @@ export function enrichLeaseRow(
|
|||||||
const contractRent = row.contractRent;
|
const contractRent = row.contractRent;
|
||||||
const monthlyRent = calcMonthlyRent(contractRent);
|
const monthlyRent = calcMonthlyRent(contractRent);
|
||||||
const receivableRent = calcReceivableRent(contractRent, cycleMonths);
|
const receivableRent = calcReceivableRent(contractRent, cycleMonths);
|
||||||
const hasReceipt = row.receivedAmount > 0 && row.actualPaymentDate;
|
const linkedReceiptIds = row.linkedReceiptIds ?? [];
|
||||||
|
const hasReceipt = row.receivedAmount > 0
|
||||||
|
&& (linkedReceiptIds.length > 0 || !isBlankDisplayValue(row.actualPaymentDate));
|
||||||
const maintenanceOrders = options?.maintenanceOrders ?? [];
|
const maintenanceOrders = options?.maintenanceOrders ?? [];
|
||||||
const accidentRecords = options?.accidentRecords ?? [];
|
const accidentRecords = options?.accidentRecords ?? [];
|
||||||
const biMileage = options?.biMileage ?? [];
|
const biMileage = options?.biMileage ?? [];
|
||||||
const hydrogenFees = options?.hydrogenFees ?? [];
|
const hydrogenFees = options?.hydrogenFees ?? [];
|
||||||
const linkedMaintenanceIds = row.linkedMaintenanceIds ?? [];
|
const receipts = options?.receipts ?? [];
|
||||||
const linkedAccidentIds = row.linkedAccidentIds ?? [];
|
const linkedAccidentIds = row.linkedAccidentIds ?? [];
|
||||||
const linkedReceiptIds = row.linkedReceiptIds ?? [];
|
|
||||||
|
const periodStartDate = calcPeriodStartDate(row);
|
||||||
|
const contractStartDate = isBlankDisplayValue(row.pickupDate) ? '' : row.pickupDate.split(' ')[0];
|
||||||
|
const contractEndDate = calcContractEndDate(row);
|
||||||
|
const returnDate = isBlankDisplayValue(row.returnDate) ? '' : row.returnDate.split(' ')[0];
|
||||||
|
const actualLeaseDays = calcLeaseDaysInMonth({ ...row, periodStartDate });
|
||||||
|
const avgDays = calcAvgDays(actualLeaseDays, row.year, row.month);
|
||||||
|
const paymentDueDate = calcPaymentDueDate({ ...row, periodStartDate });
|
||||||
|
|
||||||
|
const gpsMileage = lookupBiMileage(row.plateNo, row.year, row.month, biMileage) || row.gpsMileage;
|
||||||
|
const mileageDeductionRate = row.mileageDeductionRate;
|
||||||
|
const mileageDeduction = calcMileageDeduction(gpsMileage, mileageDeductionRate);
|
||||||
|
const otherIncome = sumLineItems(row.otherIncomeItems) || row.otherIncome || 0;
|
||||||
|
const otherDeduction = sumLineItems(row.otherDeductionItems) || row.otherDeduction || 0;
|
||||||
|
|
||||||
const maintenanceIncome = calcMaintenanceIncome(row);
|
const maintenanceIncome = calcMaintenanceIncome(row);
|
||||||
const opsFeeIncome = sumLinkedMaintenanceAmount(linkedMaintenanceIds, maintenanceOrders);
|
const opsFeeIncome = sumMaintenanceForBill(row, maintenanceOrders, '客户承担');
|
||||||
const opsFeeCost = opsFeeIncome;
|
const opsFeeCost = sumMaintenanceForBill(row, maintenanceOrders, '我司承担');
|
||||||
const insuranceSurcharge = linkedAccidentIds.length > 0
|
const insuranceSurcharge = linkedAccidentIds.length > 0
|
||||||
? sumLinkedAccidentSurcharge(linkedAccidentIds, accidentRecords)
|
? roundMoney(sumLinkedAccidentSurcharge(linkedAccidentIds, accidentRecords))
|
||||||
: row.insuranceSurcharge;
|
: row.insuranceSurcharge;
|
||||||
const serviceFee = row.serviceFee ?? 0;
|
|
||||||
const monthlyIncome = calcMonthlyIncome({
|
const monthlyIncome = calcMonthlyIncome({
|
||||||
monthlyRent,
|
monthlyRent,
|
||||||
serviceFee,
|
maintenanceIncome,
|
||||||
mileageDeduction: row.mileageDeduction,
|
insuranceSurcharge,
|
||||||
otherDeduction: row.otherDeduction,
|
opsFeeIncome,
|
||||||
|
otherIncome,
|
||||||
|
mileageDeduction,
|
||||||
|
otherDeduction,
|
||||||
});
|
});
|
||||||
|
const receivableTotal = calcReceivableTotal({
|
||||||
|
receivableRent,
|
||||||
|
insuranceSurcharge,
|
||||||
|
opsFeeIncome,
|
||||||
|
otherIncome,
|
||||||
|
mileageDeduction,
|
||||||
|
otherDeduction,
|
||||||
|
});
|
||||||
|
|
||||||
const vehicleStandardCost = PLATE_STANDARD_COST[row.plateNo] ?? row.vehicleStandardCost;
|
const vehicleStandardCost = PLATE_STANDARD_COST[row.plateNo] ?? row.vehicleStandardCost;
|
||||||
const vehicleActualCost = calcVehicleActualCost(vehicleStandardCost, row.avgDays || 30);
|
const vehicleActualCost = calcVehicleActualCost(vehicleStandardCost, actualLeaseDays);
|
||||||
const gpsMileage = lookupBiMileage(row.plateNo, row.year, row.month, biMileage) || row.gpsMileage;
|
const hydrogenFee = row.hydrogenBorneBy === '我司承担'
|
||||||
const hydrogenFeeSum = sumHydrogenFeeForBill(row, hydrogenFees);
|
? sumHydrogenFeeForBill(row, hydrogenFees)
|
||||||
const hydrogenFee = row.hydrogenBorneBy === '我司承担' ? hydrogenFeeSum : row.hydrogenFee;
|
: 0;
|
||||||
|
const insuranceFee = calcInsuranceFee(
|
||||||
|
row.insurancePurchaseAmount,
|
||||||
|
row.insuranceEffectiveDays,
|
||||||
|
actualLeaseDays,
|
||||||
|
);
|
||||||
|
const invoiceDate = resolveInvoiceDateFromReceipts(row, receipts);
|
||||||
|
const valueAddedService = resolveValueAddedService(row.plateNo);
|
||||||
|
const maintainerTime = row.maintainerTime || DEFAULT_MAINTAINER_TIME;
|
||||||
const signingCompany = PLATE_SIGNING_COMPANY[row.plateNo] || row.signingCompany;
|
const signingCompany = PLATE_SIGNING_COMPANY[row.plateNo] || row.signingCompany;
|
||||||
const assetOwner = PLATE_ASSET_OWNER[row.plateNo] || row.assetOwner || '';
|
const assetOwner = PLATE_ASSET_OWNER[row.plateNo] || row.assetOwner || '';
|
||||||
|
|
||||||
|
const brokerageFee = row.brokerageFee ?? 0;
|
||||||
|
const otherCost = row.otherCost ?? 0;
|
||||||
|
const totalCost = roundMoney(
|
||||||
|
vehicleActualCost + insuranceFee + hydrogenFee + opsFeeCost + brokerageFee + otherCost,
|
||||||
|
);
|
||||||
|
const profitLoss = roundMoney(monthlyIncome - totalCost);
|
||||||
|
const receivedAmount = row.receivedAmount ?? 0;
|
||||||
|
const status = resolvePaymentStatus(receivableTotal, receivedAmount);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...row,
|
...row,
|
||||||
vehicleBrand: brand || vehicle?.brand || '',
|
vehicleBrand: brand || vehicle?.brand || '',
|
||||||
@@ -303,35 +624,53 @@ export function enrichLeaseRow(
|
|||||||
projectName: row.projectName || contract?.projectName || '',
|
projectName: row.projectName || contract?.projectName || '',
|
||||||
paymentCycleMonths: cycleMonths,
|
paymentCycleMonths: cycleMonths,
|
||||||
paymentMethod: resolvePaymentMethod(cycleMonths),
|
paymentMethod: resolvePaymentMethod(cycleMonths),
|
||||||
|
linkedReceiptIds,
|
||||||
|
linkedAccidentIds,
|
||||||
|
periodStartDate,
|
||||||
|
contractStartDate,
|
||||||
|
contractEndDate,
|
||||||
|
returnDate,
|
||||||
|
actualLeaseDays,
|
||||||
|
avgDays,
|
||||||
monthlyRent,
|
monthlyRent,
|
||||||
receivableRent,
|
receivableRent,
|
||||||
serviceFee,
|
|
||||||
linkedMaintenanceIds,
|
|
||||||
linkedAccidentIds,
|
|
||||||
linkedReceiptIds,
|
|
||||||
maintenanceIncome,
|
maintenanceIncome,
|
||||||
opsFeeIncome,
|
opsFeeIncome,
|
||||||
opsFeeCost,
|
opsFeeCost,
|
||||||
insuranceSurcharge,
|
insuranceSurcharge,
|
||||||
|
otherIncome,
|
||||||
|
mileageDeduction,
|
||||||
|
otherDeduction,
|
||||||
monthlyIncome,
|
monthlyIncome,
|
||||||
|
receivableTotal,
|
||||||
vehicleStandardCost,
|
vehicleStandardCost,
|
||||||
vehicleActualCost,
|
vehicleActualCost,
|
||||||
gpsMileage,
|
gpsMileage,
|
||||||
hydrogenFee,
|
hydrogenFee,
|
||||||
|
insuranceFee,
|
||||||
|
invoiceDate,
|
||||||
|
valueAddedService,
|
||||||
|
maintainerTime,
|
||||||
|
paymentDueDate,
|
||||||
|
paymentDate: '',
|
||||||
|
totalCost,
|
||||||
|
profitLoss,
|
||||||
signingCompany,
|
signingCompany,
|
||||||
assetOwner,
|
assetOwner,
|
||||||
assetOwnership: row.assetOwnership || PLATE_ASSET_OWNERSHIP[row.plateNo] || '自有',
|
assetOwnership: row.assetOwnership || PLATE_ASSET_OWNERSHIP[row.plateNo] || '自有',
|
||||||
actualPaymentDate: hasReceipt ? row.actualPaymentDate : '',
|
actualPaymentDate: hasReceipt ? row.actualPaymentDate : '',
|
||||||
|
receivedAmount,
|
||||||
|
status,
|
||||||
|
archiveStatus: row.archiveStatus ?? '未归档',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canStaffEditRow(row: LeaseLedgerRow): boolean {
|
export function canStaffEditRow(row: LeaseLedgerRow): boolean {
|
||||||
if (row.status === '草稿') return true;
|
return row.archiveStatus === '草稿';
|
||||||
return IS_SUPERVISOR;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function canStaffDeleteRow(row: LeaseLedgerRow): boolean {
|
export function canStaffDeleteRow(row: LeaseLedgerRow): boolean {
|
||||||
return canStaffEditRow(row);
|
return row.status === '未收款';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function sumLeaseKpi(rows: LeaseLedgerRow[]): LeaseKpiSummary {
|
export function sumLeaseKpi(rows: LeaseLedgerRow[]): LeaseKpiSummary {
|
||||||
@@ -357,8 +696,8 @@ export function applyLeaseFilters(rows: LeaseLedgerRow[], filters: LeaseLedgerFi
|
|||||||
if (filters.plateNo && row.plateNo !== filters.plateNo) return false;
|
if (filters.plateNo && row.plateNo !== filters.plateNo) return false;
|
||||||
if (filters.status && row.status !== filters.status) return false;
|
if (filters.status && row.status !== filters.status) return false;
|
||||||
if (filters.customerName && !row.customerName.includes(filters.customerName)) return false;
|
if (filters.customerName && !row.customerName.includes(filters.customerName)) return false;
|
||||||
if (filters.paymentFrom && row.paymentDate < filters.paymentFrom) return false;
|
if (filters.paymentFrom && (row.paymentDueDate ?? '') < filters.paymentFrom) return false;
|
||||||
if (filters.paymentTo && row.paymentDate > filters.paymentTo) return false;
|
if (filters.paymentTo && (row.paymentDueDate ?? '') > filters.paymentTo) return false;
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,328 @@
|
|||||||
|
{
|
||||||
|
"documentVersion": 1,
|
||||||
|
"format": "axhub-annotation-source",
|
||||||
|
"data": {
|
||||||
|
"version": 2,
|
||||||
|
"prototypeName": "lease-business-line-overview",
|
||||||
|
"pageId": "overview",
|
||||||
|
"updatedAt": 1783328981487,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "lblo-system-intro",
|
||||||
|
"index": 1,
|
||||||
|
"title": "系统介绍",
|
||||||
|
"pageId": "overview",
|
||||||
|
"locator": {
|
||||||
|
"selectors": [
|
||||||
|
"[data-annotation-id=\"lblo-system-intro\"]"
|
||||||
|
],
|
||||||
|
"fingerprint": "lblo-system-intro",
|
||||||
|
"path": []
|
||||||
|
},
|
||||||
|
"aiPrompt": "ONE-OS 系统介绍、五大业务条线与三大数据底座总览。",
|
||||||
|
"annotationText": "1 套系统、5 大业务条线闭环 + 3 大数据底座;核心演进为业务闭环基础上打通财务系统,实现业财一体。",
|
||||||
|
"hasMarkdown": false,
|
||||||
|
"color": "#60a5fa",
|
||||||
|
"images": [],
|
||||||
|
"createdAt": 1740787200000,
|
||||||
|
"updatedAt": 1740787200000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "lblo-system-evolution",
|
||||||
|
"index": 2,
|
||||||
|
"title": "业财一体演进",
|
||||||
|
"pageId": "overview",
|
||||||
|
"locator": {
|
||||||
|
"selectors": [
|
||||||
|
"[data-annotation-id=\"lblo-system-evolution\"]"
|
||||||
|
],
|
||||||
|
"fingerprint": "lblo-system-evolution",
|
||||||
|
"path": []
|
||||||
|
},
|
||||||
|
"aiPrompt": "从业务驱动财务手工维护到业财系统联动一体化。",
|
||||||
|
"annotationText": "现阶段业务闭环内财务多手工维护;演进为打通财务系统,收付款与业务台账自动联动。",
|
||||||
|
"hasMarkdown": false,
|
||||||
|
"color": "#2dd4bf",
|
||||||
|
"images": [],
|
||||||
|
"createdAt": 1740787200000,
|
||||||
|
"updatedAt": 1740787200000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "lblo-line-switcher",
|
||||||
|
"index": 3,
|
||||||
|
"title": "五大业务条线",
|
||||||
|
"pageId": "overview",
|
||||||
|
"locator": {
|
||||||
|
"selectors": [
|
||||||
|
"[data-annotation-id=\"lblo-line-switcher\"]"
|
||||||
|
],
|
||||||
|
"fingerprint": "lblo-line-switcher",
|
||||||
|
"path": []
|
||||||
|
},
|
||||||
|
"aiPrompt": "租赁、能源、运维、安全、自营五条业务条线切换。",
|
||||||
|
"annotationText": "在系统介绍区内切换不同业务条线,查看对应模块说明与闭环路径;五条条线内容均已填充。",
|
||||||
|
"hasMarkdown": false,
|
||||||
|
"color": "#60a5fa",
|
||||||
|
"images": [],
|
||||||
|
"createdAt": 1740787200000,
|
||||||
|
"updatedAt": 1740787200000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "lblo-evolution",
|
||||||
|
"index": 4,
|
||||||
|
"title": "条线演进路径",
|
||||||
|
"pageId": "overview",
|
||||||
|
"locator": {
|
||||||
|
"selectors": [
|
||||||
|
"[data-annotation-id=\"lblo-evolution\"]"
|
||||||
|
],
|
||||||
|
"fingerprint": "lblo-evolution",
|
||||||
|
"path": []
|
||||||
|
},
|
||||||
|
"aiPrompt": "各业务条线现阶段与演进方向说明。",
|
||||||
|
"annotationText": "租赁:合同电子化到商城直连;能源:手工维护到加氢站主动上报;运维:使用环节到入库全链路;安全:司机培训到违章事故联动;自营:人工核算到业财闭环。",
|
||||||
|
"hasMarkdown": false,
|
||||||
|
"color": "#2dd4bf",
|
||||||
|
"images": [],
|
||||||
|
"createdAt": 1740787200000,
|
||||||
|
"updatedAt": 1740787200000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "lblo-hero",
|
||||||
|
"index": 5,
|
||||||
|
"title": "页面标题区",
|
||||||
|
"pageId": "overview",
|
||||||
|
"locator": {
|
||||||
|
"selectors": [
|
||||||
|
"[data-annotation-id=\"lblo-hero\"]"
|
||||||
|
],
|
||||||
|
"fingerprint": "lblo-hero",
|
||||||
|
"path": []
|
||||||
|
},
|
||||||
|
"aiPrompt": "当前条线标题与总述。",
|
||||||
|
"annotationText": "随条线切换展示对应说明。",
|
||||||
|
"hasMarkdown": false,
|
||||||
|
"color": "#2563eb",
|
||||||
|
"images": [],
|
||||||
|
"createdAt": 1740787200000,
|
||||||
|
"updatedAt": 1740787200000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "lblo-nav",
|
||||||
|
"index": 6,
|
||||||
|
"title": "模块导航",
|
||||||
|
"pageId": "overview",
|
||||||
|
"locator": {
|
||||||
|
"selectors": [
|
||||||
|
"[data-annotation-id=\"lblo-nav\"]"
|
||||||
|
],
|
||||||
|
"fingerprint": "lblo-nav",
|
||||||
|
"path": []
|
||||||
|
},
|
||||||
|
"aiPrompt": "模块锚点跳转。",
|
||||||
|
"annotationText": "点击可跳转到对应模块卡片。",
|
||||||
|
"hasMarkdown": false,
|
||||||
|
"color": "#64748b",
|
||||||
|
"images": [],
|
||||||
|
"createdAt": 1740787200000,
|
||||||
|
"updatedAt": 1740787200000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "lblo-full-loop",
|
||||||
|
"index": 7,
|
||||||
|
"title": "全链路闭环",
|
||||||
|
"pageId": "overview",
|
||||||
|
"locator": {
|
||||||
|
"selectors": [
|
||||||
|
"[data-annotation-id=\"lblo-full-loop\"]"
|
||||||
|
],
|
||||||
|
"fingerprint": "lblo-full-loop",
|
||||||
|
"path": []
|
||||||
|
},
|
||||||
|
"aiPrompt": "条线闭环总结。",
|
||||||
|
"annotationText": "当前条线的端到端闭环说明。",
|
||||||
|
"hasMarkdown": false,
|
||||||
|
"color": "#059669",
|
||||||
|
"images": [],
|
||||||
|
"createdAt": 1740787200000,
|
||||||
|
"updatedAt": 1740787200000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "lblo-data-foundations",
|
||||||
|
"index": 8,
|
||||||
|
"title": "三大数据底座",
|
||||||
|
"pageId": "overview",
|
||||||
|
"locator": {
|
||||||
|
"selectors": [
|
||||||
|
"[data-annotation-id=\"lblo-data-foundations\"]"
|
||||||
|
],
|
||||||
|
"fingerprint": "lblo-data-foundations",
|
||||||
|
"path": []
|
||||||
|
},
|
||||||
|
"aiPrompt": "车辆资产、条线盈亏、里程调度三类决策数据底座。",
|
||||||
|
"annotationText": "五条条线数据向上汇聚;里程调度聚焦补贴里程与客户里程履约,支撑智能调度与人工干预。",
|
||||||
|
"hasMarkdown": false,
|
||||||
|
"color": "#2dd4bf",
|
||||||
|
"images": [],
|
||||||
|
"createdAt": 1740787200000,
|
||||||
|
"updatedAt": 1740787200000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"markdownMap": {},
|
||||||
|
"assetMap": {},
|
||||||
|
"directory": {
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"type": "folder",
|
||||||
|
"id": "oneos-project-nav",
|
||||||
|
"title": "ONE-OS 原型导航",
|
||||||
|
"defaultExpanded": true,
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "folder",
|
||||||
|
"id": "nav-folder-1782874576229-r8atbu",
|
||||||
|
"title": "OneOS",
|
||||||
|
"defaultExpanded": true,
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-workbench",
|
||||||
|
"title": "工作台",
|
||||||
|
"href": "/prototypes/oneos-web-workbench",
|
||||||
|
"target": "self"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-lease-business-line-overview",
|
||||||
|
"title": "业务条线说明(当前)",
|
||||||
|
"href": "/prototypes/lease-business-line-overview",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "folder",
|
||||||
|
"id": "nav-folder-prototypes-oneos-web",
|
||||||
|
"title": "ONE-OS Web 端",
|
||||||
|
"defaultExpanded": true,
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-login",
|
||||||
|
"title": "登录",
|
||||||
|
"href": "/prototypes/oneos-web-login",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-vehicle-asset",
|
||||||
|
"title": "车辆管理",
|
||||||
|
"href": "/prototypes/oneos-web-vehicle-asset",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-contract-template",
|
||||||
|
"title": "合同模板管理",
|
||||||
|
"href": "/prototypes/oneos-web-contract-template",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-help-center",
|
||||||
|
"title": "帮助中心",
|
||||||
|
"href": "/prototypes/oneos-web-help-center",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-finance",
|
||||||
|
"title": "财务管理",
|
||||||
|
"href": "/prototypes/oneos-web-finance",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-procurement",
|
||||||
|
"title": "采购管理",
|
||||||
|
"href": "/prototypes/oneos-web-procurement",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-lease-contract",
|
||||||
|
"title": "车辆租赁合同",
|
||||||
|
"href": "/prototypes/oneos-web-lease-contract",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-h2-station",
|
||||||
|
"title": "加氢站管理",
|
||||||
|
"href": "/prototypes/oneos-web-h2-station",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-data-analysis",
|
||||||
|
"title": "数据分析",
|
||||||
|
"href": "/prototypes/oneos-web-data-analysis",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-ledger-data",
|
||||||
|
"title": "台账数据",
|
||||||
|
"href": "/prototypes/oneos-web-ledger-data",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-business",
|
||||||
|
"title": "业务管理",
|
||||||
|
"href": "/prototypes/oneos-web-business",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-ops",
|
||||||
|
"title": "运维管理",
|
||||||
|
"href": "/prototypes/oneos-web-ops",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-requirements",
|
||||||
|
"title": "需求说明",
|
||||||
|
"href": "/prototypes/oneos-web-requirements",
|
||||||
|
"target": "self"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "folder",
|
||||||
|
"id": "lblo-doc-root",
|
||||||
|
"title": "业务条线说明",
|
||||||
|
"defaultExpanded": true,
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "markdown",
|
||||||
|
"id": "lblo-doc-overview",
|
||||||
|
"title": "模块总览",
|
||||||
|
"markdown": "# 业务条线说明\n\n系统介绍含「业财一体演进」:现阶段业务驱动、财务手工维护 → 打通财务系统、业财联动一体化。另含 5 大业务条线闭环与 3 大数据底座。"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "lblo-link-self",
|
||||||
|
"title": "业务条线说明原型",
|
||||||
|
"href": "./index.html",
|
||||||
|
"target": "self"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
102
src/prototypes/lease-business-line-overview/data-foundations.ts
Normal file
102
src/prototypes/lease-business-line-overview/data-foundations.ts
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
import { BarChart3, Car, Route } from 'lucide-react';
|
||||||
|
import type { ModuleAccent } from './lines';
|
||||||
|
|
||||||
|
export interface DataFoundationPillar {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
accent: ModuleAccent;
|
||||||
|
icon: LucideIcon;
|
||||||
|
summary: string;
|
||||||
|
indicators: string[];
|
||||||
|
decisions: string[];
|
||||||
|
sources: string;
|
||||||
|
prototypeHref?: string;
|
||||||
|
prototypeLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DATA_FOUNDATION_INTRO = {
|
||||||
|
title: '三大数据底座 · 支持运营决策',
|
||||||
|
tagline: '五条条线数据汇聚,支撑董事长与公司管理层看全局、定方向',
|
||||||
|
summary:
|
||||||
|
'租赁、能源、运维、安全、自营五条业务条线在日常运营中产生台账、交还车、氢费、事故违章等结构化数据。三大数据底座将这些数据向上汇总为「资产—盈亏—里程履约」三类管理视图,服务战略投入、资源调配与风险督办。',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DATA_FOUNDATION_PILLARS: DataFoundationPillar[] = [
|
||||||
|
{
|
||||||
|
id: 'asset-ops',
|
||||||
|
title: '车辆资产运营情况',
|
||||||
|
subtitle: '看规模 · 看结构 · 看健康度',
|
||||||
|
accent: 'blue',
|
||||||
|
icon: Car,
|
||||||
|
summary:
|
||||||
|
'从公司视角总览氢能车资产盘子:多少在跑、多少闲置、多少存在合规风险;并提供各品牌型号车辆出勤率分析,出勤率可进一步下钻至省、市层级,支撑「该不该买、该不该卖、该不该调」的资产决策。',
|
||||||
|
indicators: [
|
||||||
|
'各品牌型号车辆出勤率分析,支持从汇总下钻至省、市层级',
|
||||||
|
'营运 / 库存 / 退出运营车辆规模及租赁、自营结构占比',
|
||||||
|
'区域运力分布、在线率与证照 / 保险异常预警',
|
||||||
|
'交还车在途、备车就绪与整备完成率',
|
||||||
|
'从验车入库到处置出库的全生命周期状态分布',
|
||||||
|
],
|
||||||
|
decisions: [
|
||||||
|
'资产利用率是否达标,闲置车辆是否处置或转自营',
|
||||||
|
'哪些品牌型号、哪些省市出勤率偏低,是否调拨运力或调整投放',
|
||||||
|
'哪些区域运力过剩 / 不足,是否启动调拨或增购',
|
||||||
|
'合规风险车辆是否暂停交车、优先整备或续保',
|
||||||
|
],
|
||||||
|
sources: '汇聚运维条线(入库—交还—出库)、车辆管理、证照 / 保险、交车 / 备车任务等数据。',
|
||||||
|
prototypeHref: '/prototypes/vehicle-management',
|
||||||
|
prototypeLabel: '打开车辆管理',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'line-pnl',
|
||||||
|
title: '各业务条线盈亏情况',
|
||||||
|
subtitle: '看收入 · 看成本 · 看利润',
|
||||||
|
accent: 'teal',
|
||||||
|
icon: BarChart3,
|
||||||
|
summary:
|
||||||
|
'横向对比租赁、自营、能源及运维 / 安全衍生成本对利润的影响,从条线、项目、单车多层级看清「赚在哪里、亏在哪里」。',
|
||||||
|
indicators: [
|
||||||
|
'租赁:应收 / 实收 / 未收、成本与单车 / 项目盈亏',
|
||||||
|
'自营:项目盈亏表、司机成本与车辆成本占比',
|
||||||
|
'能源:客户氢费应收、站点与客户余额、对账与收款完成率',
|
||||||
|
'运维 / 安全:维保、事故赔付、违章代处理等对利润侵蚀',
|
||||||
|
'业务部 / 公司级汇总利润与同比、环比变化',
|
||||||
|
],
|
||||||
|
decisions: [
|
||||||
|
'哪些条线、项目持续亏损,是否收缩、调价或更换运力',
|
||||||
|
'未收账款与业财闭环缺口是否需专项督办',
|
||||||
|
'能源、维保、事故等成本是否异常抬升,需否管理介入',
|
||||||
|
],
|
||||||
|
sources: '汇聚租赁业务台账、自营业务台账、氢费账户、收款记录、维修 / 事故 / 违章等业财数据。',
|
||||||
|
prototypeHref: '/prototypes/business-dept-ledger',
|
||||||
|
prototypeLabel: '打开业务部台账',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'mileage-dispatch',
|
||||||
|
title: '里程调度情况',
|
||||||
|
subtitle: '里程补贴 · 客户履约 · 智能干预',
|
||||||
|
accent: 'violet',
|
||||||
|
icon: Route,
|
||||||
|
summary:
|
||||||
|
'聚焦「车辆里程补贴」与「客户里程要求」两个层面:基于车机、GPS、人工三类里程数据分析履约进度,提前识别无法完成里程的车辆;通过与已完成里程任务的客户车辆进行替换,确保双方均达标;仍无法完成的由运维人工干预处理。',
|
||||||
|
indicators: [
|
||||||
|
'车辆里程补贴:单车 / 批次是否达到补贴最小里程数及完成进度',
|
||||||
|
'客户里程要求:合同 / 项目约定里程的累计完成率与剩余任务量',
|
||||||
|
'里程数据融合:车机、GPS、人工来源的里程对比与可信度标注',
|
||||||
|
'履约预警:预判无法完成里程任务的车辆与客户清单',
|
||||||
|
'替换调度:滞后车辆与已完成任务车辆的配对替换及双方履约结果跟踪',
|
||||||
|
'运维干预:人工处理记录(专项调度、里程补录等)及闭环情况',
|
||||||
|
],
|
||||||
|
decisions: [
|
||||||
|
'判定车辆已无法完成里程时,是否与已完成里程任务的其他客户车辆进行替换',
|
||||||
|
'替换配对如何安排,确保双方客户里程任务均能顺利达标',
|
||||||
|
'替换后仍无法完成时,是否启动运维人工干预(补录、专项调度等)',
|
||||||
|
'车机 / GPS / 人工里程差异过大时,是否复核数据或更换里程认定来源',
|
||||||
|
],
|
||||||
|
sources: '汇聚车机里程、GPS 轨迹、人工补录里程,并结合客户里程约定、车辆补贴规则、替换车调度及运维干预记录等数据。',
|
||||||
|
prototypeHref: '/prototypes/oneos-web-data-analysis',
|
||||||
|
prototypeLabel: '打开数据分析',
|
||||||
|
},
|
||||||
|
];
|
||||||
90
src/prototypes/lease-business-line-overview/hooks.ts
Normal file
90
src/prototypes/lease-business-line-overview/hooks.ts
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { useCallback, useEffect, useState } from 'react';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据滚动位置判定当前模块,避免 IntersectionObserver 在多段内容重叠时乱跳。
|
||||||
|
* offset 与模块 scroll-margin-top / 顶栏高度对齐。
|
||||||
|
*/
|
||||||
|
export function useScrollSpy(sectionIds: string[], offset = 80) {
|
||||||
|
const [activeId, setActiveId] = useState(sectionIds[0] ?? '');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!sectionIds.length) return undefined;
|
||||||
|
|
||||||
|
let frame = 0;
|
||||||
|
|
||||||
|
const resolveActiveId = () => {
|
||||||
|
let current = sectionIds[0];
|
||||||
|
for (const id of sectionIds) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) continue;
|
||||||
|
if (el.getBoundingClientRect().top <= offset) {
|
||||||
|
current = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setActiveId((prev) => (prev === current ? prev : current));
|
||||||
|
};
|
||||||
|
|
||||||
|
const scheduleUpdate = () => {
|
||||||
|
if (frame) return;
|
||||||
|
frame = window.requestAnimationFrame(() => {
|
||||||
|
frame = 0;
|
||||||
|
resolveActiveId();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
resolveActiveId();
|
||||||
|
window.addEventListener('scroll', scheduleUpdate, { passive: true });
|
||||||
|
window.addEventListener('resize', scheduleUpdate, { passive: true });
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', scheduleUpdate);
|
||||||
|
window.removeEventListener('resize', scheduleUpdate);
|
||||||
|
if (frame) window.cancelAnimationFrame(frame);
|
||||||
|
};
|
||||||
|
}, [offset, sectionIds]);
|
||||||
|
|
||||||
|
return activeId;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useScrolledPast(threshold = 120) {
|
||||||
|
const [scrolled, setScrolled] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onScroll = () => setScrolled(window.scrollY > threshold);
|
||||||
|
onScroll();
|
||||||
|
window.addEventListener('scroll', onScroll, { passive: true });
|
||||||
|
return () => window.removeEventListener('scroll', onScroll);
|
||||||
|
}, [threshold]);
|
||||||
|
|
||||||
|
return scrolled;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scrollToSection(id: string) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
el.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function usePresentationStep(total: number) {
|
||||||
|
const [stepIndex, setStepIndex] = useState(0);
|
||||||
|
const [presentMode, setPresentMode] = useState(false);
|
||||||
|
|
||||||
|
const goTo = useCallback((index: number) => {
|
||||||
|
const next = Math.max(0, Math.min(total - 1, index));
|
||||||
|
setStepIndex(next);
|
||||||
|
}, [total]);
|
||||||
|
|
||||||
|
const next = useCallback(() => goTo(stepIndex + 1), [goTo, stepIndex]);
|
||||||
|
const prev = useCallback(() => goTo(stepIndex - 1), [goTo, stepIndex]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
stepIndex,
|
||||||
|
presentMode,
|
||||||
|
setPresentMode,
|
||||||
|
goTo,
|
||||||
|
next,
|
||||||
|
prev,
|
||||||
|
isFirst: stepIndex <= 0,
|
||||||
|
isLast: stepIndex >= total - 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
698
src/prototypes/lease-business-line-overview/index.tsx
Normal file
698
src/prototypes/lease-business-line-overview/index.tsx
Normal file
@@ -0,0 +1,698 @@
|
|||||||
|
/**
|
||||||
|
* @name 业务条线说明
|
||||||
|
* ONE-OS 多业务条线运作说明:租赁 / 能源 / 运维 / 安全 / 自营
|
||||||
|
*/
|
||||||
|
import './style.css';
|
||||||
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import {
|
||||||
|
ArrowRight,
|
||||||
|
BarChart3,
|
||||||
|
CheckCircle2,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Flag,
|
||||||
|
GitBranch,
|
||||||
|
Layers,
|
||||||
|
MonitorPlay,
|
||||||
|
Sparkles,
|
||||||
|
Target,
|
||||||
|
Users,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
scrollToSection,
|
||||||
|
usePresentationStep,
|
||||||
|
useScrollSpy,
|
||||||
|
useScrolledPast,
|
||||||
|
} from './hooks';
|
||||||
|
import {
|
||||||
|
BUSINESS_LINES,
|
||||||
|
BUSINESS_LINE_MAP,
|
||||||
|
DEFAULT_BUSINESS_LINE_ID,
|
||||||
|
LINE_ACCENTS,
|
||||||
|
LINE_TAB_ICONS,
|
||||||
|
SYSTEM_INTRO,
|
||||||
|
type BusinessLineConfig,
|
||||||
|
type BusinessLineId,
|
||||||
|
type LineModule,
|
||||||
|
} from './lines';
|
||||||
|
import {
|
||||||
|
DATA_FOUNDATION_INTRO,
|
||||||
|
DATA_FOUNDATION_PILLARS,
|
||||||
|
type DataFoundationPillar,
|
||||||
|
} from './data-foundations';
|
||||||
|
|
||||||
|
function parseLineIdFromHash(): BusinessLineId {
|
||||||
|
const raw = window.location.hash.replace(/^#/, '').split(/[/?]/)[0];
|
||||||
|
if (raw && raw in BUSINESS_LINE_MAP) return raw as BusinessLineId;
|
||||||
|
return DEFAULT_BUSINESS_LINE_ID;
|
||||||
|
}
|
||||||
|
|
||||||
|
function outcomeClass(tone: 'ok' | 'warn' | 'danger') {
|
||||||
|
return `lblo-outcome lblo-outcome--${tone}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ModuleCardProps {
|
||||||
|
module: LineModule;
|
||||||
|
active: boolean;
|
||||||
|
index: number;
|
||||||
|
moduleCount: number;
|
||||||
|
lineId: BusinessLineId;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModuleCard({ module, active, index, moduleCount, lineId }: ModuleCardProps) {
|
||||||
|
const Icon = module.icon;
|
||||||
|
const sectionId = `${lineId}-${module.id}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
className={`lblo-module lblo-module--${module.accent}${active ? ' is-active' : ''}`}
|
||||||
|
id={sectionId}
|
||||||
|
data-annotation-id={`lblo-mod-${module.id}`}
|
||||||
|
style={{ '--lblo-index': index } as React.CSSProperties}
|
||||||
|
>
|
||||||
|
<div className="lblo-module-rail" aria-hidden>
|
||||||
|
<span className="lblo-module-rail-dot" />
|
||||||
|
{index < moduleCount - 1 && <span className="lblo-module-rail-line" />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lblo-module-panel">
|
||||||
|
<header className="lblo-module-header">
|
||||||
|
<div className="lblo-module-icon-wrap">
|
||||||
|
<Icon size={22} aria-hidden />
|
||||||
|
</div>
|
||||||
|
<div className="lblo-module-titles">
|
||||||
|
<span className="lblo-module-step">第 {module.step} 步</span>
|
||||||
|
<h2>{module.title}</h2>
|
||||||
|
<p className="lblo-module-subtitle">{module.subtitle}</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="lblo-roles" aria-label="责任部门">
|
||||||
|
{module.roles.map((role) => (
|
||||||
|
<span key={role} className="lblo-role-tag">{role}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lblo-module-body">
|
||||||
|
<section className="lblo-block lblo-block--start" aria-labelledby={`${sectionId}-start`}>
|
||||||
|
<h3 className="lblo-block-label" id={`${sectionId}-start`}>
|
||||||
|
<span className="lblo-block-icon"><Flag size={15} aria-hidden /></span>
|
||||||
|
起点
|
||||||
|
</h3>
|
||||||
|
<p>{module.start}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="lblo-block lblo-block--process" aria-labelledby={`${sectionId}-process`}>
|
||||||
|
<h3 className="lblo-block-label" id={`${sectionId}-process`}>
|
||||||
|
<span className="lblo-block-icon"><GitBranch size={15} aria-hidden /></span>
|
||||||
|
怎么运作
|
||||||
|
</h3>
|
||||||
|
<ol className="lblo-process-steps">
|
||||||
|
{module.process.map((item, stepIdx) => (
|
||||||
|
<li key={item}>
|
||||||
|
<span className="lblo-process-num">{stepIdx + 1}</span>
|
||||||
|
<span>{item}</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
{module.outcomes && (
|
||||||
|
<div className="lblo-outcomes" aria-label="关键结果">
|
||||||
|
{module.outcomes.map((item) => (
|
||||||
|
<span key={item.label} className={outcomeClass(item.tone)}>{item.label}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="lblo-block lblo-block--closure" aria-labelledby={`${sectionId}-closure`}>
|
||||||
|
<h3 className="lblo-block-label" id={`${sectionId}-closure`}>
|
||||||
|
<span className="lblo-block-icon"><CheckCircle2 size={15} aria-hidden /></span>
|
||||||
|
闭环
|
||||||
|
</h3>
|
||||||
|
<p>{module.closure}</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{module.prototypeHref ? (
|
||||||
|
<footer className="lblo-module-footer">
|
||||||
|
<p className="lblo-closure-note">演示时可进入系统查看该模块实际操作界面。</p>
|
||||||
|
<a className="lblo-link-btn" href={module.prototypeHref}>
|
||||||
|
{module.prototypeLabel}
|
||||||
|
<ArrowRight size={16} aria-hidden />
|
||||||
|
</a>
|
||||||
|
</footer>
|
||||||
|
) : (
|
||||||
|
<footer className="lblo-module-footer lblo-module-footer--muted">
|
||||||
|
<p className="lblo-closure-note">该模块将随 ONE-OS 后续迭代接入独立操作界面。</p>
|
||||||
|
</footer>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataFoundationCard({ pillar }: { pillar: DataFoundationPillar }) {
|
||||||
|
const Icon = pillar.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article className={`lblo-data-pillar lblo-data-pillar--${pillar.accent}`}>
|
||||||
|
<header className="lblo-data-pillar-header">
|
||||||
|
<span className="lblo-data-pillar-icon" aria-hidden>
|
||||||
|
<Icon size={22} />
|
||||||
|
</span>
|
||||||
|
<div>
|
||||||
|
<h3>{pillar.title}</h3>
|
||||||
|
<p className="lblo-data-pillar-subtitle">{pillar.subtitle}</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<p className="lblo-data-pillar-summary">{pillar.summary}</p>
|
||||||
|
<div className="lblo-data-pillar-block">
|
||||||
|
<h4>
|
||||||
|
<Target size={14} aria-hidden />
|
||||||
|
决策看板关注
|
||||||
|
</h4>
|
||||||
|
<ul>
|
||||||
|
{pillar.indicators.map((item) => (
|
||||||
|
<li key={item}>{item}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div className="lblo-data-pillar-block">
|
||||||
|
<h4>
|
||||||
|
<BarChart3 size={14} aria-hidden />
|
||||||
|
支持管理决策
|
||||||
|
</h4>
|
||||||
|
<ul>
|
||||||
|
{pillar.decisions.map((item) => (
|
||||||
|
<li key={item}>{item}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<p className="lblo-data-pillar-sources">{pillar.sources}</p>
|
||||||
|
{pillar.prototypeHref && (
|
||||||
|
<a className="lblo-data-pillar-link" href={pillar.prototypeHref}>
|
||||||
|
{pillar.prototypeLabel}
|
||||||
|
<ArrowRight size={16} aria-hidden />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function DataFoundationSection() {
|
||||||
|
return (
|
||||||
|
<section className="lblo-data-foundations" id="lblo-data-foundations" data-annotation-id="lblo-data-foundations">
|
||||||
|
<header className="lblo-data-foundations-header">
|
||||||
|
<h2>{DATA_FOUNDATION_INTRO.title}</h2>
|
||||||
|
<p className="lblo-data-foundations-tagline">{DATA_FOUNDATION_INTRO.tagline}</p>
|
||||||
|
<p className="lblo-data-foundations-summary">{DATA_FOUNDATION_INTRO.summary}</p>
|
||||||
|
</header>
|
||||||
|
<div className="lblo-data-pillar-grid">
|
||||||
|
{DATA_FOUNDATION_PILLARS.map((pillar) => (
|
||||||
|
<DataFoundationCard key={pillar.id} pillar={pillar} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function SystemIntro({
|
||||||
|
activeLineId,
|
||||||
|
onSelectLine,
|
||||||
|
}: {
|
||||||
|
activeLineId: BusinessLineId;
|
||||||
|
onSelectLine: (id: BusinessLineId) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<section className="lblo-system-intro" data-annotation-id="lblo-system-intro">
|
||||||
|
<div className="lblo-system-intro-inner">
|
||||||
|
<header className="lblo-system-intro-header">
|
||||||
|
<p className="lblo-system-intro-eyebrow">
|
||||||
|
<Sparkles size={16} aria-hidden />
|
||||||
|
{SYSTEM_INTRO.eyebrow}
|
||||||
|
</p>
|
||||||
|
<h1>{SYSTEM_INTRO.title}</h1>
|
||||||
|
<p className="lblo-system-intro-tagline">{SYSTEM_INTRO.tagline}</p>
|
||||||
|
<p className="lblo-system-intro-summary">{SYSTEM_INTRO.summary}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{SYSTEM_INTRO.evolution && (
|
||||||
|
<EvolutionBanner
|
||||||
|
evolution={SYSTEM_INTRO.evolution}
|
||||||
|
heading="业财一体演进"
|
||||||
|
annotationId="lblo-system-evolution"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="lblo-system-hub" aria-hidden>
|
||||||
|
<span className="lblo-system-hub-core">
|
||||||
|
<Layers size={22} />
|
||||||
|
ONE-OS
|
||||||
|
</span>
|
||||||
|
<span className="lblo-system-hub-ring" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="lblo-line-grid"
|
||||||
|
role="tablist"
|
||||||
|
aria-label="业务条线选择"
|
||||||
|
data-annotation-id="lblo-line-switcher"
|
||||||
|
>
|
||||||
|
{BUSINESS_LINES.map((item) => {
|
||||||
|
const TabIcon = LINE_TAB_ICONS[item.id];
|
||||||
|
const accent = LINE_ACCENTS[item.id];
|
||||||
|
const isActive = item.id === activeLineId;
|
||||||
|
const moduleCount = item.modules.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={item.id}
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
id={`lblo-line-tab-${item.id}`}
|
||||||
|
aria-selected={isActive}
|
||||||
|
aria-controls="lblo-line-detail"
|
||||||
|
className={`lblo-line-card lblo-line-card--${accent}${isActive ? ' is-active' : ''}${item.placeholder ? ' is-placeholder' : ''}`}
|
||||||
|
onClick={() => onSelectLine(item.id)}
|
||||||
|
>
|
||||||
|
<span className="lblo-line-card-icon" aria-hidden>
|
||||||
|
<TabIcon size={20} />
|
||||||
|
</span>
|
||||||
|
<span className="lblo-line-card-body">
|
||||||
|
<span className="lblo-line-card-title-row">
|
||||||
|
<span className="lblo-line-card-title">{item.label}</span>
|
||||||
|
{item.placeholder && <span className="lblo-line-card-badge">待补充</span>}
|
||||||
|
</span>
|
||||||
|
<span className="lblo-line-card-tagline">{item.hero.tagline}</span>
|
||||||
|
</span>
|
||||||
|
<span className="lblo-line-card-meta">
|
||||||
|
{moduleCount > 0 ? (
|
||||||
|
<span className="lblo-line-card-stat">{moduleCount} 个模块</span>
|
||||||
|
) : (
|
||||||
|
<span className="lblo-line-card-stat">筹备中</span>
|
||||||
|
)}
|
||||||
|
<ArrowRight size={16} aria-hidden />
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="lblo-system-intro-footnote">
|
||||||
|
不是点击下方条线卡片,而是点击上方条线卡片,查看该条线的模块说明与闭环路径;下方为管理层决策数据底座。
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<DataFoundationSection />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EvolutionBanner({
|
||||||
|
evolution,
|
||||||
|
heading = '条线演进路径',
|
||||||
|
annotationId = 'lblo-evolution',
|
||||||
|
}: {
|
||||||
|
evolution: NonNullable<BusinessLineConfig['evolution']>;
|
||||||
|
heading?: string;
|
||||||
|
annotationId?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="lblo-evolution" data-annotation-id={annotationId}>
|
||||||
|
<p className="lblo-evolution-heading">{heading}</p>
|
||||||
|
<div className="lblo-evolution-track">
|
||||||
|
<div className="lblo-evolution-phase">
|
||||||
|
<span className="lblo-evolution-label">{evolution.currentLabel}</span>
|
||||||
|
<p>{evolution.currentText}</p>
|
||||||
|
</div>
|
||||||
|
<div className="lblo-evolution-arrow" aria-hidden>
|
||||||
|
<ChevronRight size={22} />
|
||||||
|
</div>
|
||||||
|
<div className="lblo-evolution-phase lblo-evolution-phase--target">
|
||||||
|
<span className="lblo-evolution-label">{evolution.targetLabel}</span>
|
||||||
|
<p>{evolution.targetText}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PipelineNodeButton({
|
||||||
|
module,
|
||||||
|
lineId,
|
||||||
|
activeId,
|
||||||
|
compact = false,
|
||||||
|
}: {
|
||||||
|
module: LineModule;
|
||||||
|
lineId: BusinessLineId;
|
||||||
|
activeId: string;
|
||||||
|
compact?: boolean;
|
||||||
|
}) {
|
||||||
|
const Icon = module.icon;
|
||||||
|
const sectionId = `${lineId}-${module.id}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`lblo-pipeline-node lblo-pipeline-node--${module.accent}${compact ? ' lblo-pipeline-node--compact' : ''}${activeId === sectionId ? ' is-active' : ''}`}
|
||||||
|
onClick={() => scrollToSection(sectionId)}
|
||||||
|
aria-current={activeId === sectionId ? 'step' : undefined}
|
||||||
|
>
|
||||||
|
<span className="lblo-pipeline-icon"><Icon size={18} aria-hidden /></span>
|
||||||
|
<span className="lblo-pipeline-label">{module.shortLabel}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PipelineOverview({
|
||||||
|
line,
|
||||||
|
activeId,
|
||||||
|
}: {
|
||||||
|
line: BusinessLineConfig;
|
||||||
|
activeId: string;
|
||||||
|
}) {
|
||||||
|
const mainModules = line.modules.filter((m) => m.pipelineTrack === 'main');
|
||||||
|
const supportModules = line.modules.filter((m) => m.pipelineTrack === 'support');
|
||||||
|
const useDualLayout = mainModules.length > 0 && supportModules.length > 0;
|
||||||
|
|
||||||
|
if (useDualLayout) {
|
||||||
|
return (
|
||||||
|
<div className="lblo-pipeline-dual" aria-label="业务链路总览">
|
||||||
|
<div className="lblo-pipeline-section">
|
||||||
|
<p className="lblo-pipeline-section-label">生命周期主线</p>
|
||||||
|
<div className="lblo-pipeline lblo-pipeline--main">
|
||||||
|
{mainModules.map((module, idx) => (
|
||||||
|
<React.Fragment key={module.id}>
|
||||||
|
<PipelineNodeButton module={module} lineId={line.id} activeId={activeId} />
|
||||||
|
{idx < mainModules.length - 1 && (
|
||||||
|
<span className="lblo-pipeline-connector" aria-hidden>
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="lblo-pipeline-section">
|
||||||
|
<p className="lblo-pipeline-section-label">并行支撑能力</p>
|
||||||
|
<p className="lblo-pipeline-section-note">全生命周期内随时可能触发,无固定先后顺序</p>
|
||||||
|
<div className="lblo-pipeline-support">
|
||||||
|
{supportModules.map((module) => (
|
||||||
|
<PipelineNodeButton
|
||||||
|
key={module.id}
|
||||||
|
module={module}
|
||||||
|
lineId={line.id}
|
||||||
|
activeId={activeId}
|
||||||
|
compact
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lblo-pipeline" aria-label="业务链路总览">
|
||||||
|
{line.modules.map((module, idx) => (
|
||||||
|
<React.Fragment key={module.id}>
|
||||||
|
<PipelineNodeButton module={module} lineId={line.id} activeId={activeId} />
|
||||||
|
{idx < line.modules.length - 1 && (
|
||||||
|
<span className="lblo-pipeline-connector" aria-hidden>
|
||||||
|
<ChevronRight size={14} />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</React.Fragment>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PlaceholderPanel({ line }: { line: BusinessLineConfig }) {
|
||||||
|
const Icon = LINE_TAB_ICONS[line.id];
|
||||||
|
return (
|
||||||
|
<div className="lblo-placeholder" data-annotation-id={`lblo-placeholder-${line.id}`}>
|
||||||
|
<div className="lblo-placeholder-icon">
|
||||||
|
<Icon size={32} aria-hidden />
|
||||||
|
</div>
|
||||||
|
<h2>内容筹备中</h2>
|
||||||
|
<p>{line.placeholderHint}</p>
|
||||||
|
<p className="lblo-placeholder-note">切换至「租赁业务条线」可查看完整演示内容。</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface LineContentProps {
|
||||||
|
line: BusinessLineConfig;
|
||||||
|
presentMode: boolean;
|
||||||
|
setPresentMode: React.Dispatch<React.SetStateAction<boolean>>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function LineContent({ line, presentMode, setPresentMode }: LineContentProps) {
|
||||||
|
const sectionIds = useMemo(
|
||||||
|
() => line.modules.map((m) => `${line.id}-${m.id}`),
|
||||||
|
[line.id, line.modules],
|
||||||
|
);
|
||||||
|
const activeId = useScrollSpy(sectionIds, 72);
|
||||||
|
const headerPinned = useScrolledPast(96);
|
||||||
|
const {
|
||||||
|
stepIndex,
|
||||||
|
next,
|
||||||
|
prev,
|
||||||
|
isFirst,
|
||||||
|
isLast,
|
||||||
|
} = usePresentationStep(line.modules.length);
|
||||||
|
|
||||||
|
const activeIndex = line.modules.findIndex((m) => `${line.id}-${m.id}` === activeId);
|
||||||
|
const progress = line.modules.length
|
||||||
|
? ((activeIndex >= 0 ? activeIndex : 0) + 1) / line.modules.length
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!presentMode || !line.modules.length) return;
|
||||||
|
scrollToSection(`${line.id}-${line.modules[stepIndex].id}`);
|
||||||
|
}, [presentMode, stepIndex, line.id, line.modules]);
|
||||||
|
|
||||||
|
const hasModules = line.modules.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<header
|
||||||
|
className={`lblo-topbar lblo-topbar--line${headerPinned ? ' is-pinned' : ''}`}
|
||||||
|
data-annotation-id="lblo-nav"
|
||||||
|
>
|
||||||
|
<div className="lblo-topbar-inner">
|
||||||
|
<span className="lblo-topbar-brand">
|
||||||
|
<Layers size={18} aria-hidden />
|
||||||
|
{line.label}
|
||||||
|
</span>
|
||||||
|
{hasModules && (
|
||||||
|
<div className="lblo-topbar-progress" aria-hidden>
|
||||||
|
<span className="lblo-topbar-progress-fill" style={{ width: `${progress * 100}%` }} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{hasModules && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={`lblo-present-toggle${presentMode ? ' is-on' : ''}`}
|
||||||
|
onClick={() => setPresentMode((v) => !v)}
|
||||||
|
aria-pressed={presentMode}
|
||||||
|
>
|
||||||
|
{presentMode ? <X size={16} aria-hidden /> : <MonitorPlay size={16} aria-hidden />}
|
||||||
|
{presentMode ? '退出演示' : '演示模式'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="lblo-hero" data-annotation-id="lblo-hero">
|
||||||
|
<div className="lblo-hero-inner">
|
||||||
|
<p className="lblo-eyebrow">
|
||||||
|
<Sparkles size={16} aria-hidden />
|
||||||
|
ONE-OS · {line.shortLabel}条线
|
||||||
|
</p>
|
||||||
|
<h1>{line.hero.title}</h1>
|
||||||
|
<p className="lblo-tagline">{line.hero.tagline}</p>
|
||||||
|
<p className="lblo-summary">{line.hero.summary}</p>
|
||||||
|
|
||||||
|
{line.evolution && <EvolutionBanner evolution={line.evolution} />}
|
||||||
|
|
||||||
|
<div className="lblo-stats">
|
||||||
|
{line.hero.stats.map((stat) => (
|
||||||
|
<div key={stat.label} className="lblo-stat">
|
||||||
|
<span className="lblo-stat-value">{stat.value}</span>
|
||||||
|
<span className="lblo-stat-label">{stat.label}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{hasModules && (
|
||||||
|
<PipelineOverview line={line} activeId={activeId} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{line.placeholder ? (
|
||||||
|
<div className="lblo-shell lblo-shell--placeholder">
|
||||||
|
<PlaceholderPanel line={line} />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="lblo-shell">
|
||||||
|
<aside className="lblo-sidebar" aria-label="模块导航">
|
||||||
|
<p className="lblo-sidebar-title">浏览进度</p>
|
||||||
|
<nav className="lblo-sidebar-nav">
|
||||||
|
{line.modules.map((module) => {
|
||||||
|
const Icon = module.icon;
|
||||||
|
const sectionId = `${line.id}-${module.id}`;
|
||||||
|
const isActive = activeId === sectionId;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={module.id}
|
||||||
|
type="button"
|
||||||
|
className={`lblo-sidebar-link lblo-sidebar-link--${module.accent}${isActive ? ' is-active' : ''}`}
|
||||||
|
onClick={() => scrollToSection(sectionId)}
|
||||||
|
aria-current={isActive ? 'step' : undefined}
|
||||||
|
>
|
||||||
|
<span className="lblo-sidebar-num">{module.step}</span>
|
||||||
|
<span className="lblo-sidebar-text">
|
||||||
|
<Icon size={16} aria-hidden />
|
||||||
|
{module.title}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main id="lblo-main" className="lblo-main">
|
||||||
|
{line.modules.map((module, index) => (
|
||||||
|
<ModuleCard
|
||||||
|
key={module.id}
|
||||||
|
module={module}
|
||||||
|
active={activeId === `${line.id}-${module.id}`}
|
||||||
|
index={index}
|
||||||
|
moduleCount={line.modules.length}
|
||||||
|
lineId={line.id}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<aside className="lblo-loop-card" data-annotation-id="lblo-full-loop" aria-label="全链路闭环">
|
||||||
|
<div className="lblo-loop-icon" aria-hidden>
|
||||||
|
<Users size={20} />
|
||||||
|
<ArrowRight size={16} />
|
||||||
|
<CheckCircle2 size={20} />
|
||||||
|
</div>
|
||||||
|
<div className="lblo-loop-content">
|
||||||
|
<h3>{line.loopTitle}</h3>
|
||||||
|
<p>{line.loopSummary} 全链路数据自动串联,减少人工重复录入。</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="lblo-loop-btn"
|
||||||
|
onClick={() => scrollToSection(`${line.id}-${line.modules[0].id}`)}
|
||||||
|
>
|
||||||
|
回到第一步
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{hasModules && (
|
||||||
|
<nav className="lblo-mobile-nav" aria-label="模块快速跳转">
|
||||||
|
{line.modules.map((module) => (
|
||||||
|
<button
|
||||||
|
key={module.id}
|
||||||
|
type="button"
|
||||||
|
className={`lblo-mobile-chip${activeId === `${line.id}-${module.id}` ? ' is-active' : ''}`}
|
||||||
|
onClick={() => scrollToSection(`${line.id}-${module.id}`)}
|
||||||
|
aria-label={module.title}
|
||||||
|
>
|
||||||
|
{module.step}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{presentMode && hasModules && (
|
||||||
|
<div className="lblo-present-bar" role="toolbar" aria-label="演示控制">
|
||||||
|
<button type="button" className="lblo-present-btn" disabled={isFirst} onClick={prev}>
|
||||||
|
<ChevronLeft size={18} aria-hidden />
|
||||||
|
上一模块
|
||||||
|
</button>
|
||||||
|
<span className="lblo-present-indicator">
|
||||||
|
{stepIndex + 1}
|
||||||
|
{' / '}
|
||||||
|
{line.modules.length}
|
||||||
|
{' · '}
|
||||||
|
{line.modules[stepIndex].title}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="lblo-present-btn lblo-present-btn--primary"
|
||||||
|
disabled={isLast}
|
||||||
|
onClick={next}
|
||||||
|
>
|
||||||
|
下一模块
|
||||||
|
<ChevronRight size={18} aria-hidden />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LeaseBusinessLineOverviewApp() {
|
||||||
|
const [lineId, setLineId] = useState<BusinessLineId>(parseLineIdFromHash);
|
||||||
|
const [presentMode, setPresentMode] = useState(false);
|
||||||
|
const lineDetailRef = useRef<HTMLElement>(null);
|
||||||
|
const line = BUSINESS_LINE_MAP[lineId];
|
||||||
|
|
||||||
|
const switchLine = useCallback((id: BusinessLineId) => {
|
||||||
|
setLineId(id);
|
||||||
|
setPresentMode(false);
|
||||||
|
window.history.replaceState(null, '', `#${id}`);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
lineDetailRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onHashChange = () => {
|
||||||
|
setLineId(parseLineIdFromHash());
|
||||||
|
setPresentMode(false);
|
||||||
|
};
|
||||||
|
window.addEventListener('hashchange', onHashChange);
|
||||||
|
return () => window.removeEventListener('hashchange', onHashChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`lblo-page${presentMode ? ' lblo-page--present' : ''}`} data-annotation-id="lblo-page">
|
||||||
|
<a className="lblo-skip" href="#lblo-line-detail">跳到条线详情</a>
|
||||||
|
|
||||||
|
<div className="lblo-bg" aria-hidden>
|
||||||
|
<span className="lblo-bg-orb lblo-bg-orb--1" />
|
||||||
|
<span className="lblo-bg-orb lblo-bg-orb--2" />
|
||||||
|
<span className="lblo-bg-grid" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SystemIntro activeLineId={lineId} onSelectLine={switchLine} />
|
||||||
|
|
||||||
|
<section
|
||||||
|
ref={lineDetailRef}
|
||||||
|
id="lblo-line-detail"
|
||||||
|
className="lblo-line-detail"
|
||||||
|
role="tabpanel"
|
||||||
|
aria-labelledby={`lblo-line-tab-${lineId}`}
|
||||||
|
>
|
||||||
|
<LineContent
|
||||||
|
key={lineId}
|
||||||
|
line={line}
|
||||||
|
presentMode={presentMode}
|
||||||
|
setPresentMode={setPresentMode}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
994
src/prototypes/lease-business-line-overview/lines.ts
Normal file
994
src/prototypes/lease-business-line-overview/lines.ts
Normal file
@@ -0,0 +1,994 @@
|
|||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
import {
|
||||||
|
AlertTriangle,
|
||||||
|
Archive,
|
||||||
|
ArrowLeftRight,
|
||||||
|
BookOpen,
|
||||||
|
Building2,
|
||||||
|
Calendar,
|
||||||
|
Car,
|
||||||
|
CircleDollarSign,
|
||||||
|
ClipboardCheck,
|
||||||
|
ClipboardList,
|
||||||
|
FileSignature,
|
||||||
|
FileText,
|
||||||
|
FileWarning,
|
||||||
|
Fuel,
|
||||||
|
GraduationCap,
|
||||||
|
Library,
|
||||||
|
MapPin,
|
||||||
|
Network,
|
||||||
|
Package,
|
||||||
|
RefreshCw,
|
||||||
|
RotateCcw,
|
||||||
|
Settings,
|
||||||
|
Shield,
|
||||||
|
ShieldAlert,
|
||||||
|
ShieldCheck,
|
||||||
|
Siren,
|
||||||
|
Truck,
|
||||||
|
UserRound,
|
||||||
|
Users,
|
||||||
|
Wallet,
|
||||||
|
Wrench,
|
||||||
|
} from 'lucide-react';
|
||||||
|
|
||||||
|
export type ModuleAccent = 'blue' | 'violet' | 'indigo' | 'amber' | 'teal' | 'emerald';
|
||||||
|
|
||||||
|
/** 运维条线 Hero 链路图:main 顺序主线,support 并行能力(无先后) */
|
||||||
|
export type PipelineTrack = 'main' | 'support';
|
||||||
|
|
||||||
|
export interface LineModule {
|
||||||
|
id: string;
|
||||||
|
step: number;
|
||||||
|
title: string;
|
||||||
|
subtitle: string;
|
||||||
|
shortLabel: string;
|
||||||
|
accent: ModuleAccent;
|
||||||
|
icon: LucideIcon;
|
||||||
|
pipelineTrack?: PipelineTrack;
|
||||||
|
roles: string[];
|
||||||
|
start: string;
|
||||||
|
process: string[];
|
||||||
|
closure: string;
|
||||||
|
outcomes?: { label: string; tone: 'ok' | 'warn' | 'danger' }[];
|
||||||
|
prototypeHref?: string;
|
||||||
|
prototypeLabel?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BusinessLineId = 'lease' | 'energy' | 'ops' | 'safety' | 'self-operated';
|
||||||
|
|
||||||
|
export interface LineHeroStat {
|
||||||
|
value: string;
|
||||||
|
label: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LineEvolution {
|
||||||
|
currentLabel: string;
|
||||||
|
currentText: string;
|
||||||
|
targetLabel: string;
|
||||||
|
targetText: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BusinessLineConfig {
|
||||||
|
id: BusinessLineId;
|
||||||
|
label: string;
|
||||||
|
shortLabel: string;
|
||||||
|
hero: {
|
||||||
|
title: string;
|
||||||
|
tagline: string;
|
||||||
|
summary: string;
|
||||||
|
stats: LineHeroStat[];
|
||||||
|
};
|
||||||
|
modules: LineModule[];
|
||||||
|
loopTitle: string;
|
||||||
|
loopSummary: string;
|
||||||
|
/** 仅租赁条线展示演进路径 */
|
||||||
|
evolution?: LineEvolution;
|
||||||
|
/** 占位条线,模块内容稍后填充 */
|
||||||
|
placeholder?: boolean;
|
||||||
|
placeholderHint?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LEASE_MODULES: LineModule[] = [
|
||||||
|
{
|
||||||
|
id: 'customer',
|
||||||
|
step: 1,
|
||||||
|
title: '客户管理',
|
||||||
|
subtitle: '准入评级 · 决定能否签约、走哪条流程',
|
||||||
|
shortLabel: '客户',
|
||||||
|
accent: 'blue',
|
||||||
|
icon: Users,
|
||||||
|
roles: ['业务管理组', '法务部', '财务部', '安全部'],
|
||||||
|
start: '业务管理组维护客户基础信息,作为后续合同与账单的统一客户主数据。',
|
||||||
|
process: [
|
||||||
|
'法务部每 3 个月评估客户法律风险。',
|
||||||
|
'财务部实时维护客户财务风险等级。',
|
||||||
|
'安全部实时维护客户安全风险等级。',
|
||||||
|
'系统综合三部门结果,形成客户签约策略。',
|
||||||
|
],
|
||||||
|
closure:
|
||||||
|
'未达禁止标准:可签租赁合同;若需偏离常规范式,走「非标准流程」评审。达到禁止标准:禁止新签,并将该客户现有合同标记为「高风险」,通知业务人员跟进处理或终止。',
|
||||||
|
outcomes: [
|
||||||
|
{ label: '可标准签约', tone: 'ok' },
|
||||||
|
{ label: '可签 · 走非标', tone: 'warn' },
|
||||||
|
{ label: '禁止签约 · 高风险', tone: 'danger' },
|
||||||
|
],
|
||||||
|
prototypeHref: '/prototypes/customer-management',
|
||||||
|
prototypeLabel: '打开客户管理',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'contract-template',
|
||||||
|
step: 2,
|
||||||
|
title: '标准合同管理',
|
||||||
|
subtitle: '模板配置 · 支撑电子合同一键发起',
|
||||||
|
shortLabel: '模板',
|
||||||
|
accent: 'violet',
|
||||||
|
icon: FileText,
|
||||||
|
roles: ['法务部', '数智部'],
|
||||||
|
start: '法务部维护各车型的试用合同、正式合同等标准模板,明确条款边界与风控要求。',
|
||||||
|
process: [
|
||||||
|
'数智部按模板配置电子合同,发起时可直选模板。',
|
||||||
|
'条款可设「触发车型」——仅对应车型可见该条款或附件。',
|
||||||
|
'「风控红线」被修改时,自动进入非标准流程审批。',
|
||||||
|
'「锁定区域」内内容在合同中不可被业务随意改动。',
|
||||||
|
],
|
||||||
|
closure:
|
||||||
|
'标准模板就绪后,业务员发起合同时可直接选用;在允许范围内填写即走标准流程,触碰红线或锁定区外修改则转入法务非标审批。',
|
||||||
|
prototypeHref: '/prototypes/contract-template-management',
|
||||||
|
prototypeLabel: '打开合同模板管理',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lease-contract',
|
||||||
|
step: 3,
|
||||||
|
title: '租赁合同',
|
||||||
|
subtitle: '签约发起 · 标准 / 非标 / 禁止三条路径',
|
||||||
|
shortLabel: '签约',
|
||||||
|
accent: 'indigo',
|
||||||
|
icon: FileSignature,
|
||||||
|
roles: ['业务员', '法务部'],
|
||||||
|
start: '业务员选择羚牛签约主体与乙方客户;系统依据客户评级判断可走「标准流程」「非标准流程」或禁止发起。',
|
||||||
|
process: [
|
||||||
|
'填写合同要素,内容实时反写进电子合同预览。',
|
||||||
|
'完成后自动生成电子合同、条款附件、授权委托书等。',
|
||||||
|
'预览可在标准约束内微调;一旦越界,自动转非标准流程供法务审核。',
|
||||||
|
'支持续签、转正式合同、转三方合同、添加增值服务、添加授权委托书、主动终止等操作。',
|
||||||
|
],
|
||||||
|
closure:
|
||||||
|
'合同审批通过即进入履约阶段,并与后续提车应收、交车任务、台账账单串联;非标路径须法务与董事长确认法律合规后放行。',
|
||||||
|
prototypeHref: '/prototypes/lease-contract-management',
|
||||||
|
prototypeLabel: '打开租赁合同',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'pickup-receivable',
|
||||||
|
step: 4,
|
||||||
|
title: '提车应收款',
|
||||||
|
subtitle: '应收生成 · 实收对齐 · 触发交车',
|
||||||
|
shortLabel: '提车',
|
||||||
|
accent: 'amber',
|
||||||
|
icon: Wallet,
|
||||||
|
roles: ['业务员', '法务部', '业务管理组', '运维部'],
|
||||||
|
start: '业务员提交租赁合同审批时,系统按合同条款自动生成提车应收金额。',
|
||||||
|
process: [
|
||||||
|
'法务在「收款记录」导入到账信息(后期对接财务 YS 与银企直联自动获取)。',
|
||||||
|
'业务员 / 业务管理组将收款记录关联到账单并分配金额。',
|
||||||
|
'实收与应收对齐后,提车应收款自动标记为完成。',
|
||||||
|
],
|
||||||
|
closure:
|
||||||
|
'应收完成后,按合同约定生成交车任务;运维部对应区域操作员在交车模块查看并执行,车辆正式交付给承租方。',
|
||||||
|
prototypeHref: '/prototypes/payment-records',
|
||||||
|
prototypeLabel: '打开收款记录',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'lease-ledger',
|
||||||
|
step: 5,
|
||||||
|
title: '租赁业务台账',
|
||||||
|
subtitle: '交车后账单 · 周期收费与实收延续',
|
||||||
|
shortLabel: '台账',
|
||||||
|
accent: 'teal',
|
||||||
|
icon: BookOpen,
|
||||||
|
roles: ['运维部', '业务员', '业务管理组'],
|
||||||
|
start: '运维交车成功后,租赁业务台账自动生成该车辆的租赁账单,应付款日初始为「待设置」。',
|
||||||
|
process: [
|
||||||
|
'业务人员设置账单应付款日后,第一期账单按规则自动生成并计算应收费用。',
|
||||||
|
'提车应收款实收同步写入首期账单实收。',
|
||||||
|
'若实收多于首期应收,溢出部分自动并入第二期实收作减免,第二期仅收差额。',
|
||||||
|
],
|
||||||
|
closure:
|
||||||
|
'台账持续记录每期应收、实收与状态,支撑租期中运营管理与后续还车结算的数据基础。',
|
||||||
|
prototypeHref: '/prototypes/lease-business-ledger',
|
||||||
|
prototypeLabel: '打开租赁业务台账',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'return-settlement',
|
||||||
|
step: 6,
|
||||||
|
title: '还车应结款',
|
||||||
|
subtitle: '还车结算 · 多部门会签 · 归档闭环',
|
||||||
|
shortLabel: '还车',
|
||||||
|
accent: 'emerald',
|
||||||
|
icon: RotateCcw,
|
||||||
|
roles: ['业务管理组', '安全部', '运维部', '业务管理组-能源部'],
|
||||||
|
start: '运维完成还车后,系统自动生成还车应结款任务。',
|
||||||
|
process: [
|
||||||
|
'业务管理组、安全部、运维部、能源部各自完成本部门还车应结款审批。',
|
||||||
|
'汇总后计算应退还或应补缴金额。',
|
||||||
|
'应补缴时关联「收款记录」,应退还时关联「付款记录」,结果回写还车应结款。',
|
||||||
|
],
|
||||||
|
closure:
|
||||||
|
'业务管理组发起还车应结款总审批;审批完成后自动归档,租赁单条业务从签约到还车全链路闭环。',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ENERGY_MODULES: LineModule[] = [
|
||||||
|
{
|
||||||
|
id: 'energy-supplier',
|
||||||
|
step: 1,
|
||||||
|
title: '供应商管理',
|
||||||
|
subtitle: '加氢/充电站供应商 · 站点绑定与业财直连',
|
||||||
|
shortLabel: '供应商',
|
||||||
|
accent: 'blue',
|
||||||
|
icon: Building2,
|
||||||
|
roles: ['采购组', '财务部'],
|
||||||
|
start: '加氢站、充电站作为供应商纳入统一供应商体系,由采购组维护供应商基础信息与结算要素。',
|
||||||
|
process: [
|
||||||
|
'创建加氢站 / 充电站站点时绑定对应供应商。',
|
||||||
|
'氢费、电费对账支持供应商直连付款,付款金额与明细由系统自动生成。',
|
||||||
|
'付款、收款明细自动关联财务「收款记录」「付款记录」。',
|
||||||
|
'支撑对加氢站 / 充电站供应商的付款申请、收款申请及流程审批。',
|
||||||
|
],
|
||||||
|
closure:
|
||||||
|
'供应商主数据与站点、对账、财务记录贯通,减少手工对账与重复录入,为能源侧业财闭环打好基础。',
|
||||||
|
prototypeHref: '/prototypes/supplier-management',
|
||||||
|
prototypeLabel: '打开供应商管理',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'h2-station',
|
||||||
|
step: 2,
|
||||||
|
title: '加氢站管理',
|
||||||
|
subtitle: '签约站 / 非签约站 · 账号、余额与价格',
|
||||||
|
shortLabel: '加氢站',
|
||||||
|
accent: 'violet',
|
||||||
|
icon: MapPin,
|
||||||
|
roles: ['采购部', '加氢站'],
|
||||||
|
start: '加氢站由采购部统一管理,分为「签约站」与「非签约站」;签约站须关联供应商与系统账号。',
|
||||||
|
process: [
|
||||||
|
'签约站使用采购部分配的账号登录 ONE-OS,进入加氢订单模块(PC / 移动端 / H5)。',
|
||||||
|
'加氢站预付款余额:管理预付给加氢站的余额及使用记录。',
|
||||||
|
'营业状态管理:维护营业状态与营业时间。',
|
||||||
|
'价格配置:维护加氢站成本价,自动关联至氢费记录。',
|
||||||
|
'业务管理组-能源部将加氢记录形成对账单并发起付款申请(后期对接财务 YS 系统)。',
|
||||||
|
],
|
||||||
|
closure:
|
||||||
|
'站点、账号、余额、价格与营业状态集中管理,签约站可自助上报氢费,为对账与付款提供准确数据源。',
|
||||||
|
prototypeHref: '/prototypes/oneos-web-h2-station-site',
|
||||||
|
prototypeLabel: '打开站点信息',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'h2-order',
|
||||||
|
step: 3,
|
||||||
|
title: '加氢订单',
|
||||||
|
subtitle: '主动上报 · PLC 自动采集 · 预约接单',
|
||||||
|
shortLabel: '订单',
|
||||||
|
accent: 'indigo',
|
||||||
|
icon: ClipboardList,
|
||||||
|
roles: ['签约加氢站', '业务管理组-能源部'],
|
||||||
|
start: '签约加氢站使用系统账号登录后,通过各渠道上报加氢订单,实现氢费记录主动采集;支持 PLC 接入的站点可自动获取订单。',
|
||||||
|
process: [
|
||||||
|
'支持 PC、移动端、移动端 H5 等多端快速识别并记录加氢信息。',
|
||||||
|
'支持加氢机 PLC 数据接入的站点,通过接入 PLC 数据实现加氢订单自动获取,无需人工补录。',
|
||||||
|
'联动「小羚羚」,接收车队、司机预约加氢订单并处理;加氢订单最终由司机进行确认。',
|
||||||
|
'订单数据进入加氢记录,供能源部对账与结算使用。',
|
||||||
|
],
|
||||||
|
closure:
|
||||||
|
'签约站氢费由站点主动上报或 PLC 自动采集,预约订单在线闭环,减少能源部手工补录、提升数据时效性。',
|
||||||
|
prototypeHref: '/prototypes/oneos-web-h2-station',
|
||||||
|
prototypeLabel: '打开加氢订单',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'vehicle-h2-fee',
|
||||||
|
step: 4,
|
||||||
|
title: '车辆氢费明细',
|
||||||
|
subtitle: '非签约站补录 · 对账归档 · 余额关联',
|
||||||
|
shortLabel: '氢费',
|
||||||
|
accent: 'amber',
|
||||||
|
icon: Fuel,
|
||||||
|
roles: ['业务管理组-能源部'],
|
||||||
|
start: '非签约加氢站的车辆氢费,由业务管理组-能源部在系统中手动维护明细。',
|
||||||
|
process: [
|
||||||
|
'录入车辆加氢明细,与站点、车辆、客户等业务对象关联。',
|
||||||
|
'对账完成后将记录标记为「已对账」并归档。',
|
||||||
|
'与加氢站预付款余额、客户能源账户联动,同步更新两侧余额;系统级 OCR 及车辆电子围栏核对,辅助对车辆氢费明细进行核对和提示。',
|
||||||
|
],
|
||||||
|
closure:
|
||||||
|
'签约站与非签约站氢费数据统一归集,对账状态可追踪,余额变动与明细一致可追溯。',
|
||||||
|
prototypeHref: '/prototypes/vehicle-h2-fee-ledger',
|
||||||
|
prototypeLabel: '打开车辆氢费明细',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'h2-account',
|
||||||
|
step: 5,
|
||||||
|
title: '氢费账户',
|
||||||
|
subtitle: '客户预付款 · 对账单 · 业财收款闭环',
|
||||||
|
shortLabel: '账户',
|
||||||
|
accent: 'teal',
|
||||||
|
icon: CircleDollarSign,
|
||||||
|
roles: ['业务管理组-能源部', '财务部'],
|
||||||
|
start: '管理客户氢费预付款账户余额,以及面向客户的氢费对账单。',
|
||||||
|
process: [
|
||||||
|
'维护客户氢费预付款账户及余额变动。',
|
||||||
|
'生成客户对账单,汇总应收氢费明细。',
|
||||||
|
'对账单关联财务「收款记录」,完成氢费侧业财闭环。',
|
||||||
|
'收款确认后,将对应加氢记录标记为「已付款」。',
|
||||||
|
'支持充值发起及开票发起,直接到财务,替代现有钉钉审批流程,对接 YS 系统直接形成业财闭环。',
|
||||||
|
],
|
||||||
|
closure:
|
||||||
|
'客户氢费从明细、对账到收款全链路可核对,加氢记录付款状态与财务实收一致。',
|
||||||
|
prototypeHref: '/prototypes/payment-records',
|
||||||
|
prototypeLabel: '打开收款记录',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'energy-extension',
|
||||||
|
step: 6,
|
||||||
|
title: '扩展规划',
|
||||||
|
subtitle: '上游采购 · 下游司机服务',
|
||||||
|
shortLabel: '扩展',
|
||||||
|
accent: 'emerald',
|
||||||
|
icon: Network,
|
||||||
|
roles: ['采购部', '业务管理组-能源部', '数智部'],
|
||||||
|
start: '能源业务条线成熟后,向氢气供应链上游与氢能车服务下游延伸。',
|
||||||
|
process: [
|
||||||
|
'上游:拓展氢气采购端,支撑用氢环节采购业务场景。',
|
||||||
|
'下游:拓展外部氢能车司机端,提供加氢及其他氢能车扩展服务。',
|
||||||
|
'与现有供应商、站点、订单、氢费账户能力衔接,形成更大范围能源生态。',
|
||||||
|
],
|
||||||
|
closure:
|
||||||
|
'在现有加氢站运营与对账闭环基础上,逐步覆盖「采购—站点—用氢—结算—司机服务」全链条,支撑能源业务规模化扩展。',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const OPS_MODULES: LineModule[] = [
|
||||||
|
{
|
||||||
|
id: 'ops-supplier',
|
||||||
|
step: 1,
|
||||||
|
title: '供应商管理',
|
||||||
|
subtitle: '整车厂 / 三方租赁 · 付款底座',
|
||||||
|
shortLabel: '供应商',
|
||||||
|
pipelineTrack: 'support',
|
||||||
|
accent: 'blue',
|
||||||
|
icon: Building2,
|
||||||
|
roles: ['采购组', '财务部'],
|
||||||
|
start: '整车厂、三方租赁企业作为供应商纳入统一管理体系,维护基础信息与结算要素。',
|
||||||
|
process: [
|
||||||
|
'维护整车厂、三方租赁厂商供应商档案与联系方式。',
|
||||||
|
'为车辆采购、三方租赁合同提供供应商主数据引用。',
|
||||||
|
'支撑后续财务付款闭环的基础数据底座。',
|
||||||
|
],
|
||||||
|
closure: '供应商信息与采购合同、付款记录贯通,减少重复建档与对账差错。',
|
||||||
|
prototypeHref: '/prototypes/supplier-management',
|
||||||
|
prototypeLabel: '打开供应商管理',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ops-procurement-contract',
|
||||||
|
step: 2,
|
||||||
|
title: '车辆采购 / 三方租赁合同',
|
||||||
|
subtitle: '车型数量 · 付款关联 · 验车任务',
|
||||||
|
shortLabel: '采购合同',
|
||||||
|
pipelineTrack: 'support',
|
||||||
|
accent: 'violet',
|
||||||
|
icon: FileText,
|
||||||
|
roles: ['采购部', '法务部', '财务部'],
|
||||||
|
start: '向整车厂、三方租赁厂商发起车辆采购或三方车辆租赁合同,录入车型、数量、费用等关键条款。',
|
||||||
|
process: [
|
||||||
|
'创建并审批车辆采购 / 三方租赁合同。',
|
||||||
|
'关联财务「付款记录」,按合同条款发起付款。',
|
||||||
|
'根据合同条款自动生成验车任务,进入验车入库流程。',
|
||||||
|
],
|
||||||
|
closure: '合同、付款与验车任务联动,采购来源可追溯,为车辆入库提供合规入口。',
|
||||||
|
prototypeHref: '/prototypes/lease-contract-management',
|
||||||
|
prototypeLabel: '打开租赁合同',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ops-inspection',
|
||||||
|
step: 3,
|
||||||
|
title: '验车入库',
|
||||||
|
subtitle: '采购 / 三方来源 · 验完自动入库',
|
||||||
|
shortLabel: '验车入库',
|
||||||
|
pipelineTrack: 'main',
|
||||||
|
accent: 'indigo',
|
||||||
|
icon: ClipboardCheck,
|
||||||
|
roles: ['运维部', '采购部'],
|
||||||
|
start: '对采购或三方租赁来源的氢能车执行验车,记录验车过程与结论。',
|
||||||
|
process: [
|
||||||
|
'接收合同触发的验车任务,现场核对车辆与合同信息。',
|
||||||
|
'记录验车过程、问题项与处理结果。',
|
||||||
|
'验车完成后车辆自动入库,源头数据留痕归档。',
|
||||||
|
],
|
||||||
|
closure: '验车结果与合同、库存车辆绑定,入库车辆来源清晰、过程可审计。',
|
||||||
|
prototypeHref: '/prototypes/vehicle-management',
|
||||||
|
prototypeLabel: '打开车辆管理',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ops-plate',
|
||||||
|
step: 4,
|
||||||
|
title: '车辆上牌',
|
||||||
|
subtitle: '车架号入库 · 完成牌证登记',
|
||||||
|
shortLabel: '上牌',
|
||||||
|
pipelineTrack: 'main',
|
||||||
|
accent: 'amber',
|
||||||
|
icon: Car,
|
||||||
|
roles: ['运维部'],
|
||||||
|
start: '对仅有车架号、尚未上牌的新车执行上牌操作,补齐牌照信息。',
|
||||||
|
process: [
|
||||||
|
'从库存中筛选待上牌车辆(仅有车架号)。',
|
||||||
|
'录入或同步上牌进度与牌照信息。',
|
||||||
|
'上牌完成后更新库存车辆状态,进入可运营准备。',
|
||||||
|
],
|
||||||
|
closure: '库存车辆牌证信息完整,为证照管理与交车交付提供前置条件。',
|
||||||
|
prototypeHref: '/prototypes/oneos-web-ops',
|
||||||
|
prototypeLabel: '打开上牌管理',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ops-certificate',
|
||||||
|
step: 5,
|
||||||
|
title: '证照管理',
|
||||||
|
subtitle: '一车一档 · 到期待办推送',
|
||||||
|
shortLabel: '证照',
|
||||||
|
pipelineTrack: 'main',
|
||||||
|
accent: 'teal',
|
||||||
|
icon: Shield,
|
||||||
|
roles: ['运维部', '安全部'],
|
||||||
|
start: '对氢能车行驶证、道路运输证、登记证、特种设备使用登记证 / 标识、加氢卡、安全阀、压力表等实行一车一档管理。',
|
||||||
|
process: [
|
||||||
|
'维护各类证照档案与有效期信息。',
|
||||||
|
'核心证照临近到期时,自动生成年审、等评、换证等待办任务。',
|
||||||
|
'待办同步推送运维部、安全部等责任部门跟进处理。',
|
||||||
|
],
|
||||||
|
closure: '证照全生命周期系统化管理,降低违规与漏办风险,保障车辆合法运营。',
|
||||||
|
prototypeHref: '/prototypes/oneos-web-ops',
|
||||||
|
prototypeLabel: '打开证照管理',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ops-prep',
|
||||||
|
step: 6,
|
||||||
|
title: '备车管理',
|
||||||
|
subtitle: '交车前整备 · 随时可交付',
|
||||||
|
shortLabel: '备车',
|
||||||
|
pipelineTrack: 'main',
|
||||||
|
accent: 'emerald',
|
||||||
|
icon: Package,
|
||||||
|
roles: ['运维部'],
|
||||||
|
start: '运维日常对车辆车况提前检查整备,确保交车任务到来时可随时交付。',
|
||||||
|
process: [
|
||||||
|
'按交车计划或库存策略发起备车检查。',
|
||||||
|
'记录车况整备项、完成状态与责任人。',
|
||||||
|
'作为交车前置任务,缩短交车现场处理时长。',
|
||||||
|
],
|
||||||
|
closure: '交车前整备前置完成,提升交车效率与交付质量一致性。',
|
||||||
|
prototypeHref: '/prototypes/oneos-web-ops',
|
||||||
|
prototypeLabel: '打开备车管理',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ops-delivery',
|
||||||
|
step: 7,
|
||||||
|
title: '交车管理',
|
||||||
|
subtitle: '司机培训留痕 · 区域派单 · OCR 比对 · 电子签',
|
||||||
|
shortLabel: '交车',
|
||||||
|
pipelineTrack: 'main',
|
||||||
|
accent: 'blue',
|
||||||
|
icon: Truck,
|
||||||
|
roles: ['运维部', '业务管理组', '司机'],
|
||||||
|
start: '承接租赁 / 自营合同形成的交车任务,按交车区域自动分配属地运维人员处理;提车司机须先完成在线培训并留存电子记录。',
|
||||||
|
process: [
|
||||||
|
'提车司机在线培训:司机主动上传身份证、驾驶证、从业资格证,完成司机签名与现场拍照,形成可查的电子培训留痕记录。',
|
||||||
|
'交付已完成整备、证照及交强险 / 商业险有效的车辆。',
|
||||||
|
'记录交车里程、氢量、电量、交车位置;OCR 识别胎纹留档。',
|
||||||
|
'还车时与交车数据自动比对,计算轮胎磨损费、维修费、保养费等。',
|
||||||
|
'结合 E 签宝形成电子化签字确认与归档记录。',
|
||||||
|
],
|
||||||
|
closure:
|
||||||
|
'司机培训、证照上传与现场拍照全程电子留痕,交车过程数据完整可查,为还车费用核算与争议处理提供可信依据。',
|
||||||
|
prototypeHref: '/prototypes/oneos-web-ops',
|
||||||
|
prototypeLabel: '打开交车管理',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ops-replacement',
|
||||||
|
step: 8,
|
||||||
|
title: '替换车管理',
|
||||||
|
subtitle: '临时 / 永久替换 · 费用自动核算',
|
||||||
|
shortLabel: '替换',
|
||||||
|
pipelineTrack: 'support',
|
||||||
|
accent: 'violet',
|
||||||
|
icon: RefreshCw,
|
||||||
|
roles: ['运维部', '业务管理组'],
|
||||||
|
start: '因客户或车辆原因发起车辆替换,支持临时替换与永久替换两种模式。',
|
||||||
|
process: [
|
||||||
|
'发起替换流程,选择替换类型与新旧车辆。',
|
||||||
|
'完成替换交还操作并记录替换原因与时间。',
|
||||||
|
'客户原因导致的替换,系统自动计算新旧车费用差异。',
|
||||||
|
],
|
||||||
|
closure: '替换过程可追踪、费用可核算,保障租赁 / 自营履约不中断。',
|
||||||
|
prototypeHref: '/prototypes/oneos-web-ops',
|
||||||
|
prototypeLabel: '打开替换车管理',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ops-return',
|
||||||
|
step: 9,
|
||||||
|
title: '还车管理',
|
||||||
|
subtitle: '区域派单 · 费用自动计算 · 电子签',
|
||||||
|
shortLabel: '还车',
|
||||||
|
pipelineTrack: 'main',
|
||||||
|
accent: 'indigo',
|
||||||
|
icon: RotateCcw,
|
||||||
|
roles: ['运维部', '业务管理组'],
|
||||||
|
start: '处理业务 / 业管发起的还车任务,按还车区域自动分配属地运维人员。',
|
||||||
|
process: [
|
||||||
|
'记录还车时间、里程、氢量、电量、还车位置。',
|
||||||
|
'OCR 识别胎纹,与交车时数据对比,自动计算胎纹磨损差费用。',
|
||||||
|
'按交车至还车里程及氢量差、电量差,自动计算相关费用。',
|
||||||
|
'结合 E 签宝形成电子化签字确认与归档记录。',
|
||||||
|
],
|
||||||
|
closure: '还车费用依据交还车对比自动出具,减少人工核算与纠纷成本。',
|
||||||
|
prototypeHref: '/prototypes/oneos-web-ops',
|
||||||
|
prototypeLabel: '打开还车管理',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ops-fault',
|
||||||
|
step: 10,
|
||||||
|
title: '故障管理',
|
||||||
|
subtitle: 'AI 助手 + 人工 · 证据链归档',
|
||||||
|
shortLabel: '故障',
|
||||||
|
pipelineTrack: 'support',
|
||||||
|
accent: 'amber',
|
||||||
|
icon: AlertTriangle,
|
||||||
|
roles: ['运维部', '司机'],
|
||||||
|
start: '通过微信 AI 故障助手或人工方式,记录车辆故障发生时的关键信息。',
|
||||||
|
process: [
|
||||||
|
'记录故障时间、地点、车辆信息、故障类型与紧急程度。',
|
||||||
|
'留存图片、视频、聊天记录等证据链材料。',
|
||||||
|
'运维人员维护处理过程,直至故障关闭归档。',
|
||||||
|
'形成运维待办任务,统计执行情况来推动运维人员及时处理。',
|
||||||
|
'最终形成各品牌车型故障类型、严重程度、次数进行统计,为未来车辆资产的管理、车辆采购和三方租赁提供基础论证。',
|
||||||
|
],
|
||||||
|
closure: '故障从产生到解决全过程留痕,证据链完整,便于复盘与责任界定。',
|
||||||
|
prototypeHref: '/prototypes/oneos-web-ops',
|
||||||
|
prototypeLabel: '打开故障管理',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ops-annual-inspection',
|
||||||
|
step: 11,
|
||||||
|
title: '年审记录',
|
||||||
|
subtitle: '提前 3 个月 · OCR 刷新证照',
|
||||||
|
shortLabel: '年审',
|
||||||
|
pipelineTrack: 'support',
|
||||||
|
accent: 'teal',
|
||||||
|
icon: Calendar,
|
||||||
|
roles: ['运维部'],
|
||||||
|
start: '依据行驶证审验有效期,提前 3 个月为对应车辆生成年审任务。',
|
||||||
|
process: [
|
||||||
|
'年审任务按车辆、按月份归集,跟踪办理进度。',
|
||||||
|
'年审完成后 OCR 识别新上传行驶证,自动刷新车辆证照数据。',
|
||||||
|
'按每月年审任务数量自动计算执行完成百分比。',
|
||||||
|
],
|
||||||
|
closure: '年审任务不遗漏、进度可量化,证照数据与办理结果自动同步。',
|
||||||
|
prototypeHref: '/prototypes/oneos-web-ops',
|
||||||
|
prototypeLabel: '打开年审管理',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ops-maintenance',
|
||||||
|
step: 12,
|
||||||
|
title: '维修与保养记录',
|
||||||
|
subtitle: '费用汇总 · 业财一体 · 盈亏核算',
|
||||||
|
shortLabel: '维保',
|
||||||
|
pipelineTrack: 'support',
|
||||||
|
accent: 'emerald',
|
||||||
|
icon: Settings,
|
||||||
|
roles: ['运维部', '财务部'],
|
||||||
|
start: '首期由运维人员手动维护各自负责车辆的维修与保养记录。',
|
||||||
|
process: [
|
||||||
|
'记录配件费、人工费等明细并形成费用汇总。',
|
||||||
|
'按承担方(客户 / 我司)自动生成客户账单或纳入运维成本。',
|
||||||
|
'维修与保养费用计入车辆盈亏核算,实现业财一体化。',
|
||||||
|
],
|
||||||
|
closure: '维保成本可追溯、可分摊,支撑单车盈亏与对客户计费闭环。',
|
||||||
|
prototypeHref: '/prototypes/oneos-web-ledger-data',
|
||||||
|
prototypeLabel: '打开车辆维修明细',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ops-movement',
|
||||||
|
step: 13,
|
||||||
|
title: '车辆异动',
|
||||||
|
subtitle: '出库维修 / 年审 / 保养 · 成本归集',
|
||||||
|
shortLabel: '异动',
|
||||||
|
pipelineTrack: 'support',
|
||||||
|
accent: 'blue',
|
||||||
|
icon: MapPin,
|
||||||
|
roles: ['运维部'],
|
||||||
|
start: '车辆因当地维修、年审、保养等需要出库异动,须提前审批并记录异动前后状态。',
|
||||||
|
process: [
|
||||||
|
'发起异动审批,记录异动前氢量、电量、里程。',
|
||||||
|
'异动完成后记录结束时的氢量、电量、里程。',
|
||||||
|
'异动期间产生的加氢记录自动归集为异动成本,计入成本侧。',
|
||||||
|
],
|
||||||
|
closure: '异动过程可审批、可计量,异动成本自动归集,避免成本漏记。',
|
||||||
|
prototypeHref: '/prototypes/oneos-web-ops',
|
||||||
|
prototypeLabel: '打开异动管理',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ops-transfer',
|
||||||
|
step: 14,
|
||||||
|
title: '车辆调拨',
|
||||||
|
subtitle: '跨区调拨 · 运维权转移',
|
||||||
|
shortLabel: '调拨',
|
||||||
|
pipelineTrack: 'support',
|
||||||
|
accent: 'violet',
|
||||||
|
icon: ArrowLeftRight,
|
||||||
|
roles: ['运维部', '业务管理组'],
|
||||||
|
start: '将车辆从 A 地调拨至 B 地用于运营,须提前审批并记录调拨与接收信息。',
|
||||||
|
process: [
|
||||||
|
'记录调拨发起方、费用与运输安排。',
|
||||||
|
'接收方确认接收,完成车辆交接。',
|
||||||
|
'接收完成后,车辆运维操作权由 A 地转移至 B 地。',
|
||||||
|
],
|
||||||
|
closure: '跨区调拨过程透明,运维责任属地随车辆同步切换,避免管理真空。',
|
||||||
|
prototypeHref: '/prototypes/oneos-web-ops',
|
||||||
|
prototypeLabel: '打开调拨管理',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ops-disposal',
|
||||||
|
step: 15,
|
||||||
|
title: '车辆出售、过户、报废',
|
||||||
|
subtitle: '资产变更审批 · 过程留痕 · 自动出库',
|
||||||
|
shortLabel: '处置出库',
|
||||||
|
pipelineTrack: 'main',
|
||||||
|
accent: 'indigo',
|
||||||
|
icon: Archive,
|
||||||
|
roles: ['运维部', '董事长', '财务部'],
|
||||||
|
start: '车辆达到出售、过户或报废条件时,发起资产变更流程,录入出售方、出售价格、过户情况、报废残值等关键信息。',
|
||||||
|
process: [
|
||||||
|
'提交出售 / 过户 / 报废申请,填写交易方、价格、过户状态或报废残值等要素。',
|
||||||
|
'流程审批通过后,由董事长确认资产变更。',
|
||||||
|
'运维部记录出售、过户或报废全过程,形成最终归档。',
|
||||||
|
'完成后车辆自动标记为退出运营并出库,不再参与租赁 / 自营交付。',
|
||||||
|
],
|
||||||
|
closure: '资产处置全程审批留痕,车辆退出运营状态与库存自动同步,生命周期闭环至出库归档。',
|
||||||
|
prototypeHref: '/prototypes/vehicle-management',
|
||||||
|
prototypeLabel: '打开车辆管理',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SAFETY_MODULES: LineModule[] = [
|
||||||
|
{
|
||||||
|
id: 'safety-driver',
|
||||||
|
step: 1,
|
||||||
|
title: '司机管理',
|
||||||
|
subtitle: '自营内部司机 · 信息证照一体',
|
||||||
|
shortLabel: '司机',
|
||||||
|
accent: 'blue',
|
||||||
|
icon: UserRound,
|
||||||
|
roles: ['安全部', '业务管理组'],
|
||||||
|
start: '管理自营业务内部司机,支撑自营场景下的司机快速选取与档案维护。',
|
||||||
|
process: [
|
||||||
|
'维护司机基础信息、联系方式与在职状态。',
|
||||||
|
'管理驾驶证、从业资格证等证照信息与有效期。',
|
||||||
|
'与自营业务交车、台账等场景联动,支持司机快速选取。',
|
||||||
|
],
|
||||||
|
closure: '内部司机主数据统一维护,证照齐全可查,支撑自营运力调度与安全合规。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'safety-training',
|
||||||
|
step: 2,
|
||||||
|
title: '内部司机培训',
|
||||||
|
subtitle: '定期安全培训 · 证据链留痕',
|
||||||
|
shortLabel: '培训',
|
||||||
|
accent: 'violet',
|
||||||
|
icon: GraduationCap,
|
||||||
|
roles: ['安全部', '司机'],
|
||||||
|
start: '定期对内部司机开展安全培训,并形成可追溯的培训记录。',
|
||||||
|
process: [
|
||||||
|
'安全部制定培训计划,组织内部司机参加安全培训。',
|
||||||
|
'记录培训时间、内容、参训司机与完成情况。',
|
||||||
|
'形成完整培训证据链,供交管部门查询时出示。',
|
||||||
|
],
|
||||||
|
closure: '培训记录电子化归档,随时可调阅完整证据链,满足监管与内控要求。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'safety-library',
|
||||||
|
step: 3,
|
||||||
|
title: '安全培训资料库',
|
||||||
|
subtitle: '资料上传 · APP / 小程序学习',
|
||||||
|
shortLabel: '资料库',
|
||||||
|
accent: 'indigo',
|
||||||
|
icon: Library,
|
||||||
|
roles: ['安全部', '司机'],
|
||||||
|
start: '安全部上传安全培训及安全运营相关资料,供内部司机在线学习。',
|
||||||
|
process: [
|
||||||
|
'安全部维护安全培训课件、制度规范与运营指引等资料。',
|
||||||
|
'资料分类发布至资料库,支持版本更新与检索。',
|
||||||
|
'内部司机通过 APP / 小程序快速查看、学习相关资料。',
|
||||||
|
],
|
||||||
|
closure: '培训资料集中管理、随时更新,司机移动端可学可查,提升安全运营一致性。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'safety-violation-record',
|
||||||
|
step: 4,
|
||||||
|
title: '违规记录',
|
||||||
|
subtitle: '司机违规 · 关联评级',
|
||||||
|
shortLabel: '违规',
|
||||||
|
accent: 'amber',
|
||||||
|
icon: FileWarning,
|
||||||
|
roles: ['安全部', '业务管理组'],
|
||||||
|
start: '记录内部司机在运营过程中的违规情况,并与相关主数据联动。',
|
||||||
|
process: [
|
||||||
|
'登记司机违规事件、违规类型、发生时间与处理结果。',
|
||||||
|
'与司机管理档案关联,沉淀司机安全行为记录。',
|
||||||
|
'与客户安全部评级等安全评估体系联动,影响风险判断。',
|
||||||
|
],
|
||||||
|
closure: '司机违规可追踪、可汇总,为司机选用与客户安全评级提供依据。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'safety-traffic-violation',
|
||||||
|
step: 5,
|
||||||
|
title: '违章管理',
|
||||||
|
subtitle: '分值罚款 · 还车联动 · 代处理',
|
||||||
|
shortLabel: '违章',
|
||||||
|
accent: 'teal',
|
||||||
|
icon: ShieldAlert,
|
||||||
|
roles: ['安全部', '财务部', '业务管理组'],
|
||||||
|
start: '记录司机交通违章的关键信息,并与还车应结款等业务环节联动。',
|
||||||
|
process: [
|
||||||
|
'登记违章时间、地点、分值、罚款金额及是否已缴纳等要素。',
|
||||||
|
'与还车应结款联动,将未处理违章纳入结算考量。',
|
||||||
|
'违章逾期仍未缴纳时,安全部自动生成代处理费用。',
|
||||||
|
],
|
||||||
|
closure: '违章信息完整可查,逾期代处理费用自动形成,还车结算与财务侧口径一致。',
|
||||||
|
prototypeHref: '/prototypes/vehicle-management',
|
||||||
|
prototypeLabel: '打开车辆管理',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'safety-accident',
|
||||||
|
step: 6,
|
||||||
|
title: '事故管理',
|
||||||
|
subtitle: '权责划分 · 保险赔付 · 还车联动',
|
||||||
|
shortLabel: '事故',
|
||||||
|
accent: 'emerald',
|
||||||
|
icon: Siren,
|
||||||
|
roles: ['安全部', '财务部', '业务管理组'],
|
||||||
|
start: '记录车辆事故的关键信息,支撑定责、理赔与还车费用结算。',
|
||||||
|
process: [
|
||||||
|
'登记事故时间、地点、车辆、司机及事故权责划分情况。',
|
||||||
|
'记录保险是否介入、赔付情况、保险上浮情况等要素。',
|
||||||
|
'与还车应结款联动生成相关费用,安全部费用自动写入应结项。',
|
||||||
|
],
|
||||||
|
closure: '事故从登记到赔付、上浮与还车应结款全链路贯通,安全费用不遗漏、可追溯。',
|
||||||
|
prototypeHref: '/prototypes/vehicle-management',
|
||||||
|
prototypeLabel: '打开车辆管理',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const SELF_OPERATED_MODULES: LineModule[] = [
|
||||||
|
{
|
||||||
|
id: 'self-contract',
|
||||||
|
step: 1,
|
||||||
|
title: '自营合同',
|
||||||
|
subtitle: '关键信息录入 · 上传合同 · 自动交车',
|
||||||
|
shortLabel: '合同',
|
||||||
|
accent: 'blue',
|
||||||
|
icon: FileSignature,
|
||||||
|
roles: ['业务管理组', '业务服务组'],
|
||||||
|
start: '创建自营合同,记录客户方、车型、车辆数、交车时间与地点等关键信息,并上传合同附件。',
|
||||||
|
process: [
|
||||||
|
'录入自营项目与客户方、车型、车辆数量等合同要素。',
|
||||||
|
'填写交车时间、交车地点,上传自营合同扫描件。',
|
||||||
|
'合同提交后自动生成交车任务,推送运维条线处理交付。',
|
||||||
|
],
|
||||||
|
closure: '合同信息与交车任务联动,自营项目从签约起即可进入交付与运营环节。',
|
||||||
|
prototypeHref: '/prototypes/oneos-web-lease-contract',
|
||||||
|
prototypeLabel: '打开车辆租赁合同',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'self-ledger',
|
||||||
|
step: 2,
|
||||||
|
title: '自营台账',
|
||||||
|
subtitle: '导入台账 · 成本利润核算 · 项目盈亏',
|
||||||
|
shortLabel: '台账',
|
||||||
|
accent: 'emerald',
|
||||||
|
icon: BookOpen,
|
||||||
|
roles: ['业务服务组', '财务部'],
|
||||||
|
start: '根据导入的自营台账与账单信息,汇总项目侧收入与成本数据。',
|
||||||
|
process: [
|
||||||
|
'导入或维护自营运营台账、账单明细。',
|
||||||
|
'自动计算利润侧数据,以及司机成本、车辆成本侧数据。',
|
||||||
|
'汇集至项目盈亏表,支撑项目经营分析。',
|
||||||
|
'演进阶段关联财务「收款记录」,形成业财记录闭环。',
|
||||||
|
],
|
||||||
|
closure: '项目收入、司机与车辆成本在同一台账内可核对,盈亏结果可对接财务实收。',
|
||||||
|
prototypeHref: '/prototypes/self-operated-business-ledger',
|
||||||
|
prototypeLabel: '打开自营业务台账',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const PLACEHOLDER_STATS: LineHeroStat[] = [
|
||||||
|
{ value: '—', label: '业务模块' },
|
||||||
|
{ value: '—', label: '协同部门' },
|
||||||
|
{ value: '—', label: '闭环阶段' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const BUSINESS_LINES: BusinessLineConfig[] = [
|
||||||
|
{
|
||||||
|
id: 'lease',
|
||||||
|
label: '租赁业务条线',
|
||||||
|
shortLabel: '租赁',
|
||||||
|
hero: {
|
||||||
|
title: '租赁业务条线',
|
||||||
|
tagline: '从合同发起->业财闭环->合同结束归档',
|
||||||
|
summary:
|
||||||
|
'六大模块串联客户评级、标准合同、租赁签约、提车收款、租期台账与还车结算。每个模块都有明确责任部门、输入条件与闭环结果。',
|
||||||
|
stats: [
|
||||||
|
{ value: '6', label: '业务模块' },
|
||||||
|
{ value: '8+', label: '协同部门' },
|
||||||
|
{ value: '1', label: '全链路闭环' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
modules: LEASE_MODULES,
|
||||||
|
loopTitle: '一条业务,六个模块,完整闭环',
|
||||||
|
loopSummary:
|
||||||
|
'客户评级决定是否可签 → 标准模板支撑签约 → 租赁合同进入履约 → 提车应收对齐后交车 → 台账承接租期账单 → 还车应结款会签归档。',
|
||||||
|
evolution: {
|
||||||
|
currentLabel: '现阶段 · 合同电子化 · 业务闭环化',
|
||||||
|
currentText: '标准合同模板、电子签约、收款关联与台账闭环已在 ONE-OS 内贯通,支撑租赁全生命周期管理。',
|
||||||
|
targetLabel: '演进方向 · 移动端商城直连',
|
||||||
|
targetText: '移动端商城可直接发起租赁合同,提车应收、交车、台账与还车结算等后续业务自动打通;业务模式由主动推进转向被动接收与处理,系统自动提醒并流转待办,减少人工催办与漏单。',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'energy',
|
||||||
|
label: '能源业务条线',
|
||||||
|
shortLabel: '能源',
|
||||||
|
hero: {
|
||||||
|
title: '能源业务条线',
|
||||||
|
tagline: '从加氢站供应商管理,到加氢站/客户的氢费业财闭环;未来开放独立 SaaS 工具以供加氢站运作和我方采集数据使用',
|
||||||
|
summary:
|
||||||
|
'六大模块串联供应商管理、加氢站运营、加氢订单采集、车辆氢费明细、客户氢费账户与上下游扩展规划,覆盖签约站主动上报与非签约站补录两条路径。',
|
||||||
|
stats: [
|
||||||
|
{ value: '6', label: '业务模块' },
|
||||||
|
{ value: '5+', label: '协同部门' },
|
||||||
|
{ value: '1', label: '业财闭环' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
modules: ENERGY_MODULES,
|
||||||
|
loopTitle: '供应商到账户,氢费全链路闭环',
|
||||||
|
loopSummary:
|
||||||
|
'供应商主数据支撑站点绑定 → 签约站账号与订单主动上报 → 非签约站氢费补录对账 → 客户对账单关联收款 → 加氢记录标记已付款 → 成熟后向采购端与司机端延伸。',
|
||||||
|
evolution: {
|
||||||
|
currentLabel: '现阶段 · 手工维护氢费明细',
|
||||||
|
currentText:
|
||||||
|
'业务管理部-能源组在系统中手工维护、记录车辆氢费明细,完成非签约站补录、对账归档及加氢站与客户氢费账户余额管理。',
|
||||||
|
targetLabel: '演进方向 · 加氢站主动上报',
|
||||||
|
targetText:
|
||||||
|
'签约加氢站通过系统账号主动上报加氢记录,业务管理组-能源部由「全量手工录入」转为「核对确认」,提升数据时效、减少重复补录。',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ops',
|
||||||
|
label: '运维管理条线',
|
||||||
|
shortLabel: '运维',
|
||||||
|
hero: {
|
||||||
|
title: '运维管理条线',
|
||||||
|
tagline: '车辆全生命周期留痕、租赁/自营业务的交还车闭环',
|
||||||
|
summary:
|
||||||
|
'十五个模块串联供应商、采购合同、验车入库、牌证整备、交还车、故障维保、异动调拨与出售过户报废,覆盖氢能车从进入到退出运营的全生命周期运维场景。',
|
||||||
|
stats: [
|
||||||
|
{ value: '15', label: '业务模块' },
|
||||||
|
{ value: '6+', label: '协同部门' },
|
||||||
|
{ value: '1', label: '全链路闭环' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
modules: OPS_MODULES,
|
||||||
|
loopTitle: '从验车入库到出库归档,运维全链路留痕',
|
||||||
|
loopSummary:
|
||||||
|
'供应商与采购合同入库 → 上牌证照与备车整备 → 交车 / 替换 / 还车闭环 → 故障与年审维保 → 异动调拨 → 出售 / 过户 / 报废审批出库,车辆全生命周期数据完整可追溯。',
|
||||||
|
evolution: {
|
||||||
|
currentLabel: '现阶段 · 车辆使用环节闭环',
|
||||||
|
currentText:
|
||||||
|
'聚焦交还车、证照、故障、年审维保、异动调拨等车辆使用阶段运维能力,支撑租赁与自营业务的现场交付与费用核算。',
|
||||||
|
targetLabel: '演进方向 · 入库到出库全链路',
|
||||||
|
targetText:
|
||||||
|
'从入库 → 运作 → 出库形成完整闭环,贯通采购验车入库、牌证整备、运营交还、维保处置到车辆退出全生命周期管理。',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'safety',
|
||||||
|
label: '安全管理条线',
|
||||||
|
shortLabel: '安全',
|
||||||
|
hero: {
|
||||||
|
title: '安全管理条线',
|
||||||
|
tagline: '司机安全全链条 · 培训留痕 · 违章事故联动还车;安全事前的预判,并可与客户安全评级挂钩',
|
||||||
|
summary:
|
||||||
|
'六大模块串联司机管理、内部培训、资料库、违规与违章记录及事故管理,覆盖自营内部司机安全合规与还车应结款联动场景。',
|
||||||
|
stats: [
|
||||||
|
{ value: '6', label: '业务模块' },
|
||||||
|
{ value: '4+', label: '协同部门' },
|
||||||
|
{ value: '1', label: '安全闭环' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
modules: SAFETY_MODULES,
|
||||||
|
loopTitle: '从司机档案到违章事故,安全全链路留痕',
|
||||||
|
loopSummary:
|
||||||
|
'司机信息与证照管理 → 定期培训与资料库学习 → 违规记录关联评级 → 违章 / 事故登记 → 联动还车应结款与代处理费用,安全证据链完整可追溯。',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'self-operated',
|
||||||
|
label: '自营业务条线',
|
||||||
|
shortLabel: '自营',
|
||||||
|
hero: {
|
||||||
|
title: '自营业务条线',
|
||||||
|
tagline: '自营物流条线合同->账单业财闭环',
|
||||||
|
summary:
|
||||||
|
'两大模块串联自营合同与自营台账:合同驱动交车任务,台账导入后自动核算司机、车辆成本与项目盈亏,逐步对接财务收款形成业财闭环。',
|
||||||
|
stats: [
|
||||||
|
{ value: '2', label: '业务模块' },
|
||||||
|
{ value: '4+', label: '协同部门' },
|
||||||
|
{ value: '1', label: '业财闭环' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
modules: SELF_OPERATED_MODULES,
|
||||||
|
loopTitle: '合同到台账,自营经营可核算',
|
||||||
|
loopSummary:
|
||||||
|
'自营合同录入并生成交车任务 → 运营数据导入自营台账 → 自动汇总利润与司机、车辆成本 → 汇集项目盈亏表 → 关联收款记录完成业财闭环。',
|
||||||
|
evolution: {
|
||||||
|
currentLabel: '现阶段 · 人工核算台账',
|
||||||
|
currentText:
|
||||||
|
'人工发起自营合同,人工计算车辆成本、司机成本与自营台账,项目盈亏依赖线下表格汇总与人工核对。',
|
||||||
|
targetLabel: '演进方向 · 业财记录闭环',
|
||||||
|
targetText:
|
||||||
|
'仍人工发起合同;司机在线管理、司机成本自动计算;自营台账人工导入后自动汇总,关联财务「收款记录」模块,形成业财记录闭环。',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const BUSINESS_LINE_MAP = Object.fromEntries(
|
||||||
|
BUSINESS_LINES.map((line) => [line.id, line]),
|
||||||
|
) as Record<BusinessLineId, BusinessLineConfig>;
|
||||||
|
|
||||||
|
export const DEFAULT_BUSINESS_LINE_ID: BusinessLineId = 'lease';
|
||||||
|
|
||||||
|
export const SYSTEM_INTRO = {
|
||||||
|
eyebrow: 'ONE-OS',
|
||||||
|
title: '系统介绍',
|
||||||
|
tagline: '1 套系统,5 大业务条线闭环,3 大数据底座决策,走向业财一体',
|
||||||
|
summary:
|
||||||
|
'ONE-OS 以统一平台承载租赁、能源、运维、安全、自营五条业务条线,业务侧模块闭环已逐步成型;条线数据向上汇集成车辆资产、条线盈亏、里程履约三大数据底座,支撑管理层看全局、定方向、督风险。系统建设的核心方向,是在业务闭环跑通的基础上打通财务系统,由「业务驱动、财务手工维护」转向业财联动、数据同源。',
|
||||||
|
evolution: {
|
||||||
|
currentLabel: '现阶段 · 业务驱动 · 财务手工维护',
|
||||||
|
currentText:
|
||||||
|
'租赁、能源、自营等条线的合同、台账、对账、收付款多在 ONE-OS 内完成业务闭环,但财务侧收款、付款、核销仍大量依赖人工录入、导出与核对,业财之间存在时差与重复维护。',
|
||||||
|
targetLabel: '演进方向 · 业财闭环一体化',
|
||||||
|
targetText:
|
||||||
|
'在各业务条线系统闭环成熟的基础上打通财务系统(如 YS),业务单据与台账自动关联财务「收款记录」「付款记录」,收付款状态双向回写,实现业财一体、减少手工维护、账实一致。',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const LINE_ACCENTS: Record<BusinessLineId, ModuleAccent> = {
|
||||||
|
lease: 'blue',
|
||||||
|
energy: 'teal',
|
||||||
|
ops: 'violet',
|
||||||
|
safety: 'amber',
|
||||||
|
'self-operated': 'emerald',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** @deprecated 使用 BUSINESS_LINE_MAP.lease */
|
||||||
|
export const LINE_MODULES = LEASE_MODULES;
|
||||||
|
|
||||||
|
/** @deprecated 使用 BUSINESS_LINE_MAP.lease.hero */
|
||||||
|
export const LINE_HERO = BUSINESS_LINE_MAP.lease.hero;
|
||||||
|
|
||||||
|
/** @deprecated 使用 BUSINESS_LINE_MAP.lease.loopSummary */
|
||||||
|
export const LOOP_SUMMARY = BUSINESS_LINE_MAP.lease.loopSummary;
|
||||||
|
|
||||||
|
export const LINE_TAB_ICONS: Record<BusinessLineId, LucideIcon> = {
|
||||||
|
lease: FileSignature,
|
||||||
|
energy: Fuel,
|
||||||
|
ops: Wrench,
|
||||||
|
safety: ShieldCheck,
|
||||||
|
'self-operated': Truck,
|
||||||
|
};
|
||||||
1630
src/prototypes/lease-business-line-overview/style.css
Normal file
1630
src/prototypes/lease-business-line-overview/style.css
Normal file
File diff suppressed because it is too large
Load Diff
@@ -10,7 +10,8 @@
|
|||||||
├── 左侧编辑列(可滚动)
|
├── 左侧编辑列(可滚动)
|
||||||
│ ├── 主体合同信息(完成度徽章)
|
│ ├── 主体合同信息(完成度徽章)
|
||||||
│ ├── 附件1:租赁订单
|
│ ├── 附件1:租赁订单
|
||||||
│ └── 授权委托书
|
│ ├── 授权委托书
|
||||||
|
│ └── 合同备注(独立卡片)
|
||||||
├── 右侧:实时预览(V26.6 完整合同 + 审批类型徽章)
|
├── 右侧:实时预览(V26.6 完整合同 + 审批类型徽章)
|
||||||
└── 底栏:取消 / 保存草稿 / 提交审核
|
└── 底栏:取消 / 保存草稿 / 提交审核
|
||||||
```
|
```
|
||||||
@@ -30,6 +31,8 @@
|
|||||||
| 合同编号 | 是 | 前缀 `HT-ZL-` + 用户输入 |
|
| 合同编号 | 是 | 前缀 `HT-ZL-` + 用户输入 |
|
||||||
| 甲方 | 是 | 出租方签约主体,来自组织机构 |
|
| 甲方 | 是 | 出租方签约主体,来自组织机构 |
|
||||||
| 乙方 | 是 | 客户选择器;仅 A/B 级客户可选 |
|
| 乙方 | 是 | 客户选择器;仅 A/B 级客户可选 |
|
||||||
|
| 乙方负责人姓名 | 否 | 签约对接负责人,可与档案联系人不同 |
|
||||||
|
| 乙方负责人手机号 | 否 | 11 位手机号 |
|
||||||
|
|
||||||
选择甲方/乙方后,下方只读面板自动带出档案:
|
选择甲方/乙方后,下方只读面板自动带出档案:
|
||||||
|
|
||||||
@@ -37,6 +40,24 @@
|
|||||||
|
|
||||||
**乙方**:企业名称、开户银行、银行账号、纳税人识别号、企业地址、企业电话、通讯地址、联系人、电话、邮箱;资质证照缩略图可点击放大。
|
**乙方**:企业名称、开户银行、银行账号、纳税人识别号、企业地址、企业电话、通讯地址、联系人、电话、邮箱;资质证照缩略图可点击放大。
|
||||||
|
|
||||||
|
#### 客户资质证照(OCR 与有效期)
|
||||||
|
|
||||||
|
对以下四类证照做 **OCR 识别**并校验有效期:
|
||||||
|
|
||||||
|
| 证照 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 客户营业执照 | 识别统一社会信用代码与营业期限 |
|
||||||
|
| 法人身份证(正面) | 识别姓名、证件号与有效期 |
|
||||||
|
| 法人身份证(反面) | 识别签发机关与有效期 |
|
||||||
|
| 道路运输许可证 | 识别许可编号与有效期 |
|
||||||
|
|
||||||
|
**业务规则:**
|
||||||
|
|
||||||
|
1. 上传后自动 OCR 提取有效期,证照区展示「OCR 已识别」、有效期及状态(有效 / 即将到期 / 已过期)。
|
||||||
|
2. 距到期 **≤ 3 个月** 时,系统在工作台提前生成待办任务,推送给业管人员更新客户档案。
|
||||||
|
3. 若业管尚未更新,业务人员选择该客户后仍可查看证照,但 **禁止提交新合同**;提交时给出明确拦截提示。
|
||||||
|
4. 原型演示:客户「杭州某某租赁有限公司(B)」含即将到期与已过期证照样例,可用于验证提示与拦截。
|
||||||
|
|
||||||
未选双方时展示空状态引导。
|
未选双方时展示空状态引导。
|
||||||
|
|
||||||
### 2.2 里程标准
|
### 2.2 里程标准
|
||||||
@@ -55,9 +76,9 @@
|
|||||||
| 字段 | 必填 | 说明 |
|
| 字段 | 必填 | 说明 |
|
||||||
|---|---|:---:|
|
|---|---|:---:|
|
||||||
| 付款周期 | 是 | 如月付、季付 |
|
| 付款周期 | 是 | 如月付、季付 |
|
||||||
| 氢费支付方式 | 是 | 后付 / 预付 |
|
| 氢费支付方式 | 是 | 自行解决 / 预付款 / 按月结算(选按月结算时预览切换月结算占位条款) |
|
||||||
| 预付金额 | 条件 | 预付时必填 |
|
| 预付金额 | 条件 | 选择预付款时必填 |
|
||||||
| 提前付款工作日 | 条件 | 预付时必填 |
|
| 提前付款工作日 | 条件 | 选择预付款时必填 |
|
||||||
| 还车氢量差单价 | 是 | 元 |
|
| 还车氢量差单价 | 是 | 元 |
|
||||||
|
|
||||||
**完成度**:上述必填项均已填则卡片显示「已完成」。
|
**完成度**:上述必填项均已填则卡片显示「已完成」。
|
||||||
@@ -85,13 +106,35 @@
|
|||||||
|
|
||||||
支持新增行、删除行。
|
支持新增行、删除行。
|
||||||
|
|
||||||
**合同备注**:授权委托书区块最下方提供文本域,支持自定义输入,非必填;列表页不再展示备注列。
|
|
||||||
|
|
||||||
**完成度**:至少一行且三项均非空。
|
**完成度**:至少一行且三项均非空。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 5. 实时预览
|
## 5. 合同备注
|
||||||
|
|
||||||
|
独立卡片,与授权委托书分离。
|
||||||
|
|
||||||
|
提供文本域,支持自定义输入,非必填(最多 500 字);列表页不展示备注列。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5.1 用章类型(第 6 步)
|
||||||
|
|
||||||
|
右侧工作区「用章类型」支持多选,可选范围:
|
||||||
|
|
||||||
|
| 用章 | 说明 |
|
||||||
|
|---|---|
|
||||||
|
| 合同章 | 合同正文用章 |
|
||||||
|
| 公章 | 公司公章 |
|
||||||
|
| 法人章 | 法定代表人章 |
|
||||||
|
|
||||||
|
**不含**财务章、发票章(租赁合同电子签章场景不使用)。
|
||||||
|
|
||||||
|
默认选中合同章;至少选择一种用章后方可提交审核。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 实时预览
|
||||||
|
|
||||||
- 基于标准合同模板 V26.6 拼装 HTML,A4 分页展示。
|
- 基于标准合同模板 V26.6 拼装 HTML,A4 分页展示。
|
||||||
- 支持缩放(50%—150%)、连续预览 / 单页预览切换。
|
- 支持缩放(50%—150%)、连续预览 / 单页预览切换。
|
||||||
@@ -108,7 +151,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 6. 审批类型判定
|
## 7. 审批类型判定
|
||||||
|
|
||||||
| 类型 | 触发条件 |
|
| 类型 | 触发条件 |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -121,7 +164,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 7. 底栏操作
|
## 8. 底栏操作
|
||||||
|
|
||||||
| 按钮 | 行为 |
|
| 按钮 | 行为 |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -131,7 +174,7 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 8. 与合同模板的关系
|
## 9. 与合同模板的关系
|
||||||
|
|
||||||
- 创建时默认引用 **标准合同模板**(合同模板管理 `isDefault=true` 的已发布版本)。
|
- 创建时默认引用 **标准合同模板**(合同模板管理 `isDefault=true` 的已发布版本)。
|
||||||
- 模板内风控红线条款、条件条款、锁定区标记在预览中保留。
|
- 模板内风控红线条款、条件条款、锁定区标记在预览中保留。
|
||||||
|
|||||||
@@ -30,7 +30,7 @@
|
|||||||
| 合同类型 | 多选下拉 | 全部 / 正式合同 / 试用合同 |
|
| 合同类型 | 多选下拉 | 全部 / 正式合同 / 试用合同 |
|
||||||
| 审批类型 | 多选下拉 | 全部 / 标准合同 / 非标准合同 |
|
| 审批类型 | 多选下拉 | 全部 / 标准合同 / 非标准合同 |
|
||||||
| 创建人 | 多选下拉 | 业务相关人员 |
|
| 创建人 | 多选下拉 | 业务相关人员 |
|
||||||
| 合同结束日期 | 日期范围 | 开始—结束,精确至日 |
|
| 合同结束日期 | 日期范围 | 单输入框双日历,中文界面,开始—结束精确至日 |
|
||||||
|
|
||||||
**交互**:
|
**交互**:
|
||||||
|
|
||||||
@@ -46,10 +46,10 @@
|
|||||||
| 卡片 | 口径 |
|
| 卡片 | 口径 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 全部合同 | 不做 KPI 维度过滤 |
|
| 全部合同 | 不做 KPI 维度过滤 |
|
||||||
| 进行中 | 合同状态为「合同进行中」且结束日期 ≥ 今日 |
|
| 进行中 | 至少有一辆租赁车辆已交车且尚未还车 |
|
||||||
| 已终止 | 合同状态为「已终止」或「已结束」 |
|
| 审批中 | 审批状态为「待审批」或「审批中」 |
|
||||||
| 临期合同 | 距离合同结束日期 ≤ 30 天且未过期 |
|
| 临期合同 | 存在在租车辆,且合同签订有效期距今日 ≤ 30 天(以最后一辆在租车辆为准) |
|
||||||
| 过期合同 | 合同状态为「已过期」或「到期合同」 |
|
| 已终止 | 全部车辆已还车;或续签/转三方产生新合同后旧合同自动标记为已终止 |
|
||||||
|
|
||||||
卡片右上角 `?` 悬停展示口径说明。
|
卡片右上角 `?` 悬停展示口径说明。
|
||||||
|
|
||||||
@@ -119,8 +119,8 @@
|
|||||||
| 续签合同 | 进行中 / 到期 | 跳转续签页 |
|
| 续签合同 | 进行中 / 到期 | 跳转续签页 |
|
||||||
| 添加被授权人 | 合同进行中 | 弹窗编辑被授权人列表 |
|
| 添加被授权人 | 合同进行中 | 弹窗编辑被授权人列表 |
|
||||||
| 附加费用 | 合同进行中 | 弹窗按车辆录入附加费用 |
|
| 附加费用 | 合同进行中 | 弹窗按车辆录入附加费用 |
|
||||||
| 变更为三方合同 | 合同进行中 | 跳转变更页 |
|
| 转三方合同 | 合同进行中 | 弹窗新增三方协议,上传协议附件与对方公函 |
|
||||||
| 转正式合同 | 试用合同 | 跳转转正式页 |
|
| 试用转正式 | 试用合同 | 单步弹窗:自动拉取客户/授权人/车辆订单(车辆可编辑),可选同时转三方 |
|
||||||
| 终止合同 | 合同进行中 | 二次确认,提交终止审批 |
|
| 终止合同 | 合同进行中 | 二次确认,提交终止审批 |
|
||||||
| 上传盖章合同 | 审批通过且法务未上传 | 仅法务部可见;多文件上传后关闭入口 |
|
| 上传盖章合同 | 审批通过且法务未上传 | 仅法务部可见;多文件上传后关闭入口 |
|
||||||
|
|
||||||
@@ -152,4 +152,6 @@
|
|||||||
- **终止**:「是否确认终止合同」→ 提交审核
|
- **终止**:「是否确认终止合同」→ 提交审核
|
||||||
- **添加被授权人**:姓名、电话、身份证;支持增删行
|
- **添加被授权人**:姓名、电话、身份证;支持增删行
|
||||||
- **附加费用**:按车辆展示类型/品牌/型号/车牌,可填服务项目、费用(元,2 位小数)、生效日期
|
- **附加费用**:按车辆展示类型/品牌/型号/车牌,可填服务项目、费用(元,2 位小数)、生效日期
|
||||||
|
- **转三方合同**:支持新增多条三方协议;每条可填协议名称、上传协议附件与对方公函
|
||||||
|
- **试用转正式**:自动带出客户信息、授权人与车辆订单;车辆订单字段可编辑;可勾选「同时转三方合同」并在同一步维护三方协议
|
||||||
- **上传盖章合同**:拖拽多文件;确认后标记已上传
|
- **上传盖章合同**:拖拽多文件;确认后标记已上传
|
||||||
|
|||||||
@@ -1,7 +1,17 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { Row, Col, Select, message } from 'antd';
|
import { Row, Col, Select, message } from 'antd';
|
||||||
import { Info, Check } from 'lucide-react';
|
import { Info, Check } from 'lucide-react';
|
||||||
import { DEFAULT_MILEAGE_STANDARD, DEFAULT_FEE_INFO } from '../contract-template-management/contract-template-vars.js';
|
import {
|
||||||
|
DEFAULT_MILEAGE_STANDARD,
|
||||||
|
DEFAULT_FEE_INFO,
|
||||||
|
generateAutoContractCodeSuffix,
|
||||||
|
getLeaseCustomerById,
|
||||||
|
} from '../contract-template-management/contract-template-vars.js';
|
||||||
|
import {
|
||||||
|
hasBlockingCustomerCredentials,
|
||||||
|
summarizeCustomerCredentials,
|
||||||
|
formatCredentialSubmitBlockMessage,
|
||||||
|
} from './lease-customer-credentials.js';
|
||||||
import {
|
import {
|
||||||
getPublishedContractTemplateOptions,
|
getPublishedContractTemplateOptions,
|
||||||
subscribePublishedContractTemplateOptions,
|
subscribePublishedContractTemplateOptions,
|
||||||
@@ -9,13 +19,13 @@ import {
|
|||||||
getContractTypeLabel,
|
getContractTypeLabel,
|
||||||
} from '../contract-template-management/contract-template-catalog.js';
|
} from '../contract-template-management/contract-template-catalog.js';
|
||||||
import { buildLeaseContractEditFormState } from './lease-contract-edit-bridge.js';
|
import { buildLeaseContractEditFormState } from './lease-contract-edit-bridge.js';
|
||||||
import { createDefaultLeaseOrderState, createDefaultPowerOfAttorneyState } from './lease-order-vars.js';
|
import { createDefaultLeaseOrderState, createDefaultPowerOfAttorneyState, hasLeaseOrderRentBelowMinimum } from './lease-order-vars.js';
|
||||||
import { buildLeaseContractPreviewHtml } from './lease-contract-preview-build.js';
|
import { buildLeaseContractPreviewHtml } from './lease-contract-preview-build.js';
|
||||||
import {
|
import {
|
||||||
resolveContractApprovalType,
|
resolveLeaseContractApprovalType,
|
||||||
STANDARD_CONTRACT_APPROVAL,
|
STANDARD_CONTRACT_APPROVAL,
|
||||||
NONSTANDARD_CONTRACT_APPROVAL,
|
NONSTANDARD_CONTRACT_APPROVAL,
|
||||||
} from './lease-contract-risk-detect.js';
|
} from './lease-contract-approval-rules.js';
|
||||||
import {
|
import {
|
||||||
isMainContractFormComplete,
|
isMainContractFormComplete,
|
||||||
isLeaseOrderFormComplete,
|
isLeaseOrderFormComplete,
|
||||||
@@ -29,16 +39,23 @@ import LeaseContractPreviewPanel, { PreviewEditableHint } from './LeaseContractP
|
|||||||
import LeaseContractEditorForm, {
|
import LeaseContractEditorForm, {
|
||||||
LeaseContractLeaseOrderSection,
|
LeaseContractLeaseOrderSection,
|
||||||
LeaseContractPowerOfAttorneySection,
|
LeaseContractPowerOfAttorneySection,
|
||||||
|
LeaseContractRemarkSection,
|
||||||
} from './LeaseContractEditorForm.jsx';
|
} from './LeaseContractEditorForm.jsx';
|
||||||
|
|
||||||
var SEAL_TYPE_OPTIONS = [
|
var SEAL_TYPE_OPTIONS = [
|
||||||
{ value: 'contract', label: '合同章' },
|
{ value: 'contract', label: '合同章' },
|
||||||
{ value: 'official', label: '公章' },
|
{ value: 'official', label: '公章' },
|
||||||
{ value: 'legal_person', label: '法人章' },
|
{ value: 'legal_person', label: '法人章' },
|
||||||
{ value: 'finance', label: '财务章' },
|
|
||||||
{ value: 'invoice', label: '发票章' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
|
function normalizeSealTypes(types) {
|
||||||
|
var allowed = SEAL_TYPE_OPTIONS.map(function (item) { return item.value; });
|
||||||
|
var next = (types || ['contract']).filter(function (value) {
|
||||||
|
return allowed.indexOf(value) >= 0;
|
||||||
|
});
|
||||||
|
return next.length ? next : ['contract'];
|
||||||
|
}
|
||||||
|
|
||||||
export default function LeaseContractCreate({ onBack, initialScrollSection, editRecord }) {
|
export default function LeaseContractCreate({ onBack, initialScrollSection, editRecord }) {
|
||||||
var contractTypeState = useState('');
|
var contractTypeState = useState('');
|
||||||
var contractType = contractTypeState[0];
|
var contractType = contractTypeState[0];
|
||||||
@@ -93,7 +110,7 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
|
|||||||
var customerIdState = useState('');
|
var customerIdState = useState('');
|
||||||
var mileageState = useState(Object.assign({}, DEFAULT_MILEAGE_STANDARD));
|
var mileageState = useState(Object.assign({}, DEFAULT_MILEAGE_STANDARD));
|
||||||
var feeInfoState = useState(Object.assign({}, DEFAULT_FEE_INFO));
|
var feeInfoState = useState(Object.assign({}, DEFAULT_FEE_INFO));
|
||||||
var contractCodeState = useState('');
|
var contractCodeState = useState(function () { return generateAutoContractCodeSuffix(); });
|
||||||
var businessDeptState = useState('');
|
var businessDeptState = useState('');
|
||||||
var businessOwnerState = useState('');
|
var businessOwnerState = useState('');
|
||||||
var leaseOrderState = useState(function () {
|
var leaseOrderState = useState(function () {
|
||||||
@@ -140,6 +157,12 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
|
|||||||
var contractRemarkState = useState('');
|
var contractRemarkState = useState('');
|
||||||
var contractRemark = contractRemarkState[0];
|
var contractRemark = contractRemarkState[0];
|
||||||
var setContractRemark = contractRemarkState[1];
|
var setContractRemark = contractRemarkState[1];
|
||||||
|
var customerPrincipalNameState = useState('');
|
||||||
|
var customerPrincipalName = customerPrincipalNameState[0];
|
||||||
|
var setCustomerPrincipalName = customerPrincipalNameState[1];
|
||||||
|
var customerPrincipalPhoneState = useState('');
|
||||||
|
var customerPrincipalPhone = customerPrincipalPhoneState[0];
|
||||||
|
var setCustomerPrincipalPhone = customerPrincipalPhoneState[1];
|
||||||
var sealTypesState = useState(['contract']);
|
var sealTypesState = useState(['contract']);
|
||||||
var sealTypes = sealTypesState[0];
|
var sealTypes = sealTypesState[0];
|
||||||
var setSealTypes = sealTypesState[1];
|
var setSealTypes = sealTypesState[1];
|
||||||
@@ -162,12 +185,18 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
|
|||||||
}, [contractTemplateId, lessorId, customerId, mileage, feeInfo, contractCode, leaseOrder, powerOfAttorney]);
|
}, [contractTemplateId, lessorId, customerId, mileage, feeInfo, contractCode, leaseOrder, powerOfAttorney]);
|
||||||
|
|
||||||
var handlePreviewContentChange = useCallback(function (mergedHtml) {
|
var handlePreviewContentChange = useCallback(function (mergedHtml) {
|
||||||
setContractApprovalType(resolveContractApprovalType(previewHtml, mergedHtml));
|
setContractApprovalType(resolveLeaseContractApprovalType(previewHtml, mergedHtml, {
|
||||||
}, [previewHtml]);
|
leaseOrder: leaseOrder,
|
||||||
|
}));
|
||||||
|
}, [previewHtml, leaseOrder]);
|
||||||
|
|
||||||
useEffect(function () {
|
useEffect(function () {
|
||||||
|
if (hasLeaseOrderRentBelowMinimum(leaseOrder)) {
|
||||||
|
setContractApprovalType(NONSTANDARD_CONTRACT_APPROVAL);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setContractApprovalType(STANDARD_CONTRACT_APPROVAL);
|
setContractApprovalType(STANDARD_CONTRACT_APPROVAL);
|
||||||
}, [previewHtml]);
|
}, [previewHtml, leaseOrder]);
|
||||||
|
|
||||||
var isNonStandardApproval = contractApprovalType === NONSTANDARD_CONTRACT_APPROVAL;
|
var isNonStandardApproval = contractApprovalType === NONSTANDARD_CONTRACT_APPROVAL;
|
||||||
var isTemplateSelected = Boolean(contractTemplateId);
|
var isTemplateSelected = Boolean(contractTemplateId);
|
||||||
@@ -240,6 +269,7 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
|
|||||||
main: useRef(null),
|
main: useRef(null),
|
||||||
leaseOrder: useRef(null),
|
leaseOrder: useRef(null),
|
||||||
poa: useRef(null),
|
poa: useRef(null),
|
||||||
|
remark: useRef(null),
|
||||||
seal: useRef(null),
|
seal: useRef(null),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -282,7 +312,9 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
setContractRemark(formState.contractRemark || '');
|
setContractRemark(formState.contractRemark || '');
|
||||||
setSealTypes((formState.sealTypes || ['contract']).slice());
|
setCustomerPrincipalName(formState.customerPrincipalName || '');
|
||||||
|
setCustomerPrincipalPhone(formState.customerPrincipalPhone || '');
|
||||||
|
setSealTypes(normalizeSealTypes(formState.sealTypes));
|
||||||
}, [editRecord && editRecord.id]);
|
}, [editRecord && editRecord.id]);
|
||||||
|
|
||||||
var createSteps = useMemo(function () {
|
var createSteps = useMemo(function () {
|
||||||
@@ -315,6 +347,13 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
|
|||||||
optional: isPowerOfAttorneyEmpty,
|
optional: isPowerOfAttorneyEmpty,
|
||||||
locked: !isTemplateSelected,
|
locked: !isTemplateSelected,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'remark',
|
||||||
|
label: '合同备注',
|
||||||
|
done: Boolean((contractRemark || '').trim()),
|
||||||
|
optional: true,
|
||||||
|
locked: !isTemplateSelected,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'seal',
|
key: 'seal',
|
||||||
label: '用章类型',
|
label: '用章类型',
|
||||||
@@ -330,6 +369,7 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
|
|||||||
isPowerOfAttorneyComplete,
|
isPowerOfAttorneyComplete,
|
||||||
isPowerOfAttorneyEmpty,
|
isPowerOfAttorneyEmpty,
|
||||||
isSealTypeComplete,
|
isSealTypeComplete,
|
||||||
|
contractRemark,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
var requiredDoneCount = useMemo(function () {
|
var requiredDoneCount = useMemo(function () {
|
||||||
@@ -512,6 +552,7 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
|
|||||||
lessorId: lessorId,
|
lessorId: lessorId,
|
||||||
customerId: customerId,
|
customerId: customerId,
|
||||||
contractCode: contractCode,
|
contractCode: contractCode,
|
||||||
|
contractCodeReadonly: !(editRecord && editRecord.id),
|
||||||
businessDept: businessDept,
|
businessDept: businessDept,
|
||||||
businessOwner: businessOwner,
|
businessOwner: businessOwner,
|
||||||
mileage: mileage,
|
mileage: mileage,
|
||||||
@@ -525,6 +566,12 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
|
|||||||
},
|
},
|
||||||
onMileageChange: setMileage,
|
onMileageChange: setMileage,
|
||||||
onFeeInfoChange: setFeeInfo,
|
onFeeInfoChange: setFeeInfo,
|
||||||
|
customerPrincipalName: customerPrincipalName,
|
||||||
|
customerPrincipalPhone: customerPrincipalPhone,
|
||||||
|
onCustomerPrincipalChange: function (name, phone) {
|
||||||
|
setCustomerPrincipalName(name);
|
||||||
|
setCustomerPrincipalPhone(phone);
|
||||||
|
},
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
renderSectionLock('main'),
|
renderSectionLock('main'),
|
||||||
@@ -598,11 +645,36 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
|
|||||||
React.createElement(LeaseContractPowerOfAttorneySection, {
|
React.createElement(LeaseContractPowerOfAttorneySection, {
|
||||||
powerOfAttorney: powerOfAttorney,
|
powerOfAttorney: powerOfAttorney,
|
||||||
onPowerOfAttorneyChange: setPowerOfAttorney,
|
onPowerOfAttorneyChange: setPowerOfAttorney,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
renderSectionLock('poa'),
|
||||||
|
),
|
||||||
|
React.createElement('section', {
|
||||||
|
className: 'ct-editor-column-card lc-create-section'
|
||||||
|
+ (isTemplateSelected ? '' : ' lc-create-section--disabled'),
|
||||||
|
'aria-label': '合同备注',
|
||||||
|
'data-annotation-id': 'lc-create-contract-remark',
|
||||||
|
id: 'lc-create-section-remark',
|
||||||
|
ref: sectionRefs.remark,
|
||||||
|
},
|
||||||
|
React.createElement('div', { className: 'ct-panel-head' },
|
||||||
|
React.createElement('div', { className: 'ct-panel-head__title' }, '第 5 步:合同备注'),
|
||||||
|
React.createElement('span', {
|
||||||
|
className: 'lc-form-completion-badge '
|
||||||
|
+ ((contractRemark || '').trim()
|
||||||
|
? 'lc-form-completion-badge--done'
|
||||||
|
: 'lc-form-completion-badge--optional'),
|
||||||
|
role: 'status',
|
||||||
|
'aria-live': 'polite',
|
||||||
|
}, (contractRemark || '').trim() ? '已填写' : '选填'),
|
||||||
|
),
|
||||||
|
React.createElement('div', { className: 'ct-editor-panel lc-create-editor-panel' },
|
||||||
|
React.createElement(LeaseContractRemarkSection, {
|
||||||
contractRemark: contractRemark,
|
contractRemark: contractRemark,
|
||||||
onContractRemarkChange: setContractRemark,
|
onContractRemarkChange: setContractRemark,
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
renderSectionLock('poa'),
|
renderSectionLock('remark'),
|
||||||
),
|
),
|
||||||
React.createElement('section', {
|
React.createElement('section', {
|
||||||
className: 'ct-editor-column-card lc-create-section'
|
className: 'ct-editor-column-card lc-create-section'
|
||||||
@@ -616,7 +688,7 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
|
|||||||
className: 'ct-panel-head lc-create-section__head lc-create-section-seal-head',
|
className: 'ct-panel-head lc-create-section__head lc-create-section-seal-head',
|
||||||
},
|
},
|
||||||
React.createElement('div', { className: 'lc-template-step-card__title-row' },
|
React.createElement('div', { className: 'lc-template-step-card__title-row' },
|
||||||
React.createElement('div', { className: 'ct-panel-head__title' }, '第 5 步:用章类型'),
|
React.createElement('div', { className: 'ct-panel-head__title' }, '第 6 步:用章类型'),
|
||||||
sealTypes.length
|
sealTypes.length
|
||||||
? React.createElement('span', {
|
? React.createElement('span', {
|
||||||
className: 'lc-seal-type-head-tags',
|
className: 'lc-seal-type-head-tags',
|
||||||
@@ -733,6 +805,12 @@ export default function LeaseContractCreate({ onBack, initialScrollSection, edit
|
|||||||
message.warning('请先完成左侧必填项');
|
message.warning('请先完成左侧必填项');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
var submitCustomer = getLeaseCustomerById(customerId);
|
||||||
|
if (hasBlockingCustomerCredentials(submitCustomer)) {
|
||||||
|
message.error(formatCredentialSubmitBlockMessage(summarizeCustomerCredentials(submitCustomer)));
|
||||||
|
scrollToCreateSection('main');
|
||||||
|
return;
|
||||||
|
}
|
||||||
message.info('提交审核:' + getContractTypeLabel(contractType) + ' · ' + contractApprovalType + '(原型)');
|
message.info('提交审核:' + getContractTypeLabel(contractType) + ' · ' + contractApprovalType + '(原型)');
|
||||||
},
|
},
|
||||||
}, '提交审核'),
|
}, '提交审核'),
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import {
|
|||||||
MOCK_LEASE_CUSTOMERS,
|
MOCK_LEASE_CUSTOMERS,
|
||||||
DEFAULT_MILEAGE_STANDARD,
|
DEFAULT_MILEAGE_STANDARD,
|
||||||
MILEAGE_PERIOD_OPTIONS,
|
MILEAGE_PERIOD_OPTIONS,
|
||||||
CUSTOMER_CREDENTIAL_ITEMS,
|
|
||||||
DEFAULT_FEE_INFO,
|
DEFAULT_FEE_INFO,
|
||||||
PAYMENT_PERIOD_OPTIONS,
|
PAYMENT_PERIOD_OPTIONS,
|
||||||
HYDROGEN_PAYMENT_METHOD_OPTIONS,
|
HYDROGEN_PAYMENT_METHOD_OPTIONS,
|
||||||
@@ -23,6 +22,10 @@ import {
|
|||||||
getLeaseCustomerRiskLevelLabel,
|
getLeaseCustomerRiskLevelLabel,
|
||||||
isLeaseCustomerSelectable,
|
isLeaseCustomerSelectable,
|
||||||
} from '../contract-template-management/contract-template-vars.js';
|
} from '../contract-template-management/contract-template-vars.js';
|
||||||
|
import {
|
||||||
|
summarizeCustomerCredentials,
|
||||||
|
CREDENTIAL_EXPIRY_WARN_MONTHS,
|
||||||
|
} from './lease-customer-credentials.js';
|
||||||
import {
|
import {
|
||||||
createEmptyLeaseOrderRow,
|
createEmptyLeaseOrderRow,
|
||||||
createDefaultLeaseOrderState,
|
createDefaultLeaseOrderState,
|
||||||
@@ -52,6 +55,9 @@ import {
|
|||||||
calcLeaseOrderKpis,
|
calcLeaseOrderKpis,
|
||||||
formatKpiAmount,
|
formatKpiAmount,
|
||||||
getExtraServiceNames,
|
getExtraServiceNames,
|
||||||
|
countNationalInStockByBrandModel,
|
||||||
|
getLeaseMinRentForBrandModel,
|
||||||
|
isLeaseRentBelowMinimum,
|
||||||
} from './lease-order-vars.js';
|
} from './lease-order-vars.js';
|
||||||
import {
|
import {
|
||||||
LEASE_BUSINESS_ORG_TREE,
|
LEASE_BUSINESS_ORG_TREE,
|
||||||
@@ -712,6 +718,10 @@ function LeaseOrderBrandModelField(props) {
|
|||||||
var summary = labels.length <= 2
|
var summary = labels.length <= 2
|
||||||
? labels.join('、')
|
? labels.join('、')
|
||||||
: (labels.length > 0 ? '已选 ' + labels.length + ' 个型号' : '');
|
: (labels.length > 0 ? '已选 ' + labels.length + ' 个型号' : '');
|
||||||
|
var stockHints = selected.map(function (pair) {
|
||||||
|
var count = countNationalInStockByBrandModel(pair[0], pair[1]);
|
||||||
|
return formatBrandModelPair(pair[0], pair[1]) + ':全国在库 ' + count + ' 台';
|
||||||
|
});
|
||||||
var searchText = query.trim();
|
var searchText = query.trim();
|
||||||
var isSearchMode = searchText.length > 0;
|
var isSearchMode = searchText.length > 0;
|
||||||
var resolvedBrand = activeBrand || LEASE_VEHICLE_BRAND_MODEL_CATALOG[0].brand;
|
var resolvedBrand = activeBrand || LEASE_VEHICLE_BRAND_MODEL_CATALOG[0].brand;
|
||||||
@@ -893,6 +903,11 @@ function LeaseOrderBrandModelField(props) {
|
|||||||
React.createElement(ChevronDown, { size: 14, className: 'vm-filter-picker-chevron', 'aria-hidden': true }),
|
React.createElement(ChevronDown, { size: 14, className: 'vm-filter-picker-chevron', 'aria-hidden': true }),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
stockHints.length > 0 ? React.createElement('p', {
|
||||||
|
className: 'lc-brand-model-stock-hint',
|
||||||
|
'data-annotation-id': 'lc-lease-order-brand-model-stock',
|
||||||
|
role: 'status',
|
||||||
|
}, stockHints.join(';')) : null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1208,7 +1223,12 @@ function LeaseOrderEditor(props) {
|
|||||||
React.createElement('th', { rowSpan: 2, scope: 'col', className: 'lc-lease-order-editor__th-subtotal' }, '租金及服务费小计(元/月/辆,含税)'),
|
React.createElement('th', { rowSpan: 2, scope: 'col', className: 'lc-lease-order-editor__th-subtotal' }, '租金及服务费小计(元/月/辆,含税)'),
|
||||||
React.createElement('th', { colSpan: 3, scope: 'colgroup', className: 'lc-lease-order-editor__th-group' }, '价格及服务内容'),
|
React.createElement('th', { colSpan: 3, scope: 'colgroup', className: 'lc-lease-order-editor__th-group' }, '价格及服务内容'),
|
||||||
React.createElement('th', { rowSpan: 2, scope: 'col' }, '保证金(元/辆)'),
|
React.createElement('th', { rowSpan: 2, scope: 'col' }, '保证金(元/辆)'),
|
||||||
React.createElement('th', { rowSpan: 2, scope: 'col', className: 'lc-lease-order-editor__th-period' }, '租赁期限(月)'),
|
React.createElement('th', {
|
||||||
|
rowSpan: 2,
|
||||||
|
scope: 'col',
|
||||||
|
className: 'lc-lease-order-editor__th-period',
|
||||||
|
'data-annotation-id': 'lc-lease-order-lease-period',
|
||||||
|
}, '租赁期限(月)'),
|
||||||
React.createElement('th', { rowSpan: 2, scope: 'col', className: 'lc-lease-order-editor__actions-col' }, '操作'),
|
React.createElement('th', { rowSpan: 2, scope: 'col', className: 'lc-lease-order-editor__actions-col' }, '操作'),
|
||||||
),
|
),
|
||||||
React.createElement('tr', { className: 'lc-lease-order-editor__head-row lc-lease-order-editor__head-row--sub' },
|
React.createElement('tr', { className: 'lc-lease-order-editor__head-row lc-lease-order-editor__head-row--sub' },
|
||||||
@@ -1224,6 +1244,14 @@ function LeaseOrderEditor(props) {
|
|||||||
var plateNos = normalizePlateNos(row);
|
var plateNos = normalizePlateNos(row);
|
||||||
var vehicleCount = getRowVehicleCountForPricing(row, order);
|
var vehicleCount = getRowVehicleCountForPricing(row, order);
|
||||||
var subtotal = calcRentServiceSubtotal(row.rent, row.serviceFee, vehicleCount);
|
var subtotal = calcRentServiceSubtotal(row.rent, row.serviceFee, vehicleCount);
|
||||||
|
var rentBelowMin = brandModels.some(function (pair) {
|
||||||
|
return isLeaseRentBelowMinimum(row.rent, pair[0], pair[1]);
|
||||||
|
});
|
||||||
|
var minRentHint = brandModels.map(function (pair) {
|
||||||
|
var minRent = getLeaseMinRentForBrandModel(pair[0], pair[1]);
|
||||||
|
if (minRent == null) return null;
|
||||||
|
return formatBrandModelPair(pair[0], pair[1]) + ' 最低 ' + minRent.toLocaleString('zh-CN') + ' 元/月';
|
||||||
|
}).filter(Boolean).join(';');
|
||||||
return React.createElement('tr', { key: row.id },
|
return React.createElement('tr', { key: row.id },
|
||||||
React.createElement('td', { className: 'lc-lease-order-editor__brand-cell' },
|
React.createElement('td', { className: 'lc-lease-order-editor__brand-cell' },
|
||||||
React.createElement(LeaseOrderBrandModelField, {
|
React.createElement(LeaseOrderBrandModelField, {
|
||||||
@@ -1253,7 +1281,7 @@ function LeaseOrderEditor(props) {
|
|||||||
React.createElement('td', null,
|
React.createElement('td', null,
|
||||||
React.createElement(LeaseOrderAmountField, null,
|
React.createElement(LeaseOrderAmountField, null,
|
||||||
React.createElement(InputNumber, {
|
React.createElement(InputNumber, {
|
||||||
className: 'lc-form-number vm-focus-border',
|
className: 'lc-form-number vm-focus-border' + (rentBelowMin ? ' lc-form-number--warn' : ''),
|
||||||
controls: false,
|
controls: false,
|
||||||
min: 0,
|
min: 0,
|
||||||
step: 0.01,
|
step: 0.01,
|
||||||
@@ -1263,7 +1291,13 @@ function LeaseOrderEditor(props) {
|
|||||||
placeholder: '车辆租金',
|
placeholder: '车辆租金',
|
||||||
style: { width: '100%' },
|
style: { width: '100%' },
|
||||||
'aria-label': '车辆租金',
|
'aria-label': '车辆租金',
|
||||||
|
'data-annotation-id': 'lc-lease-order-rent',
|
||||||
}),
|
}),
|
||||||
|
rentBelowMin ? React.createElement('p', {
|
||||||
|
className: 'lc-rent-min-warn',
|
||||||
|
'data-annotation-id': 'lc-lease-order-rent-min-warn',
|
||||||
|
role: 'alert',
|
||||||
|
}, '低于系统最低租金,将触发非标准合同审批' + (minRentHint ? '(' + minRentHint + ')' : '')) : null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
React.createElement('td', null,
|
React.createElement('td', null,
|
||||||
@@ -1323,7 +1357,6 @@ function LeaseOrderEditor(props) {
|
|||||||
},
|
},
|
||||||
placeholder: '月数',
|
placeholder: '月数',
|
||||||
'aria-label': '车辆租赁期限',
|
'aria-label': '车辆租赁期限',
|
||||||
'data-annotation-id': 'lc-lease-order-lease-period',
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
React.createElement('td', { className: 'lc-lease-order-editor__actions-col' },
|
React.createElement('td', { className: 'lc-lease-order-editor__actions-col' },
|
||||||
@@ -1488,7 +1521,15 @@ function PowerOfAttorneyEditor(props) {
|
|||||||
function CustomerCredentialThumb(props) {
|
function CustomerCredentialThumb(props) {
|
||||||
var label = props.label;
|
var label = props.label;
|
||||||
var previewUrl = props.previewUrl;
|
var previewUrl = props.previewUrl;
|
||||||
return React.createElement('div', { className: 'lc-attach-thumb' },
|
var meta = props.meta || {};
|
||||||
|
var status = meta.status || 'unknown';
|
||||||
|
var thumbClass = 'lc-attach-thumb'
|
||||||
|
+ (status === 'expired' ? ' is-expired' : '')
|
||||||
|
+ (status === 'expiring' ? ' is-expiring' : '');
|
||||||
|
return React.createElement('div', {
|
||||||
|
className: thumbClass,
|
||||||
|
'aria-label': label,
|
||||||
|
},
|
||||||
React.createElement(Image, {
|
React.createElement(Image, {
|
||||||
className: 'lc-attach-thumb__img',
|
className: 'lc-attach-thumb__img',
|
||||||
src: previewUrl,
|
src: previewUrl,
|
||||||
@@ -1498,10 +1539,44 @@ function CustomerCredentialThumb(props) {
|
|||||||
}),
|
}),
|
||||||
React.createElement('div', { className: 'lc-attach-thumb__meta' },
|
React.createElement('div', { className: 'lc-attach-thumb__meta' },
|
||||||
React.createElement('span', { className: 'lc-attach-thumb__label' }, label),
|
React.createElement('span', { className: 'lc-attach-thumb__label' }, label),
|
||||||
|
meta.ocrVerified
|
||||||
|
? React.createElement('span', { className: 'lc-attach-thumb__ocr', title: '已通过 OCR 识别证照信息' }, 'OCR 已识别')
|
||||||
|
: React.createElement('span', { className: 'lc-attach-thumb__ocr is-pending' }, '待 OCR'),
|
||||||
|
meta.expiryDate
|
||||||
|
? React.createElement('span', { className: 'lc-attach-thumb__expiry' }, '有效期至 ' + meta.expiryDate)
|
||||||
|
: null,
|
||||||
|
React.createElement('span', {
|
||||||
|
className: 'lc-attach-thumb__status lc-attach-thumb__status--' + status,
|
||||||
|
role: 'status',
|
||||||
|
}, meta.statusLabel || '待识别'),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CustomerCredentialsAlerts(props) {
|
||||||
|
var summary = props.summary;
|
||||||
|
if (!summary || (!summary.hasExpired && !summary.hasExpiring)) return null;
|
||||||
|
|
||||||
|
return React.createElement('div', { className: 'lc-credentials-alerts', role: 'status' },
|
||||||
|
summary.hasExpiring
|
||||||
|
? React.createElement('p', { className: 'lc-credentials-alert lc-credentials-alert--warn' },
|
||||||
|
'证照「'
|
||||||
|
+ summary.expiringItems.map(function (item) { return item.label; }).join('、')
|
||||||
|
+ '」将在 '
|
||||||
|
+ CREDENTIAL_EXPIRY_WARN_MONTHS
|
||||||
|
+ ' 个月内到期,系统已提前在工作台生成待办,请业管人员及时更新客户档案。',
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
summary.hasExpired
|
||||||
|
? React.createElement('p', { className: 'lc-credentials-alert lc-credentials-alert--danger' },
|
||||||
|
'证照「'
|
||||||
|
+ summary.expiredItems.map(function (item) { return item.label; }).join('、')
|
||||||
|
+ '」已超过有效期。业管未更新前,禁止提交新合同。',
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function FormFieldLabelHint(props) {
|
function FormFieldLabelHint(props) {
|
||||||
if (!props.text) return null;
|
if (!props.text) return null;
|
||||||
|
|
||||||
@@ -1613,6 +1688,7 @@ function EditorCard(props) {
|
|||||||
className: 'vm-detail-card lc-form-card' + (collapsible && !expanded ? ' is-collapsed' : ''),
|
className: 'vm-detail-card lc-form-card' + (collapsible && !expanded ? ' is-collapsed' : ''),
|
||||||
'aria-labelledby': props.titleId,
|
'aria-labelledby': props.titleId,
|
||||||
'aria-describedby': hintEl ? hintId : undefined,
|
'aria-describedby': hintEl ? hintId : undefined,
|
||||||
|
'data-annotation-id': props.annotationId || undefined,
|
||||||
},
|
},
|
||||||
collapsible
|
collapsible
|
||||||
? React.createElement('div', { className: 'lc-form-card__header' }, titleRowEl, toggleEl)
|
? React.createElement('div', { className: 'lc-form-card__header' }, titleRowEl, toggleEl)
|
||||||
@@ -1638,6 +1714,10 @@ export default function LeaseContractEditorForm(props) {
|
|||||||
var onBusinessAssignmentChange = props.onBusinessAssignmentChange;
|
var onBusinessAssignmentChange = props.onBusinessAssignmentChange;
|
||||||
var onMileageChange = props.onMileageChange;
|
var onMileageChange = props.onMileageChange;
|
||||||
var onFeeInfoChange = props.onFeeInfoChange;
|
var onFeeInfoChange = props.onFeeInfoChange;
|
||||||
|
var customerPrincipalName = props.customerPrincipalName || '';
|
||||||
|
var customerPrincipalPhone = props.customerPrincipalPhone || '';
|
||||||
|
var onCustomerPrincipalChange = props.onCustomerPrincipalChange;
|
||||||
|
var contractCodeReadonly = props.contractCodeReadonly !== false;
|
||||||
|
|
||||||
var lessorCompanies = getLessorCompanies();
|
var lessorCompanies = getLessorCompanies();
|
||||||
var selectedLessor = getLessorCompanyById(lessorId);
|
var selectedLessor = getLessorCompanyById(lessorId);
|
||||||
@@ -1672,24 +1752,30 @@ export default function LeaseContractEditorForm(props) {
|
|||||||
React.createElement(EditorCard, {
|
React.createElement(EditorCard, {
|
||||||
titleId: 'lc-card-signing',
|
titleId: 'lc-card-signing',
|
||||||
title: '签约信息',
|
title: '签约信息',
|
||||||
desc: '填写合同编号并选择甲乙双方;下方将自动带出档案信息。',
|
desc: '系统自动生成合同编号并选择甲乙双方;下方将自动带出档案信息。',
|
||||||
collapsible: true,
|
collapsible: true,
|
||||||
expanded: signingExpanded,
|
expanded: signingExpanded,
|
||||||
onToggle: function () { setSigningExpanded(!signingExpanded); },
|
onToggle: function () { setSigningExpanded(!signingExpanded); },
|
||||||
bodyId: 'lc-card-signing-body',
|
bodyId: 'lc-card-signing-body',
|
||||||
|
annotationId: 'lc-card-signing',
|
||||||
},
|
},
|
||||||
React.createElement('div', { className: 'lc-signing-core' },
|
React.createElement('div', { className: 'lc-signing-core' },
|
||||||
React.createElement('div', { className: 'lc-form-grid' },
|
React.createElement('div', { className: 'lc-form-grid' },
|
||||||
React.createElement(FormField, { label: '合同编号', required: true },
|
React.createElement(FormField, { label: '合同编号', required: true },
|
||||||
React.createElement(Input, {
|
React.createElement('div', {
|
||||||
className: 'lc-form-input lc-contract-code-input vm-focus-border',
|
className: 'lc-contract-code-auto',
|
||||||
addonBefore: CONTRACT_CODE_PREFIX,
|
'data-annotation-id': 'lc-contract-code-auto',
|
||||||
value: contractCode,
|
},
|
||||||
onChange: function (e) { onContractCodeChange(e.target.value); },
|
React.createElement('span', { className: 'lc-contract-code-auto__prefix' }, CONTRACT_CODE_PREFIX),
|
||||||
placeholder: '请输入合同编号',
|
React.createElement(ReadonlyValue, {
|
||||||
allowClear: true,
|
value: contractCode || '—',
|
||||||
'aria-label': '合同编号',
|
label: '合同编号',
|
||||||
}),
|
emptyHint: '系统生成中…',
|
||||||
|
}),
|
||||||
|
contractCodeReadonly
|
||||||
|
? React.createElement('span', { className: 'lc-contract-code-auto__tag' }, '系统自动生成')
|
||||||
|
: null,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
React.createElement(FormField, {
|
React.createElement(FormField, {
|
||||||
label: '业务部门 / 业务人员',
|
label: '业务部门 / 业务人员',
|
||||||
@@ -1738,6 +1824,39 @@ export default function LeaseContractEditorForm(props) {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
customerId ? React.createElement('div', {
|
||||||
|
className: 'lc-form-grid lc-form-grid--pair lc-signing-customer-principal',
|
||||||
|
'data-annotation-id': 'lc-signing-customer-principal',
|
||||||
|
},
|
||||||
|
React.createElement(FormField, { label: '乙方负责人姓名' },
|
||||||
|
React.createElement(Input, {
|
||||||
|
className: 'lc-form-input vm-focus-border',
|
||||||
|
value: customerPrincipalName,
|
||||||
|
maxLength: 32,
|
||||||
|
placeholder: '请输入乙方负责人姓名',
|
||||||
|
onChange: function (e) {
|
||||||
|
if (onCustomerPrincipalChange) {
|
||||||
|
onCustomerPrincipalChange(e.target.value, customerPrincipalPhone);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'aria-label': '乙方负责人姓名',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
React.createElement(FormField, { label: '乙方负责人手机号' },
|
||||||
|
React.createElement(Input, {
|
||||||
|
className: 'lc-form-input vm-focus-border',
|
||||||
|
value: customerPrincipalPhone,
|
||||||
|
maxLength: 11,
|
||||||
|
placeholder: '请输入手机号',
|
||||||
|
onChange: function (e) {
|
||||||
|
if (onCustomerPrincipalChange) {
|
||||||
|
onCustomerPrincipalChange(customerPrincipalName, e.target.value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'aria-label': '乙方负责人手机号',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
) : null,
|
||||||
lessorId || customerId
|
lessorId || customerId
|
||||||
? React.createElement('div', { className: 'lc-party-profiles' },
|
? React.createElement('div', { className: 'lc-party-profiles' },
|
||||||
lessorId ? React.createElement(ReadonlyPanel, {
|
lessorId ? React.createElement(ReadonlyPanel, {
|
||||||
@@ -1789,25 +1908,39 @@ export default function LeaseContractEditorForm(props) {
|
|||||||
React.createElement(FormField, { label: '邮箱' }, React.createElement(ReadonlyValue, { value: customerContact.email, emptyHint: customerEmptyHint })),
|
React.createElement(FormField, { label: '邮箱' }, React.createElement(ReadonlyValue, { value: customerContact.email, emptyHint: customerEmptyHint })),
|
||||||
),
|
),
|
||||||
) : null,
|
) : null,
|
||||||
customerId ? React.createElement('div', { className: 'lc-credentials-block' },
|
customerId ? (function () {
|
||||||
React.createElement('div', { className: 'lc-credentials-block__head' },
|
var credentialSummary = summarizeCustomerCredentials(selectedCustomer);
|
||||||
React.createElement('h4', { className: 'lc-credentials-block__title' }, '客户资质证照'),
|
return React.createElement('div', {
|
||||||
React.createElement('span', { className: 'lc-credentials-block__hint' }, '点击缩略图可放大预览'),
|
className: 'lc-credentials-block',
|
||||||
),
|
'data-annotation-id': 'lc-credentials-ocr',
|
||||||
React.createElement('div', {
|
|
||||||
className: 'lc-attach-gallery',
|
|
||||||
'aria-label': '客户资质证照预览',
|
|
||||||
},
|
},
|
||||||
CUSTOMER_CREDENTIAL_ITEMS.map(function (item) {
|
React.createElement('div', { className: 'lc-credentials-block__head' },
|
||||||
var previewUrl = getCustomerAttachmentPreviewUrl(selectedCustomer, item.key, item.label);
|
React.createElement('h4', { className: 'lc-credentials-block__title' }, '客户资质证照'),
|
||||||
return React.createElement(CustomerCredentialThumb, {
|
React.createElement('span', { className: 'lc-credentials-block__hint' }, 'OCR 识别有效期 · 点击缩略图放大'),
|
||||||
key: item.key,
|
),
|
||||||
label: item.label,
|
React.createElement('p', { className: 'lc-credentials-block__policy' },
|
||||||
previewUrl: previewUrl,
|
'营业执照、法人身份证正反面及道路运输许可证上传后自动 OCR 识别并校验有效期;'
|
||||||
});
|
+ '距到期 '
|
||||||
}),
|
+ CREDENTIAL_EXPIRY_WARN_MONTHS
|
||||||
),
|
+ ' 个月内将在工作台生成待办推送给业管;存在已过期证照时禁止提交新合同。',
|
||||||
) : null,
|
),
|
||||||
|
React.createElement(CustomerCredentialsAlerts, { summary: credentialSummary }),
|
||||||
|
React.createElement('div', {
|
||||||
|
className: 'lc-attach-gallery',
|
||||||
|
'aria-label': '客户资质证照预览',
|
||||||
|
},
|
||||||
|
credentialSummary.items.map(function (item) {
|
||||||
|
var previewUrl = getCustomerAttachmentPreviewUrl(selectedCustomer, item.key, item.label);
|
||||||
|
return React.createElement(CustomerCredentialThumb, {
|
||||||
|
key: item.key,
|
||||||
|
label: item.label,
|
||||||
|
previewUrl: previewUrl,
|
||||||
|
meta: item,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}()) : null,
|
||||||
)
|
)
|
||||||
: React.createElement('div', { className: 'lc-party-profiles-empty', role: 'status' },
|
: React.createElement('div', { className: 'lc-party-profiles-empty', role: 'status' },
|
||||||
React.createElement('p', { className: 'lc-party-profiles-empty__title' }, '选择签约双方后展示档案'),
|
React.createElement('p', { className: 'lc-party-profiles-empty__title' }, '选择签约双方后展示档案'),
|
||||||
@@ -1878,6 +2011,41 @@ export default function LeaseContractEditorForm(props) {
|
|||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
|
React.createElement('div', {
|
||||||
|
className: 'lc-mileage-reduction',
|
||||||
|
'data-annotation-id': 'lc-card-mileage-reduction',
|
||||||
|
},
|
||||||
|
React.createElement(FormField, { label: '租金减免金额', required: true },
|
||||||
|
React.createElement(InputNumber, {
|
||||||
|
className: 'lc-form-number vm-focus-border',
|
||||||
|
controls: false,
|
||||||
|
min: 0,
|
||||||
|
step: 100,
|
||||||
|
precision: 0,
|
||||||
|
value: mileage.reductionYuan,
|
||||||
|
onChange: function (v) {
|
||||||
|
patchMileage({ reductionYuan: v != null ? v : DEFAULT_MILEAGE_STANDARD.reductionYuan });
|
||||||
|
},
|
||||||
|
addonAfter: '元/辆',
|
||||||
|
style: { width: '100%' },
|
||||||
|
'aria-label': '租金减免金额',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
React.createElement(FormField, { label: '减免有效期至' },
|
||||||
|
React.createElement(DatePicker, {
|
||||||
|
className: 'lc-form-datepicker vm-focus-border',
|
||||||
|
style: { width: '100%' },
|
||||||
|
value: mileage.validUntil ? dayjs(mileage.validUntil) : null,
|
||||||
|
onChange: function (value) {
|
||||||
|
patchMileage({ validUntil: value ? value.format('YYYY-MM-DD') : null });
|
||||||
|
},
|
||||||
|
'aria-label': '租金减免有效期',
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
React.createElement(FieldHint, { annotationId: 'lc-mileage-reduction-hint' },
|
||||||
|
'与合同正文 2.1.5 租金减免条款联动;附件章节删减后,预览区将自动重排剩余章节序号。',
|
||||||
|
),
|
||||||
|
),
|
||||||
) : null,
|
) : null,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -1915,6 +2083,7 @@ export default function LeaseContractEditorForm(props) {
|
|||||||
},
|
},
|
||||||
options: HYDROGEN_PAYMENT_METHOD_OPTIONS,
|
options: HYDROGEN_PAYMENT_METHOD_OPTIONS,
|
||||||
'aria-label': '氢费支付方式',
|
'aria-label': '氢费支付方式',
|
||||||
|
'data-annotation-id': 'lc-hydrogen-payment-method',
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
showPrepayAmount ? React.createElement(FormField, { label: '预付款金额', required: true },
|
showPrepayAmount ? React.createElement(FormField, { label: '预付款金额', required: true },
|
||||||
@@ -1985,32 +2154,35 @@ export function LeaseContractLeaseOrderSection(props) {
|
|||||||
export function LeaseContractPowerOfAttorneySection(props) {
|
export function LeaseContractPowerOfAttorneySection(props) {
|
||||||
var powerOfAttorney = props.powerOfAttorney;
|
var powerOfAttorney = props.powerOfAttorney;
|
||||||
var onPowerOfAttorneyChange = props.onPowerOfAttorneyChange;
|
var onPowerOfAttorneyChange = props.onPowerOfAttorneyChange;
|
||||||
var contractRemark = props.contractRemark;
|
|
||||||
var onContractRemarkChange = props.onContractRemarkChange;
|
|
||||||
|
|
||||||
return React.createElement('div', { className: 'lc-create-editor-scroll lc-create-editor-scroll--section' },
|
return React.createElement('div', { className: 'lc-create-editor-scroll lc-create-editor-scroll--section' },
|
||||||
React.createElement(PowerOfAttorneyEditor, {
|
React.createElement(PowerOfAttorneyEditor, {
|
||||||
poa: powerOfAttorney || createDefaultPowerOfAttorneyState(),
|
poa: powerOfAttorney || createDefaultPowerOfAttorneyState(),
|
||||||
onChange: onPowerOfAttorneyChange,
|
onChange: onPowerOfAttorneyChange,
|
||||||
}),
|
}),
|
||||||
React.createElement('div', {
|
);
|
||||||
className: 'lc-contract-remark',
|
}
|
||||||
'data-annotation-id': 'lc-create-contract-remark',
|
|
||||||
},
|
export function LeaseContractRemarkSection(props) {
|
||||||
React.createElement(FormField, { label: '合同备注' },
|
var contractRemark = props.contractRemark;
|
||||||
React.createElement(Input.TextArea, {
|
var onContractRemarkChange = props.onContractRemarkChange;
|
||||||
className: 'lc-contract-remark__textarea vm-focus-border',
|
|
||||||
rows: 4,
|
return React.createElement('div', {
|
||||||
maxLength: 500,
|
className: 'lc-create-editor-scroll lc-create-editor-scroll--section lc-contract-remark-section',
|
||||||
showCount: true,
|
},
|
||||||
value: contractRemark || '',
|
React.createElement(FormField, { label: '合同备注' },
|
||||||
onChange: function (e) {
|
React.createElement(Input.TextArea, {
|
||||||
if (onContractRemarkChange) onContractRemarkChange(e.target.value);
|
className: 'lc-contract-remark__textarea vm-focus-border',
|
||||||
},
|
rows: 4,
|
||||||
placeholder: '请输入合同备注(选填)',
|
maxLength: 500,
|
||||||
'aria-label': '合同备注',
|
showCount: true,
|
||||||
}),
|
value: contractRemark || '',
|
||||||
),
|
onChange: function (e) {
|
||||||
|
if (onContractRemarkChange) onContractRemarkChange(e.target.value);
|
||||||
|
},
|
||||||
|
placeholder: '请输入合同备注(选填)',
|
||||||
|
'aria-label': '合同备注',
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
// 在租车辆概览 — 按品牌型号统计
|
// 在租车辆概览 — 按品牌型号统计(KPI 卡片 + 下方车型面板 / 隐藏后弹层)
|
||||||
import { buildOnLeaseFleetByBrandModel } from './lease-contract-list-data.js';
|
import { buildOnLeaseFleetByBrandModel } from './lease-contract-list-data.js';
|
||||||
|
|
||||||
function FleetDetailModal(props) {
|
function FleetDetailModal(props) {
|
||||||
@@ -71,18 +71,126 @@ function FleetDetailModal(props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FleetModelCard(props) {
|
||||||
|
var item = props.item;
|
||||||
|
var totalOnLease = props.totalOnLease;
|
||||||
|
var maxCount = props.maxCount;
|
||||||
|
var variant = props.variant || 'default';
|
||||||
|
var onOpenDetail = props.onOpenDetail;
|
||||||
|
|
||||||
|
var share = totalOnLease ? Math.round((item.count / totalOnLease) * 100) : 0;
|
||||||
|
var barWidth = maxCount ? Math.max(8, Math.round((item.count / maxCount) * 100)) : 8;
|
||||||
|
|
||||||
|
return React.createElement('article', {
|
||||||
|
className: 'lc-fleet-summary__card' + (variant === 'compact' ? ' lc-fleet-summary__card--compact' : ''),
|
||||||
|
role: 'listitem',
|
||||||
|
tabIndex: 0,
|
||||||
|
onClick: function () { onOpenDetail(item); },
|
||||||
|
onKeyDown: function (e) {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
onOpenDetail(item);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'aria-label': item.brand + ' ' + item.model + ',' + item.count + ' 辆在租,点击查看明细',
|
||||||
|
},
|
||||||
|
React.createElement('div', { className: 'lc-fleet-summary__card-top' },
|
||||||
|
React.createElement('span', { className: 'lc-fleet-summary__card-brand' }, item.brand),
|
||||||
|
React.createElement('span', { className: 'lc-fleet-summary__card-type' }, item.vehicleType),
|
||||||
|
),
|
||||||
|
React.createElement('h3', { className: 'lc-fleet-summary__card-model', title: item.model }, item.model),
|
||||||
|
React.createElement('div', { className: 'lc-fleet-summary__card-metrics' },
|
||||||
|
React.createElement('span', { className: 'lc-fleet-summary__card-count tabular-nums' }, item.count),
|
||||||
|
React.createElement('span', { className: 'lc-fleet-summary__card-unit' }, '辆'),
|
||||||
|
variant !== 'compact'
|
||||||
|
? React.createElement('span', { className: 'lc-fleet-summary__card-share tabular-nums' }, '占 ' + share + '%')
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
variant !== 'compact'
|
||||||
|
? React.createElement('div', { className: 'lc-fleet-summary__card-bar', 'aria-hidden': true },
|
||||||
|
React.createElement('span', {
|
||||||
|
className: 'lc-fleet-summary__card-bar-fill',
|
||||||
|
style: { width: barWidth + '%' },
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
React.createElement('span', { className: 'lc-fleet-summary__card-hint' }, '点击查看车辆明细'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function FleetQuickViewModal(props) {
|
||||||
|
var Modal = props.Modal;
|
||||||
|
var open = props.open;
|
||||||
|
var onClose = props.onClose;
|
||||||
|
var onPinPanel = props.onPinPanel;
|
||||||
|
var summary = props.summary;
|
||||||
|
var onOpenDetail = props.onOpenDetail;
|
||||||
|
|
||||||
|
if (!Modal || !open) return null;
|
||||||
|
|
||||||
|
var totalOnLease = summary.reduce(function (sum, item) { return sum + item.count; }, 0);
|
||||||
|
var maxCount = summary.length ? Math.max.apply(null, summary.map(function (item) { return item.count; })) : 1;
|
||||||
|
|
||||||
|
return React.createElement(Modal, {
|
||||||
|
className: 'lc-fleet-quick-modal',
|
||||||
|
rootClassName: 'lc-fleet-quick-modal-root',
|
||||||
|
title: React.createElement('div', { className: 'lc-fleet-quick-modal__title' },
|
||||||
|
React.createElement('span', null, '在租车辆 · 各车型分布'),
|
||||||
|
React.createElement('span', { className: 'lc-fleet-quick-modal__badge tabular-nums' }, totalOnLease + ' 辆'),
|
||||||
|
),
|
||||||
|
open: open,
|
||||||
|
onCancel: onClose,
|
||||||
|
footer: React.createElement('div', { className: 'lc-fleet-quick-modal__footer' },
|
||||||
|
React.createElement('button', {
|
||||||
|
type: 'button',
|
||||||
|
className: 'vm-btn vm-btn-ghost',
|
||||||
|
onClick: onClose,
|
||||||
|
}, '关闭'),
|
||||||
|
React.createElement('button', {
|
||||||
|
type: 'button',
|
||||||
|
className: 'vm-btn vm-btn-primary',
|
||||||
|
onClick: function () {
|
||||||
|
onPinPanel();
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
}, '展开固定面板'),
|
||||||
|
),
|
||||||
|
width: 720,
|
||||||
|
centered: true,
|
||||||
|
destroyOnClose: true,
|
||||||
|
},
|
||||||
|
summary.length
|
||||||
|
? React.createElement('div', { className: 'lc-fleet-summary__grid lc-fleet-summary__grid--quick', role: 'list' },
|
||||||
|
summary.map(function (item) {
|
||||||
|
return React.createElement(FleetModelCard, {
|
||||||
|
key: item.key,
|
||||||
|
item: item,
|
||||||
|
totalOnLease: totalOnLease,
|
||||||
|
maxCount: maxCount,
|
||||||
|
variant: 'compact',
|
||||||
|
onOpenDetail: onOpenDetail,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: React.createElement('div', { className: 'lc-fleet-summary__empty' }, '暂无在租车辆'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function LeaseContractFleetSummary(props) {
|
export default function LeaseContractFleetSummary(props) {
|
||||||
var records = props.records || [];
|
var records = props.records || [];
|
||||||
var Modal = props.Modal;
|
var Modal = props.Modal;
|
||||||
var renderVehicleNativeTableHeader = props.renderVehicleNativeTableHeader;
|
var renderVehicleNativeTableHeader = props.renderVehicleNativeTableHeader;
|
||||||
var renderVehicleNativeTableRow = props.renderVehicleNativeTableRow;
|
var renderVehicleNativeTableRow = props.renderVehicleNativeTableRow;
|
||||||
|
var expanded = Boolean(props.expanded);
|
||||||
|
var panelHidden = Boolean(props.panelHidden);
|
||||||
|
var quickViewOpen = Boolean(props.quickViewOpen);
|
||||||
|
var onExpandedChange = props.onExpandedChange;
|
||||||
|
var onPanelHiddenChange = props.onPanelHiddenChange;
|
||||||
|
var onQuickViewOpenChange = props.onQuickViewOpenChange;
|
||||||
|
|
||||||
var useState = React.useState;
|
var useState = React.useState;
|
||||||
var useMemo = React.useMemo;
|
var useMemo = React.useMemo;
|
||||||
|
|
||||||
var _expanded = useState(false);
|
|
||||||
var expanded = _expanded[0];
|
|
||||||
var setExpanded = _expanded[1];
|
|
||||||
var _brand = useState('全部');
|
var _brand = useState('全部');
|
||||||
var brandFilter = _brand[0];
|
var brandFilter = _brand[0];
|
||||||
var setBrandFilter = _brand[1];
|
var setBrandFilter = _brand[1];
|
||||||
@@ -103,6 +211,17 @@ export default function LeaseContractFleetSummary(props) {
|
|||||||
setDetailItem(null);
|
setDetailItem(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleHidePanel() {
|
||||||
|
if (onPanelHiddenChange) onPanelHiddenChange(true);
|
||||||
|
if (onExpandedChange) onExpandedChange(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePinPanel() {
|
||||||
|
if (onPanelHiddenChange) onPanelHiddenChange(false);
|
||||||
|
if (onExpandedChange) onExpandedChange(true);
|
||||||
|
if (onQuickViewOpenChange) onQuickViewOpenChange(false);
|
||||||
|
}
|
||||||
|
|
||||||
var summary = useMemo(function () {
|
var summary = useMemo(function () {
|
||||||
return buildOnLeaseFleetByBrandModel(records);
|
return buildOnLeaseFleetByBrandModel(records);
|
||||||
}, [records]);
|
}, [records]);
|
||||||
@@ -127,112 +246,85 @@ export default function LeaseContractFleetSummary(props) {
|
|||||||
|
|
||||||
if (!totalOnLease) return null;
|
if (!totalOnLease) return null;
|
||||||
|
|
||||||
return React.createElement('section', {
|
var showBody = expanded && !panelHidden;
|
||||||
className: 'lc-fleet-summary' + (expanded ? ' is-expanded' : ' is-collapsed'),
|
|
||||||
'data-annotation-id': 'lc-fleet-summary',
|
return React.createElement(React.Fragment, null,
|
||||||
'aria-label': '在租车辆概览',
|
showBody
|
||||||
},
|
? React.createElement('section', {
|
||||||
React.createElement('header', { className: 'lc-fleet-summary__header' },
|
className: 'lc-fleet-summary is-expanded is-embedded',
|
||||||
React.createElement('div', { className: 'lc-fleet-summary__title-block' },
|
'aria-label': '在租车辆概览',
|
||||||
React.createElement('div', { className: 'lc-fleet-summary__title-row' },
|
},
|
||||||
React.createElement('span', { className: 'lc-fleet-summary__icon', 'aria-hidden': true },
|
React.createElement('div', { className: 'lc-fleet-summary__body' },
|
||||||
React.createElement('svg', {
|
React.createElement('div', { className: 'lc-fleet-summary__body-toolbar' },
|
||||||
xmlns: 'http://www.w3.org/2000/svg', width: 20, height: 20, viewBox: '0 0 24 24',
|
React.createElement('div', { className: 'lc-fleet-summary__brands', role: 'group', 'aria-label': '按品牌筛选' },
|
||||||
fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round',
|
React.createElement('button', {
|
||||||
},
|
type: 'button',
|
||||||
React.createElement('path', { d: 'M14 16H9m10 0h3v-3.5a1.5 1.5 0 0 0-1.5-1.5h-15A1.5 1.5 0 0 0 3 16.5V20h3' }),
|
className: 'lc-fleet-summary__brand-chip' + (brandFilter === '全部' ? ' is-active' : ''),
|
||||||
React.createElement('circle', { cx: 7.5, cy: 17.5, r: 2.5 }),
|
onClick: function () { setBrandFilter('全部'); },
|
||||||
React.createElement('circle', { cx: 17.5, cy: 17.5, r: 2.5 }),
|
'aria-pressed': brandFilter === '全部',
|
||||||
),
|
}, '全部'),
|
||||||
),
|
brandOptions.map(function (brand) {
|
||||||
React.createElement('h2', { className: 'lc-fleet-summary__title' }, '在租车辆'),
|
return React.createElement('button', {
|
||||||
React.createElement('span', { className: 'lc-fleet-summary__badge tabular-nums' }, totalOnLease + ' 辆'),
|
key: brand,
|
||||||
),
|
type: 'button',
|
||||||
),
|
className: 'lc-fleet-summary__brand-chip' + (brandFilter === brand ? ' is-active' : ''),
|
||||||
React.createElement('div', { className: 'lc-fleet-summary__toolbar' },
|
onClick: function () { setBrandFilter(brandFilter === brand ? '全部' : brand); },
|
||||||
React.createElement('button', {
|
'aria-pressed': brandFilter === brand,
|
||||||
type: 'button',
|
}, brand);
|
||||||
className: 'lc-fleet-summary__toggle',
|
|
||||||
onClick: function () { setExpanded(!expanded); },
|
|
||||||
'aria-expanded': expanded,
|
|
||||||
'aria-label': expanded ? '收起在租车辆概览' : '查看在租车辆详情',
|
|
||||||
},
|
|
||||||
React.createElement('svg', {
|
|
||||||
xmlns: 'http://www.w3.org/2000/svg', width: 16, height: 16, viewBox: '0 0 24 24',
|
|
||||||
fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round',
|
|
||||||
'aria-hidden': true,
|
|
||||||
className: 'lc-fleet-summary__toggle-icon',
|
|
||||||
},
|
|
||||||
React.createElement('path', { d: expanded ? 'm18 15-6-6-6 6' : 'm6 9 6 6 6-6' }),
|
|
||||||
),
|
|
||||||
expanded ? '收起' : '查看在租车辆详情',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
expanded ? React.createElement('div', { className: 'lc-fleet-summary__body' },
|
|
||||||
React.createElement('div', { className: 'lc-fleet-summary__brands', role: 'group', 'aria-label': '按品牌筛选' },
|
|
||||||
React.createElement('button', {
|
|
||||||
type: 'button',
|
|
||||||
className: 'lc-fleet-summary__brand-chip' + (brandFilter === '全部' ? ' is-active' : ''),
|
|
||||||
onClick: function () { setBrandFilter('全部'); },
|
|
||||||
'aria-pressed': brandFilter === '全部',
|
|
||||||
}, '全部'),
|
|
||||||
brandOptions.map(function (brand) {
|
|
||||||
return React.createElement('button', {
|
|
||||||
key: brand,
|
|
||||||
type: 'button',
|
|
||||||
className: 'lc-fleet-summary__brand-chip' + (brandFilter === brand ? ' is-active' : ''),
|
|
||||||
onClick: function () { setBrandFilter(brandFilter === brand ? '全部' : brand); },
|
|
||||||
'aria-pressed': brandFilter === brand,
|
|
||||||
}, brand);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
filtered.length ? React.createElement('div', { className: 'lc-fleet-summary__grid', role: 'list' },
|
|
||||||
filtered.map(function (item) {
|
|
||||||
var share = Math.round((item.count / totalOnLease) * 100);
|
|
||||||
var barWidth = Math.max(8, Math.round((item.count / maxCount) * 100));
|
|
||||||
return React.createElement('article', {
|
|
||||||
key: item.key,
|
|
||||||
className: 'lc-fleet-summary__card',
|
|
||||||
role: 'listitem',
|
|
||||||
tabIndex: 0,
|
|
||||||
onClick: function () { openDetail(item); },
|
|
||||||
onKeyDown: function (e) {
|
|
||||||
if (e.key === 'Enter' || e.key === ' ') {
|
|
||||||
e.preventDefault();
|
|
||||||
openDetail(item);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
'aria-label': item.brand + ' ' + item.model + ',' + item.count + ' 辆在租,点击查看明细',
|
|
||||||
},
|
|
||||||
React.createElement('div', { className: 'lc-fleet-summary__card-top' },
|
|
||||||
React.createElement('span', { className: 'lc-fleet-summary__card-brand' }, item.brand),
|
|
||||||
React.createElement('span', { className: 'lc-fleet-summary__card-type' }, item.vehicleType),
|
|
||||||
),
|
|
||||||
React.createElement('h3', { className: 'lc-fleet-summary__card-model', title: item.model }, item.model),
|
|
||||||
React.createElement('div', { className: 'lc-fleet-summary__card-metrics' },
|
|
||||||
React.createElement('span', { className: 'lc-fleet-summary__card-count tabular-nums' }, item.count),
|
|
||||||
React.createElement('span', { className: 'lc-fleet-summary__card-unit' }, '辆'),
|
|
||||||
React.createElement('span', { className: 'lc-fleet-summary__card-share tabular-nums' }, '占 ' + share + '%'),
|
|
||||||
),
|
|
||||||
React.createElement('div', { className: 'lc-fleet-summary__card-bar', 'aria-hidden': true },
|
|
||||||
React.createElement('span', {
|
|
||||||
className: 'lc-fleet-summary__card-bar-fill',
|
|
||||||
style: { width: barWidth + '%' },
|
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
React.createElement('span', { className: 'lc-fleet-summary__card-hint' }, '点击查看车辆明细'),
|
React.createElement('button', {
|
||||||
);
|
type: 'button',
|
||||||
}),
|
className: 'lc-fleet-summary__hide-btn',
|
||||||
) : React.createElement('div', { className: 'lc-fleet-summary__empty' },
|
onClick: handleHidePanel,
|
||||||
React.createElement('p', null, '该品牌暂无在租车型'),
|
'aria-label': '隐藏在租车辆面板',
|
||||||
React.createElement('button', {
|
},
|
||||||
type: 'button',
|
React.createElement('svg', {
|
||||||
className: 'lc-fleet-summary__empty-reset',
|
xmlns: 'http://www.w3.org/2000/svg', width: 14, height: 14, viewBox: '0 0 24 24',
|
||||||
onClick: function () { setBrandFilter('全部'); },
|
fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round',
|
||||||
}, '查看全部'),
|
'aria-hidden': true,
|
||||||
),
|
},
|
||||||
) : null,
|
React.createElement('path', { d: 'M9.88 9.88a3 3 0 1 0 4.24 4.24' }),
|
||||||
|
React.createElement('path', { d: 'M10.73 5.08A10.43 10.43 0 0 1 12 5c7 0 10 7 10 7a13.16 13.16 0 0 1-1.67 2.68' }),
|
||||||
|
React.createElement('path', { d: 'M6.61 6.61A13.526 13.526 0 0 0 2 12s3 7 10 7a9.74 9.74 0 0 0 5.39-1.61' }),
|
||||||
|
React.createElement('line', { x1: 2, y1: 2, x2: 22, y2: 22 }),
|
||||||
|
),
|
||||||
|
'隐藏',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
filtered.length
|
||||||
|
? React.createElement('div', { className: 'lc-fleet-summary__grid', role: 'list' },
|
||||||
|
filtered.map(function (item) {
|
||||||
|
return React.createElement(FleetModelCard, {
|
||||||
|
key: item.key,
|
||||||
|
item: item,
|
||||||
|
totalOnLease: totalOnLease,
|
||||||
|
maxCount: maxCount,
|
||||||
|
onOpenDetail: openDetail,
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
: React.createElement('div', { className: 'lc-fleet-summary__empty' },
|
||||||
|
React.createElement('p', null, '该品牌暂无在租车型'),
|
||||||
|
React.createElement('button', {
|
||||||
|
type: 'button',
|
||||||
|
className: 'lc-fleet-summary__empty-reset',
|
||||||
|
onClick: function () { setBrandFilter('全部'); },
|
||||||
|
}, '查看全部'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: null,
|
||||||
|
React.createElement(FleetQuickViewModal, {
|
||||||
|
Modal: Modal,
|
||||||
|
open: panelHidden && quickViewOpen,
|
||||||
|
onClose: function () {
|
||||||
|
if (onQuickViewOpenChange) onQuickViewOpenChange(false);
|
||||||
|
},
|
||||||
|
onPinPanel: handlePinPanel,
|
||||||
|
summary: summary,
|
||||||
|
onOpenDetail: openDetail,
|
||||||
|
}),
|
||||||
React.createElement(FleetDetailModal, {
|
React.createElement(FleetDetailModal, {
|
||||||
Modal: Modal,
|
Modal: Modal,
|
||||||
item: detailItem,
|
item: detailItem,
|
||||||
@@ -243,3 +335,5 @@ export default function LeaseContractFleetSummary(props) {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export { buildOnLeaseFleetByBrandModel };
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { StatusTag } from '../vehicle-management/components/StatusTag';
|
|||||||
import {
|
import {
|
||||||
LEASE_CONTRACT_LIST_RECORDS,
|
LEASE_CONTRACT_LIST_RECORDS,
|
||||||
enrichLeaseContractListRecords,
|
enrichLeaseContractListRecords,
|
||||||
|
buildOnLeaseFleetByBrandModel,
|
||||||
formatDeliveryRegion,
|
formatDeliveryRegion,
|
||||||
formatPaymentPeriodLabel,
|
formatPaymentPeriodLabel,
|
||||||
formatHydrogenPaymentLabel,
|
formatHydrogenPaymentLabel,
|
||||||
@@ -43,6 +44,8 @@ import {
|
|||||||
formatVehicleDeliveryPlanDateLabel,
|
formatVehicleDeliveryPlanDateLabel,
|
||||||
resolveVehicleDeliveryDateTbd,
|
resolveVehicleDeliveryDateTbd,
|
||||||
resolveVehiclePlannedDeliveryDate,
|
resolveVehiclePlannedDeliveryDate,
|
||||||
|
isContractExpiringForKpi,
|
||||||
|
isContractTerminatedForKpi,
|
||||||
resolveContractStatusByVehicleLease,
|
resolveContractStatusByVehicleLease,
|
||||||
} from './lease-contract-list-data.js';
|
} from './lease-contract-list-data.js';
|
||||||
import { getLessorCompanies } from '../contract-template-management/contract-template-vars.js';
|
import { getLessorCompanies } from '../contract-template-management/contract-template-vars.js';
|
||||||
@@ -83,8 +86,11 @@ const Component = function(props) {
|
|||||||
// 筛选展开(默认收起,只显示第一行 4 列,与车辆管理一致)
|
// 筛选展开(默认收起,只显示第一行 4 列,与车辆管理一致)
|
||||||
var _filterExpanded = useState(false);
|
var _filterExpanded = useState(false);
|
||||||
var _kpiTab = useState('all');
|
var _kpiTab = useState('all');
|
||||||
|
var _fleetExpanded = useState(false);
|
||||||
|
var _fleetPanelHidden = useState(false);
|
||||||
|
var _fleetQuickOpen = useState(false);
|
||||||
var _page = useState(1);
|
var _page = useState(1);
|
||||||
var _pageSize = useState(10);
|
var _pageSize = useState(20);
|
||||||
var _view = useState('list');
|
var _view = useState('list');
|
||||||
var _createInitialSection = useState(null);
|
var _createInitialSection = useState(null);
|
||||||
var _editContractRecord = useState(null);
|
var _editContractRecord = useState(null);
|
||||||
@@ -140,6 +146,15 @@ const Component = function(props) {
|
|||||||
var _stampModalVisible = useState(false);
|
var _stampModalVisible = useState(false);
|
||||||
var _stampModalRecord = useState(null);
|
var _stampModalRecord = useState(null);
|
||||||
var _stampFileList = useState([]);
|
var _stampFileList = useState([]);
|
||||||
|
var _tripartiteModalVisible = useState(false);
|
||||||
|
var _tripartiteModalRecord = useState(null);
|
||||||
|
var _tripartiteAgreements = useState([]);
|
||||||
|
var _trialToFormalModalVisible = useState(false);
|
||||||
|
var _trialToFormalRecord = useState(null);
|
||||||
|
var _trialToFormalDelegates = useState([]);
|
||||||
|
var _trialToFormalVehicles = useState([]);
|
||||||
|
var _trialToFormalTripartite = useState(false);
|
||||||
|
var _trialToFormalAgreements = useState([]);
|
||||||
// 上传盖章合同完成后,按合同 id 记录已上传(原型:与列表 legalStampedContractUploaded 合并判断)
|
// 上传盖章合同完成后,按合同 id 记录已上传(原型:与列表 legalStampedContractUploaded 合并判断)
|
||||||
var _stampedUploadedOverride = useState({});
|
var _stampedUploadedOverride = useState({});
|
||||||
// 撤回等列表行状态覆盖(原型)
|
// 撤回等列表行状态覆盖(原型)
|
||||||
@@ -238,57 +253,37 @@ const Component = function(props) {
|
|||||||
|
|
||||||
var KPI_CARDS = [
|
var KPI_CARDS = [
|
||||||
{ key: 'all', title: '全部合同', desc: '显示全部合同' },
|
{ key: 'all', title: '全部合同', desc: '显示全部合同' },
|
||||||
{ key: 'inProgress', title: '进行中', desc: '显示所有进行中的合同;(进行中指合同在有效期内,且合同状态在进行中的合同)' },
|
{ key: 'inProgress', title: '进行中', desc: '显示至少有一辆车辆已交车但尚未还车的合同' },
|
||||||
{ key: 'terminated', title: '已终止', desc: '显示所有合同状态为「已终止」的合同' },
|
{ key: 'inApproval', title: '审批中', desc: '显示审批状态为「待审批」或「审批中」的合同' },
|
||||||
{ key: 'expiring', title: '临期合同', desc: '显示所有离过期日期还有30天的合同' },
|
{ key: 'expiring', title: '临期合同', desc: '显示最后一辆在租车辆对应合同,且距离合同签订有效期不足 30 天的合同' },
|
||||||
{ key: 'expired', title: '过期合同', desc: '显示所有合同状态为「已过期」的合同' }
|
{ key: 'terminated', title: '已终止', desc: '显示所有车辆已还车,或因续签/转三方产生新合同而自动终止的合同' }
|
||||||
];
|
];
|
||||||
|
|
||||||
function parseContractDateYmd(str) {
|
function contractHasDeliveredNotReturnedVehicle(record) {
|
||||||
if (!str) return null;
|
var vehicles = record && record.vehicles;
|
||||||
var parts = String(str).split('-');
|
if (!vehicles || !vehicles.length) return false;
|
||||||
if (parts.length < 3) return null;
|
for (var vi = 0; vi < vehicles.length; vi++) {
|
||||||
return new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2]));
|
if (canContractVehicleReturn(vehicles[vi])) return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function daysUntilContractEnd(endDateStr) {
|
function isInApprovalStatus(status) {
|
||||||
var end = parseContractDateYmd(endDateStr);
|
return status === '待审批' || status === '审批中';
|
||||||
if (!end) return null;
|
|
||||||
var today = new Date();
|
|
||||||
today.setHours(0, 0, 0, 0);
|
|
||||||
end.setHours(0, 0, 0, 0);
|
|
||||||
return Math.round((end.getTime() - today.getTime()) / 86400000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function isInProgressContractStatus(status) {
|
|
||||||
return status === '合同进行中' || status === '进行中';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isTerminatedContractStatus(status) {
|
|
||||||
return status === '已终止' || status === '已结束';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isExpiredContractStatus(status) {
|
|
||||||
return status === '已过期' || status === '到期合同';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function matchContractKpi(record, key) {
|
function matchContractKpi(record, key) {
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case 'all':
|
case 'all':
|
||||||
return true;
|
return true;
|
||||||
case 'inProgress': {
|
case 'inProgress':
|
||||||
if (!isInProgressContractStatus(record.contractStatus)) return false;
|
return contractHasDeliveredNotReturnedVehicle(record);
|
||||||
var remain = daysUntilContractEnd(record.contractEndDate);
|
case 'inApproval':
|
||||||
return remain !== null && remain >= 0;
|
return isInApprovalStatus(record.approvalStatus);
|
||||||
}
|
case 'expiring':
|
||||||
|
return isContractExpiringForKpi(record);
|
||||||
case 'terminated':
|
case 'terminated':
|
||||||
return isTerminatedContractStatus(record.contractStatus);
|
return isContractTerminatedForKpi(record);
|
||||||
case 'expiring': {
|
|
||||||
var days = daysUntilContractEnd(record.contractEndDate);
|
|
||||||
return days !== null && days >= 0 && days <= 30;
|
|
||||||
}
|
|
||||||
case 'expired':
|
|
||||||
return isExpiredContractStatus(record.contractStatus);
|
|
||||||
default:
|
default:
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -309,13 +304,13 @@ const Component = function(props) {
|
|||||||
React.createElement('circle', { cx: 12, cy: 12, r: 10 }),
|
React.createElement('circle', { cx: 12, cy: 12, r: 10 }),
|
||||||
React.createElement('polygon', { points: '10 8 16 12 10 16 10 8' })
|
React.createElement('polygon', { points: '10 8 16 12 10 16 10 8' })
|
||||||
),
|
),
|
||||||
terminated: React.createElement('svg', {
|
inApproval: React.createElement('svg', {
|
||||||
xmlns: 'http://www.w3.org/2000/svg', width: 18, height: 18, viewBox: '0 0 24 24',
|
xmlns: 'http://www.w3.org/2000/svg', width: 18, height: 18, viewBox: '0 0 24 24',
|
||||||
fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true
|
fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true
|
||||||
},
|
},
|
||||||
React.createElement('circle', { cx: 12, cy: 12, r: 10 }),
|
React.createElement('path', { d: 'M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z' }),
|
||||||
React.createElement('path', { d: 'm15 9-6 6' }),
|
React.createElement('path', { d: 'M14 2v4a2 2 0 0 0 2 2h4' }),
|
||||||
React.createElement('path', { d: 'm9 9 6 6' })
|
React.createElement('path', { d: 'm9 15 2 2 4-4' })
|
||||||
),
|
),
|
||||||
expiring: React.createElement('svg', {
|
expiring: React.createElement('svg', {
|
||||||
xmlns: 'http://www.w3.org/2000/svg', width: 18, height: 18, viewBox: '0 0 24 24',
|
xmlns: 'http://www.w3.org/2000/svg', width: 18, height: 18, viewBox: '0 0 24 24',
|
||||||
@@ -324,13 +319,13 @@ const Component = function(props) {
|
|||||||
React.createElement('circle', { cx: 12, cy: 12, r: 10 }),
|
React.createElement('circle', { cx: 12, cy: 12, r: 10 }),
|
||||||
React.createElement('polyline', { points: '12 6 12 12 16 14' })
|
React.createElement('polyline', { points: '12 6 12 12 16 14' })
|
||||||
),
|
),
|
||||||
expired: React.createElement('svg', {
|
terminated: React.createElement('svg', {
|
||||||
xmlns: 'http://www.w3.org/2000/svg', width: 18, height: 18, viewBox: '0 0 24 24',
|
xmlns: 'http://www.w3.org/2000/svg', width: 18, height: 18, viewBox: '0 0 24 24',
|
||||||
fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true
|
fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true
|
||||||
},
|
},
|
||||||
React.createElement('circle', { cx: 12, cy: 12, r: 10 }),
|
React.createElement('circle', { cx: 12, cy: 12, r: 10 }),
|
||||||
React.createElement('line', { x1: 12, y1: 8, x2: 12, y2: 12 }),
|
React.createElement('path', { d: 'm15 9-6 6' }),
|
||||||
React.createElement('line', { x1: 12, y1: 16, x2: 12.01, y2: 16 })
|
React.createElement('path', { d: 'm9 9 6 6' })
|
||||||
)
|
)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -362,6 +357,42 @@ const Component = function(props) {
|
|||||||
});
|
});
|
||||||
}, [rawList, _recordOverrides[0]]);
|
}, [rawList, _recordOverrides[0]]);
|
||||||
|
|
||||||
|
var fleetOnLeaseSummary = useMemo(function () {
|
||||||
|
return buildOnLeaseFleetByBrandModel(fleetSourceList);
|
||||||
|
}, [fleetSourceList]);
|
||||||
|
|
||||||
|
var fleetTotalOnLease = useMemo(function () {
|
||||||
|
return fleetOnLeaseSummary.reduce(function (sum, item) { return sum + item.count; }, 0);
|
||||||
|
}, [fleetOnLeaseSummary]);
|
||||||
|
|
||||||
|
var fleetKpiActive = _fleetPanelHidden[0] ? _fleetQuickOpen[0] : _fleetExpanded[0];
|
||||||
|
|
||||||
|
function handleFleetKpiClick() {
|
||||||
|
if (_fleetPanelHidden[0]) {
|
||||||
|
_fleetQuickOpen[1](!_fleetQuickOpen[0]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_fleetExpanded[1](!_fleetExpanded[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function collapseFleetKpiPanel() {
|
||||||
|
_fleetExpanded[1](false);
|
||||||
|
_fleetQuickOpen[1](false);
|
||||||
|
}
|
||||||
|
|
||||||
|
var FLEET_KPI_ICON = React.createElement('svg', {
|
||||||
|
xmlns: 'http://www.w3.org/2000/svg', width: 18, height: 18, viewBox: '0 0 24 24',
|
||||||
|
fill: 'none', stroke: 'currentColor', strokeWidth: 2, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true,
|
||||||
|
},
|
||||||
|
React.createElement('path', { d: 'M14 16H9m10 0h3v-3.5a1.5 1.5 0 0 0-1.5-1.5h-15A1.5 1.5 0 0 0 3 16.5V20h3' }),
|
||||||
|
React.createElement('circle', { cx: 7.5, cy: 17.5, r: 2.5 }),
|
||||||
|
React.createElement('circle', { cx: 17.5, cy: 17.5, r: 2.5 }),
|
||||||
|
);
|
||||||
|
|
||||||
|
var fleetKpiDesc = '按品牌型号统计在租车辆;点击展开下方车型卡片。隐藏面板后,再次点击将弹出各车型数量卡片。';
|
||||||
|
|
||||||
|
var listChromeOffsetPx = (_fleetExpanded[0] && !_fleetPanelHidden[0]) ? 592 : 468;
|
||||||
|
|
||||||
var filteredList = useMemo(function() {
|
var filteredList = useMemo(function() {
|
||||||
var overrides = _recordOverrides[0] || {};
|
var overrides = _recordOverrides[0] || {};
|
||||||
var list = rawList.map(function (r) {
|
var list = rawList.map(function (r) {
|
||||||
@@ -688,6 +719,180 @@ const Component = function(props) {
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
function mockUploadRequest(opts) {
|
||||||
|
setTimeout(function() {
|
||||||
|
if (opts.onSuccess) opts.onSuccess('ok');
|
||||||
|
}, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEmptyTripartiteAgreement() {
|
||||||
|
return {
|
||||||
|
id: 'tp-' + Date.now() + '-' + Math.random().toString(36).slice(2, 6),
|
||||||
|
name: '',
|
||||||
|
agreementFiles: [],
|
||||||
|
letterFiles: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapDelegatesForConversionModal(record) {
|
||||||
|
var delegates = getAuthorizedDelegates(record);
|
||||||
|
if (!delegates.length) {
|
||||||
|
return [{ name: '', phone: '', idCard: '' }];
|
||||||
|
}
|
||||||
|
return delegates.map(function(d) {
|
||||||
|
return {
|
||||||
|
name: d.name || '',
|
||||||
|
phone: d.contact || d.phone || '',
|
||||||
|
idCard: d.idNumber || d.idCard || '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapVehiclesForFormalModal(record) {
|
||||||
|
return (record.vehicles || []).map(function(v, index) {
|
||||||
|
return {
|
||||||
|
key: String(index),
|
||||||
|
vehicleType: v.vehicleType || '-',
|
||||||
|
brand: v.brand || '',
|
||||||
|
model: v.model || '',
|
||||||
|
plateNo: v.plateNo || '-',
|
||||||
|
rent: v.rent != null ? v.rent : '',
|
||||||
|
leasePeriodMonths: v.leasePeriodMonths != null ? v.leasePeriodMonths : '',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTripartiteModal(record) {
|
||||||
|
_tripartiteModalRecord[1](record);
|
||||||
|
_tripartiteAgreements[1]([createEmptyTripartiteAgreement()]);
|
||||||
|
_tripartiteModalVisible[1](true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTrialToFormalModal(record) {
|
||||||
|
_trialToFormalRecord[1](record);
|
||||||
|
_trialToFormalDelegates[1](mapDelegatesForConversionModal(record));
|
||||||
|
_trialToFormalVehicles[1](mapVehiclesForFormalModal(record));
|
||||||
|
_trialToFormalTripartite[1](false);
|
||||||
|
_trialToFormalAgreements[1]([createEmptyTripartiteAgreement()]);
|
||||||
|
_trialToFormalModalVisible[1](true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTripartiteAgreementList(setter, index, field, value) {
|
||||||
|
setter(function(prev) {
|
||||||
|
var list = prev.slice();
|
||||||
|
var row = list[index] || createEmptyTripartiteAgreement();
|
||||||
|
var next = {};
|
||||||
|
next[field] = value;
|
||||||
|
list[index] = Object.assign({}, row, next);
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function addTripartiteAgreementRow(setter) {
|
||||||
|
setter(function(prev) { return prev.concat([createEmptyTripartiteAgreement()]); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeTripartiteAgreementRow(setter, index) {
|
||||||
|
setter(function(prev) {
|
||||||
|
var list = prev.slice();
|
||||||
|
list.splice(index, 1);
|
||||||
|
return list.length ? list : [createEmptyTripartiteAgreement()];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var updateTrialToFormalDelegate = useCallback(function(index, field, value) {
|
||||||
|
_trialToFormalDelegates[1](function(prev) {
|
||||||
|
var list = prev.slice();
|
||||||
|
var row = list[index] || { name: '', phone: '', idCard: '' };
|
||||||
|
var next = {};
|
||||||
|
next[field] = value;
|
||||||
|
list[index] = Object.assign({}, row, next);
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
var updateTrialToFormalVehicle = useCallback(function(index, field, value) {
|
||||||
|
_trialToFormalVehicles[1](function(prev) {
|
||||||
|
var list = prev.slice();
|
||||||
|
var row = list[index] || {};
|
||||||
|
var next = {};
|
||||||
|
next[field] = value;
|
||||||
|
list[index] = Object.assign({}, row, next);
|
||||||
|
return list;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
function renderTripartiteAgreementEditor(agreements, setAgreements, annotationId) {
|
||||||
|
return React.createElement('div', {
|
||||||
|
className: 'lc-tripartite-agreements',
|
||||||
|
'data-annotation-id': annotationId || undefined,
|
||||||
|
},
|
||||||
|
agreements.map(function(row, index) {
|
||||||
|
return React.createElement('div', { key: row.id || index, className: 'lc-tripartite-agreement-card' },
|
||||||
|
React.createElement('div', { className: 'lc-tripartite-agreement-card__head' },
|
||||||
|
React.createElement('span', { className: 'lc-tripartite-agreement-card__title' }, '三方协议 ' + (index + 1)),
|
||||||
|
agreements.length > 1
|
||||||
|
? React.createElement(Button, {
|
||||||
|
type: 'link',
|
||||||
|
size: 'small',
|
||||||
|
danger: true,
|
||||||
|
onClick: function() { removeTripartiteAgreementRow(setAgreements, index); },
|
||||||
|
}, '删除')
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
React.createElement('div', { className: 'lc-tripartite-agreement-card__field' },
|
||||||
|
React.createElement('label', { className: 'lc-tripartite-agreement-card__label' }, '协议名称'),
|
||||||
|
React.createElement(Input, {
|
||||||
|
value: row.name,
|
||||||
|
placeholder: '请输入三方协议名称',
|
||||||
|
onChange: function(e) {
|
||||||
|
updateTripartiteAgreementList(setAgreements, index, 'name', e.target.value);
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
React.createElement('div', { className: 'lc-tripartite-agreement-card__field' },
|
||||||
|
React.createElement('label', { className: 'lc-tripartite-agreement-card__label' }, '三方协议附件'),
|
||||||
|
React.createElement(Upload, {
|
||||||
|
fileList: row.agreementFiles || [],
|
||||||
|
onChange: function(info) {
|
||||||
|
updateTripartiteAgreementList(setAgreements, index, 'agreementFiles', info.fileList);
|
||||||
|
},
|
||||||
|
customRequest: mockUploadRequest,
|
||||||
|
accept: '.pdf,.doc,.docx,image/*',
|
||||||
|
},
|
||||||
|
React.createElement(Button, null, '上传协议'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
React.createElement('div', { className: 'lc-tripartite-agreement-card__field' },
|
||||||
|
React.createElement('label', { className: 'lc-tripartite-agreement-card__label' }, '对方公函'),
|
||||||
|
React.createElement(Upload, {
|
||||||
|
fileList: row.letterFiles || [],
|
||||||
|
onChange: function(info) {
|
||||||
|
updateTripartiteAgreementList(setAgreements, index, 'letterFiles', info.fileList);
|
||||||
|
},
|
||||||
|
customRequest: mockUploadRequest,
|
||||||
|
accept: '.pdf,.doc,.docx,image/*',
|
||||||
|
},
|
||||||
|
React.createElement(Button, null, '上传公函'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
React.createElement(Button, {
|
||||||
|
type: 'dashed',
|
||||||
|
className: 'lc-tripartite-agreements__add',
|
||||||
|
onClick: function() { addTripartiteAgreementRow(setAgreements); },
|
||||||
|
}, '新增三方协议'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderModalContractCode(record) {
|
||||||
|
return React.createElement('div', { className: 'lc-flow-modal__meta' },
|
||||||
|
'合同编码:',
|
||||||
|
React.createElement('span', { className: 'lc-flow-modal__meta-value' }, record ? record.contractCode : '-'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
// 租赁/已交车 Popover:卡片式车辆明细
|
// 租赁/已交车 Popover:卡片式车辆明细
|
||||||
function renderVehiclePopoverIcon(type) {
|
function renderVehiclePopoverIcon(type) {
|
||||||
@@ -718,6 +923,12 @@ const Component = function(props) {
|
|||||||
return plate;
|
return plate;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var VEHICLE_PLATE_UNSELECTED_LABEL = '车辆待选';
|
||||||
|
|
||||||
|
function getVehiclePlateDisplayLabel(plateNo) {
|
||||||
|
return formatVehiclePlateLabel(plateNo) || VEHICLE_PLATE_UNSELECTED_LABEL;
|
||||||
|
}
|
||||||
|
|
||||||
function renderVehiclePopoverCard(vehicle, index, variant) {
|
function renderVehiclePopoverCard(vehicle, index, variant) {
|
||||||
var plate = formatVehiclePlateLabel(vehicle.plateNo);
|
var plate = formatVehiclePlateLabel(vehicle.plateNo);
|
||||||
var brandModel = [vehicle.brand, vehicle.model].filter(function (part) {
|
var brandModel = [vehicle.brand, vehicle.model].filter(function (part) {
|
||||||
@@ -732,7 +943,7 @@ const Component = function(props) {
|
|||||||
React.createElement('span', { className: 'lc-vehicle-popover-card__type', title: vehicle.vehicleType || '' }, vehicle.vehicleType || '-'),
|
React.createElement('span', { className: 'lc-vehicle-popover-card__type', title: vehicle.vehicleType || '' }, vehicle.vehicleType || '-'),
|
||||||
plate
|
plate
|
||||||
? React.createElement('span', { className: 'lc-vehicle-popover-card__plate tabular-nums' }, plate)
|
? React.createElement('span', { className: 'lc-vehicle-popover-card__plate tabular-nums' }, plate)
|
||||||
: React.createElement('span', { className: 'lc-vehicle-popover-card__plate lc-vehicle-popover-card__plate--pending' }, '车辆待选'),
|
: React.createElement('span', { className: 'lc-vehicle-popover-card__plate lc-vehicle-popover-card__plate--pending' }, VEHICLE_PLATE_UNSELECTED_LABEL),
|
||||||
),
|
),
|
||||||
React.createElement('div', { className: 'lc-vehicle-popover-card__body' },
|
React.createElement('div', { className: 'lc-vehicle-popover-card__body' },
|
||||||
React.createElement('div', { className: 'lc-vehicle-popover-card__field' },
|
React.createElement('div', { className: 'lc-vehicle-popover-card__field' },
|
||||||
@@ -968,7 +1179,7 @@ const Component = function(props) {
|
|||||||
}));
|
}));
|
||||||
_extraFeeModalVisible[1](true);
|
_extraFeeModalVisible[1](true);
|
||||||
} });
|
} });
|
||||||
items.push({ key: 'toTripartite', label: '变更为三方合同', onClick: function() { message.info('变更为三方合同(原型)'); } });
|
items.push({ key: 'toTripartite', label: '转三方合同', onClick: function() { openTripartiteModal(record); } });
|
||||||
items.push({ key: 'terminate', label: '终止合同', danger: true, onClick: function() { _terminateModalRecord[1](record); _terminateModalVisible[1](true); } });
|
items.push({ key: 'terminate', label: '终止合同', danger: true, onClick: function() { _terminateModalRecord[1](record); _terminateModalVisible[1](true); } });
|
||||||
}
|
}
|
||||||
if (status === '到期合同') {
|
if (status === '到期合同') {
|
||||||
@@ -981,7 +1192,7 @@ const Component = function(props) {
|
|||||||
items.push({ key: 'editReject', label: '编辑', onClick: function() { message.info('编辑(原型)'); } });
|
items.push({ key: 'editReject', label: '编辑', onClick: function() { message.info('编辑(原型)'); } });
|
||||||
}
|
}
|
||||||
if (type === '试用合同') {
|
if (type === '试用合同') {
|
||||||
items.push({ key: 'toFormal', label: '转正式合同', onClick: function() { message.info('转正式合同(原型)'); } });
|
items.push({ key: 'toFormal', label: '试用转正式', onClick: function() { openTrialToFormalModal(record); } });
|
||||||
}
|
}
|
||||||
// 审批通过且法务审核环节尚未上传盖章合同附件时,法务部员工可在「更多」中上传(上传完成后入口关闭)
|
// 审批通过且法务审核环节尚未上传盖章合同附件时,法务部员工可在「更多」中上传(上传完成后入口关闭)
|
||||||
if (approval === '审批通过' && isLegalDeptUser) {
|
if (approval === '审批通过' && isLegalDeptUser) {
|
||||||
@@ -1077,7 +1288,7 @@ const Component = function(props) {
|
|||||||
return React.createElement('div', { className: 'lc-contract-pickup-payment lc-contract-pickup-payment--empty' }, '—');
|
return React.createElement('div', { className: 'lc-contract-pickup-payment lc-contract-pickup-payment--empty' }, '—');
|
||||||
}
|
}
|
||||||
var paid = Boolean(vehicle.pickupPaymentPaid);
|
var paid = Boolean(vehicle.pickupPaymentPaid);
|
||||||
var plate = formatVehiclePlateLabel(vehicle.plateNo) || '待上牌';
|
var plate = getVehiclePlateDisplayLabel(vehicle.plateNo);
|
||||||
var tag = React.createElement(StatusTag, {
|
var tag = React.createElement(StatusTag, {
|
||||||
label: paid ? '已支付' : '未支付',
|
label: paid ? '已支付' : '未支付',
|
||||||
tone: paid ? 'green' : 'red',
|
tone: paid ? 'green' : 'red',
|
||||||
@@ -1105,7 +1316,7 @@ const Component = function(props) {
|
|||||||
var displayLabel = resolveReturnSettlementDisplayLabel(status);
|
var displayLabel = resolveReturnSettlementDisplayLabel(status);
|
||||||
var tone = getReturnSettlementDisplayTone(displayLabel);
|
var tone = getReturnSettlementDisplayTone(displayLabel);
|
||||||
var mode = resolveReturnSettlementCellMode(status);
|
var mode = resolveReturnSettlementCellMode(status);
|
||||||
var plate = formatVehiclePlateLabel(vehicle.plateNo) || '待上牌';
|
var plate = getVehiclePlateDisplayLabel(vehicle.plateNo);
|
||||||
var tag = React.createElement(StatusTag, {
|
var tag = React.createElement(StatusTag, {
|
||||||
label: displayLabel,
|
label: displayLabel,
|
||||||
tone: tone,
|
tone: tone,
|
||||||
@@ -1285,7 +1496,7 @@ const Component = function(props) {
|
|||||||
if (!canContractVehicleReturn(vehicle)) {
|
if (!canContractVehicleReturn(vehicle)) {
|
||||||
return React.createElement('span', { className: 'lc-contract-vehicle-action-empty' }, '—');
|
return React.createElement('span', { className: 'lc-contract-vehicle-action-empty' }, '—');
|
||||||
}
|
}
|
||||||
var plate = formatVehiclePlateLabel(vehicle.plateNo) || '待上牌';
|
var plate = getVehiclePlateDisplayLabel(vehicle.plateNo);
|
||||||
return React.createElement(Button, {
|
return React.createElement(Button, {
|
||||||
type: 'link',
|
type: 'link',
|
||||||
size: 'small',
|
size: 'small',
|
||||||
@@ -1304,7 +1515,7 @@ const Component = function(props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderVehicleInfoCell(vehicle) {
|
function renderVehicleInfoCell(vehicle) {
|
||||||
var plateLabel = formatVehiclePlateLabel(vehicle.plateNo) || '待上牌';
|
var plateLabel = getVehiclePlateDisplayLabel(vehicle.plateNo);
|
||||||
var vinLabel = vehicle.vin && String(vehicle.vin).trim() ? vehicle.vin : '-';
|
var vinLabel = vehicle.vin && String(vehicle.vin).trim() ? vehicle.vin : '-';
|
||||||
var brandModelLabel = formatVehicleBrandModelLabel(vehicle);
|
var brandModelLabel = formatVehicleBrandModelLabel(vehicle);
|
||||||
return React.createElement('div', { className: 'lc-contract-vehicle-info' },
|
return React.createElement('div', { className: 'lc-contract-vehicle-info' },
|
||||||
@@ -1353,7 +1564,7 @@ const Component = function(props) {
|
|||||||
var timeText = compact
|
var timeText = compact
|
||||||
? formatContractHandoverDateTimeCompact(time)
|
? formatContractHandoverDateTimeCompact(time)
|
||||||
: timeTextFull;
|
: timeTextFull;
|
||||||
var plate = formatVehiclePlateLabel(vehicle.plateNo) || '待上牌';
|
var plate = getVehiclePlateDisplayLabel(vehicle.plateNo);
|
||||||
var jumpTitle = kind === 'delivery'
|
var jumpTitle = kind === 'delivery'
|
||||||
? '点击查看交车管理:' + plate
|
? '点击查看交车管理:' + plate
|
||||||
: '点击查看还车管理:' + plate;
|
: '点击查看还车管理:' + plate;
|
||||||
@@ -1580,12 +1791,12 @@ const Component = function(props) {
|
|||||||
var expanded = _expandedRowKeys[0].indexOf(record.id) >= 0;
|
var expanded = _expandedRowKeys[0].indexOf(record.id) >= 0;
|
||||||
if (!expandable) {
|
if (!expandable) {
|
||||||
return React.createElement('span', {
|
return React.createElement('span', {
|
||||||
className: 'ant-table-row-expand-icon ant-table-row-spaced',
|
className: 'ant-table-row-expand-icon ant-table-row-expand-icon-spaced',
|
||||||
'aria-hidden': true,
|
'aria-hidden': true,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return React.createElement('span', {
|
return React.createElement('span', {
|
||||||
className: 'ant-table-row-expand-icon ' + (expanded ? 'ant-table-row-expanded' : 'ant-table-row-collapsed'),
|
className: 'ant-table-row-expand-icon ' + (expanded ? 'ant-table-row-expand-icon-expanded' : 'ant-table-row-expand-icon-collapsed'),
|
||||||
role: 'button',
|
role: 'button',
|
||||||
tabIndex: 0,
|
tabIndex: 0,
|
||||||
'aria-label': expanded ? '收起车辆明细' : '展开车辆明细',
|
'aria-label': expanded ? '收起车辆明细' : '展开车辆明细',
|
||||||
@@ -1901,7 +2112,7 @@ const Component = function(props) {
|
|||||||
var draftDate = _vehicleDeliveryChangeDraftDate[0];
|
var draftDate = _vehicleDeliveryChangeDraftDate[0];
|
||||||
var draftTbd = _vehicleDeliveryChangeDraftTbd[0];
|
var draftTbd = _vehicleDeliveryChangeDraftTbd[0];
|
||||||
var draftValue = draftDate && dayjs && !draftTbd ? dayjs(draftDate) : null;
|
var draftValue = draftDate && dayjs && !draftTbd ? dayjs(draftDate) : null;
|
||||||
var plate = formatVehiclePlateLabel(vehicle.plateNo) || '待上牌';
|
var plate = getVehiclePlateDisplayLabel(vehicle.plateNo);
|
||||||
return React.createElement('div', {
|
return React.createElement('div', {
|
||||||
className: 'lc-delivery-change__panel',
|
className: 'lc-delivery-change__panel',
|
||||||
role: 'dialog',
|
role: 'dialog',
|
||||||
@@ -2248,6 +2459,7 @@ const Component = function(props) {
|
|||||||
role: 'button',
|
role: 'button',
|
||||||
tabIndex: 0,
|
tabIndex: 0,
|
||||||
'aria-label': '更多操作',
|
'aria-label': '更多操作',
|
||||||
|
'data-annotation-id': 'lc-list-action-more',
|
||||||
onClick: function (e) { e.stopPropagation(); },
|
onClick: function (e) { e.stopPropagation(); },
|
||||||
}, renderOperationMoreIcon()),
|
}, renderOperationMoreIcon()),
|
||||||
),
|
),
|
||||||
@@ -2518,11 +2730,17 @@ const Component = function(props) {
|
|||||||
React.createElement('label', { key: 'endDate', className: 'vm-filter-field' },
|
React.createElement('label', { key: 'endDate', className: 'vm-filter-field' },
|
||||||
React.createElement('span', null, '合同结束日期'),
|
React.createElement('span', null, '合同结束日期'),
|
||||||
React.createElement(RangePicker, {
|
React.createElement(RangePicker, {
|
||||||
className: 'lc-filter-select',
|
className: 'lc-filter-select lc-filter-date-range',
|
||||||
|
popupClassName: 'lc-filter-date-dropdown',
|
||||||
style: { width: '100%' },
|
style: { width: '100%' },
|
||||||
placeholder: ['开始日期', '结束日期'],
|
placeholder: ['开始日期', '结束日期'],
|
||||||
|
separator: '至',
|
||||||
|
format: 'YYYY-MM-DD',
|
||||||
|
picker: 'date',
|
||||||
|
allowClear: true,
|
||||||
|
inputReadOnly: true,
|
||||||
value: _endDateRange[0],
|
value: _endDateRange[0],
|
||||||
onChange: function(v) { _endDateRange[1](v); }
|
onChange: function(v) { _endDateRange[1](v); },
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
];
|
];
|
||||||
@@ -2542,7 +2760,10 @@ const Component = function(props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return React.createElement(App, null,
|
return React.createElement(App, null,
|
||||||
React.createElement('div', { className: 'vm-page lc-page' },
|
React.createElement('div', {
|
||||||
|
className: 'vm-page lc-page lc-page--list-dense',
|
||||||
|
style: { '--lc-list-chrome-offset': listChromeOffsetPx + 'px' },
|
||||||
|
},
|
||||||
React.createElement('style', null, ONEOS_ANT_TABLE_GLOBAL_FIX.join('\n')),
|
React.createElement('style', null, ONEOS_ANT_TABLE_GLOBAL_FIX.join('\n')),
|
||||||
React.createElement('section', { className: 'vm-filter-card', 'data-annotation-id': 'lc-list-filter', 'aria-label': '筛选条件' },
|
React.createElement('section', { className: 'vm-filter-card', 'data-annotation-id': 'lc-list-filter', 'aria-label': '筛选条件' },
|
||||||
React.createElement('header', { className: 'vm-filter-header' },
|
React.createElement('header', { className: 'vm-filter-header' },
|
||||||
@@ -2578,13 +2799,19 @@ const Component = function(props) {
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
),
|
),
|
||||||
React.createElement('div', { className: 'vm-kpi-row', role: 'group', 'aria-label': '合同统计', 'data-annotation-id': 'lc-list-kpi' },
|
React.createElement('div', {
|
||||||
|
className: 'vm-kpi-row' + (fleetTotalOnLease > 0 ? ' vm-kpi-row--with-fleet' : ''),
|
||||||
|
role: 'group',
|
||||||
|
'aria-label': '合同统计',
|
||||||
|
'data-annotation-id': 'lc-list-kpi',
|
||||||
|
},
|
||||||
KPI_CARDS.map(function(card) {
|
KPI_CARDS.map(function(card) {
|
||||||
return React.createElement('button', {
|
return React.createElement('button', {
|
||||||
key: card.key,
|
key: card.key,
|
||||||
type: 'button',
|
type: 'button',
|
||||||
className: 'vm-kpi-card' + (_kpiTab[0] === card.key ? ' active' : ''),
|
className: 'vm-kpi-card' + (_kpiTab[0] === card.key ? ' active' : ''),
|
||||||
onClick: function() {
|
onClick: function() {
|
||||||
|
collapseFleetKpiPanel();
|
||||||
_kpiTab[1](card.key);
|
_kpiTab[1](card.key);
|
||||||
_page[1](1);
|
_page[1](1);
|
||||||
},
|
},
|
||||||
@@ -2607,13 +2834,46 @@ const Component = function(props) {
|
|||||||
React.createElement('span', { className: 'vm-kpi-val' }, kpiCounts[card.key] != null ? kpiCounts[card.key] : 0)
|
React.createElement('span', { className: 'vm-kpi-val' }, kpiCounts[card.key] != null ? kpiCounts[card.key] : 0)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
})
|
}),
|
||||||
|
fleetTotalOnLease > 0
|
||||||
|
? React.createElement('button', {
|
||||||
|
key: 'onLeaseFleet',
|
||||||
|
type: 'button',
|
||||||
|
className: 'vm-kpi-card lc-fleet-kpi-card' + (fleetKpiActive ? ' active' : ''),
|
||||||
|
onClick: handleFleetKpiClick,
|
||||||
|
'aria-pressed': fleetKpiActive,
|
||||||
|
'data-annotation-id': 'lc-fleet-summary',
|
||||||
|
},
|
||||||
|
React.createElement('span', {
|
||||||
|
className: 'vm-kpi-tip',
|
||||||
|
tabIndex: 0,
|
||||||
|
role: 'note',
|
||||||
|
'aria-label': '在租车辆说明',
|
||||||
|
onClick: function(e) { e.stopPropagation(); },
|
||||||
|
onMouseDown: function(e) { e.stopPropagation(); }
|
||||||
|
},
|
||||||
|
kpiHelpIcon,
|
||||||
|
React.createElement('span', { className: 'vm-kpi-tooltip', role: 'tooltip' }, fleetKpiDesc)
|
||||||
|
),
|
||||||
|
React.createElement('span', { className: 'vm-kpi-icon', 'aria-hidden': true }, FLEET_KPI_ICON),
|
||||||
|
React.createElement('span', { className: 'vm-kpi-main' },
|
||||||
|
React.createElement('span', { className: 'vm-kpi-eyebrow' }, '在租车辆'),
|
||||||
|
React.createElement('span', { className: 'vm-kpi-val tabular-nums' }, fleetTotalOnLease)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
: null
|
||||||
),
|
),
|
||||||
React.createElement(LeaseContractFleetSummary, {
|
React.createElement(LeaseContractFleetSummary, {
|
||||||
records: fleetSourceList,
|
records: fleetSourceList,
|
||||||
Modal: Modal,
|
Modal: Modal,
|
||||||
renderVehicleNativeTableHeader: renderContractVehicleNativeTableHeader,
|
renderVehicleNativeTableHeader: renderContractVehicleNativeTableHeader,
|
||||||
renderVehicleNativeTableRow: renderContractVehicleNativeTableRow,
|
renderVehicleNativeTableRow: renderContractVehicleNativeTableRow,
|
||||||
|
expanded: _fleetExpanded[0],
|
||||||
|
onExpandedChange: _fleetExpanded[1],
|
||||||
|
panelHidden: _fleetPanelHidden[0],
|
||||||
|
onPanelHiddenChange: _fleetPanelHidden[1],
|
||||||
|
quickViewOpen: _fleetQuickOpen[0],
|
||||||
|
onQuickViewOpenChange: _fleetQuickOpen[1],
|
||||||
onViewContract: function (record) {
|
onViewContract: function (record) {
|
||||||
message.info('查看合同(原型):' + (record.contractCode || record.projectName || ''));
|
message.info('查看合同(原型):' + (record.contractCode || record.projectName || ''));
|
||||||
},
|
},
|
||||||
@@ -2659,7 +2919,7 @@ const Component = function(props) {
|
|||||||
columns: columns,
|
columns: columns,
|
||||||
dataSource: pagedList,
|
dataSource: pagedList,
|
||||||
tableLayout: 'fixed',
|
tableLayout: 'fixed',
|
||||||
scroll: { x: listTableScrollX, y: 'calc(100vh - 640px)' },
|
scroll: { x: listTableScrollX, y: 'max(calc(100vh - var(--lc-list-chrome-offset, 472px)), 240px)' },
|
||||||
size: 'small',
|
size: 'small',
|
||||||
pagination: false,
|
pagination: false,
|
||||||
expandable: {
|
expandable: {
|
||||||
@@ -2830,6 +3090,132 @@ const Component = function(props) {
|
|||||||
},
|
},
|
||||||
accept: '.pdf,.doc,.docx,image/*'
|
accept: '.pdf,.doc,.docx,image/*'
|
||||||
}, React.createElement('p', { style: { margin: 0, padding: '20px 0' } }, '点击或拖拽文件到此区域上传;支持一次选择多个附件,可分批添加'))
|
}, React.createElement('p', { style: { margin: 0, padding: '20px 0' } }, '点击或拖拽文件到此区域上传;支持一次选择多个附件,可分批添加'))
|
||||||
|
)),
|
||||||
|
React.createElement(Modal, {
|
||||||
|
title: '转三方合同',
|
||||||
|
open: _tripartiteModalVisible[0],
|
||||||
|
className: 'lc-tripartite-modal',
|
||||||
|
onCancel: function() {
|
||||||
|
_tripartiteModalVisible[1](false);
|
||||||
|
_tripartiteModalRecord[1](null);
|
||||||
|
_tripartiteAgreements[1]([]);
|
||||||
|
},
|
||||||
|
width: 640,
|
||||||
|
footer: [
|
||||||
|
React.createElement(Button, { key: 'cancel', onClick: function() {
|
||||||
|
_tripartiteModalVisible[1](false);
|
||||||
|
_tripartiteModalRecord[1](null);
|
||||||
|
_tripartiteAgreements[1]([]);
|
||||||
|
} }, '取消'),
|
||||||
|
React.createElement(Button, { key: 'ok', type: 'primary', onClick: function() {
|
||||||
|
message.success('转三方合同已提交审核(原型)');
|
||||||
|
_tripartiteModalVisible[1](false);
|
||||||
|
_tripartiteModalRecord[1](null);
|
||||||
|
_tripartiteAgreements[1]([]);
|
||||||
|
} }, '提交审核'),
|
||||||
|
],
|
||||||
|
}, React.createElement('div', {
|
||||||
|
className: 'lc-flow-modal lc-flow-modal--tripartite',
|
||||||
|
'data-annotation-id': 'lc-action-convert-tripartite',
|
||||||
|
},
|
||||||
|
renderModalContractCode(_tripartiteModalRecord[0]),
|
||||||
|
React.createElement('p', { className: 'lc-flow-modal__hint' }, '支持新增多条三方协议,并分别上传协议附件与对方公函。'),
|
||||||
|
renderTripartiteAgreementEditor(_tripartiteAgreements[0], _tripartiteAgreements[1], 'lc-tripartite-agreements-editor'),
|
||||||
|
)),
|
||||||
|
React.createElement(Modal, {
|
||||||
|
title: '试用转正式',
|
||||||
|
open: _trialToFormalModalVisible[0],
|
||||||
|
className: 'lc-trial-to-formal-modal',
|
||||||
|
onCancel: function() {
|
||||||
|
_trialToFormalModalVisible[1](false);
|
||||||
|
_trialToFormalRecord[1](null);
|
||||||
|
_trialToFormalDelegates[1]([]);
|
||||||
|
_trialToFormalVehicles[1]([]);
|
||||||
|
_trialToFormalTripartite[1](false);
|
||||||
|
_trialToFormalAgreements[1]([]);
|
||||||
|
},
|
||||||
|
width: 920,
|
||||||
|
footer: [
|
||||||
|
React.createElement(Button, { key: 'cancel', onClick: function() {
|
||||||
|
_trialToFormalModalVisible[1](false);
|
||||||
|
_trialToFormalRecord[1](null);
|
||||||
|
_trialToFormalDelegates[1]([]);
|
||||||
|
_trialToFormalVehicles[1]([]);
|
||||||
|
_trialToFormalTripartite[1](false);
|
||||||
|
_trialToFormalAgreements[1]([]);
|
||||||
|
} }, '取消'),
|
||||||
|
React.createElement(Button, { key: 'ok', type: 'primary', onClick: function() {
|
||||||
|
var withTripartite = _trialToFormalTripartite[0];
|
||||||
|
message.success(withTripartite
|
||||||
|
? '试用转正式(含三方协议)已提交审核(原型)'
|
||||||
|
: '试用转正式已提交审核(原型)');
|
||||||
|
_trialToFormalModalVisible[1](false);
|
||||||
|
_trialToFormalRecord[1](null);
|
||||||
|
_trialToFormalDelegates[1]([]);
|
||||||
|
_trialToFormalVehicles[1]([]);
|
||||||
|
_trialToFormalTripartite[1](false);
|
||||||
|
_trialToFormalAgreements[1]([]);
|
||||||
|
} }, '提交审核'),
|
||||||
|
],
|
||||||
|
}, React.createElement('div', {
|
||||||
|
className: 'lc-flow-modal lc-flow-modal--trial-formal',
|
||||||
|
'data-annotation-id': 'lc-action-trial-to-formal',
|
||||||
|
},
|
||||||
|
renderModalContractCode(_trialToFormalRecord[0]),
|
||||||
|
React.createElement('p', { className: 'lc-flow-modal__hint' }, '自动拉取当前合同客户、授权人与车辆订单信息;车辆订单可编辑。转三方可与转正式在同一步完成。'),
|
||||||
|
React.createElement('section', { className: 'lc-flow-modal__section' },
|
||||||
|
React.createElement('h4', { className: 'lc-flow-modal__section-title' }, '客户信息'),
|
||||||
|
React.createElement('div', { className: 'lc-flow-modal__readonly-grid' },
|
||||||
|
React.createElement('div', null, React.createElement('span', { className: 'lc-flow-modal__readonly-label' }, '客户名称'), React.createElement('span', null, _trialToFormalRecord[0] ? _trialToFormalRecord[0].customerName : '-')),
|
||||||
|
React.createElement('div', null, React.createElement('span', { className: 'lc-flow-modal__readonly-label' }, '联系人'), React.createElement('span', null, _trialToFormalRecord[0] ? _trialToFormalRecord[0].contactName : '-')),
|
||||||
|
React.createElement('div', null, React.createElement('span', { className: 'lc-flow-modal__readonly-label' }, '联系电话'), React.createElement('span', null, _trialToFormalRecord[0] ? _trialToFormalRecord[0].contactPhone : '-')),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
React.createElement('section', { className: 'lc-flow-modal__section' },
|
||||||
|
React.createElement('h4', { className: 'lc-flow-modal__section-title' }, '授权人(可编辑)'),
|
||||||
|
React.createElement(Table, {
|
||||||
|
rowKey: function(_, i) { return String(i); },
|
||||||
|
size: 'small',
|
||||||
|
columns: [
|
||||||
|
{ title: '姓名', key: 'name', width: 120, render: function(_, row, index) { return React.createElement(Input, { value: row.name, onChange: function(e) { updateTrialToFormalDelegate(index, 'name', e.target.value); }, placeholder: '请输入' }); } },
|
||||||
|
{ title: '联系方式', key: 'phone', width: 140, render: function(_, row, index) { return React.createElement(Input, { value: row.phone, onChange: function(e) { updateTrialToFormalDelegate(index, 'phone', e.target.value); }, placeholder: '请输入' }); } },
|
||||||
|
{ title: '证件号码', key: 'idCard', width: 180, render: function(_, row, index) { return React.createElement(Input, { value: row.idCard, onChange: function(e) { updateTrialToFormalDelegate(index, 'idCard', e.target.value); }, placeholder: '请输入' }); } },
|
||||||
|
],
|
||||||
|
dataSource: _trialToFormalDelegates[0],
|
||||||
|
pagination: false,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
React.createElement('section', { className: 'lc-flow-modal__section' },
|
||||||
|
React.createElement('h4', { className: 'lc-flow-modal__section-title' }, '车辆订单(可编辑)'),
|
||||||
|
React.createElement(Table, {
|
||||||
|
rowKey: 'key',
|
||||||
|
size: 'small',
|
||||||
|
columns: [
|
||||||
|
{ title: '品牌', key: 'brand', width: 90, render: function(_, row, index) { return React.createElement(Input, { value: row.brand, onChange: function(e) { updateTrialToFormalVehicle(index, 'brand', e.target.value); } }); } },
|
||||||
|
{ title: '型号', key: 'model', width: 120, render: function(_, row, index) { return React.createElement(Input, { value: row.model, onChange: function(e) { updateTrialToFormalVehicle(index, 'model', e.target.value); } }); } },
|
||||||
|
{ title: '车牌号', key: 'plateNo', width: 100, render: function(_, row, index) { return React.createElement(Input, { value: row.plateNo, onChange: function(e) { updateTrialToFormalVehicle(index, 'plateNo', e.target.value); } }); } },
|
||||||
|
{ title: '租金(元/月)', key: 'rent', width: 120, render: function(_, row, index) { return React.createElement(Input, { value: row.rent, onChange: function(e) { updateTrialToFormalVehicle(index, 'rent', e.target.value); }, placeholder: '请输入' }); } },
|
||||||
|
{ title: '租期(月)', key: 'leasePeriodMonths', width: 100, render: function(_, row, index) { return React.createElement(Input, { value: row.leasePeriodMonths, onChange: function(e) { updateTrialToFormalVehicle(index, 'leasePeriodMonths', e.target.value); }, placeholder: '月数' }); } },
|
||||||
|
],
|
||||||
|
dataSource: _trialToFormalVehicles[0],
|
||||||
|
pagination: false,
|
||||||
|
scroll: { x: 640 },
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
React.createElement('section', { className: 'lc-flow-modal__section' },
|
||||||
|
React.createElement(Checkbox, {
|
||||||
|
checked: _trialToFormalTripartite[0],
|
||||||
|
onChange: function(e) {
|
||||||
|
_trialToFormalTripartite[1](e.target.checked);
|
||||||
|
if (e.target.checked && !_trialToFormalAgreements[0].length) {
|
||||||
|
_trialToFormalAgreements[1]([createEmptyTripartiteAgreement()]);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}, '同时转三方合同(可在本步骤新增三方协议)'),
|
||||||
|
_trialToFormalTripartite[0]
|
||||||
|
? renderTripartiteAgreementEditor(_trialToFormalAgreements[0], _trialToFormalAgreements[1], 'lc-trial-formal-tripartite-editor')
|
||||||
|
: null,
|
||||||
|
),
|
||||||
))
|
))
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -4,6 +4,9 @@
|
|||||||
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
||||||
import * as antd from 'antd';
|
import * as antd from 'antd';
|
||||||
import { ConfigProvider } from 'antd';
|
import { ConfigProvider } from 'antd';
|
||||||
|
import zhCN from 'antd/locale/zh_CN';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import 'dayjs/locale/zh-cn';
|
||||||
import {
|
import {
|
||||||
AnnotationViewer,
|
AnnotationViewer,
|
||||||
type AnnotationDirectoryRouteNode,
|
type AnnotationDirectoryRouteNode,
|
||||||
@@ -34,6 +37,8 @@ declare global {
|
|||||||
window.React = React;
|
window.React = React;
|
||||||
window.antd = antd;
|
window.antd = antd;
|
||||||
|
|
||||||
|
dayjs.locale('zh-cn');
|
||||||
|
|
||||||
/** 与 vehicle-management / ONE-OS 主题一致 */
|
/** 与 vehicle-management / ONE-OS 主题一致 */
|
||||||
const vmTheme = {
|
const vmTheme = {
|
||||||
token: {
|
token: {
|
||||||
@@ -55,8 +60,8 @@ const vmTheme = {
|
|||||||
headerColor: '#71717a',
|
headerColor: '#71717a',
|
||||||
rowHoverBg: '#f6f7f7',
|
rowHoverBg: '#f6f7f7',
|
||||||
borderColor: '#e5e7eb',
|
borderColor: '#e5e7eb',
|
||||||
cellPaddingBlock: 8,
|
cellPaddingBlock: 6,
|
||||||
cellPaddingInline: 12,
|
cellPaddingInline: 10,
|
||||||
},
|
},
|
||||||
Card: {
|
Card: {
|
||||||
borderRadiusLG: 12,
|
borderRadiusLG: 12,
|
||||||
@@ -64,6 +69,13 @@ const vmTheme = {
|
|||||||
InputNumber: {
|
InputNumber: {
|
||||||
handleVisible: true,
|
handleVisible: true,
|
||||||
},
|
},
|
||||||
|
DatePicker: {
|
||||||
|
cellActiveWithRangeBg: 'rgba(50, 160, 110, 0.12)',
|
||||||
|
cellHoverWithRangeBg: 'rgba(50, 160, 110, 0.08)',
|
||||||
|
cellRangeBorderColor: '#32a06e',
|
||||||
|
activeBorderColor: '#32a06e',
|
||||||
|
hoverBorderColor: '#3fb87c',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -96,7 +108,7 @@ export default function LeaseContractManagementPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ConfigProvider theme={vmTheme}>
|
<ConfigProvider theme={vmTheme} locale={zhCN}>
|
||||||
<LeaseContractApp
|
<LeaseContractApp
|
||||||
navigationRef={navigationRef}
|
navigationRef={navigationRef}
|
||||||
onPageViewChange={setAnnotationPageId}
|
onPageViewChange={setAnnotationPageId}
|
||||||
|
|||||||
16
src/prototypes/lease-contract-management/lease-contract-approval-rules.js
vendored
Normal file
16
src/prototypes/lease-contract-management/lease-contract-approval-rules.js
vendored
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { hasLeaseOrderRentBelowMinimum } from './lease-order-vars.js';
|
||||||
|
import {
|
||||||
|
resolveContractApprovalType,
|
||||||
|
STANDARD_CONTRACT_APPROVAL,
|
||||||
|
NONSTANDARD_CONTRACT_APPROVAL,
|
||||||
|
} from './lease-contract-risk-detect.js';
|
||||||
|
|
||||||
|
export { STANDARD_CONTRACT_APPROVAL, NONSTANDARD_CONTRACT_APPROVAL };
|
||||||
|
|
||||||
|
/** 综合风控红线修改与租金低于下限等规则,判定审批类型 */
|
||||||
|
export function resolveLeaseContractApprovalType(baselineHtml, currentHtml, form) {
|
||||||
|
if (form && hasLeaseOrderRentBelowMinimum(form.leaseOrder)) {
|
||||||
|
return NONSTANDARD_CONTRACT_APPROVAL;
|
||||||
|
}
|
||||||
|
return resolveContractApprovalType(baselineHtml, currentHtml);
|
||||||
|
}
|
||||||
@@ -91,10 +91,14 @@ function buildMileageFromRecord(record) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildFeeInfoFromRecord(record) {
|
function buildFeeInfoFromRecord(record) {
|
||||||
var isPrepay = record.hydrogenPaymentMethod === 'prepay';
|
var hydrogenPaymentMethod = record.hydrogenPaymentMethod || DEFAULT_FEE_INFO.hydrogenPaymentMethod;
|
||||||
|
if (record.hydrogenSettlementMode === 'month' && hydrogenPaymentMethod !== 'month') {
|
||||||
|
hydrogenPaymentMethod = 'month';
|
||||||
|
}
|
||||||
|
var isPrepay = hydrogenPaymentMethod === 'prepay';
|
||||||
return Object.assign({}, DEFAULT_FEE_INFO, {
|
return Object.assign({}, DEFAULT_FEE_INFO, {
|
||||||
paymentPeriod: record.paymentPeriod != null ? record.paymentPeriod : DEFAULT_FEE_INFO.paymentPeriod,
|
paymentPeriod: record.paymentPeriod != null ? record.paymentPeriod : DEFAULT_FEE_INFO.paymentPeriod,
|
||||||
hydrogenPaymentMethod: record.hydrogenPaymentMethod || DEFAULT_FEE_INFO.hydrogenPaymentMethod,
|
hydrogenPaymentMethod: hydrogenPaymentMethod,
|
||||||
prepayAmount: record.prepayAmount != null ? record.prepayAmount : (isPrepay ? 50000 : null),
|
prepayAmount: record.prepayAmount != null ? record.prepayAmount : (isPrepay ? 50000 : null),
|
||||||
payAheadWorkdays: record.payAheadWorkdays != null ? record.payAheadWorkdays : DEFAULT_FEE_INFO.payAheadWorkdays,
|
payAheadWorkdays: record.payAheadWorkdays != null ? record.payAheadWorkdays : DEFAULT_FEE_INFO.payAheadWorkdays,
|
||||||
returnHydrogenDiffUnitPrice: record.returnHydrogenDiffUnitPrice != null
|
returnHydrogenDiffUnitPrice: record.returnHydrogenDiffUnitPrice != null
|
||||||
@@ -127,6 +131,8 @@ export function buildLeaseContractEditFormState(record) {
|
|||||||
: [createEmptyDelegateRow()],
|
: [createEmptyDelegateRow()],
|
||||||
},
|
},
|
||||||
contractRemark: record.poaRemark || '',
|
contractRemark: record.poaRemark || '',
|
||||||
|
customerPrincipalName: record.customerPrincipalName || '',
|
||||||
|
customerPrincipalPhone: record.customerPrincipalPhone || '',
|
||||||
sealTypes: Array.isArray(record.sealTypes) && record.sealTypes.length
|
sealTypes: Array.isArray(record.sealTypes) && record.sealTypes.length
|
||||||
? record.sealTypes.slice()
|
? record.sealTypes.slice()
|
||||||
: ['contract'],
|
: ['contract'],
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ function isMainContractFieldsComplete(form) {
|
|||||||
if (mileage.hasRequirement) {
|
if (mileage.hasRequirement) {
|
||||||
if (!mileage.period) return false;
|
if (!mileage.period) return false;
|
||||||
if (mileage.targetKm == null || mileage.targetKm === '') return false;
|
if (mileage.targetKm == null || mileage.targetKm === '') return false;
|
||||||
|
if (mileage.reductionYuan == null || mileage.reductionYuan === '') return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
var feeInfo = form.feeInfo || {};
|
var feeInfo = form.feeInfo || {};
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export var PAYMENT_PERIOD_LABELS = {
|
|||||||
export var HYDROGEN_PAYMENT_LABELS = {
|
export var HYDROGEN_PAYMENT_LABELS = {
|
||||||
self: '自行解决',
|
self: '自行解决',
|
||||||
prepay: '预付款',
|
prepay: '预付款',
|
||||||
|
month: '按月结算',
|
||||||
};
|
};
|
||||||
|
|
||||||
var LEGACY_PAYMENT_PERIOD_MAP = {
|
var LEGACY_PAYMENT_PERIOD_MAP = {
|
||||||
@@ -42,6 +43,7 @@ var LEGACY_PAYMENT_PERIOD_MAP = {
|
|||||||
var LEGACY_HYDROGEN_PAYMENT_MAP = {
|
var LEGACY_HYDROGEN_PAYMENT_MAP = {
|
||||||
'自行解决': 'self',
|
'自行解决': 'self',
|
||||||
'预付款': 'prepay',
|
'预付款': 'prepay',
|
||||||
|
'按月结算': 'month',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function formatPaymentPeriodLabel(period) {
|
export function formatPaymentPeriodLabel(period) {
|
||||||
@@ -842,7 +844,9 @@ export var LEASE_CONTRACT_LIST_RECORDS = [
|
|||||||
{ vehicleType: '4.5吨冷链车', brand: '现代', model: '帕力安牌4.5吨冷链车', plateNo: '苏F90010', actualDelivery: '2024-03-02 10:00', returnTime: '2024-12-20 15:00', deliveryPerson: '卫运维', leasePeriodMonths: 12 },
|
{ vehicleType: '4.5吨冷链车', brand: '现代', model: '帕力安牌4.5吨冷链车', plateNo: '苏F90010', actualDelivery: '2024-03-02 10:00', returnTime: '2024-12-20 15:00', deliveryPerson: '卫运维', leasePeriodMonths: 12 },
|
||||||
],
|
],
|
||||||
approvalStatus: '审批通过',
|
approvalStatus: '审批通过',
|
||||||
contractStatus: '到期合同',
|
contractStatus: '已终止',
|
||||||
|
terminatedBy: 'renewal',
|
||||||
|
supersededByContractCode: 'HT-ZL-2025-012',
|
||||||
customerName: '杭州某某租赁有限公司',
|
customerName: '杭州某某租赁有限公司',
|
||||||
signingCompany: '嘉兴羚牛',
|
signingCompany: '嘉兴羚牛',
|
||||||
businessDept: '业务3部',
|
businessDept: '业务3部',
|
||||||
@@ -1148,6 +1152,80 @@ export function canContractVehicleReturn(vehicle) {
|
|||||||
return isContractVehicleDelivered(vehicle) && !isContractVehicleReturned(vehicle);
|
return isContractVehicleDelivered(vehicle) && !isContractVehicleReturned(vehicle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getVehicleDeliveryTimestamp(vehicle) {
|
||||||
|
var time = vehicle && (vehicle.deliveryTime || vehicle.actualDelivery);
|
||||||
|
if (!time || String(time).trim() === '-') return null;
|
||||||
|
var normalized = String(time).trim().replace(' ', 'T');
|
||||||
|
var date = new Date(normalized);
|
||||||
|
if (isNaN(date.getTime())) {
|
||||||
|
date = parseContractDateOnly(time);
|
||||||
|
}
|
||||||
|
return date && !isNaN(date.getTime()) ? date.getTime() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 合同中最后一辆「已交车但未还车」的车辆(按交车时间最晚) */
|
||||||
|
export function getLastDeliveredNotReturnedVehicle(record) {
|
||||||
|
var vehicles = record && record.vehicles;
|
||||||
|
if (!vehicles || !vehicles.length) return null;
|
||||||
|
var lastVehicle = null;
|
||||||
|
var lastTs = -1;
|
||||||
|
for (var i = 0; i < vehicles.length; i++) {
|
||||||
|
var vehicle = vehicles[i];
|
||||||
|
if (!canContractVehicleReturn(vehicle)) continue;
|
||||||
|
var ts = getVehicleDeliveryTimestamp(vehicle);
|
||||||
|
if (ts == null) ts = i;
|
||||||
|
if (ts >= lastTs) {
|
||||||
|
lastTs = ts;
|
||||||
|
lastVehicle = vehicle;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lastVehicle;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function daysUntilContractValidityEnd(record) {
|
||||||
|
if (!record || !record.contractEndDate) return null;
|
||||||
|
var end = parseContractDateOnly(record.contractEndDate);
|
||||||
|
if (!end) return null;
|
||||||
|
var today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
end.setHours(0, 0, 0, 0);
|
||||||
|
return Math.round((end.getTime() - today.getTime()) / 86400000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** KPI「临期合同」:存在在租车辆,且合同签订有效期距今日 ≤ 30 天 */
|
||||||
|
export function isContractExpiringForKpi(record) {
|
||||||
|
if (!getLastDeliveredNotReturnedVehicle(record)) return false;
|
||||||
|
var days = daysUntilContractValidityEnd(record);
|
||||||
|
return days !== null && days >= 0 && days <= 30;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isContractMarkedTerminated(record) {
|
||||||
|
if (!record) return false;
|
||||||
|
var status = String(record.contractStatus || '').trim();
|
||||||
|
if (status === '已终止' || status === '已结束') return true;
|
||||||
|
if (record.supersededByContractId || record.supersededByContractCode) return true;
|
||||||
|
if (record.terminatedBy === 'renewal' || record.terminatedBy === 'tripartite') return true;
|
||||||
|
if (record.autoTerminatedByRenewal || record.autoTerminatedByTripartite) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function contractAllVehiclesDeliveredAndReturned(record) {
|
||||||
|
var vehicles = record && record.vehicles;
|
||||||
|
if (!vehicles || !vehicles.length) return false;
|
||||||
|
for (var i = 0; i < vehicles.length; i++) {
|
||||||
|
var vehicle = vehicles[i];
|
||||||
|
if (!isContractVehicleDelivered(vehicle)) return false;
|
||||||
|
if (!isContractVehicleReturned(vehicle)) return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** KPI「已终止」:全部车辆已还车,或续签/转三方后旧合同自动终止 */
|
||||||
|
export function isContractTerminatedForKpi(record) {
|
||||||
|
if (isContractMarkedTerminated(record)) return true;
|
||||||
|
return contractAllVehiclesDeliveredAndReturned(record);
|
||||||
|
}
|
||||||
|
|
||||||
/** 不计入在租统计的合同状态 */
|
/** 不计入在租统计的合同状态 */
|
||||||
var ON_LEASE_EXCLUDED_CONTRACT_STATUSES = ['草稿', '已结束', '已终止', '未提交', '已提交审批'];
|
var ON_LEASE_EXCLUDED_CONTRACT_STATUSES = ['草稿', '已结束', '已终止', '未提交', '已提交审批'];
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
import { getContractTemplateBaseHtml } from '../contract-template-management/contract-template-catalog.js';
|
import { getContractTemplateBaseHtml } from '../contract-template-management/contract-template-catalog.js';
|
||||||
import { getTemplateSectionVehicleBindings } from '../contract-template-management/contract-template-store.js';
|
import { getTemplateSectionVehicleBindings } from '../contract-template-management/contract-template-store.js';
|
||||||
import { applyAttachmentSectionVehicleFilter } from '../contract-template-management/contract-template-section-filter.js';
|
import { applyAttachmentSectionVehicleFilter, renumberAttachmentSections } from '../contract-template-management/contract-template-section-filter.js';
|
||||||
import {
|
import {
|
||||||
applyTemplateVars,
|
applyTemplateVars,
|
||||||
buildLeasePreviewVars,
|
buildLeasePreviewVars,
|
||||||
@@ -138,6 +138,27 @@ export function applyPaymentPeriodPatch(html, feeInfo) {
|
|||||||
return head + slice + tail;
|
return head + slice + tail;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 2.2 氢费支付方式:按月结算时切换为占位条款(正式文案待法务确认) */
|
||||||
|
export function applyHydrogenSettlementPatch(html, feeInfo) {
|
||||||
|
if (!html || !feeInfo || feeInfo.hydrogenPaymentMethod !== 'month') return html;
|
||||||
|
var start = html.indexOf('2.2.1');
|
||||||
|
if (start < 0) return html;
|
||||||
|
var end = html.indexOf('2.2.3', start);
|
||||||
|
if (end < 0) end = html.indexOf('2.3', start);
|
||||||
|
if (end < 0) end = start + 2800;
|
||||||
|
var head = html.slice(0, start);
|
||||||
|
var slice = html.slice(start, end);
|
||||||
|
var tail = html.slice(end);
|
||||||
|
var placeholder = '<p class="p6 lc-h2-settlement-prototype" style="margin-left:12px;color:#92400e;background:#fffbeb;padding:8px 10px;border-radius:6px">'
|
||||||
|
+ '【原型占位 · 待法务确认】氢费按月结算:每月 1 日至当月最后一日为一个结算周期,'
|
||||||
|
+ '次月 5 日前结清上月氢费;具体条款以法务定稿为准。'
|
||||||
|
+ '</p>';
|
||||||
|
if (slice.indexOf('lc-h2-settlement-prototype') < 0) {
|
||||||
|
slice = slice.replace(/(2\.2\.1[\s\S]*?<\/p>)/, '$1' + placeholder);
|
||||||
|
}
|
||||||
|
return head + slice + tail;
|
||||||
|
}
|
||||||
|
|
||||||
/** 2.2.1 氢费支付:提前 x 个工作日 */
|
/** 2.2.1 氢费支付:提前 x 个工作日 */
|
||||||
export function applyFeeInfoPatch(html, feeInfo) {
|
export function applyFeeInfoPatch(html, feeInfo) {
|
||||||
if (!html || !feeInfo) return html;
|
if (!html || !feeInfo) return html;
|
||||||
@@ -418,6 +439,7 @@ export function buildLeaseContractPreviewHtml(form) {
|
|||||||
var html = stripUnresolvedPlaceholders(applyTemplateVars(base, vars));
|
var html = stripUnresolvedPlaceholders(applyTemplateVars(base, vars));
|
||||||
var vehicleKeys = collectVehicleKeysFromLeaseOrder(form);
|
var vehicleKeys = collectVehicleKeysFromLeaseOrder(form);
|
||||||
html = applyAttachmentSectionVehicleFilter(html, sectionBindings, vehicleKeys);
|
html = applyAttachmentSectionVehicleFilter(html, sectionBindings, vehicleKeys);
|
||||||
|
html = renumberAttachmentSections(html);
|
||||||
html = injectPrototypeVehicleClauses(html);
|
html = injectPrototypeVehicleClauses(html);
|
||||||
var customer = getLeaseCustomerById(form && form.customerId);
|
var customer = getLeaseCustomerById(form && form.customerId);
|
||||||
var lessor = getLessorCompanyById(form && form.lessorId);
|
var lessor = getLessorCompanyById(form && form.lessorId);
|
||||||
@@ -427,6 +449,7 @@ export function buildLeaseContractPreviewHtml(form) {
|
|||||||
html = applyMileageStandardPatch(html, form && form.mileage);
|
html = applyMileageStandardPatch(html, form && form.mileage);
|
||||||
html = applyPaymentPeriodPatch(html, form && form.feeInfo);
|
html = applyPaymentPeriodPatch(html, form && form.feeInfo);
|
||||||
html = applyFeeInfoPatch(html, form && form.feeInfo);
|
html = applyFeeInfoPatch(html, form && form.feeInfo);
|
||||||
|
html = applyHydrogenSettlementPatch(html, form && form.feeInfo);
|
||||||
html = applyLeaseOrderAttachmentPatch(html, form, customer, lessor);
|
html = applyLeaseOrderAttachmentPatch(html, form, customer, lessor);
|
||||||
html = applyPowerOfAttorneyPatch(html, form);
|
html = applyPowerOfAttorneyPatch(html, form);
|
||||||
html = appendCustomerCredentialAttachments(html, customer);
|
html = appendCustomerCredentialAttachments(html, customer);
|
||||||
|
|||||||
96
src/prototypes/lease-contract-management/lease-customer-credentials.js
vendored
Normal file
96
src/prototypes/lease-contract-management/lease-customer-credentials.js
vendored
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
/**
|
||||||
|
* 客户资质证照 OCR / 有效期校验(租赁合同签约信息)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { CUSTOMER_CREDENTIAL_ITEMS } from '../contract-template-management/contract-template-vars.js';
|
||||||
|
|
||||||
|
export var CREDENTIAL_EXPIRY_WARN_MONTHS = 3;
|
||||||
|
|
||||||
|
var STATUS_LABELS = {
|
||||||
|
valid: '有效',
|
||||||
|
expiring: '即将到期',
|
||||||
|
expired: '已过期',
|
||||||
|
unknown: '待识别',
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseYmd(str) {
|
||||||
|
if (!str) return null;
|
||||||
|
var parts = String(str).trim().split('-');
|
||||||
|
if (parts.length < 3) return null;
|
||||||
|
var y = Number(parts[0]);
|
||||||
|
var m = Number(parts[1]) - 1;
|
||||||
|
var d = Number(parts[2]);
|
||||||
|
if (!y || m < 0 || d < 1) return null;
|
||||||
|
var date = new Date(y, m, d);
|
||||||
|
date.setHours(23, 59, 59, 999);
|
||||||
|
return date;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addMonths(date, months) {
|
||||||
|
var next = new Date(date.getTime());
|
||||||
|
next.setMonth(next.getMonth() + months);
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCredentialExpiryStatus(expiryDate, refDate) {
|
||||||
|
var expiry = parseYmd(expiryDate);
|
||||||
|
if (!expiry) return 'unknown';
|
||||||
|
var now = refDate ? new Date(refDate) : new Date();
|
||||||
|
now.setHours(0, 0, 0, 0);
|
||||||
|
if (expiry.getTime() < now.getTime()) return 'expired';
|
||||||
|
var warnBefore = addMonths(now, CREDENTIAL_EXPIRY_WARN_MONTHS);
|
||||||
|
if (expiry.getTime() <= warnBefore.getTime()) return 'expiring';
|
||||||
|
return 'valid';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCredentialStatusLabel(status) {
|
||||||
|
return STATUS_LABELS[status] || STATUS_LABELS.unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCustomerCredentialMeta(customer, key) {
|
||||||
|
var file = customer && customer.attachments ? customer.attachments[key] : null;
|
||||||
|
var expiryDate = file && file.expiryDate ? file.expiryDate : '';
|
||||||
|
var status = getCredentialExpiryStatus(expiryDate);
|
||||||
|
return {
|
||||||
|
key: key,
|
||||||
|
ocrVerified: Boolean(file && file.ocrVerified),
|
||||||
|
expiryDate: expiryDate,
|
||||||
|
status: status,
|
||||||
|
statusLabel: getCredentialStatusLabel(status),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function summarizeCustomerCredentials(customer) {
|
||||||
|
var items = CUSTOMER_CREDENTIAL_ITEMS.map(function (item) {
|
||||||
|
var meta = getCustomerCredentialMeta(customer, item.key);
|
||||||
|
return {
|
||||||
|
key: item.key,
|
||||||
|
label: item.label,
|
||||||
|
ocrVerified: meta.ocrVerified,
|
||||||
|
expiryDate: meta.expiryDate,
|
||||||
|
status: meta.status,
|
||||||
|
statusLabel: meta.statusLabel,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
var expired = items.filter(function (item) { return item.status === 'expired'; });
|
||||||
|
var expiring = items.filter(function (item) { return item.status === 'expiring'; });
|
||||||
|
return {
|
||||||
|
items: items,
|
||||||
|
expiredItems: expired,
|
||||||
|
expiringItems: expiring,
|
||||||
|
hasExpired: expired.length > 0,
|
||||||
|
hasExpiring: expiring.length > 0,
|
||||||
|
blocksContractSubmit: expired.length > 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasBlockingCustomerCredentials(customer) {
|
||||||
|
if (!customer) return false;
|
||||||
|
return summarizeCustomerCredentials(customer).blocksContractSubmit;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCredentialSubmitBlockMessage(summary) {
|
||||||
|
if (!summary || !summary.hasExpired) return '';
|
||||||
|
var names = summary.expiredItems.map(function (item) { return item.label; }).join('、');
|
||||||
|
return '客户资质证照已过期(' + names + '),须由业管更新档案后方可提交新合同。';
|
||||||
|
}
|
||||||
@@ -175,6 +175,50 @@ export function getRowVehicleCountForPricing(row, order) {
|
|||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function countNationalInStockByBrandModel(brand, model) {
|
||||||
|
if (!brand || !model) return 0;
|
||||||
|
return vehiclesData.filter(function (vehicle) {
|
||||||
|
return vehicle.brand === brand
|
||||||
|
&& vehicle.model === model
|
||||||
|
&& (vehicle.operateStatus === '可运营' || vehicle.operateStatus === '待运营');
|
||||||
|
}).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 车型最低租金配置(原型);键为 brand|model */
|
||||||
|
export var LEASE_VEHICLE_MIN_RENT_BY_MODEL = {
|
||||||
|
'飞驰|集卡头': 15000,
|
||||||
|
'飞驰|半挂车': 12000,
|
||||||
|
'现代|18吨氢燃料电池车': 18000,
|
||||||
|
'苏龙|9.6米氢燃料电池车': 16000,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getLeaseMinRentForBrandModel(brand, model) {
|
||||||
|
if (!brand || !model) return null;
|
||||||
|
var key = brand + '|' + model;
|
||||||
|
if (LEASE_VEHICLE_MIN_RENT_BY_MODEL[key] != null) {
|
||||||
|
return LEASE_VEHICLE_MIN_RENT_BY_MODEL[key];
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isLeaseRentBelowMinimum(rent, brand, model) {
|
||||||
|
var minRent = getLeaseMinRentForBrandModel(brand, model);
|
||||||
|
if (minRent == null) return false;
|
||||||
|
if (rent == null || rent === '') return false;
|
||||||
|
return Number(rent) < minRent;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasLeaseOrderRentBelowMinimum(order) {
|
||||||
|
var rows = (order && order.rows) || [];
|
||||||
|
return rows.some(function (row) {
|
||||||
|
var models = normalizeBrandModels(row);
|
||||||
|
if (!models.length || row.rent == null || row.rent === '') return false;
|
||||||
|
return models.some(function (pair) {
|
||||||
|
return isLeaseRentBelowMinimum(row.rent, pair[0], pair[1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export function calcInsuredVehicleCount(order) {
|
export function calcInsuredVehicleCount(order) {
|
||||||
var rows = (order && order.rows) || [];
|
var rows = (order && order.rows) || [];
|
||||||
var autoFromPlates = 0;
|
var autoFromPlates = 0;
|
||||||
|
|||||||
@@ -13,13 +13,23 @@ const fullPrd = fs.readFileSync(resourcesPrd, 'utf8');
|
|||||||
|
|
||||||
const NODE_DEFS = [
|
const NODE_DEFS = [
|
||||||
{ id: 'lc-list-filter', title: '筛选条件', pageId: 'list', color: '#2563eb', aiPrompt: '列表筛选区字段与查询逻辑。', annotationText: '默认展示 4 项,可展开至 12 项;查询与 KPI 联动。' },
|
{ id: 'lc-list-filter', title: '筛选条件', pageId: 'list', color: '#2563eb', aiPrompt: '列表筛选区字段与查询逻辑。', annotationText: '默认展示 4 项,可展开至 12 项;查询与 KPI 联动。' },
|
||||||
{ id: 'lc-list-kpi', title: 'KPI 统计卡片', pageId: 'list', color: '#64748b', aiPrompt: '五张 KPI 卡片口径与点击筛选。', annotationText: '全部 / 进行中 / 已终止 / 临期 / 过期。' },
|
{ id: 'lc-list-kpi', title: 'KPI 统计卡片', pageId: 'list', color: '#64748b', aiPrompt: '五张 KPI 卡片口径与点击筛选。', annotationText: '全部 / 进行中 / 审批中 / 临期 / 已终止。' },
|
||||||
{ id: 'lc-fleet-summary', title: '在租车辆概览', pageId: 'list', color: '#32a06e', aiPrompt: '按品牌型号统计在租车辆,支持品牌标签筛选。', annotationText: '顶部展示在租总数;品牌标签筛选车型卡片;点击卡片查看车辆明细。' },
|
{ id: 'lc-fleet-summary', title: '在租车辆概览', pageId: 'list', color: '#32a06e', aiPrompt: '按品牌型号统计在租车辆,支持品牌标签筛选。', annotationText: '顶部展示在租总数;品牌标签筛选车型卡片;点击卡片查看车辆明细。' },
|
||||||
{ id: 'lc-list-toolbar', title: '列表工具栏', pageId: 'list', color: '#0f766e', aiPrompt: '费用模板、导出、新增入口。', annotationText: '「新增」进入租赁合同创建页。' },
|
{ id: 'lc-list-toolbar', title: '列表工具栏', pageId: 'list', color: '#0f766e', aiPrompt: '费用模板、导出、新增入口。', annotationText: '「新增」进入租赁合同创建页。' },
|
||||||
{ id: 'lc-list-table', title: '合同台账列表', pageId: 'list', color: '#2563eb', aiPrompt: '台账表格列与操作。', annotationText: '' },
|
{ id: 'lc-list-table', title: '合同台账列表', pageId: 'list', color: '#2563eb', aiPrompt: '台账表格列与操作。', annotationText: '' },
|
||||||
|
{ id: 'lc-list-action-more', title: '更多操作', pageId: 'list', color: '#2563eb', aiPrompt: '列表行更多菜单入口。', annotationText: '' },
|
||||||
|
{ id: 'lc-action-convert-tripartite', title: '转三方合同', pageId: 'list', color: '#7c3aed', aiPrompt: '进行中合同转三方。', annotationText: '' },
|
||||||
|
{ id: 'lc-action-trial-to-formal', title: '试用转正式', pageId: 'list', color: '#0f766e', aiPrompt: '试用合同一步转正式。', annotationText: '' },
|
||||||
{ id: 'lc-create-main-contract', title: '主体合同信息', pageId: 'create', color: '#32a06e', aiPrompt: '签约、里程、费用三块表单。', annotationText: '完成度徽章;甲乙档案只读同步。' },
|
{ id: 'lc-create-main-contract', title: '主体合同信息', pageId: 'create', color: '#32a06e', aiPrompt: '签约、里程、费用三块表单。', annotationText: '完成度徽章;甲乙档案只读同步。' },
|
||||||
|
{ id: 'lc-card-signing', title: '签约信息', pageId: 'create', color: '#0d9488', aiPrompt: '合同编号、甲乙双方与乙方负责人。', annotationText: '' },
|
||||||
|
{ id: 'lc-signing-customer-principal', title: '乙方负责人', pageId: 'create', color: '#0d9488', aiPrompt: '乙方签约对接负责人。', annotationText: '' },
|
||||||
{ id: 'lc-create-lease-order', title: '附件1:租赁订单', pageId: 'create', color: '#2563eb', aiPrompt: '订单车辆与交车信息。', annotationText: '三者责任险等为完成度必填项。' },
|
{ id: 'lc-create-lease-order', title: '附件1:租赁订单', pageId: 'create', color: '#2563eb', aiPrompt: '订单车辆与交车信息。', annotationText: '三者责任险等为完成度必填项。' },
|
||||||
|
{ id: 'lc-lease-order-brand-model', title: '品牌车型与在库', pageId: 'create', color: '#0f766e', aiPrompt: '选择车型后展示全国在库台数。', annotationText: '运营状态为「可运营」「待运营」计为在库;数据来自车辆管理主数据。' },
|
||||||
|
{ id: 'lc-lease-order-rent', title: '车辆租金与最低租金', pageId: 'create', color: '#c2410c', aiPrompt: '租金低于车型最低值触发非标审批。', annotationText: '最低租金可在后台按品牌·型号配置;原型配置见 lease-order-vars.js。' },
|
||||||
|
{ id: 'lc-lease-order-service-content', title: '服务项配置', pageId: 'create', color: '#7c3aed', aiPrompt: '服务项后台可配置与计价规则。', annotationText: '' },
|
||||||
|
{ id: 'lc-lease-order-lease-period', title: '租赁期限与到期计算', pageId: 'create', color: '#0369a1', aiPrompt: '租期两种计算模式与合同到期规则。', annotationText: '' },
|
||||||
{ id: 'lc-create-poa', title: '授权委托书', pageId: 'create', color: '#7c3aed', aiPrompt: '受托人列表编辑。', annotationText: '至少一行且姓名/联系方式/身份证完整。' },
|
{ id: 'lc-create-poa', title: '授权委托书', pageId: 'create', color: '#7c3aed', aiPrompt: '受托人列表编辑。', annotationText: '至少一行且姓名/联系方式/身份证完整。' },
|
||||||
|
{ id: 'lc-create-contract-remark', title: '合同备注', pageId: 'create', color: '#64748b', aiPrompt: '独立卡片填写合同备注。', annotationText: '选填,最多 500 字;与授权委托书分离。' },
|
||||||
{ id: 'lc-create-preview', title: '实时预览', pageId: 'create', color: '#0ea5e9', aiPrompt: 'Word 版式预览与可编辑正文。', annotationText: '改动风控红线条款将触发非标准合同。' },
|
{ id: 'lc-create-preview', title: '实时预览', pageId: 'create', color: '#0ea5e9', aiPrompt: 'Word 版式预览与可编辑正文。', annotationText: '改动风控红线条款将触发非标准合同。' },
|
||||||
{ id: 'lc-create-footer', title: '底栏操作', pageId: 'create', color: '#64748b', aiPrompt: '取消、保存草稿、提交审核。', annotationText: '保存/提交时展示将走的审批类型。' },
|
{ id: 'lc-create-footer', title: '底栏操作', pageId: 'create', color: '#64748b', aiPrompt: '取消、保存草稿、提交审核。', annotationText: '保存/提交时展示将走的审批类型。' },
|
||||||
];
|
];
|
||||||
@@ -63,12 +73,93 @@ const NODE_PRD_MAP = {
|
|||||||
'lc-list-table': { prd: 'list', heading: '## 7. 列表字段说明', combine: ['## 7. 列表字段说明', '## 8. 操作与弹窗'] },
|
'lc-list-table': { prd: 'list', heading: '## 7. 列表字段说明', combine: ['## 7. 列表字段说明', '## 8. 操作与弹窗'] },
|
||||||
'lc-create-main-contract': { prd: 'create', heading: '## 2. 主体合同信息' },
|
'lc-create-main-contract': { prd: 'create', heading: '## 2. 主体合同信息' },
|
||||||
'lc-create-lease-order': { prd: 'create', heading: '## 3. 附件1:租赁订单' },
|
'lc-create-lease-order': { prd: 'create', heading: '## 3. 附件1:租赁订单' },
|
||||||
|
'lc-lease-order-brand-model': { prd: 'create', heading: '## 3. 附件1:租赁订单', sub: '### 3.1 品牌车型' },
|
||||||
|
'lc-lease-order-rent': { prd: 'create', heading: '## 3. 附件1:租赁订单', sub: '### 3.2 租金' },
|
||||||
|
'lc-lease-order-service-content': { prd: 'create', heading: '## 3. 附件1:租赁订单', sub: '### 3.3 服务内容' },
|
||||||
'lc-create-poa': { prd: 'create', heading: '## 4. 授权委托书' },
|
'lc-create-poa': { prd: 'create', heading: '## 4. 授权委托书' },
|
||||||
'lc-create-preview': { prd: 'create', heading: '## 5. 实时预览', combine: ['## 5. 实时预览', '## 6. 审批类型判定'] },
|
'lc-create-contract-remark': { prd: 'create', heading: '## 5. 合同备注' },
|
||||||
'lc-create-footer': { prd: 'create', heading: '## 7. 底栏操作' },
|
'lc-create-preview': { prd: 'create', heading: '## 6. 实时预览', combine: ['## 6. 实时预览', '## 7. 审批类型判定'] },
|
||||||
|
'lc-create-footer': { prd: 'create', heading: '## 8. 底栏操作' },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
|
const CUSTOM_NODE_MARKDOWN = {
|
||||||
|
'lc-lease-order-brand-model': `# 品牌车型与在库
|
||||||
|
|
||||||
|
选择品牌 / 型号后,在选框下方展示该车型**全国在库**台数。
|
||||||
|
|
||||||
|
- 统计口径:车辆管理中运营状态为「可运营」或「待运营」
|
||||||
|
- 按品牌 + 型号精确匹配
|
||||||
|
- 用于签约前评估库存是否满足交付需求`,
|
||||||
|
'lc-lease-order-rent': `# 车辆租金与最低租金
|
||||||
|
|
||||||
|
- 车型最低租金可在后台按「品牌 · 型号」配置(原型见 \`LEASE_VEHICLE_MIN_RENT_BY_MODEL\`)
|
||||||
|
- 录入租金 **低于** 系统最低值时,行内提示并自动判定为 **非标准合同** 审批
|
||||||
|
- 与风控红线条款修改规则并列生效`,
|
||||||
|
'lc-lease-order-service-content': `# 服务项配置需求
|
||||||
|
|
||||||
|
1. **后台可配置**:支持新增、编辑、自定义服务项名称和计价规则;后续新增服务项可直接在签合同环节选用,无需迭代修改产品。
|
||||||
|
|
||||||
|
2. **两类计价逻辑**:
|
||||||
|
- **固定金额类**:如无忧包、尾板服务费,直接录入固定总价,计入合同初始服务费合计
|
||||||
|
- **动态计费类**:如维保费、运保费,仅在合同中展示约定单价(例:0.1 元/公里),不计入合同初始总价;后续按每月实际行驶里程核算后合并计入租金账单,首期账单默认不包含该类动态服务费
|
||||||
|
|
||||||
|
3. **合同同步**:所有选中的服务项自动同步展示到正式合同对应章节,支持单独添加备注说明,作为合同不可缺失的组成部分。`,
|
||||||
|
'lc-signing-customer-principal': `# 乙方负责人
|
||||||
|
|
||||||
|
选择乙方后,在签约信息中补充填写:
|
||||||
|
|
||||||
|
- **乙方负责人姓名**:签约对接负责人,可与客户档案中的联系人不同
|
||||||
|
- **乙方负责人手机号**:用于履约沟通与通知
|
||||||
|
|
||||||
|
选填;用于合同履约联系,不覆盖客户档案只读信息。`,
|
||||||
|
'lc-lease-order-lease-period': `# 租赁期限与到期计算
|
||||||
|
|
||||||
|
租赁到期时间支持两种计算模式:
|
||||||
|
|
||||||
|
## 1. 固定日期模式
|
||||||
|
|
||||||
|
手动指定合同的开始日期与结束日期;系统到达设定的结束日期时,自动标记合同为过期状态。
|
||||||
|
|
||||||
|
## 2. 租期月数模式
|
||||||
|
|
||||||
|
无需手动指定起止日期,仅输入租赁总月数。系统自动以 **车辆实际交付时间** 作为租期起始点,向后顺延对应月数,自动计算合同到期日期;到达该日期后自动标记合同为过期。
|
||||||
|
|
||||||
|
### 多车合同
|
||||||
|
|
||||||
|
当合同下关联多台车辆时,按固定月份规则取当前合同下 **第一辆到期** 的车辆到期时间,作为合同整体到期时间。
|
||||||
|
|
||||||
|
> 原型表格列「租赁期限(月)」当前展示租期月数模式输入;固定日期模式在正式产品中于同行或弹层切换。`,
|
||||||
|
'lc-list-action-more': `# 更多操作
|
||||||
|
|
||||||
|
列表操作列「更多」入口,按合同状态与类型展示不同菜单项。
|
||||||
|
|
||||||
|
- **合同进行中**:含转三方合同、续签、增车等
|
||||||
|
- **试用合同**:含试用转正式`,
|
||||||
|
'lc-action-convert-tripartite': `# 转三方合同
|
||||||
|
|
||||||
|
**显示条件**:合同状态为「合同进行中」。
|
||||||
|
|
||||||
|
**能力**:
|
||||||
|
|
||||||
|
1. 支持 **新增多条三方协议**(协议名称)
|
||||||
|
2. 每条协议可上传 **三方协议附件**
|
||||||
|
3. 每条协议可上传 **对方公函**
|
||||||
|
|
||||||
|
提交后走变更审批(原型演示 Toast)。`,
|
||||||
|
'lc-action-trial-to-formal': `# 试用转正式
|
||||||
|
|
||||||
|
**显示条件**:合同类型为「试用合同」。
|
||||||
|
|
||||||
|
**单步完成**(转正式与转三方可合并,无需分两步):
|
||||||
|
|
||||||
|
1. **自动拉取**当前合同的客户信息、授权人、车辆订单
|
||||||
|
2. **车辆订单可编辑**(品牌、型号、车牌、租金、租期月数等)
|
||||||
|
3. 可勾选 **同时转三方合同**,在同一步新增三方协议并上传附件/公函
|
||||||
|
|
||||||
|
提交后生成正式合同并走审批(原型演示 Toast)。`,
|
||||||
|
};
|
||||||
|
|
||||||
function buildFallbackMarkdown(node) {
|
function buildFallbackMarkdown(node) {
|
||||||
const parts = [`# ${node.title}`, ''];
|
const parts = [`# ${node.title}`, ''];
|
||||||
if (node.aiPrompt) parts.push(node.aiPrompt, '');
|
if (node.aiPrompt) parts.push(node.aiPrompt, '');
|
||||||
@@ -77,6 +168,9 @@ function buildFallbackMarkdown(node) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildNodeMarkdown(node) {
|
function buildNodeMarkdown(node) {
|
||||||
|
if (CUSTOM_NODE_MARKDOWN[node.id]) {
|
||||||
|
return CUSTOM_NODE_MARKDOWN[node.id];
|
||||||
|
}
|
||||||
const mapping = NODE_PRD_MAP[node.id];
|
const mapping = NODE_PRD_MAP[node.id];
|
||||||
if (mapping) {
|
if (mapping) {
|
||||||
const prdBody = extractFromPrd(mapping);
|
const prdBody = extractFromPrd(mapping);
|
||||||
|
|||||||
@@ -554,6 +554,100 @@
|
|||||||
color: var(--ln-muted);
|
color: var(--ln-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-create-page .lc-credentials-block__policy {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--ln-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-create-page .lc-credentials-alerts {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-create-page .lc-credentials-alert {
|
||||||
|
margin: 0;
|
||||||
|
padding: 10px 12px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
border-radius: var(--ln-radius-control);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-create-page .lc-credentials-alert--warn {
|
||||||
|
color: #92400e;
|
||||||
|
background: #fffbeb;
|
||||||
|
border: 1px solid #fcd34d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-create-page .lc-credentials-alert--danger {
|
||||||
|
color: #991b1b;
|
||||||
|
background: #fef2f2;
|
||||||
|
border: 1px solid #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-create-page .lc-main-contract-form .lc-attach-thumb.is-expired {
|
||||||
|
border-color: #fca5a5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-create-page .lc-main-contract-form .lc-attach-thumb.is-expiring {
|
||||||
|
border-color: #fcd34d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-create-page .lc-attach-thumb__ocr {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
width: fit-content;
|
||||||
|
padding: 1px 6px;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #047857;
|
||||||
|
background: #ecfdf5;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-create-page .lc-attach-thumb__ocr.is-pending {
|
||||||
|
color: #6b7280;
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-create-page .lc-attach-thumb__expiry {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--ln-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-create-page .lc-attach-thumb__status {
|
||||||
|
display: inline-flex;
|
||||||
|
width: fit-content;
|
||||||
|
padding: 1px 6px;
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
border-radius: 999px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-create-page .lc-attach-thumb__status--valid {
|
||||||
|
color: #047857;
|
||||||
|
background: #d1fae5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-create-page .lc-attach-thumb__status--expiring {
|
||||||
|
color: #b45309;
|
||||||
|
background: #fef3c7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-create-page .lc-attach-thumb__status--expired {
|
||||||
|
color: #b91c1c;
|
||||||
|
background: #fee2e2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-create-page .lc-attach-thumb__status--unknown {
|
||||||
|
color: #6b7280;
|
||||||
|
background: #f3f4f6;
|
||||||
|
}
|
||||||
|
|
||||||
.vm-page.lc-create-page .lc-mileage-types-wrap {
|
.vm-page.lc-create-page .lc-mileage-types-wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -2736,3 +2830,76 @@
|
|||||||
min-height: 240px;
|
min-height: 240px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 合同编号:系统自动生成(新建) */
|
||||||
|
.vm-page.lc-create-page .lc-contract-code-auto {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px 12px;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 6px 12px;
|
||||||
|
border: 1px solid var(--ln-hairline);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--ln-surface-muted, #f8fafc);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-create-page .lc-contract-code-auto__prefix {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ln-muted);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-create-page .lc-contract-code-auto__tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
color: #0f766e;
|
||||||
|
background: rgba(15, 118, 110, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 里程减免金额 */
|
||||||
|
.vm-page.lc-create-page .lc-mileage-reduction {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 12px 16px;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding-top: 16px;
|
||||||
|
border-top: 1px dashed var(--ln-hairline);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-create-page .lc-mileage-reduction .lc-field-hint {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 品牌车型在库提示 */
|
||||||
|
.vm-page.lc-create-page .lc-brand-model-stock-hint {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #0f766e;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 租金低于最低值警告 */
|
||||||
|
.vm-page.lc-create-page .lc-rent-min-warn {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: #c2410c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-create-page .lc-form-number--warn .ant-input-number-input {
|
||||||
|
color: #c2410c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-create-page .lc-signing-customer-principal {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-create-page .lc-contract-remark-section .lc-contract-remark__textarea {
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* 租赁合同管理 — Ant Design 控件与 vm 布局对齐
|
* 租赁合同管理 — Ant Design 控件与 vm 布局对齐
|
||||||
|
* 列表壳层与台账页对齐说明见 ledger-shared/DESIGN.md(ldb-page / lc-page)
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* 筛选区:Ant Select / DatePicker 与 vm-input 同高 */
|
/* 筛选区:Ant Select / DatePicker 与 vm-input 同高 */
|
||||||
@@ -26,11 +27,235 @@
|
|||||||
padding-bottom: 2px;
|
padding-bottom: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 筛选区:合同结束日期 RangePicker */
|
||||||
|
.vm-page.lc-page .lc-filter-date-range.ant-picker {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page .lc-filter-date-range.ant-picker .ant-picker-input > input {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--ln-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page .lc-filter-date-range.ant-picker .ant-picker-input > input::placeholder {
|
||||||
|
color: var(--ln-muted-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page .lc-filter-date-range.ant-picker .ant-picker-separator {
|
||||||
|
color: var(--ln-muted);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page .lc-filter-date-range.ant-picker .ant-picker-suffix {
|
||||||
|
color: var(--ln-muted-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page .vm-filter-field .lc-filter-date-range.ant-picker:hover,
|
||||||
|
.vm-page.lc-page .vm-filter-field .lc-filter-date-range.ant-picker.ant-picker-focused {
|
||||||
|
border-color: color-mix(in srgb, var(--ln-primary) 42%, var(--ln-hairline-strong)) !important;
|
||||||
|
box-shadow: 0 0 0 2px color-mix(in srgb, var(--ln-primary) 14%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lc-filter-date-dropdown.ant-picker-dropdown .ant-picker-header-view button:hover {
|
||||||
|
color: var(--ln-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lc-filter-date-dropdown.ant-picker-dropdown .ant-picker-cell-in-view.ant-picker-cell-selected .ant-picker-cell-inner,
|
||||||
|
.lc-filter-date-dropdown.ant-picker-dropdown .ant-picker-cell-in-view.ant-picker-cell-range-start .ant-picker-cell-inner,
|
||||||
|
.lc-filter-date-dropdown.ant-picker-dropdown .ant-picker-cell-in-view.ant-picker-cell-range-end .ant-picker-cell-inner {
|
||||||
|
background: var(--ln-primary, #32a06e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lc-filter-date-dropdown.ant-picker-dropdown .ant-picker-cell-in-view.ant-picker-cell-today .ant-picker-cell-inner::before {
|
||||||
|
border-color: var(--ln-primary, #32a06e);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lc-filter-date-dropdown.ant-picker-dropdown .ant-picker-footer .ant-btn-primary {
|
||||||
|
background: var(--ln-primary, #32a06e);
|
||||||
|
}
|
||||||
|
|
||||||
/* 列表工具栏:上下间距一致(以下方 16px 为准) */
|
/* 列表工具栏:上下间距一致(以下方 16px 为准) */
|
||||||
.vm-page.lc-page .vm-filter-card {
|
.vm-page.lc-page .vm-filter-card {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* —— 列表紧凑模式:一屏展示更多行 —— */
|
||||||
|
.vm-page.lc-page.lc-page--list-dense {
|
||||||
|
--lc-list-chrome-offset: 468px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: calc(100vh - 32px);
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.lc-page--list-dense .vm-filter-card {
|
||||||
|
padding: 16px 20px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.lc-page--list-dense .vm-filter-header {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.lc-page--list-dense .vm-filter-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.lc-page--list-dense .vm-filter-grid {
|
||||||
|
gap: 12px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.lc-page--list-dense .vm-kpi-row {
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.lc-page--list-dense .vm-kpi-card {
|
||||||
|
min-height: 72px;
|
||||||
|
padding: 12px 30px 12px 14px;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.lc-page--list-dense .vm-kpi-icon {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.lc-page--list-dense .vm-kpi-icon svg {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.lc-page--list-dense .vm-kpi-main {
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.lc-page--list-dense .vm-kpi-val {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
line-height: 1.15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.lc-page--list-dense .lc-fleet-summary.is-embedded {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.lc-page--list-dense .vm-table-section {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.lc-page--list-dense .vm-table-card {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.lc-page--list-dense .vm-table-card > .vm-table-wrap {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.lc-page--list-dense .vm-table-section .vm-table-toolbar {
|
||||||
|
padding-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.lc-page--list-dense .vm-table-footer {
|
||||||
|
padding-top: 10px;
|
||||||
|
padding-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.lc-page--list-dense .vm-list-table .ant-table-thead > tr > th {
|
||||||
|
padding: 6px 10px !important;
|
||||||
|
font-size: 0.6875rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.lc-page--list-dense .vm-list-table .ant-table-tbody > tr:not(.ant-table-measure-row) > td {
|
||||||
|
padding: 5px 10px !important;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.lc-page--list-dense .vm-list-table .vm-stack {
|
||||||
|
gap: 1px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.lc-page--list-dense .vm-list-table .vm-stack .sub,
|
||||||
|
.vm-page.lc-page.lc-page--list-dense .vm-list-table .vm-stack .code {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.lc-page--list-dense .lc-project-info-cell {
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.lc-page--list-dense .lc-lease-order-counts__item {
|
||||||
|
min-height: 34px;
|
||||||
|
min-width: 40px;
|
||||||
|
padding: 2px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.lc-page--list-dense .lc-lease-order-counts__value {
|
||||||
|
font-size: 0.9375rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.lc-page--list-dense .lc-lease-order-counts__label {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.lc-page--list-dense .lc-fee-info-cell__side {
|
||||||
|
gap: 3px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.lc-page--list-dense .lc-fee-info-cell__value {
|
||||||
|
min-height: 20px;
|
||||||
|
padding: 1px 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.lc-page--list-dense .lc-fee-info-cell__sep {
|
||||||
|
margin: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.lc-page--list-dense .lc-delivery-cell {
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.lc-page--list-dense .lc-approval-status-cell__approver {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.lc-page--list-dense .vm-list-table .ant-btn-link {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
padding: 0 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.lc-page--list-dense .lc-row-actions {
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.lc-page--list-dense .lc-action-more-btn {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-height: 820px) {
|
||||||
|
.vm-page.lc-page.lc-page--list-dense {
|
||||||
|
--lc-list-chrome-offset: 440px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-height: 960px) {
|
||||||
|
.vm-page.lc-page.lc-page--list-dense {
|
||||||
|
--lc-list-chrome-offset: 452px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.vm-page.lc-page .vm-table-section .vm-table-toolbar {
|
.vm-page.lc-page .vm-table-section .vm-table-toolbar {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
@@ -68,6 +293,95 @@
|
|||||||
outline-offset: 2px;
|
outline-offset: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 转三方 / 试用转正式弹窗 */
|
||||||
|
.vm-page.lc-page .lc-flow-modal__meta {
|
||||||
|
margin-bottom: 8px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--ln-muted, #64748b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page .lc-flow-modal__meta-value {
|
||||||
|
color: var(--ln-body, #334155);
|
||||||
|
font-weight: 600;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page .lc-flow-modal__hint {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.55;
|
||||||
|
color: var(--ln-muted, #64748b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page .lc-flow-modal__section {
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page .lc-flow-modal__section-title {
|
||||||
|
margin: 0 0 10px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ln-body, #334155);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page .lc-flow-modal__readonly-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 12px 16px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid var(--ln-hairline, #e2e8f0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page .lc-flow-modal__readonly-label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--ln-muted, #64748b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page .lc-tripartite-agreements__add {
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page .lc-tripartite-agreement-card {
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid var(--ln-hairline, #e2e8f0);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page .lc-tripartite-agreement-card__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page .lc-tripartite-agreement-card__title {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ln-body, #334155);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page .lc-tripartite-agreement-card__field {
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page .lc-tripartite-agreement-card__field:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page .lc-tripartite-agreement-card__label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--ln-muted, #64748b);
|
||||||
|
}
|
||||||
|
|
||||||
/* 列表区:Ant Table 嵌入 vm-table-card */
|
/* 列表区:Ant Table 嵌入 vm-table-card */
|
||||||
.vm-page.lc-page .vm-table-card .vm-list-table.ant-table-wrapper,
|
.vm-page.lc-page .vm-table-card .vm-list-table.ant-table-wrapper,
|
||||||
.vm-page.lc-page .vm-table-card .vm-list-table .ant-table-wrapper {
|
.vm-page.lc-page .vm-table-card .vm-list-table .ant-table-wrapper {
|
||||||
@@ -79,9 +393,18 @@
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page .vm-kpi-row.vm-kpi-row--with-fleet {
|
||||||
|
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page .lc-fleet-kpi-card.active {
|
||||||
|
border-color: var(--ln-primary);
|
||||||
|
box-shadow: 0 0 0 1px var(--ln-primary-soft, rgba(50, 160, 110, 0.25));
|
||||||
|
}
|
||||||
|
|
||||||
.vm-page.lc-page .vm-table-wrap.vm-list-table {
|
.vm-page.lc-page .vm-table-wrap.vm-list-table {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
max-height: calc(100vh - 640px);
|
max-height: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 列表宽表:横向滚动交给 Ant Table 内部,避免外层滚动抢滚动上下文 */
|
/* 列表宽表:横向滚动交给 Ant Table 内部,避免外层滚动抢滚动上下文 */
|
||||||
@@ -199,18 +522,40 @@
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 主表横向滚动容器:子表宽度以此为基准,不随主表列一起滑走 */
|
||||||
|
.vm-page.lc-page .lc-list-table--nested .ant-table-body {
|
||||||
|
container-type: inline-size;
|
||||||
|
container-name: lc-main-table-scroll;
|
||||||
|
}
|
||||||
|
|
||||||
.vm-page.lc-page .lc-list-table--nested .ant-table-expanded-row > td {
|
.vm-page.lc-page .lc-list-table--nested .ant-table-expanded-row > td {
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vm-page.lc-page .lc-list-table--nested .ant-table-expanded-row .lc-contract-vehicle-table-wrap {
|
.vm-page.lc-page .lc-list-table--nested .ant-table-expanded-row .lc-contract-vehicle-table-wrap {
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 2;
|
||||||
|
box-sizing: border-box;
|
||||||
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@supports (width: 100cqw) {
|
||||||
|
.vm-page.lc-page .lc-list-table--nested .ant-table-expanded-row .lc-contract-vehicle-table-wrap {
|
||||||
|
width: 100cqw;
|
||||||
|
max-width: 100cqw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 1280px) {
|
@media (max-width: 1280px) {
|
||||||
.vm-page.lc-page .vm-kpi-row {
|
.vm-page.lc-page .vm-kpi-row {
|
||||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page .vm-kpi-row.vm-kpi-row--with-fleet {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 960px) {
|
@media (max-width: 960px) {
|
||||||
@@ -1247,6 +1592,7 @@
|
|||||||
.vm-page.lc-page .lc-list-table--nested .ant-table-expanded-row > td:not(.ant-table-cell-fix-start) {
|
.vm-page.lc-page .lc-list-table--nested .ant-table-expanded-row > td:not(.ant-table-cell-fix-start) {
|
||||||
padding: 8px 8px 12px;
|
padding: 8px 8px 12px;
|
||||||
background: var(--ln-canvas-soft, #f8fafc);
|
background: var(--ln-canvas-soft, #f8fafc);
|
||||||
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vm-page.lc-page .lc-contract-vehicle-table-wrap {
|
.vm-page.lc-page .lc-contract-vehicle-table-wrap {
|
||||||
@@ -1949,6 +2295,94 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* —— 在租车辆概览 —— */
|
/* —— 在租车辆概览 —— */
|
||||||
|
.vm-page.lc-page .lc-fleet-summary.is-embedded {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page .lc-fleet-summary.is-embedded .lc-fleet-summary__body {
|
||||||
|
padding: 14px 16px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page .lc-fleet-summary__body-toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px 12px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page .lc-fleet-summary__body-toolbar .lc-fleet-summary__brands {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page .lc-fleet-summary__hide-btn {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid var(--ln-hairline);
|
||||||
|
border-radius: var(--ln-radius-control, 8px);
|
||||||
|
background: var(--ln-canvas);
|
||||||
|
color: var(--ln-muted);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
cursor: pointer;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: border-color 0.15s ease, color 0.15s ease, background 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page .lc-fleet-summary__hide-btn:hover {
|
||||||
|
border-color: var(--ln-hairline-strong);
|
||||||
|
color: var(--ln-ink);
|
||||||
|
background: var(--ln-surface-pearl);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page .lc-fleet-summary__grid--quick {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(148px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page .lc-fleet-summary__card--compact {
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page .lc-fleet-summary__card--compact .lc-fleet-summary__card-model {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page .lc-fleet-summary__card--compact .lc-fleet-summary__card-hint {
|
||||||
|
font-size: 0.6875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lc-fleet-quick-modal__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lc-fleet-quick-modal__badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 22px;
|
||||||
|
padding: 0 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: var(--ln-primary-soft);
|
||||||
|
color: var(--ln-primary-focus);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lc-fleet-quick-modal__footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
.vm-page.lc-page .lc-fleet-summary {
|
.vm-page.lc-page .lc-fleet-summary {
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
border: 1px solid var(--ln-hairline);
|
border: 1px solid var(--ln-hairline);
|
||||||
|
|||||||
69
src/prototypes/ledger-shared/DESIGN.md
Normal file
69
src/prototypes/ledger-shared/DESIGN.md
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
# 台账类列表页 · 统一设计规范
|
||||||
|
|
||||||
|
与 **租赁合同管理**(`vm-page lc-page` + `lease-contract.css`)及 **vehicle-management**(Linear 主题 token)对齐的台账列表壳层规范。
|
||||||
|
|
||||||
|
**分页、表格底栏等全局列表交互**见 [`vm-shared/DESIGN.md`](../vm-shared/DESIGN.md)(强制使用 `TablePagination` + `vm-pagination*` 样式)。
|
||||||
|
|
||||||
|
## 适用范围
|
||||||
|
|
||||||
|
| 模块 | 页面类名 | 样式入口 |
|
||||||
|
|------|----------|----------|
|
||||||
|
| 租赁业务台账 | `vm-page ldb-page` | `lease-business-ledger/styles/index.css` |
|
||||||
|
| 自营业务台账 | `vm-page ldb-page` | 同上(复用租赁台账模块样式) |
|
||||||
|
| 租赁合同管理 | `vm-page lc-page` | `lease-contract-management/styles/lease-contract.css` |
|
||||||
|
|
||||||
|
台账页(`ldb-page`)与合同列表页(`lc-page`)在以下维度保持一致:
|
||||||
|
|
||||||
|
- 页面内边距与底部留白
|
||||||
|
- 筛选卡片 → KPI → 工具栏 → 表格卡片 的 **16px 垂直节奏**
|
||||||
|
- 工具栏右对齐、按钮高度与 hover 反馈
|
||||||
|
- 宽表横向滚动 + 左右冻结列阴影
|
||||||
|
- 空值留白、操作列文字按钮、删除二次确认
|
||||||
|
|
||||||
|
## 页面结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
vm-page ldb-page
|
||||||
|
├── vm-filter-card ldb-filter-card # 模块标题 + 4 列筛选 + 更多筛选
|
||||||
|
├── ldb-kpi-row # 6 张 KPI 卡片
|
||||||
|
├── vm-table-section
|
||||||
|
│ ├── vm-table-toolbar ldb-table-toolbar
|
||||||
|
│ └── vm-table-card
|
||||||
|
│ ├── ldb-table-wrap > ldb-table
|
||||||
|
│ └── vm-table-footer ldb-table-footer # 左侧总条数 + TablePagination(见 vm-shared/DESIGN.md)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 类名约定
|
||||||
|
|
||||||
|
| 前缀 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `vm-*` | vehicle-management 基础布局与控件 |
|
||||||
|
| `ldb-*` | 台账模块共享(筛选展开、KPI、表格、工具栏、弹窗) |
|
||||||
|
| `lc-*` | 租赁合同 Ant Design 列表专用 |
|
||||||
|
|
||||||
|
## 筛选区
|
||||||
|
|
||||||
|
- 默认一行 **4 项**,其余放入 `ldb-filter-expand` 动画区
|
||||||
|
- 日期区间分隔符使用「**至**」(`ldb-date-range-sep`),不用 em dash
|
||||||
|
- 重置 / 查询按钮加 `ldb-toolbar-btn`
|
||||||
|
|
||||||
|
## KPI
|
||||||
|
|
||||||
|
- 使用 `ldb-kpi-row` + `ldb-kpi-card`(6 列网格,1280/640 断点降列)
|
||||||
|
- 盈亏类数值:`ldb-kpi-val--profit` / `ldb-kpi-val--loss`
|
||||||
|
|
||||||
|
## 表格
|
||||||
|
|
||||||
|
- 容器:`ldb-table-wrap` + `ldb-table`
|
||||||
|
- 底部分页:使用 `src/common/TablePagination.tsx`,布局见 `vm-shared/DESIGN.md`
|
||||||
|
- 表头排序:`ldb-th-sort`;列宽:`ldb-col-resize-handle`
|
||||||
|
- 金额列:`ldb-col-money` + `tabular-nums`
|
||||||
|
- 操作列:`ldb-action-btn--edit` / `--delete`,右冻结 `sticky-col-right`
|
||||||
|
- 勾选列:左冻结 `sticky-col-left`
|
||||||
|
|
||||||
|
## 共享样式文件
|
||||||
|
|
||||||
|
- 页面壳层:`ledger-shared/styles/ledger-page.css`
|
||||||
|
- 台账组件样式:`lease-business-ledger/styles/index.css`(import 上述壳层)
|
||||||
|
|
||||||
|
新增台账类页面应直接复用 `ldb-page` 与现有组件,避免再建模块专属页面类(如 `sob-page`)。
|
||||||
195
src/prototypes/ledger-shared/styles/ledger-page.css
Normal file
195
src/prototypes/ledger-shared/styles/ledger-page.css
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
/**
|
||||||
|
* ONE-OS 台账类列表页 — 统一页面壳层
|
||||||
|
*
|
||||||
|
* 适用:租赁业务台账、自营业务台账(vm-page + ldb-page)
|
||||||
|
* 对齐:租赁合同管理(vm-page + lc-page)的间距、卡片层次与工具栏布局
|
||||||
|
* 依赖:vehicle-management/style.css(Linear 主题 token)
|
||||||
|
*/
|
||||||
|
|
||||||
|
.vm-page.ldb-page {
|
||||||
|
padding-bottom: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.ldb-page .vm-filter-card.ldb-filter-card {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.ldb-page .vm-filter-header {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-page-title {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ln-ink);
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.ldb-page .ldb-kpi-row {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.ldb-page .vm-table-section {
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.ldb-page .vm-table-toolbar.ldb-table-toolbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: var(--vm-btn-height);
|
||||||
|
padding: 0 0 16px;
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.ldb-page .vm-table-toolbar.ldb-table-toolbar .vm-table-actions {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.ldb-page .vm-table-card > .ldb-table-wrap {
|
||||||
|
overflow: auto;
|
||||||
|
scrollbar-gutter: stable;
|
||||||
|
max-height: calc(100vh - 640px);
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 表头「?」提示需溢出卡片,避免被 vm-table-card 的 overflow:hidden 裁切 */
|
||||||
|
.vm-page.ldb-page .vm-table-card {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-table thead th {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-table thead th:has(.ldb-th-tip:hover),
|
||||||
|
.ldb-table thead th:has(.ldb-th-tip:focus-within) {
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-table {
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-table .sticky-col-left {
|
||||||
|
position: sticky;
|
||||||
|
left: 0;
|
||||||
|
z-index: 2;
|
||||||
|
box-shadow: 4px 0 8px -4px rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-table .sticky-col-right {
|
||||||
|
position: sticky;
|
||||||
|
right: 0;
|
||||||
|
z-index: 2;
|
||||||
|
box-shadow: -4px 0 8px -4px rgba(15, 23, 42, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-table .sticky-col-right::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: -12px;
|
||||||
|
width: 12px;
|
||||||
|
pointer-events: none;
|
||||||
|
background: linear-gradient(to left, var(--ln-surface-card) 40%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-table thead th.sticky-col-left,
|
||||||
|
.ldb-table thead th.sticky-col-right {
|
||||||
|
z-index: 3;
|
||||||
|
background: var(--ln-canvas-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-table tbody td.sticky-col-left,
|
||||||
|
.ldb-table tbody td.sticky-col-right {
|
||||||
|
z-index: 2;
|
||||||
|
background: var(--ln-surface-card);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-table tbody tr:hover td.sticky-col-left,
|
||||||
|
.ldb-table tbody tr:hover td.sticky-col-right {
|
||||||
|
background: var(--ln-canvas-soft);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-table-footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-table-footer-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-table-total {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--ln-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-date-range {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-date-range input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-date-range-sep {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--ln-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-filter-expand {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: 0fr;
|
||||||
|
transition: grid-template-rows 0.28s ease, opacity 0.22s ease;
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-filter-expand.is-expanded {
|
||||||
|
grid-template-rows: 1fr;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-filter-expand-inner {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-filter-expand-grid {
|
||||||
|
padding-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-filter-toggle-icon {
|
||||||
|
transition: transform 0.25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-filter-toggle-icon.is-open {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-toolbar-btn {
|
||||||
|
transition: transform 0.12s ease, box-shadow 0.15s ease, border-color 0.15s ease, background-color 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-toolbar-btn:hover {
|
||||||
|
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ldb-toolbar-btn:active {
|
||||||
|
transform: translateY(1px);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* @name 数据分析
|
* @name 数据分析
|
||||||
* 自 ONE-OS web端 原稿复刻(9 个页面)
|
* 自 ONE-OS web端 原稿复刻(8 个页面;业务部台账已迁至 OneOS → 数据分析)
|
||||||
*/
|
*/
|
||||||
import '../../common/oneosWebLegacy/legacyGlobals';
|
import '../../common/oneosWebLegacy/legacyGlobals';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
@@ -8,11 +8,10 @@ import Page1 from './pages/01-客户服务部业务统计报表.jsx';
|
|||||||
import Page2 from './pages/02-物流业务台账.jsx';
|
import Page2 from './pages/02-物流业务台账.jsx';
|
||||||
import Page3 from './pages/03-物流业务月度统计.jsx';
|
import Page3 from './pages/03-物流业务月度统计.jsx';
|
||||||
import Page4 from './pages/04-业务部汇总台账.jsx';
|
import Page4 from './pages/04-业务部汇总台账.jsx';
|
||||||
import Page5 from './pages/05-业务部台账.jsx';
|
import Page5 from './pages/06-业务部业绩明细.jsx';
|
||||||
import Page6 from './pages/06-业务部业绩明细.jsx';
|
import Page6 from './pages/07-业务台账.jsx';
|
||||||
import Page7 from './pages/07-业务台账.jsx';
|
import Page7 from './pages/08-租赁车辆收入明细.jsx';
|
||||||
import Page8 from './pages/08-租赁车辆收入明细.jsx';
|
import Page8 from './pages/09-租赁客户氢费台账.jsx';
|
||||||
import Page9 from './pages/09-租赁客户氢费台账.jsx';
|
|
||||||
import { OneosWebLegacyShell } from '../../common/oneosWebLegacy/OneosWebLegacyShell';
|
import { OneosWebLegacyShell } from '../../common/oneosWebLegacy/OneosWebLegacyShell';
|
||||||
|
|
||||||
const pages = [
|
const pages = [
|
||||||
@@ -20,11 +19,10 @@ const pages = [
|
|||||||
{ id: 'u6570u636eu5206u6790-u7269u6d41u4e1au52a1u53f0u8', title: '物流业务台账', component: Page2 },
|
{ id: 'u6570u636eu5206u6790-u7269u6d41u4e1au52a1u53f0u8', title: '物流业务台账', component: Page2 },
|
||||||
{ id: 'u6570u636eu5206u6790-u7269u6d41u4e1au52a1u6708u5', title: '物流业务月度统计', component: Page3 },
|
{ id: 'u6570u636eu5206u6790-u7269u6d41u4e1au52a1u6708u5', title: '物流业务月度统计', component: Page3 },
|
||||||
{ id: 'u6570u636eu5206u6790-u4e1au52a1u90e8u6c47u603bu5', title: '业务部汇总台账', component: Page4 },
|
{ id: 'u6570u636eu5206u6790-u4e1au52a1u90e8u6c47u603bu5', title: '业务部汇总台账', component: Page4 },
|
||||||
{ id: 'u6570u636eu5206u6790-u4e1au52a1u90e8u53f0u8d26', title: '业务部台账', component: Page5 },
|
{ id: 'u6570u636eu5206u6790-u4e1au52a1u90e8u4e1au7ee9u6', title: '业务部业绩明细', component: Page5 },
|
||||||
{ id: 'u6570u636eu5206u6790-u4e1au52a1u90e8u4e1au7ee9u6', title: '业务部业绩明细', component: Page6 },
|
{ id: 'u6570u636eu5206u6790-u4e1au52a1u53f0u8d26', title: '业务台账', component: Page6 },
|
||||||
{ id: 'u6570u636eu5206u6790-u4e1au52a1u53f0u8d26', title: '业务台账', component: Page7 },
|
{ id: 'u6570u636eu5206u6790-u79dfu8d41u8f66u8f86u6536u5', title: '租赁车辆收入明细', component: Page7 },
|
||||||
{ id: 'u6570u636eu5206u6790-u79dfu8d41u8f66u8f86u6536u5', title: '租赁车辆收入明细', component: Page8 },
|
{ id: 'u6570u636eu5206u6790-u79dfu8d41u5ba2u6237u6c22u8', title: '租赁客户氢费台账', component: Page8 },
|
||||||
{ id: 'u6570u636eu5206u6790-u79dfu8d41u5ba2u6237u6c22u8', title: '租赁客户氢费台账', component: Page9 },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function OneosWebDataAnalysis() {
|
export default function OneosWebDataAnalysis() {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// 【重要】必须使用 const Component 作为组件变量名
|
// 【重要】必须使用 const Component 作为组件变量名
|
||||||
// 数据分析 - 业务部台账(多业务板块:业绩 / 成本 / 利润,按月汇总)
|
// OneOS · 业务部台账(多业务板块:业绩 / 成本 / 利润,按月汇总)
|
||||||
|
|
||||||
const Component = function () {
|
const Component = function () {
|
||||||
var useState = React.useState;
|
var useState = React.useState;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"version": 2,
|
"version": 2,
|
||||||
"prototypeName": "oneos-web-h2-station-site",
|
"prototypeName": "oneos-web-h2-station-site",
|
||||||
"pageId": "site-info",
|
"pageId": "site-info",
|
||||||
"updatedAt": 1782912426557,
|
"updatedAt": 1783328981488,
|
||||||
"nodes": [
|
"nodes": [
|
||||||
{
|
{
|
||||||
"id": "h2-site-filter",
|
"id": "h2-site-filter",
|
||||||
@@ -112,31 +112,10 @@
|
|||||||
"title": "ONE-OS 原型导航",
|
"title": "ONE-OS 原型导航",
|
||||||
"defaultExpanded": true,
|
"defaultExpanded": true,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"id": "nav-link-lease-business-ledger",
|
|
||||||
"title": "租赁业务台账",
|
|
||||||
"href": "/prototypes/lease-business-ledger",
|
|
||||||
"target": "self"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"id": "nav-link-vehicle-h2-fee-ledger",
|
|
||||||
"title": "车辆氢费明细",
|
|
||||||
"href": "/prototypes/vehicle-h2-fee-ledger",
|
|
||||||
"target": "self"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"id": "nav-link-self-operated-business-ledger",
|
|
||||||
"title": "自营业务台账",
|
|
||||||
"href": "/prototypes/self-operated-business-ledger",
|
|
||||||
"target": "self"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "folder",
|
"type": "folder",
|
||||||
"id": "nav-folder-prototypes-oneos-web",
|
"id": "nav-folder-1782874576229-r8atbu",
|
||||||
"title": "ONE-OS Web 端",
|
"title": "OneOS",
|
||||||
"defaultExpanded": true,
|
"defaultExpanded": true,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
@@ -145,7 +124,22 @@
|
|||||||
"title": "工作台",
|
"title": "工作台",
|
||||||
"href": "/prototypes/oneos-web-workbench",
|
"href": "/prototypes/oneos-web-workbench",
|
||||||
"target": "self"
|
"target": "self"
|
||||||
},
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-lease-business-line-overview",
|
||||||
|
"title": "业务条线说明",
|
||||||
|
"href": "/prototypes/lease-business-line-overview",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "folder",
|
||||||
|
"id": "nav-folder-prototypes-oneos-web",
|
||||||
|
"title": "ONE-OS Web 端",
|
||||||
|
"defaultExpanded": true,
|
||||||
|
"children": [
|
||||||
{
|
{
|
||||||
"type": "link",
|
"type": "link",
|
||||||
"id": "nav-link-oneos-web-login",
|
"id": "nav-link-oneos-web-login",
|
||||||
|
|||||||
221
src/prototypes/payment-records/annotation-source.json
Normal file
221
src/prototypes/payment-records/annotation-source.json
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
{
|
||||||
|
"documentVersion": 1,
|
||||||
|
"format": "axhub-annotation-source",
|
||||||
|
"data": {
|
||||||
|
"version": 2,
|
||||||
|
"prototypeName": "payment-records",
|
||||||
|
"pageId": "list",
|
||||||
|
"updatedAt": 1783328981488,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": "pr-filter",
|
||||||
|
"index": 1,
|
||||||
|
"title": "筛选条件",
|
||||||
|
"pageId": "list",
|
||||||
|
"locator": {
|
||||||
|
"selectors": [
|
||||||
|
"[data-annotation-id=\"pr-filter\"]"
|
||||||
|
],
|
||||||
|
"fingerprint": "pr-filter",
|
||||||
|
"path": []
|
||||||
|
},
|
||||||
|
"aiPrompt": "客户、收款时间、操作人、未认领筛选。",
|
||||||
|
"annotationText": "默认展示 4 项筛选;支持重置与查询。",
|
||||||
|
"hasMarkdown": false,
|
||||||
|
"color": "#2563eb",
|
||||||
|
"images": [],
|
||||||
|
"createdAt": 1740787200000,
|
||||||
|
"updatedAt": 1740787200000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pr-list",
|
||||||
|
"index": 2,
|
||||||
|
"title": "列表与工具栏",
|
||||||
|
"pageId": "list",
|
||||||
|
"locator": {
|
||||||
|
"selectors": [
|
||||||
|
"[data-annotation-id=\"pr-list\"]"
|
||||||
|
],
|
||||||
|
"fingerprint": "pr-list",
|
||||||
|
"path": []
|
||||||
|
},
|
||||||
|
"aiPrompt": "新增、批量导入、导出与收款列表。",
|
||||||
|
"annotationText": "列表含收款时间、客户、金额、备注、关联账单、未认领金额、操作人、操作时间;操作列支持关联账单。",
|
||||||
|
"hasMarkdown": false,
|
||||||
|
"color": "#0f766e",
|
||||||
|
"images": [],
|
||||||
|
"createdAt": 1740787200000,
|
||||||
|
"updatedAt": 1740787200000
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pr-bill-link-modal",
|
||||||
|
"index": 3,
|
||||||
|
"title": "关联账单",
|
||||||
|
"pageId": "list",
|
||||||
|
"locator": {
|
||||||
|
"selectors": [
|
||||||
|
"[data-annotation-id=\"pr-bill-link-modal\"]"
|
||||||
|
],
|
||||||
|
"fingerprint": "pr-bill-link-modal",
|
||||||
|
"path": []
|
||||||
|
},
|
||||||
|
"aiPrompt": "关联租赁/自营/氢费账单,支持多选。",
|
||||||
|
"annotationText": "按账单类型切换;勾选后填写本次认领金额;支持单选及多选。",
|
||||||
|
"hasMarkdown": false,
|
||||||
|
"color": "#7c3aed",
|
||||||
|
"images": [],
|
||||||
|
"createdAt": 1740787200000,
|
||||||
|
"updatedAt": 1740787200000
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"markdownMap": {},
|
||||||
|
"assetMap": {},
|
||||||
|
"directory": {
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"type": "folder",
|
||||||
|
"id": "oneos-project-nav",
|
||||||
|
"title": "ONE-OS 原型导航",
|
||||||
|
"defaultExpanded": true,
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "folder",
|
||||||
|
"id": "nav-folder-1782874576229-r8atbu",
|
||||||
|
"title": "OneOS",
|
||||||
|
"defaultExpanded": true,
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-workbench",
|
||||||
|
"title": "工作台",
|
||||||
|
"href": "/prototypes/oneos-web-workbench",
|
||||||
|
"target": "self"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-lease-business-line-overview",
|
||||||
|
"title": "业务条线说明",
|
||||||
|
"href": "/prototypes/lease-business-line-overview",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "folder",
|
||||||
|
"id": "nav-folder-prototypes-oneos-web",
|
||||||
|
"title": "ONE-OS Web 端",
|
||||||
|
"defaultExpanded": true,
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-login",
|
||||||
|
"title": "登录",
|
||||||
|
"href": "/prototypes/oneos-web-login",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-vehicle-asset",
|
||||||
|
"title": "车辆管理",
|
||||||
|
"href": "/prototypes/oneos-web-vehicle-asset",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-contract-template",
|
||||||
|
"title": "合同模板管理",
|
||||||
|
"href": "/prototypes/oneos-web-contract-template",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-help-center",
|
||||||
|
"title": "帮助中心",
|
||||||
|
"href": "/prototypes/oneos-web-help-center",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-finance",
|
||||||
|
"title": "财务管理",
|
||||||
|
"href": "/prototypes/oneos-web-finance",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-procurement",
|
||||||
|
"title": "采购管理",
|
||||||
|
"href": "/prototypes/oneos-web-procurement",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-lease-contract",
|
||||||
|
"title": "车辆租赁合同",
|
||||||
|
"href": "/prototypes/oneos-web-lease-contract",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-h2-station",
|
||||||
|
"title": "加氢站管理",
|
||||||
|
"href": "/prototypes/oneos-web-h2-station",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-data-analysis",
|
||||||
|
"title": "数据分析",
|
||||||
|
"href": "/prototypes/oneos-web-data-analysis",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-ledger-data",
|
||||||
|
"title": "台账数据",
|
||||||
|
"href": "/prototypes/oneos-web-ledger-data",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-business",
|
||||||
|
"title": "业务管理",
|
||||||
|
"href": "/prototypes/oneos-web-business",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-ops",
|
||||||
|
"title": "运维管理",
|
||||||
|
"href": "/prototypes/oneos-web-ops",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-requirements",
|
||||||
|
"title": "需求说明",
|
||||||
|
"href": "/prototypes/oneos-web-requirements",
|
||||||
|
"target": "self"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "folder",
|
||||||
|
"id": "pr-doc-root",
|
||||||
|
"title": "收款记录说明",
|
||||||
|
"defaultExpanded": true,
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "markdown",
|
||||||
|
"id": "pr-doc-overview",
|
||||||
|
"title": "模块总览",
|
||||||
|
"markdown": "# 收款记录\n\n维护客户到账收款,支持关联租赁账单、自营账单、氢费账单。KPI 按收款记录统计:到账总金额 = 收款金额合计;认领总金额 = 已认领金额合计;未关联总金额 = 到账金额 − 已认领金额。"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
107
src/prototypes/payment-records/components/AddPaymentModal.tsx
Normal file
107
src/prototypes/payment-records/components/AddPaymentModal.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import React, { useEffect, useState } from 'react';
|
||||||
|
import { DatePicker, Input, InputNumber, Modal } from 'antd';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
export interface AddPaymentFormValues {
|
||||||
|
paymentTime: string;
|
||||||
|
customerName: string;
|
||||||
|
amount: number;
|
||||||
|
remark: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface AddPaymentModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: (values: AddPaymentFormValues) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_FORM: AddPaymentFormValues = {
|
||||||
|
paymentTime: '',
|
||||||
|
customerName: '',
|
||||||
|
amount: 0,
|
||||||
|
remark: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AddPaymentModal({ open, onClose, onConfirm }: AddPaymentModalProps) {
|
||||||
|
const [form, setForm] = useState<AddPaymentFormValues>(DEFAULT_FORM);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) setForm(DEFAULT_FORM);
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (!form.paymentTime || !form.customerName.trim() || !form.amount || form.amount <= 0) return;
|
||||||
|
onConfirm({
|
||||||
|
paymentTime: form.paymentTime,
|
||||||
|
customerName: form.customerName.trim(),
|
||||||
|
amount: Number(form.amount),
|
||||||
|
remark: form.remark.trim(),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const valid = Boolean(form.paymentTime && form.customerName.trim() && form.amount > 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="新增收款"
|
||||||
|
open={open}
|
||||||
|
onCancel={onClose}
|
||||||
|
onOk={handleConfirm}
|
||||||
|
okText="保存"
|
||||||
|
cancelText="取消"
|
||||||
|
okButtonProps={{ disabled: !valid }}
|
||||||
|
destroyOnClose
|
||||||
|
width={560}
|
||||||
|
data-annotation-id="pr-add-modal"
|
||||||
|
>
|
||||||
|
<div className="pr-form-grid">
|
||||||
|
<label className="vm-filter-field">
|
||||||
|
<span>收款时间 <em className="pr-required">*</em></span>
|
||||||
|
<DatePicker
|
||||||
|
showTime
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
placeholder="请选择到账时间"
|
||||||
|
value={form.paymentTime ? dayjs(form.paymentTime) : null}
|
||||||
|
onChange={(value) => setForm((prev) => ({
|
||||||
|
...prev,
|
||||||
|
paymentTime: value ? value.format('YYYY-MM-DD HH:mm:ss') : '',
|
||||||
|
}))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="vm-filter-field">
|
||||||
|
<span>客户名称 <em className="pr-required">*</em></span>
|
||||||
|
<Input
|
||||||
|
value={form.customerName}
|
||||||
|
placeholder="请输入客户名称"
|
||||||
|
aria-label="客户名称"
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, customerName: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="vm-filter-field">
|
||||||
|
<span>金额(元) <em className="pr-required">*</em></span>
|
||||||
|
<InputNumber
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
className="tabular-nums"
|
||||||
|
min={0}
|
||||||
|
step={0.01}
|
||||||
|
precision={2}
|
||||||
|
value={form.amount || undefined}
|
||||||
|
placeholder="请输入到账金额"
|
||||||
|
aria-label="金额"
|
||||||
|
onChange={(value) => setForm((prev) => ({ ...prev, amount: Number(value) || 0 }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="vm-filter-field pr-form-field--full">
|
||||||
|
<span>备注</span>
|
||||||
|
<Input.TextArea
|
||||||
|
rows={3}
|
||||||
|
value={form.remark}
|
||||||
|
placeholder="选填,如银行转账、到账说明等"
|
||||||
|
aria-label="备注"
|
||||||
|
onChange={(e) => setForm((prev) => ({ ...prev, remark: e.target.value }))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
226
src/prototypes/payment-records/components/BillLinkModal.tsx
Normal file
226
src/prototypes/payment-records/components/BillLinkModal.tsx
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { InputNumber, Modal, Table, Tabs } from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import type { BillCandidate, BillType, LinkedBill, PaymentRecord } from '../types';
|
||||||
|
import { BILL_TYPE_LABELS } from '../types';
|
||||||
|
import { calcUnclaimedAmount, filterBillsByTypeAndCustomer, formatMoney } from '../utils/payment';
|
||||||
|
|
||||||
|
interface BillLinkModalProps {
|
||||||
|
open: boolean;
|
||||||
|
record: PaymentRecord | null;
|
||||||
|
billPool: BillCandidate[];
|
||||||
|
onClose: () => void;
|
||||||
|
onConfirm: (recordId: string, linkedBills: LinkedBill[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
type BillTab = BillType | 'all';
|
||||||
|
|
||||||
|
export function BillLinkModal({ open, record, billPool, onClose, onConfirm }: BillLinkModalProps) {
|
||||||
|
const [activeTab, setActiveTab] = useState<BillTab>('all');
|
||||||
|
const [selected, setSelected] = useState<Set<string>>(new Set());
|
||||||
|
const [allocations, setAllocations] = useState<Record<string, number>>({});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !record) return;
|
||||||
|
const initialSelected = new Set<string>();
|
||||||
|
const initialAlloc: Record<string, number> = {};
|
||||||
|
record.linkedBills.forEach((bill) => {
|
||||||
|
const key = `${bill.type}:${bill.id}`;
|
||||||
|
initialSelected.add(key);
|
||||||
|
initialAlloc[key] = bill.allocatedAmount;
|
||||||
|
});
|
||||||
|
setSelected(initialSelected);
|
||||||
|
setAllocations(initialAlloc);
|
||||||
|
setActiveTab('all');
|
||||||
|
}, [open, record?.id]);
|
||||||
|
|
||||||
|
const filteredBills = useMemo(() => {
|
||||||
|
if (!record) return [];
|
||||||
|
return filterBillsByTypeAndCustomer(billPool, activeTab, record.customerName);
|
||||||
|
}, [record, billPool, activeTab]);
|
||||||
|
|
||||||
|
const linkedTotal = useMemo(
|
||||||
|
() => Object.values(allocations).reduce((sum, v) => sum + v, 0),
|
||||||
|
[allocations],
|
||||||
|
);
|
||||||
|
|
||||||
|
const unclaimedBefore = record ? calcUnclaimedAmount(record) : 0;
|
||||||
|
const remaining = record
|
||||||
|
? Math.max(0, Math.round((record.amount - linkedTotal) * 100) / 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
const toggleBill = (bill: BillCandidate, checked: boolean) => {
|
||||||
|
if (!record) return;
|
||||||
|
const key = `${bill.type}:${bill.id}`;
|
||||||
|
if (!checked) {
|
||||||
|
setSelected((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(key);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setAllocations((prev) => {
|
||||||
|
const copy = { ...prev };
|
||||||
|
delete copy[key];
|
||||||
|
return copy;
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setSelected((prev) => new Set(prev).add(key));
|
||||||
|
setAllocations((prev) => {
|
||||||
|
const used = Object.values(prev).reduce((sum, v) => sum + v, 0);
|
||||||
|
const room = Math.max(0, record.amount - used);
|
||||||
|
const alloc = Math.min(bill.unreceived, room);
|
||||||
|
return { ...prev, [key]: alloc };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateAllocation = (key: string, value: number, max: number) => {
|
||||||
|
setAllocations((prev) => ({ ...prev, [key]: Math.min(Math.max(0, value), max) }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
if (!record) return;
|
||||||
|
const linkedBills: LinkedBill[] = Array.from(selected).map((key) => {
|
||||||
|
const [type, id] = key.split(':') as [BillType, string];
|
||||||
|
const bill = billPool.find((b) => b.id === id && b.type === type);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
type,
|
||||||
|
label: bill?.label || id,
|
||||||
|
period: bill?.period,
|
||||||
|
customerName: bill?.customerName,
|
||||||
|
projectName: bill?.projectName,
|
||||||
|
contractCode: bill?.contractCode,
|
||||||
|
allocatedAmount: allocations[key] ?? 0,
|
||||||
|
};
|
||||||
|
}).filter((b) => b.allocatedAmount > 0);
|
||||||
|
onConfirm(record.id, linkedBills);
|
||||||
|
setSelected(new Set());
|
||||||
|
setAllocations({});
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnsType<BillCandidate>>(() => {
|
||||||
|
if (!record) return [];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
title: '账单类型',
|
||||||
|
key: 'type',
|
||||||
|
width: 108,
|
||||||
|
render: (_, bill) => BILL_TYPE_LABELS[bill.type],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '账单摘要',
|
||||||
|
dataIndex: 'label',
|
||||||
|
key: 'label',
|
||||||
|
width: 220,
|
||||||
|
ellipsis: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '账期',
|
||||||
|
dataIndex: 'period',
|
||||||
|
key: 'period',
|
||||||
|
width: 120,
|
||||||
|
className: 'tabular-nums',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '未收金额',
|
||||||
|
key: 'unreceived',
|
||||||
|
width: 120,
|
||||||
|
align: 'right',
|
||||||
|
className: 'tabular-nums',
|
||||||
|
render: (_, bill) => formatMoney(bill.unreceived),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '本次认领',
|
||||||
|
key: 'alloc',
|
||||||
|
width: 140,
|
||||||
|
align: 'right',
|
||||||
|
render: (_, bill) => {
|
||||||
|
const key = `${bill.type}:${bill.id}`;
|
||||||
|
const checked = selected.has(key);
|
||||||
|
const alloc = allocations[key] ?? 0;
|
||||||
|
const maxAlloc = Math.min(bill.unreceived, record.amount);
|
||||||
|
if (!checked) return '—';
|
||||||
|
return (
|
||||||
|
<InputNumber
|
||||||
|
size="small"
|
||||||
|
className="tabular-nums"
|
||||||
|
value={alloc || undefined}
|
||||||
|
min={0}
|
||||||
|
max={maxAlloc}
|
||||||
|
step={0.01}
|
||||||
|
precision={2}
|
||||||
|
aria-label={`${bill.label} 认领金额`}
|
||||||
|
onChange={(value) => updateAllocation(key, Number(value) || 0, maxAlloc)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}, [record, selected, allocations]);
|
||||||
|
|
||||||
|
const tabItems = [
|
||||||
|
{ key: 'all', label: '全部' },
|
||||||
|
{ key: 'lease', label: BILL_TYPE_LABELS.lease },
|
||||||
|
{ key: 'self-operated', label: BILL_TYPE_LABELS['self-operated'] },
|
||||||
|
{ key: 'clearing', label: BILL_TYPE_LABELS.clearing },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title="关联账单"
|
||||||
|
open={open}
|
||||||
|
onCancel={onClose}
|
||||||
|
onOk={handleConfirm}
|
||||||
|
okText="确认关联"
|
||||||
|
cancelText="取消"
|
||||||
|
okButtonProps={{ disabled: selected.size === 0 }}
|
||||||
|
width={920}
|
||||||
|
destroyOnClose
|
||||||
|
data-annotation-id="pr-bill-link-modal"
|
||||||
|
>
|
||||||
|
{record ? (
|
||||||
|
<>
|
||||||
|
<div className="pr-bill-summary">
|
||||||
|
<span>客户 <strong>{record.customerName}</strong></span>
|
||||||
|
<span>收款金额 <strong className="tabular-nums">{formatMoney(record.amount)}</strong></span>
|
||||||
|
<span>当前未认领 <strong className="tabular-nums">{formatMoney(unclaimedBefore)}</strong></span>
|
||||||
|
<span>本次认领合计 <strong className="tabular-nums">{formatMoney(linkedTotal)}</strong></span>
|
||||||
|
<span>
|
||||||
|
认领后剩余{' '}
|
||||||
|
<strong className={`tabular-nums ${remaining > 0 ? 'pr-unclaimed--pending' : ''}`}>
|
||||||
|
{formatMoney(remaining)}
|
||||||
|
</strong>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="pr-bill-hint">
|
||||||
|
支持单选或多选;可关联租赁账单、自营账单、氢费账单。仅展示与当前客户匹配且仍有未收金额的账单。
|
||||||
|
</p>
|
||||||
|
<Tabs
|
||||||
|
activeKey={activeTab}
|
||||||
|
onChange={(key) => setActiveTab(key as BillTab)}
|
||||||
|
items={tabItems}
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
/>
|
||||||
|
<Table<BillCandidate>
|
||||||
|
className="vm-list-table"
|
||||||
|
rowKey={(bill) => `${bill.type}:${bill.id}`}
|
||||||
|
columns={columns}
|
||||||
|
dataSource={filteredBills}
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
scroll={{ y: 320 }}
|
||||||
|
rowSelection={{
|
||||||
|
selectedRowKeys: Array.from(selected),
|
||||||
|
onSelect: (bill, checked) => toggleBill(bill, checked),
|
||||||
|
onSelectAll: (checked, _rows, changeRows) => {
|
||||||
|
changeRows.forEach((bill) => toggleBill(bill, checked));
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
locale={{ emptyText: '暂无匹配的账单' }}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
131
src/prototypes/payment-records/components/FilterPanel.tsx
Normal file
131
src/prototypes/payment-records/components/FilterPanel.tsx
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { DatePicker, Select } from 'antd';
|
||||||
|
import type { Dayjs } from 'dayjs';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import type { PaymentRecord, PaymentRecordFilters } from '../types';
|
||||||
|
import { buildCustomerOptions, buildOperatorOptions } from '../utils/payment';
|
||||||
|
|
||||||
|
const { RangePicker } = DatePicker;
|
||||||
|
|
||||||
|
const DATE_FORMAT = 'YYYY-MM-DD';
|
||||||
|
|
||||||
|
const UNCLAIMED_OPTIONS = [
|
||||||
|
{ value: '', label: '全部' },
|
||||||
|
{ value: 'yes', label: '有未关联' },
|
||||||
|
{ value: 'no', label: '已全部关联' },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface FilterPanelProps {
|
||||||
|
records: PaymentRecord[];
|
||||||
|
filters: PaymentRecordFilters;
|
||||||
|
onChange: (patch: Partial<PaymentRecordFilters>) => void;
|
||||||
|
onReset: () => void;
|
||||||
|
onSearch: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FilterPanel({ records, filters, onChange, onReset, onSearch }: FilterPanelProps) {
|
||||||
|
const customerOptions = useMemo(() => buildCustomerOptions(records), [records]);
|
||||||
|
const operatorOptions = useMemo(
|
||||||
|
() => buildOperatorOptions(records).map((name) => ({ value: name, label: name })),
|
||||||
|
[records],
|
||||||
|
);
|
||||||
|
|
||||||
|
const dateRangeValue = useMemo((): [Dayjs, Dayjs] | null => {
|
||||||
|
if (!filters.paymentFrom && !filters.paymentTo) return null;
|
||||||
|
const from = filters.paymentFrom ? dayjs(filters.paymentFrom) : null;
|
||||||
|
const to = filters.paymentTo ? dayjs(filters.paymentTo) : null;
|
||||||
|
if (from?.isValid() && to?.isValid()) return [from, to];
|
||||||
|
return null;
|
||||||
|
}, [filters.paymentFrom, filters.paymentTo]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="vm-filter-card" data-annotation-id="pr-filter" aria-label="筛选条件">
|
||||||
|
<header className="vm-filter-header">
|
||||||
|
<h2 className="vm-filter-title">筛选条件</h2>
|
||||||
|
</header>
|
||||||
|
<div className="vm-filter-grid">
|
||||||
|
<label className="vm-filter-field">
|
||||||
|
<span>客户名称</span>
|
||||||
|
<Select
|
||||||
|
placeholder="请选择或输入客户名称"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={filters.customerName || undefined}
|
||||||
|
onChange={(value) => onChange({ customerName: value || '' })}
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
options={customerOptions}
|
||||||
|
filterOption={(input, option) =>
|
||||||
|
(option?.label ?? '').toString().toLowerCase().includes(input.toLowerCase())}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="vm-filter-field">
|
||||||
|
<span>收款时间</span>
|
||||||
|
<RangePicker
|
||||||
|
className="lc-filter-select pr-payment-range"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
picker="date"
|
||||||
|
showTime={false}
|
||||||
|
format={DATE_FORMAT}
|
||||||
|
separator="至"
|
||||||
|
placeholder={['开始日期', '结束日期']}
|
||||||
|
allowClear
|
||||||
|
inputReadOnly
|
||||||
|
value={dateRangeValue}
|
||||||
|
onChange={(values) => {
|
||||||
|
onChange({
|
||||||
|
paymentFrom: values?.[0]?.format(DATE_FORMAT) ?? '',
|
||||||
|
paymentTo: values?.[1]?.format(DATE_FORMAT) ?? '',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="vm-filter-field">
|
||||||
|
<span>操作人</span>
|
||||||
|
<Select
|
||||||
|
placeholder="全部操作人"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={filters.operatorName || undefined}
|
||||||
|
onChange={(value) => onChange({ operatorName: value || '' })}
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
options={operatorOptions}
|
||||||
|
filterOption={(input, option) =>
|
||||||
|
(option?.label ?? '').toString().toLowerCase().includes(input.toLowerCase())}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="vm-filter-field">
|
||||||
|
<span>未关联</span>
|
||||||
|
<Select
|
||||||
|
placeholder="全部"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={filters.unclaimed || undefined}
|
||||||
|
onChange={(value) => onChange({ unclaimed: (value ?? '') as PaymentRecordFilters['unclaimed'] })}
|
||||||
|
allowClear
|
||||||
|
options={UNCLAIMED_OPTIONS.filter((o) => o.value !== '')}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="vm-filter-actions">
|
||||||
|
<button type="button" className="vm-btn vm-btn-ghost" onClick={onReset}>重置</button>
|
||||||
|
<button type="button" className="vm-btn vm-btn-primary" onClick={onSearch}>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<path d="m21 21-4.3-4.3" />
|
||||||
|
</svg>
|
||||||
|
查询
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import type { BillCandidate, BillType, EnrichedLinkedBill, LinkedBill } from '../types';
|
||||||
|
import { BILL_TYPE_LABELS } from '../types';
|
||||||
|
import { calcBillUnreceivedAmount, enrichLinkedBills, formatMoney } from '../utils/payment';
|
||||||
|
|
||||||
|
const BILL_TYPE_SHORT: Record<BillType, string> = {
|
||||||
|
lease: '租赁',
|
||||||
|
'self-operated': '自营',
|
||||||
|
clearing: '氢费',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface LinkedBillsNestedTableProps {
|
||||||
|
linkedBills: LinkedBill[];
|
||||||
|
billPool: BillCandidate[];
|
||||||
|
paymentCustomerName?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBillType(type: LinkedBill['type']) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={`pr-bill-type-tag pr-bill-type-tag--${type}`}
|
||||||
|
title={BILL_TYPE_LABELS[type]}
|
||||||
|
>
|
||||||
|
{BILL_TYPE_SHORT[type]}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBillInfo(row: EnrichedLinkedBill) {
|
||||||
|
const customerName = row.customerName || '—';
|
||||||
|
const contractCode = row.contractCode || '—';
|
||||||
|
const projectName = row.projectName || '—';
|
||||||
|
const title = [customerName, contractCode, projectName].join(' / ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="vm-stack pr-linked-bill-info" title={title}>
|
||||||
|
<span className="primary">{customerName}</span>
|
||||||
|
<span className="code tabular-nums">{contractCode}</span>
|
||||||
|
<span className="sub">{projectName}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function LinkedBillsNestedTable({
|
||||||
|
linkedBills,
|
||||||
|
billPool,
|
||||||
|
paymentCustomerName,
|
||||||
|
}: LinkedBillsNestedTableProps) {
|
||||||
|
const rows = useMemo(
|
||||||
|
() => enrichLinkedBills(linkedBills, billPool, paymentCustomerName),
|
||||||
|
[linkedBills, billPool, paymentCustomerName],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return <div className="lc-contract-vehicle-expand__empty">暂无关联账单</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="lc-contract-vehicle-table-wrap pr-linked-bill-table-wrap">
|
||||||
|
<table className="lc-contract-vehicle-native-table pr-linked-bill-native-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col" className="pr-linked-bill-native-table__type">类型</th>
|
||||||
|
<th scope="col" className="pr-linked-bill-native-table__info">项目信息</th>
|
||||||
|
<th scope="col" className="pr-linked-bill-native-table__period">账期</th>
|
||||||
|
<th scope="col" className="pr-linked-bill-native-table__money">应收</th>
|
||||||
|
<th scope="col" className="pr-linked-bill-native-table__money">认领</th>
|
||||||
|
<th scope="col" className="pr-linked-bill-native-table__money">未收</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((row: EnrichedLinkedBill) => {
|
||||||
|
const unreceived = calcBillUnreceivedAmount(row.receivable, row.allocatedAmount);
|
||||||
|
return (
|
||||||
|
<tr key={`${row.type}:${row.id}`}>
|
||||||
|
<td className="pr-linked-bill-native-table__type">{renderBillType(row.type)}</td>
|
||||||
|
<td className="pr-linked-bill-native-table__info">{renderBillInfo(row)}</td>
|
||||||
|
<td className="pr-linked-bill-native-table__period tabular-nums">{row.period || '—'}</td>
|
||||||
|
<td className="pr-linked-bill-native-table__money tabular-nums">
|
||||||
|
{row.receivable != null ? formatMoney(row.receivable) : '—'}
|
||||||
|
</td>
|
||||||
|
<td className="pr-linked-bill-native-table__money tabular-nums">
|
||||||
|
{formatMoney(row.allocatedAmount)}
|
||||||
|
</td>
|
||||||
|
<td className={`pr-linked-bill-native-table__money tabular-nums${unreceived != null && unreceived < 0 ? ' pr-bill-unreceived--negative' : ''}`}>
|
||||||
|
{unreceived != null ? formatMoney(unreceived) : '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { DatePicker, Select } from 'antd';
|
||||||
|
import type { Dayjs } from 'dayjs';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import type { PaymentKpiDetailFilters } from '../types';
|
||||||
|
import type { PaymentKpiKey } from '../utils/payment';
|
||||||
|
|
||||||
|
const { RangePicker } = DatePicker;
|
||||||
|
const DATE_FORMAT = 'YYYY-MM-DD';
|
||||||
|
|
||||||
|
interface PaymentKpiDetailFilterBarProps {
|
||||||
|
kpiKey: PaymentKpiKey;
|
||||||
|
filters: PaymentKpiDetailFilters;
|
||||||
|
customerOptions: Array<{ value: string; label: string }>;
|
||||||
|
onChange: (patch: Partial<PaymentKpiDetailFilters>) => void;
|
||||||
|
onReset: () => void;
|
||||||
|
onSearch: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PaymentKpiDetailFilterBar({
|
||||||
|
kpiKey,
|
||||||
|
filters,
|
||||||
|
customerOptions,
|
||||||
|
onChange,
|
||||||
|
onReset,
|
||||||
|
onSearch,
|
||||||
|
}: PaymentKpiDetailFilterBarProps) {
|
||||||
|
const dateRangeValue = useMemo((): [Dayjs, Dayjs] | null => {
|
||||||
|
if (!filters.paymentFrom && !filters.paymentTo) return null;
|
||||||
|
const from = filters.paymentFrom ? dayjs(filters.paymentFrom) : null;
|
||||||
|
const to = filters.paymentTo ? dayjs(filters.paymentTo) : null;
|
||||||
|
if (from?.isValid() && to?.isValid()) return [from, to];
|
||||||
|
return null;
|
||||||
|
}, [filters.paymentFrom, filters.paymentTo]);
|
||||||
|
|
||||||
|
const inlineLayout = kpiKey === 'linked' || kpiKey === 'unlinked';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
className={`pr-kpi-detail-filter${inlineLayout ? ' pr-kpi-detail-filter--inline' : ''}`}
|
||||||
|
aria-label="明细筛选"
|
||||||
|
>
|
||||||
|
<div className="pr-kpi-detail-filter__grid">
|
||||||
|
<label className="pr-kpi-detail-filter__field">
|
||||||
|
<span>客户名称</span>
|
||||||
|
<Select
|
||||||
|
placeholder="全部客户"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
value={filters.customerName || undefined}
|
||||||
|
onChange={(value) => onChange({ customerName: value || '' })}
|
||||||
|
allowClear
|
||||||
|
showSearch
|
||||||
|
options={customerOptions}
|
||||||
|
filterOption={(input, option) =>
|
||||||
|
(option?.label ?? '').toString().toLowerCase().includes(input.toLowerCase())}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label className="pr-kpi-detail-filter__field">
|
||||||
|
<span>收款时间</span>
|
||||||
|
<RangePicker
|
||||||
|
className="lc-filter-select pr-payment-range"
|
||||||
|
style={{ width: '100%' }}
|
||||||
|
picker="date"
|
||||||
|
showTime={false}
|
||||||
|
format={DATE_FORMAT}
|
||||||
|
separator="至"
|
||||||
|
placeholder={['开始日期', '结束日期']}
|
||||||
|
allowClear
|
||||||
|
inputReadOnly
|
||||||
|
value={dateRangeValue}
|
||||||
|
onChange={(values) => {
|
||||||
|
onChange({
|
||||||
|
paymentFrom: values?.[0]?.format(DATE_FORMAT) ?? '',
|
||||||
|
paymentTo: values?.[1]?.format(DATE_FORMAT) ?? '',
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="pr-kpi-detail-filter__actions">
|
||||||
|
<button type="button" className="vm-btn vm-btn-ghost vm-btn-sm" onClick={onReset}>重置</button>
|
||||||
|
<button type="button" className="vm-btn vm-btn-primary vm-btn-sm" onClick={onSearch}>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={14}
|
||||||
|
height={14}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
<path d="m21 21-4.3-4.3" />
|
||||||
|
</svg>
|
||||||
|
查询
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,343 @@
|
|||||||
|
import React, { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { Modal, Table, message } from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import {
|
||||||
|
LN_MODAL_DETAIL_TABLE_CLASS,
|
||||||
|
sumColumnScrollX,
|
||||||
|
warnMissingModalColumnWidths,
|
||||||
|
} from '../../../common/modalDetailTable';
|
||||||
|
import type { PaymentRecord } from '../types';
|
||||||
|
import { EMPTY_PAYMENT_KPI_DETAIL_FILTERS } from '../types';
|
||||||
|
import {
|
||||||
|
buildDetailCustomerOptions,
|
||||||
|
buildLinkedDetailRows,
|
||||||
|
buildReceivedDetailRows,
|
||||||
|
buildUnlinkedDetailRows,
|
||||||
|
exportKpiDetailCsv,
|
||||||
|
filterLinkedDetailRows,
|
||||||
|
filterReceivedDetailRows,
|
||||||
|
filterUnlinkedDetailRows,
|
||||||
|
formatMoney,
|
||||||
|
sumLinkedDetailAmount,
|
||||||
|
sumReceivedDetailAmount,
|
||||||
|
sumUnlinkedDetailAmount,
|
||||||
|
type PaymentKpiKey,
|
||||||
|
type PaymentLinkedDetailRow,
|
||||||
|
type PaymentReceivedDetailRow,
|
||||||
|
type PaymentUnlinkedDetailRow,
|
||||||
|
} from '../utils/payment';
|
||||||
|
import { PaymentKpiDetailFilterBar } from './PaymentKpiDetailFilterBar';
|
||||||
|
|
||||||
|
interface KpiModalMeta {
|
||||||
|
title: string;
|
||||||
|
amountLabel: string;
|
||||||
|
width: number;
|
||||||
|
subtitle: string;
|
||||||
|
tone: 'received' | 'linked' | 'unlinked';
|
||||||
|
}
|
||||||
|
|
||||||
|
const KPI_MODAL_META: Record<PaymentKpiKey, KpiModalMeta> = {
|
||||||
|
received: {
|
||||||
|
title: '到账总金额明细',
|
||||||
|
amountLabel: '到账金额合计',
|
||||||
|
width: 800,
|
||||||
|
subtitle: '当前筛选范围内全部收款记录的到账明细',
|
||||||
|
tone: 'received',
|
||||||
|
},
|
||||||
|
linked: {
|
||||||
|
title: '认领总金额明细',
|
||||||
|
amountLabel: '认领金额合计',
|
||||||
|
width: 860,
|
||||||
|
subtitle: '按收款记录汇总已认领金额(到账金额中已关联账单的部分)',
|
||||||
|
tone: 'linked',
|
||||||
|
},
|
||||||
|
unlinked: {
|
||||||
|
title: '未关联总金额明细',
|
||||||
|
amountLabel: '未关联金额合计',
|
||||||
|
width: 900,
|
||||||
|
subtitle: '到账金额减去已认领金额后,尚未关联的部分',
|
||||||
|
tone: 'unlinked',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
function KpiModalIcon({ tone }: { tone: KpiModalMeta['tone'] }) {
|
||||||
|
if (tone === 'linked') {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width={22} height={22} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||||
|
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||||
|
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (tone === 'unlinked') {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width={22} height={22} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||||
|
<circle cx="12" cy="12" r="10" />
|
||||||
|
<path d="M12 8v4" />
|
||||||
|
<path d="M12 16h.01" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width={22} height={22} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||||
|
<path d="M12 2v20" />
|
||||||
|
<path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ExportIcon() {
|
||||||
|
return (
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width={14} height={14} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" aria-hidden>
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="7 10 12 15 17 10" />
|
||||||
|
<line x1="12" x2="12" y1="15" y2="3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const RECEIVED_COLUMNS: ColumnsType<PaymentReceivedDetailRow> = [
|
||||||
|
{ title: '收款时间', dataIndex: 'paymentTime', key: 'paymentTime', width: 156, fixed: 'left', className: 'tabular-nums ln-modal-detail-table__sticky-time' },
|
||||||
|
{ title: '客户名称', dataIndex: 'customerName', key: 'customerName', width: 220, ellipsis: true },
|
||||||
|
{ title: '到账金额', dataIndex: 'amount', key: 'amount', width: 112, align: 'right', className: 'tabular-nums ln-modal-detail-table__money', render: formatMoney },
|
||||||
|
{ title: '备注', dataIndex: 'remark', key: 'remark', width: 172, ellipsis: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const LINKED_COLUMNS: ColumnsType<PaymentLinkedDetailRow> = [
|
||||||
|
{ title: '收款时间', dataIndex: 'paymentTime', key: 'paymentTime', width: 156, fixed: 'left', className: 'tabular-nums ln-modal-detail-table__sticky-time' },
|
||||||
|
{ title: '客户名称', dataIndex: 'customerName', key: 'customerName', width: 200, ellipsis: true },
|
||||||
|
{ title: '到账金额', dataIndex: 'amount', key: 'amount', width: 112, align: 'right', className: 'tabular-nums ln-modal-detail-table__money', render: formatMoney },
|
||||||
|
{ title: '认领金额', dataIndex: 'linkedAmount', key: 'linkedAmount', width: 112, align: 'right', className: 'tabular-nums ln-modal-detail-table__money', render: formatMoney },
|
||||||
|
{ title: '备注', dataIndex: 'remark', key: 'remark', width: 160, ellipsis: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
const UNLINKED_COLUMNS: ColumnsType<PaymentUnlinkedDetailRow> = [
|
||||||
|
{ title: '收款时间', dataIndex: 'paymentTime', key: 'paymentTime', width: 148, fixed: 'left', className: 'tabular-nums ln-modal-detail-table__sticky-time' },
|
||||||
|
{ title: '客户名称', dataIndex: 'customerName', key: 'customerName', width: 168, ellipsis: true },
|
||||||
|
{ title: '到账金额', dataIndex: 'amount', key: 'amount', width: 100, align: 'right', className: 'tabular-nums ln-modal-detail-table__money', render: formatMoney },
|
||||||
|
{ title: '已认领', dataIndex: 'linkedAmount', key: 'linkedAmount', width: 100, align: 'right', className: 'tabular-nums ln-modal-detail-table__money', render: formatMoney },
|
||||||
|
{ title: '未关联金额', dataIndex: 'unlinkedAmount', key: 'unlinkedAmount', width: 112, align: 'right', className: 'tabular-nums ln-modal-detail-table__money', render: (v: number) => (
|
||||||
|
<span className={v > 0 ? 'pr-unlinked--pending' : undefined}>{formatMoney(v)}</span>
|
||||||
|
) },
|
||||||
|
{ title: '备注', dataIndex: 'remark', key: 'remark', width: 140, ellipsis: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
warnMissingModalColumnWidths(RECEIVED_COLUMNS, 'PaymentKpiDetailModal.received');
|
||||||
|
warnMissingModalColumnWidths(LINKED_COLUMNS, 'PaymentKpiDetailModal.linked');
|
||||||
|
warnMissingModalColumnWidths(UNLINKED_COLUMNS, 'PaymentKpiDetailModal.unlinked');
|
||||||
|
|
||||||
|
interface PaymentKpiDetailModalProps {
|
||||||
|
open: boolean;
|
||||||
|
kpiKey: PaymentKpiKey | null;
|
||||||
|
records: PaymentRecord[];
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ModalTitle({ meta }: { meta: KpiModalMeta }) {
|
||||||
|
return (
|
||||||
|
<div className="pr-kpi-detail-modal__title">
|
||||||
|
<span className={`pr-kpi-detail-modal__icon pr-kpi-detail-modal__icon--${meta.tone}`} aria-hidden>
|
||||||
|
<KpiModalIcon tone={meta.tone} />
|
||||||
|
</span>
|
||||||
|
<span className="pr-kpi-detail-modal__title-text">
|
||||||
|
<span className="pr-kpi-detail-modal__title-main">{meta.title}</span>
|
||||||
|
<span className="pr-kpi-detail-modal__title-sub">{meta.subtitle}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EmptyDetail({ text }: { text: string }) {
|
||||||
|
return (
|
||||||
|
<div className="ln-modal-detail-empty" role="status">
|
||||||
|
<p>{text}</p>
|
||||||
|
<p className="ln-modal-detail-empty__hint">可调整筛选条件后重新查询</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PaymentKpiDetailModal({
|
||||||
|
open,
|
||||||
|
kpiKey,
|
||||||
|
records,
|
||||||
|
onClose,
|
||||||
|
}: PaymentKpiDetailModalProps) {
|
||||||
|
const meta = kpiKey ? KPI_MODAL_META[kpiKey] : null;
|
||||||
|
const [pendingFilters, setPendingFilters] = useState(EMPTY_PAYMENT_KPI_DETAIL_FILTERS);
|
||||||
|
const [appliedFilters, setAppliedFilters] = useState(EMPTY_PAYMENT_KPI_DETAIL_FILTERS);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setPendingFilters(EMPTY_PAYMENT_KPI_DETAIL_FILTERS);
|
||||||
|
setAppliedFilters(EMPTY_PAYMENT_KPI_DETAIL_FILTERS);
|
||||||
|
}
|
||||||
|
}, [open, kpiKey]);
|
||||||
|
|
||||||
|
const allReceivedRows = useMemo(
|
||||||
|
() => (kpiKey === 'received' ? buildReceivedDetailRows(records) : []),
|
||||||
|
[kpiKey, records],
|
||||||
|
);
|
||||||
|
const allLinkedRows = useMemo(
|
||||||
|
() => (kpiKey === 'linked' ? buildLinkedDetailRows(records) : []),
|
||||||
|
[kpiKey, records],
|
||||||
|
);
|
||||||
|
const allUnlinkedRows = useMemo(
|
||||||
|
() => (kpiKey === 'unlinked' ? buildUnlinkedDetailRows(records) : []),
|
||||||
|
[kpiKey, records],
|
||||||
|
);
|
||||||
|
|
||||||
|
const receivedRows = useMemo(
|
||||||
|
() => filterReceivedDetailRows(allReceivedRows, appliedFilters),
|
||||||
|
[allReceivedRows, appliedFilters],
|
||||||
|
);
|
||||||
|
const linkedRows = useMemo(
|
||||||
|
() => filterLinkedDetailRows(allLinkedRows, appliedFilters),
|
||||||
|
[allLinkedRows, appliedFilters],
|
||||||
|
);
|
||||||
|
const unlinkedRows = useMemo(
|
||||||
|
() => filterUnlinkedDetailRows(allUnlinkedRows, appliedFilters),
|
||||||
|
[allUnlinkedRows, appliedFilters],
|
||||||
|
);
|
||||||
|
|
||||||
|
const customerOptions = useMemo(() => {
|
||||||
|
if (kpiKey === 'received') {
|
||||||
|
return buildDetailCustomerOptions(allReceivedRows.map((row) => row.customerName));
|
||||||
|
}
|
||||||
|
if (kpiKey === 'linked') {
|
||||||
|
return buildDetailCustomerOptions(allLinkedRows.map((row) => row.customerName));
|
||||||
|
}
|
||||||
|
if (kpiKey === 'unlinked') {
|
||||||
|
return buildDetailCustomerOptions(allUnlinkedRows.map((row) => row.customerName));
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}, [kpiKey, allReceivedRows, allLinkedRows, allUnlinkedRows]);
|
||||||
|
|
||||||
|
const rowCount = kpiKey === 'received'
|
||||||
|
? receivedRows.length
|
||||||
|
: kpiKey === 'linked'
|
||||||
|
? linkedRows.length
|
||||||
|
: unlinkedRows.length;
|
||||||
|
|
||||||
|
const filteredTotalAmount = kpiKey === 'received'
|
||||||
|
? sumReceivedDetailAmount(receivedRows)
|
||||||
|
: kpiKey === 'linked'
|
||||||
|
? sumLinkedDetailAmount(linkedRows)
|
||||||
|
: sumUnlinkedDetailAmount(unlinkedRows);
|
||||||
|
|
||||||
|
const tableProps = {
|
||||||
|
className: LN_MODAL_DETAIL_TABLE_CLASS,
|
||||||
|
rowKey: 'key' as const,
|
||||||
|
size: 'small' as const,
|
||||||
|
tableLayout: 'fixed' as const,
|
||||||
|
pagination: {
|
||||||
|
pageSize: 8,
|
||||||
|
showSizeChanger: false,
|
||||||
|
hideOnSinglePage: true,
|
||||||
|
showTotal: (total: number) => `共 ${total} 条`,
|
||||||
|
},
|
||||||
|
scroll: { y: 320 },
|
||||||
|
};
|
||||||
|
|
||||||
|
const amountToneClass = meta?.tone === 'linked'
|
||||||
|
? 'pr-kpi-detail-modal__stat-val--linked'
|
||||||
|
: meta?.tone === 'unlinked'
|
||||||
|
? 'pr-kpi-detail-modal__stat-val--warn'
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
if (!kpiKey) return;
|
||||||
|
const count = exportKpiDetailCsv(kpiKey, receivedRows, linkedRows, unlinkedRows);
|
||||||
|
message.success(`已导出 ${count} 条明细(原型演示)`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
className={`pr-kpi-detail-modal pr-kpi-detail-modal--${meta?.tone ?? 'received'}`}
|
||||||
|
title={meta ? <ModalTitle meta={meta} /> : null}
|
||||||
|
open={open}
|
||||||
|
onCancel={onClose}
|
||||||
|
centered
|
||||||
|
width={meta?.width ?? 800}
|
||||||
|
destroyOnClose
|
||||||
|
maskClosable
|
||||||
|
footer={null}
|
||||||
|
>
|
||||||
|
<div className="pr-kpi-detail-modal__body">
|
||||||
|
{kpiKey && (
|
||||||
|
<PaymentKpiDetailFilterBar
|
||||||
|
kpiKey={kpiKey}
|
||||||
|
filters={pendingFilters}
|
||||||
|
customerOptions={customerOptions}
|
||||||
|
onChange={(patch) => setPendingFilters((prev) => ({ ...prev, ...patch }))}
|
||||||
|
onReset={() => {
|
||||||
|
setPendingFilters(EMPTY_PAYMENT_KPI_DETAIL_FILTERS);
|
||||||
|
setAppliedFilters(EMPTY_PAYMENT_KPI_DETAIL_FILTERS);
|
||||||
|
}}
|
||||||
|
onSearch={() => setAppliedFilters(pendingFilters)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="pr-kpi-detail-modal__stats" role="group" aria-label="汇总统计">
|
||||||
|
<div className={`pr-kpi-detail-modal__stat-card pr-kpi-detail-modal__stat-card--primary pr-kpi-detail-modal__stat-card--${meta?.tone ?? 'received'}`}>
|
||||||
|
<span className="pr-kpi-detail-modal__stat-label">{meta?.amountLabel}</span>
|
||||||
|
<strong className={`pr-kpi-detail-modal__stat-val tabular-nums ${amountToneClass}`}>
|
||||||
|
{formatMoney(filteredTotalAmount)}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="pr-kpi-detail-modal__stat-card">
|
||||||
|
<span className="pr-kpi-detail-modal__stat-label">明细条数</span>
|
||||||
|
<strong className="pr-kpi-detail-modal__stat-val tabular-nums">{rowCount}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="ln-modal-detail-panel" aria-label="明细列表">
|
||||||
|
<header className="ln-modal-detail-panel__head pr-kpi-detail-panel__head">
|
||||||
|
<span className="ln-modal-detail-panel__title">明细列表</span>
|
||||||
|
<div className="pr-kpi-detail-panel__head-actions">
|
||||||
|
<span className="ln-modal-detail-panel__meta tabular-nums">{rowCount} 条记录</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="vm-btn vm-btn-ghost vm-btn-sm pr-kpi-detail-panel__export"
|
||||||
|
onClick={handleExport}
|
||||||
|
disabled={rowCount === 0}
|
||||||
|
>
|
||||||
|
<ExportIcon />
|
||||||
|
导出
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{kpiKey === 'received' && (
|
||||||
|
<Table<PaymentReceivedDetailRow>
|
||||||
|
{...tableProps}
|
||||||
|
columns={RECEIVED_COLUMNS}
|
||||||
|
dataSource={receivedRows}
|
||||||
|
scroll={{ ...tableProps.scroll, x: sumColumnScrollX(RECEIVED_COLUMNS) }}
|
||||||
|
locale={{ emptyText: <EmptyDetail text="当前筛选条件下暂无收款记录" /> }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{kpiKey === 'linked' && (
|
||||||
|
<Table<PaymentLinkedDetailRow>
|
||||||
|
{...tableProps}
|
||||||
|
columns={LINKED_COLUMNS}
|
||||||
|
dataSource={linkedRows}
|
||||||
|
scroll={{ ...tableProps.scroll, x: sumColumnScrollX(LINKED_COLUMNS) }}
|
||||||
|
locale={{ emptyText: <EmptyDetail text="当前筛选条件下暂无已认领收款记录" /> }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{kpiKey === 'unlinked' && (
|
||||||
|
<Table<PaymentUnlinkedDetailRow>
|
||||||
|
{...tableProps}
|
||||||
|
columns={UNLINKED_COLUMNS}
|
||||||
|
dataSource={unlinkedRows}
|
||||||
|
scroll={{ ...tableProps.scroll, x: sumColumnScrollX(UNLINKED_COLUMNS) }}
|
||||||
|
locale={{ emptyText: <EmptyDetail text="当前筛选条件下暂无未关联收款记录" /> }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { Button, Table } from 'antd';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import type { BillCandidate, PaymentRecord } from '../types';
|
||||||
|
import { calcUnclaimedAmount, formatMoney } from '../utils/payment';
|
||||||
|
import { LinkedBillsNestedTable } from './LinkedBillsNestedTable';
|
||||||
|
|
||||||
|
const ONEOS_ANT_TABLE_GLOBAL_FIX = [
|
||||||
|
'.ant-table-container .ant-table-header { margin-bottom: 0 !important; }',
|
||||||
|
'.ant-table-container .ant-table-body { margin-top: 0 !important; }',
|
||||||
|
'.ant-table-container .ant-table-body > table, .ant-table-content table { margin-top: 0 !important; }',
|
||||||
|
'.ant-table-tbody > tr.ant-table-measure-row, .ant-table-tbody > tr.ant-table-measure-row > td, .ant-table-tbody > tr.ant-table-measure-row > th { display: none !important; height: 0 !important; max-height: 0 !important; min-height: 0 !important; padding: 0 !important; margin: 0 !important; border: none !important; line-height: 0 !important; font-size: 0 !important; overflow: hidden !important; visibility: hidden !important; pointer-events: none !important; }',
|
||||||
|
'.vm-page.lc-page.pr-page .vm-list-table .ant-table-tbody > tr.ant-table-expanded-row > td { padding: 8px 120px 12px 8px !important; overflow: visible !important; }',
|
||||||
|
'.vm-list-table .ant-table-thead th,.vm-list-table .ant-table-tbody td{white-space:nowrap;}',
|
||||||
|
].join('\n');
|
||||||
|
|
||||||
|
interface PaymentRecordsTableProps {
|
||||||
|
records: PaymentRecord[];
|
||||||
|
billPool: BillCandidate[];
|
||||||
|
loading?: boolean;
|
||||||
|
onLinkBills: (record: PaymentRecord) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function PaymentRecordsTable({
|
||||||
|
records,
|
||||||
|
billPool,
|
||||||
|
loading = false,
|
||||||
|
onLinkBills,
|
||||||
|
}: PaymentRecordsTableProps) {
|
||||||
|
const [expandedRowKeys, setExpandedRowKeys] = useState<React.Key[]>([]);
|
||||||
|
|
||||||
|
const toggleRowExpand = useCallback((recordId: string) => {
|
||||||
|
setExpandedRowKeys((prev) => (
|
||||||
|
prev.includes(recordId)
|
||||||
|
? prev.filter((key) => key !== recordId)
|
||||||
|
: [...prev, recordId]
|
||||||
|
));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const columns = useMemo<ColumnsType<PaymentRecord>>(() => [
|
||||||
|
{
|
||||||
|
title: '收款时间',
|
||||||
|
dataIndex: 'paymentTime',
|
||||||
|
key: 'paymentTime',
|
||||||
|
width: 168,
|
||||||
|
className: 'tabular-nums',
|
||||||
|
render: (value: string) => value || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '客户名称',
|
||||||
|
dataIndex: 'customerName',
|
||||||
|
key: 'customerName',
|
||||||
|
width: 200,
|
||||||
|
ellipsis: true,
|
||||||
|
render: (value: string) => value || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '金额',
|
||||||
|
dataIndex: 'amount',
|
||||||
|
key: 'amount',
|
||||||
|
width: 120,
|
||||||
|
align: 'right',
|
||||||
|
className: 'tabular-nums',
|
||||||
|
render: (value: number) => formatMoney(value),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '备注',
|
||||||
|
dataIndex: 'remark',
|
||||||
|
key: 'remark',
|
||||||
|
width: 160,
|
||||||
|
ellipsis: true,
|
||||||
|
className: 'pr-col-remark',
|
||||||
|
render: (value: string) => value || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '关联账单',
|
||||||
|
key: 'linkedBills',
|
||||||
|
width: 96,
|
||||||
|
className: 'pr-linked-col',
|
||||||
|
render: (_, row) => {
|
||||||
|
const count = row.linkedBills?.length ?? 0;
|
||||||
|
if (count === 0) {
|
||||||
|
return <span className="pr-linked-empty">未关联</span>;
|
||||||
|
}
|
||||||
|
const expanded = expandedRowKeys.includes(row.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="pr-linked-count-btn"
|
||||||
|
aria-expanded={expanded}
|
||||||
|
onClick={() => toggleRowExpand(row.id)}
|
||||||
|
>
|
||||||
|
{count} 条
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '未关联金额',
|
||||||
|
key: 'unlinked',
|
||||||
|
width: 120,
|
||||||
|
align: 'right',
|
||||||
|
className: 'tabular-nums',
|
||||||
|
render: (_, row) => {
|
||||||
|
const unlinked = calcUnclaimedAmount(row);
|
||||||
|
return (
|
||||||
|
<span className={unlinked > 0 ? 'pr-unlinked--pending' : undefined}>
|
||||||
|
{formatMoney(unlinked)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作人',
|
||||||
|
dataIndex: 'operatorName',
|
||||||
|
key: 'operatorName',
|
||||||
|
width: 100,
|
||||||
|
render: (value: string) => value || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作时间',
|
||||||
|
dataIndex: 'operatedAt',
|
||||||
|
key: 'operatedAt',
|
||||||
|
width: 168,
|
||||||
|
className: 'tabular-nums',
|
||||||
|
render: (value: string) => value || '—',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 108,
|
||||||
|
fixed: 'right',
|
||||||
|
render: (_, row) => (
|
||||||
|
<Button type="link" size="small" onClick={() => onLinkBills(row)}>
|
||||||
|
关联账单
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
], [billPool, expandedRowKeys, onLinkBills, toggleRowExpand]);
|
||||||
|
|
||||||
|
const scrollX = columns.reduce((sum, col) => sum + (Number(col.width) || 0), 0) + 48;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<style>{ONEOS_ANT_TABLE_GLOBAL_FIX}</style>
|
||||||
|
<div className="vm-table-wrap" data-annotation-id="pr-table">
|
||||||
|
<Table<PaymentRecord>
|
||||||
|
className="vm-list-table pr-list-table lc-list-table--nested"
|
||||||
|
rowKey="id"
|
||||||
|
columns={columns}
|
||||||
|
dataSource={records}
|
||||||
|
loading={loading}
|
||||||
|
tableLayout="fixed"
|
||||||
|
scroll={{ x: scrollX, y: 'calc(100vh - 560px)' }}
|
||||||
|
size="small"
|
||||||
|
pagination={false}
|
||||||
|
expandable={{
|
||||||
|
columnWidth: 48,
|
||||||
|
expandedRowKeys,
|
||||||
|
onExpandedRowsChange: (keys) => setExpandedRowKeys(keys),
|
||||||
|
expandedRowRender: (record) => (
|
||||||
|
<LinkedBillsNestedTable
|
||||||
|
linkedBills={record.linkedBills}
|
||||||
|
billPool={billPool}
|
||||||
|
paymentCustomerName={record.customerName}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
rowExpandable: (record) => (record.linkedBills?.length ?? 0) > 0,
|
||||||
|
}}
|
||||||
|
locale={{
|
||||||
|
emptyText: (
|
||||||
|
<div className="vm-table-empty" role="status">
|
||||||
|
<p>暂无收款记录</p>
|
||||||
|
<p className="vm-table-empty__hint">可点击「新增收款」录入,或通过批量导入上传 Excel。</p>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
17
src/prototypes/payment-records/data/bills.json
Normal file
17
src/prototypes/payment-records/data/bills.json
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
{
|
||||||
|
"lease": [
|
||||||
|
{ "id": "lb-1", "type": "lease", "customerName": "安徽驰远供应链管理有限公司", "projectName": "嘉兴氢能示范项目", "contractCode": "HT-ZL-2026-001", "label": "沪A32896F · 2026年1月", "period": "2026-01", "receivable": 4500, "unreceived": 4500 },
|
||||||
|
{ "id": "lb-2", "type": "lease", "customerName": "嘉兴港区韵达快递有限公司", "projectName": "嘉兴氢能示范项目", "contractCode": "HT-ZL-2026-002", "label": "粤AGF4535 · 2026年2月", "period": "2026-02", "receivable": 3500, "unreceived": 3500 },
|
||||||
|
{ "id": "lb-3", "type": "lease", "customerName": "上海朔宇物流有限公司", "projectName": "上海物流租赁项目", "contractCode": "HT-ZL-2025-003", "label": "浙F05580F · 2025年12月", "period": "2025-12", "receivable": 2500, "unreceived": 2500 },
|
||||||
|
{ "id": "lb-4", "type": "lease", "customerName": "上海利合供应链管理有限公司", "projectName": "上海物流租赁项目", "contractCode": "HT-ZL-2025-004", "label": "浙F08991F · 2025年12月", "period": "2025-12", "receivable": 4000, "unreceived": 0 }
|
||||||
|
],
|
||||||
|
"self-operated": [
|
||||||
|
{ "id": "sb-1", "type": "self-operated", "customerName": "安徽驰远供应链管理有限公司", "projectName": "嘉兴城配自营项目", "contractCode": "ZY-202601-001", "label": "自营出车 · 2026年1月", "period": "2026-01", "receivable": 2800, "unreceived": 2800 },
|
||||||
|
{ "id": "sb-2", "type": "self-operated", "customerName": "嘉兴港区韵达快递有限公司", "projectName": "嘉兴城配自营项目", "contractCode": "ZY-202602-001", "label": "自营出车 · 2026年2月", "period": "2026-02", "receivable": 1600, "unreceived": 1600 }
|
||||||
|
],
|
||||||
|
"clearing": [
|
||||||
|
{ "id": "cb-1", "type": "clearing", "customerName": "安徽驰远供应链管理有限公司", "projectName": "嘉兴氢能示范项目", "contractCode": "CF-202601-008", "label": "氢费单 CF-202601-008", "period": "2026-01", "receivable": 1200, "unreceived": 1200 },
|
||||||
|
{ "id": "cb-2", "type": "clearing", "customerName": "上海朔宇物流有限公司", "projectName": "上海物流租赁项目", "contractCode": "CF-202512-021", "label": "氢费单 CF-202512-021", "period": "2025-12", "receivable": 800, "unreceived": 800 },
|
||||||
|
{ "id": "cb-3", "type": "clearing", "customerName": "苏州望亭远方物流有限公司", "projectName": "苏州望亭城配项目", "contractCode": "CF-202601-015", "label": "氢费单 CF-202601-015", "period": "2026-01", "receivable": 650, "unreceived": 650 }
|
||||||
|
]
|
||||||
|
}
|
||||||
73
src/prototypes/payment-records/data/records.json
Normal file
73
src/prototypes/payment-records/data/records.json
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"id": "pr1",
|
||||||
|
"paymentTime": "2026-01-19 14:30",
|
||||||
|
"customerName": "安徽驰远供应链管理有限公司",
|
||||||
|
"amount": 5700,
|
||||||
|
"remark": "银行转账",
|
||||||
|
"linkedBills": [
|
||||||
|
{ "id": "lb-1", "type": "lease", "label": "沪A32896F · 2026年1月", "period": "2026-01", "allocatedAmount": 4500 },
|
||||||
|
{ "id": "sb-1", "type": "self-operated", "label": "自营出车 · 2026年1月", "period": "2026-01", "allocatedAmount": 800 },
|
||||||
|
{ "id": "cb-1", "type": "clearing", "label": "氢费单 CF-202601-008", "period": "2026-01", "allocatedAmount": 400 }
|
||||||
|
],
|
||||||
|
"operatorName": "张专员",
|
||||||
|
"operatedAt": "2026-01-19 15:02"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pr2",
|
||||||
|
"paymentTime": "2026-02-02 10:00",
|
||||||
|
"customerName": "嘉兴港区韵达快递有限公司",
|
||||||
|
"amount": 3500,
|
||||||
|
"remark": "分两笔到账",
|
||||||
|
"linkedBills": [
|
||||||
|
{ "id": "lb-2", "type": "lease", "label": "粤AGF4535 · 2026年2月", "allocatedAmount": 2000 }
|
||||||
|
],
|
||||||
|
"operatorName": "李主管",
|
||||||
|
"operatedAt": "2026-02-02 10:18"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pr3",
|
||||||
|
"paymentTime": "2026-02-02 16:20",
|
||||||
|
"customerName": "嘉兴港区韵达快递有限公司",
|
||||||
|
"amount": 1500,
|
||||||
|
"remark": "第二笔到账",
|
||||||
|
"linkedBills": [],
|
||||||
|
"operatorName": "李主管",
|
||||||
|
"operatedAt": "2026-02-02 16:25"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pr4",
|
||||||
|
"paymentTime": "2025-12-25 09:15",
|
||||||
|
"customerName": "上海朔宇物流有限公司",
|
||||||
|
"amount": 2500,
|
||||||
|
"remark": "部分认领",
|
||||||
|
"linkedBills": [
|
||||||
|
{ "id": "lb-3", "type": "lease", "label": "浙F05580F · 2025年12月", "period": "2025-12", "allocatedAmount": 1200 },
|
||||||
|
{ "id": "cb-2", "type": "clearing", "label": "氢费单 CF-202512-021", "period": "2025-12", "allocatedAmount": 300 }
|
||||||
|
],
|
||||||
|
"operatorName": "王专员",
|
||||||
|
"operatedAt": "2025-12-25 09:40"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pr5",
|
||||||
|
"paymentTime": "2025-12-26 15:40",
|
||||||
|
"customerName": "上海利合供应链管理有限公司",
|
||||||
|
"amount": 4000,
|
||||||
|
"remark": "全额到账",
|
||||||
|
"linkedBills": [
|
||||||
|
{ "id": "lb-4", "type": "lease", "label": "浙F08991F · 2025年12月", "allocatedAmount": 4000 }
|
||||||
|
],
|
||||||
|
"operatorName": "张专员",
|
||||||
|
"operatedAt": "2025-12-26 16:00"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "pr6",
|
||||||
|
"paymentTime": "2026-01-08 11:20",
|
||||||
|
"customerName": "苏州望亭远方物流有限公司",
|
||||||
|
"amount": 1800,
|
||||||
|
"remark": "待关联氢费账单",
|
||||||
|
"linkedBills": [],
|
||||||
|
"operatorName": "赵专员",
|
||||||
|
"operatedAt": "2026-01-08 11:22"
|
||||||
|
}
|
||||||
|
]
|
||||||
344
src/prototypes/payment-records/index.tsx
Normal file
344
src/prototypes/payment-records/index.tsx
Normal file
@@ -0,0 +1,344 @@
|
|||||||
|
/**
|
||||||
|
* @name 收款记录
|
||||||
|
* 设计规范:oneos-app.css · vm-page lc-page(对齐租赁合同管理)
|
||||||
|
*/
|
||||||
|
import 'antd/dist/reset.css';
|
||||||
|
import '../vehicle-management/style.css';
|
||||||
|
import './styles/index.css';
|
||||||
|
import React, { useMemo, useState } from 'react';
|
||||||
|
import { App, ConfigProvider, message } from 'antd';
|
||||||
|
import zhCN from 'antd/locale/zh_CN';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
import 'dayjs/locale/zh-cn';
|
||||||
|
import {
|
||||||
|
AnnotationViewer,
|
||||||
|
type AnnotationDirectoryRouteNode,
|
||||||
|
type AnnotationSourceDocument,
|
||||||
|
type AnnotationViewerOptions,
|
||||||
|
} from '@axhub/annotation';
|
||||||
|
import seedRecords from './data/records.json';
|
||||||
|
import seedBills from './data/bills.json';
|
||||||
|
import { FilterPanel } from './components/FilterPanel';
|
||||||
|
import { PaymentRecordsTable } from './components/PaymentRecordsTable';
|
||||||
|
import { AddPaymentModal, type AddPaymentFormValues } from './components/AddPaymentModal';
|
||||||
|
import { BillLinkModal } from './components/BillLinkModal';
|
||||||
|
import { PaymentKpiDetailModal } from './components/PaymentKpiDetailModal';
|
||||||
|
import { TablePagination } from '../vehicle-management/components/TablePagination';
|
||||||
|
import type { BillCandidate, LinkedBill, PaymentRecord, PaymentRecordFilters } from './types';
|
||||||
|
import { EMPTY_PAYMENT_FILTERS } from './types';
|
||||||
|
import {
|
||||||
|
applyPaymentFilters,
|
||||||
|
flattenBillPool,
|
||||||
|
formatMoney,
|
||||||
|
sumLinkedAmount,
|
||||||
|
sumPaymentAmount,
|
||||||
|
sumUnclaimedAmount,
|
||||||
|
type PaymentKpiKey,
|
||||||
|
} from './utils/payment';
|
||||||
|
import annotationSourceDocument from './annotation-source.json';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
const OPERATOR_NAME = '张专员';
|
||||||
|
|
||||||
|
dayjs.locale('zh-cn');
|
||||||
|
|
||||||
|
const vmTheme = {
|
||||||
|
token: {
|
||||||
|
colorPrimary: '#32a06e',
|
||||||
|
colorLink: '#32a06e',
|
||||||
|
colorLinkHover: '#3fb87c',
|
||||||
|
borderRadius: 8,
|
||||||
|
fontFamily:
|
||||||
|
'Inter, -apple-system, BlinkMacSystemFont, "SF Pro Display", "PingFang SC", "Microsoft YaHei", sans-serif',
|
||||||
|
fontSize: 14,
|
||||||
|
colorText: '#18181b',
|
||||||
|
colorTextSecondary: '#52525b',
|
||||||
|
colorBorder: '#e5e7eb',
|
||||||
|
colorBgContainer: '#ffffff',
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
Table: {
|
||||||
|
headerBg: '#f6f7f7',
|
||||||
|
headerColor: '#71717a',
|
||||||
|
rowHoverBg: '#f6f7f7',
|
||||||
|
borderColor: '#e5e7eb',
|
||||||
|
cellPaddingBlock: 8,
|
||||||
|
cellPaddingInline: 12,
|
||||||
|
},
|
||||||
|
Card: {
|
||||||
|
borderRadiusLG: 12,
|
||||||
|
},
|
||||||
|
InputNumber: {
|
||||||
|
handleVisible: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const AMOUNT_KPI_CARDS = [
|
||||||
|
{ key: 'received', title: '到账总金额', desc: '当前筛选范围内全部收款记录的到账金额合计' },
|
||||||
|
{ key: 'linked', title: '认领总金额', desc: '当前筛选范围内收款记录已认领金额合计' },
|
||||||
|
{ key: 'unlinked', title: '未关联总金额', desc: '当前筛选范围内收款记录未关联金额合计(到账金额 − 已认领金额)' },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
function ExportIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="7 10 12 15 17 10" />
|
||||||
|
<line x1="12" x2="12" y1="15" y2="3" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ImportIcon() {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width={16}
|
||||||
|
height={16}
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
aria-hidden
|
||||||
|
>
|
||||||
|
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
|
||||||
|
<polyline points="17 8 12 3 7 8" />
|
||||||
|
<line x1="12" x2="12" y1="3" y2="15" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaymentRecordsContent() {
|
||||||
|
const [records, setRecords] = useState<PaymentRecord[]>(seedRecords as PaymentRecord[]);
|
||||||
|
const [pendingFilters, setPendingFilters] = useState<PaymentRecordFilters>(EMPTY_PAYMENT_FILTERS);
|
||||||
|
const [appliedFilters, setAppliedFilters] = useState<PaymentRecordFilters>(EMPTY_PAYMENT_FILTERS);
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [pageSize, setPageSize] = useState(PAGE_SIZE);
|
||||||
|
const [tableLoading, setTableLoading] = useState(false);
|
||||||
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
|
const [linkRow, setLinkRow] = useState<PaymentRecord | null>(null);
|
||||||
|
const [kpiDetailKey, setKpiDetailKey] = useState<PaymentKpiKey | null>(null);
|
||||||
|
|
||||||
|
const billPool = useMemo(
|
||||||
|
() => flattenBillPool(seedBills as Record<string, BillCandidate[]>),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
const filtered = useMemo(
|
||||||
|
() => applyPaymentFilters(records, appliedFilters, billPool),
|
||||||
|
[records, appliedFilters, billPool],
|
||||||
|
);
|
||||||
|
|
||||||
|
const amountKpis = useMemo(() => ({
|
||||||
|
received: sumPaymentAmount(filtered),
|
||||||
|
linked: sumLinkedAmount(filtered),
|
||||||
|
unlinked: sumUnclaimedAmount(filtered),
|
||||||
|
}), [filtered]);
|
||||||
|
|
||||||
|
const paged = useMemo(() => {
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
return filtered.slice(start, start + pageSize);
|
||||||
|
}, [filtered, page, pageSize]);
|
||||||
|
|
||||||
|
const handleSearch = () => {
|
||||||
|
setTableLoading(true);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
setAppliedFilters(pendingFilters);
|
||||||
|
setPage(1);
|
||||||
|
setTableLoading(false);
|
||||||
|
}, 280);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
setPendingFilters(EMPTY_PAYMENT_FILTERS);
|
||||||
|
setAppliedFilters(EMPTY_PAYMENT_FILTERS);
|
||||||
|
setPage(1);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAdd = (values: AddPaymentFormValues) => {
|
||||||
|
const now = new Date();
|
||||||
|
const operatedAt = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
|
||||||
|
const row: PaymentRecord = {
|
||||||
|
id: `pr-${Date.now()}`,
|
||||||
|
paymentTime: values.paymentTime.length === 16 ? `${values.paymentTime}:00` : values.paymentTime,
|
||||||
|
customerName: values.customerName,
|
||||||
|
amount: values.amount,
|
||||||
|
remark: values.remark,
|
||||||
|
linkedBills: [],
|
||||||
|
operatorName: OPERATOR_NAME,
|
||||||
|
operatedAt,
|
||||||
|
};
|
||||||
|
setRecords((prev) => [row, ...prev]);
|
||||||
|
setAddOpen(false);
|
||||||
|
message.success('收款记录已新增(原型演示)');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleLinkBills = (recordId: string, linkedBills: LinkedBill[]) => {
|
||||||
|
const operatedAt = new Date().toLocaleString('zh-CN', { hour12: false }).replace(/\//g, '-');
|
||||||
|
setRecords((prev) => prev.map((row) => (
|
||||||
|
row.id === recordId
|
||||||
|
? { ...row, linkedBills, operatedAt, operatorName: OPERATOR_NAME }
|
||||||
|
: row
|
||||||
|
)));
|
||||||
|
setLinkRow(null);
|
||||||
|
message.success('账单关联成功(原型演示)');
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveAmountKpiValue = (key: typeof AMOUNT_KPI_CARDS[number]['key']) => {
|
||||||
|
if (key === 'received') return formatMoney(amountKpis.received);
|
||||||
|
if (key === 'linked') return formatMoney(amountKpis.linked);
|
||||||
|
return formatMoney(amountKpis.unlinked);
|
||||||
|
};
|
||||||
|
|
||||||
|
const resolveAmountKpiClass = (key: typeof AMOUNT_KPI_CARDS[number]['key']) => {
|
||||||
|
if (key === 'linked') return 'pr-kpi-val--linked';
|
||||||
|
if (key === 'unlinked') return 'pr-kpi-val--warn';
|
||||||
|
return '';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="vm-page lc-page pr-page">
|
||||||
|
<FilterPanel
|
||||||
|
records={records}
|
||||||
|
filters={pendingFilters}
|
||||||
|
onChange={(patch) => setPendingFilters((prev) => ({ ...prev, ...patch }))}
|
||||||
|
onReset={handleReset}
|
||||||
|
onSearch={handleSearch}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="vm-kpi-row" role="group" aria-label="收款金额统计" data-annotation-id="pr-kpi">
|
||||||
|
{AMOUNT_KPI_CARDS.map((card) => (
|
||||||
|
<button
|
||||||
|
key={card.key}
|
||||||
|
type="button"
|
||||||
|
className={`vm-kpi-card pr-kpi-card pr-kpi-card--clickable${kpiDetailKey === card.key ? ' pr-kpi-card--active' : ''}`}
|
||||||
|
title={`${card.desc},点击查看明细`}
|
||||||
|
aria-label={`${card.title} ${resolveAmountKpiValue(card.key)},点击查看明细`}
|
||||||
|
onClick={() => setKpiDetailKey(card.key)}
|
||||||
|
>
|
||||||
|
<span className="vm-kpi-main">
|
||||||
|
<span className="vm-kpi-eyebrow">{card.title}</span>
|
||||||
|
<span className={`vm-kpi-val tabular-nums ${resolveAmountKpiClass(card.key)}`}>
|
||||||
|
{resolveAmountKpiValue(card.key)}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="vm-table-section" data-annotation-id="pr-list">
|
||||||
|
<div className="vm-table-toolbar" data-annotation-id="pr-toolbar">
|
||||||
|
<div className="vm-table-actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="vm-btn vm-btn-ghost"
|
||||||
|
onClick={() => message.info('批量导入:请下载模板后上传 Excel(原型演示)')}
|
||||||
|
>
|
||||||
|
<ImportIcon />
|
||||||
|
批量导入
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="vm-btn vm-btn-ghost"
|
||||||
|
onClick={() => message.info(`已导出 ${filtered.length} 条收款记录(原型演示)`)}
|
||||||
|
>
|
||||||
|
<ExportIcon />
|
||||||
|
导出
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="vm-btn vm-btn-primary"
|
||||||
|
onClick={() => setAddOpen(true)}
|
||||||
|
data-annotation-id="pr-add-btn"
|
||||||
|
>
|
||||||
|
新增收款
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="vm-table-card">
|
||||||
|
<PaymentRecordsTable
|
||||||
|
records={paged}
|
||||||
|
billPool={billPool}
|
||||||
|
loading={tableLoading}
|
||||||
|
onLinkBills={setLinkRow}
|
||||||
|
/>
|
||||||
|
<div className="vm-table-footer">
|
||||||
|
<TablePagination
|
||||||
|
page={page}
|
||||||
|
pageSize={pageSize}
|
||||||
|
total={filtered.length}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setPageSize(size);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<AddPaymentModal
|
||||||
|
open={addOpen}
|
||||||
|
onClose={() => setAddOpen(false)}
|
||||||
|
onConfirm={handleAdd}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<BillLinkModal
|
||||||
|
open={Boolean(linkRow)}
|
||||||
|
record={linkRow}
|
||||||
|
billPool={billPool}
|
||||||
|
onClose={() => setLinkRow(null)}
|
||||||
|
onConfirm={handleLinkBills}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<PaymentKpiDetailModal
|
||||||
|
open={kpiDetailKey != null}
|
||||||
|
kpiKey={kpiDetailKey}
|
||||||
|
records={filtered}
|
||||||
|
onClose={() => setKpiDetailKey(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PaymentRecordsApp() {
|
||||||
|
const annotationOptions = useMemo<AnnotationViewerOptions>(
|
||||||
|
() => ({
|
||||||
|
showToolbar: true,
|
||||||
|
showThemeToggle: true,
|
||||||
|
showColorFilter: true,
|
||||||
|
emptyWhenNoData: false,
|
||||||
|
toolbarEdge: 'right',
|
||||||
|
currentPageId: 'list',
|
||||||
|
onDirectoryRoute: (_node: AnnotationDirectoryRouteNode) => {},
|
||||||
|
}),
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfigProvider theme={vmTheme} locale={zhCN}>
|
||||||
|
<App>
|
||||||
|
<PaymentRecordsContent />
|
||||||
|
</App>
|
||||||
|
<AnnotationViewer
|
||||||
|
source={annotationSourceDocument as AnnotationSourceDocument}
|
||||||
|
options={annotationOptions}
|
||||||
|
/>
|
||||||
|
</ConfigProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
584
src/prototypes/payment-records/styles/index.css
Normal file
584
src/prototypes/payment-records/styles/index.css
Normal file
@@ -0,0 +1,584 @@
|
|||||||
|
/**
|
||||||
|
* 收款记录 — 对齐租赁合同管理 vm-page lc-page
|
||||||
|
*/
|
||||||
|
@import url('../../lease-contract-management/styles/lease-contract.css');
|
||||||
|
|
||||||
|
.vm-page.lc-page.pr-page .vm-kpi-row {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.pr-page .pr-kpi-card {
|
||||||
|
cursor: default;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.pr-page .pr-kpi-card--clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid var(--ln-hairline, #e2e8f0);
|
||||||
|
background: var(--ln-surface-card, #fff);
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
width: 100%;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.pr-page .pr-kpi-card--clickable:hover {
|
||||||
|
border-color: rgba(50, 160, 110, 0.45);
|
||||||
|
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.pr-page .pr-kpi-card--clickable:focus-visible {
|
||||||
|
outline: 2px solid var(--ln-primary, #32a06e);
|
||||||
|
outline-offset: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.pr-page .pr-kpi-card--clickable.pr-kpi-card--active {
|
||||||
|
border-color: var(--ln-primary, #32a06e);
|
||||||
|
box-shadow: 0 0 0 1px rgba(50, 160, 110, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-modal__summary {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px 16px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid var(--ln-hairline, #e2e8f0);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--ln-muted, #64748b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-modal__summary strong {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
color: var(--ln-body, #0f172a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-modal__count {
|
||||||
|
margin-left: auto;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* KPI 明细弹窗 */
|
||||||
|
.pr-kpi-detail-modal .ant-modal-content {
|
||||||
|
border-radius: var(--ln-radius-card, 12px);
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: var(--ln-shadow-modal, 0 20px 40px rgba(24, 24, 27, 0.14));
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-modal .ant-modal-header {
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding: 18px 20px 14px;
|
||||||
|
border-bottom: 1px solid var(--ln-hairline, #e2e8f0);
|
||||||
|
background: linear-gradient(180deg, #ffffff 0%, #f8fafc 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-modal .ant-modal-body {
|
||||||
|
padding: 16px 20px 12px;
|
||||||
|
background: var(--ln-canvas-parchment, #f5f6f6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-modal .ant-modal-footer {
|
||||||
|
margin-top: 0;
|
||||||
|
padding: 12px 20px 16px;
|
||||||
|
border-top: 1px solid var(--ln-hairline, #e2e8f0);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-modal .ant-modal-close {
|
||||||
|
top: 14px;
|
||||||
|
inset-inline-end: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-modal__title {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 12px;
|
||||||
|
padding-right: 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-modal__icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border-radius: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-modal__icon--received {
|
||||||
|
color: #0f766e;
|
||||||
|
background: rgba(15, 118, 110, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-modal__icon--linked {
|
||||||
|
color: #1d4ed8;
|
||||||
|
background: rgba(29, 78, 216, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-modal__icon--unlinked {
|
||||||
|
color: #c2410c;
|
||||||
|
background: rgba(194, 65, 12, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-modal__title-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-modal__title-main {
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.35;
|
||||||
|
color: var(--ln-ink, #18181b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-modal__title-sub {
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--ln-muted, #71717a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-modal__body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-modal__stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1.4fr) minmax(0, 0.8fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-modal__stat-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--ln-hairline, #e2e8f0);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-modal__stat-card--primary {
|
||||||
|
border-color: rgba(50, 160, 110, 0.22);
|
||||||
|
background: linear-gradient(135deg, rgba(50, 160, 110, 0.06) 0%, #fff 72%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-modal__stat-card--linked {
|
||||||
|
border-color: rgba(15, 118, 110, 0.22);
|
||||||
|
background: linear-gradient(135deg, rgba(15, 118, 110, 0.06) 0%, #fff 72%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-modal__stat-card--unlinked {
|
||||||
|
border-color: rgba(194, 65, 12, 0.2);
|
||||||
|
background: linear-gradient(135deg, rgba(194, 65, 12, 0.05) 0%, #fff 72%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-modal__stat-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--ln-muted, #71717a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-modal__stat-val {
|
||||||
|
font-size: 1.375rem;
|
||||||
|
font-weight: 700;
|
||||||
|
line-height: 1.2;
|
||||||
|
color: var(--ln-ink, #18181b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-modal__stat-val--linked {
|
||||||
|
color: #0f766e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-modal__stat-val--warn {
|
||||||
|
color: #c2410c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-modal__footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-modal__footer-hint {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--ln-muted, #94a3b8);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-filter {
|
||||||
|
padding: 12px 14px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--ln-hairline, #e2e8f0);
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-filter__grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 10px 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-filter__field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-filter__field > span {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--ln-muted, #71717a);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-filter__actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid var(--ln-hairline, #e2e8f0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 认领 / 未关联总金额明细:筛选项与按钮单行排列 */
|
||||||
|
.pr-kpi-detail-filter--inline {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-filter--inline .pr-kpi-detail-filter__grid {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 10px 12px;
|
||||||
|
grid-template-columns: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-filter--inline .pr-kpi-detail-filter__field {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-filter--inline .pr-kpi-detail-filter__field:nth-child(2) {
|
||||||
|
flex: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-filter--inline .pr-kpi-detail-filter__actions {
|
||||||
|
margin-top: 0;
|
||||||
|
padding-top: 0;
|
||||||
|
border-top: none;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-panel__head {
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-panel__head-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-panel__export {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-panel__export:disabled {
|
||||||
|
opacity: 0.45;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-btn-sm {
|
||||||
|
min-height: 32px;
|
||||||
|
padding: 0 12px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.pr-kpi-detail-filter--inline {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-filter--inline .pr-kpi-detail-filter__grid {
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-filter--inline .pr-kpi-detail-filter__actions {
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: 10px;
|
||||||
|
border-top: 1px solid var(--ln-hairline, #e2e8f0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-filter__grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-modal__stats {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-modal__footer {
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-kpi-detail-modal__footer-hint {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.pr-page .pr-kpi-val--linked {
|
||||||
|
color: #0f766e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.pr-page .pr-kpi-val--warn {
|
||||||
|
color: #c2410c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-col-remark {
|
||||||
|
max-width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-linked-empty {
|
||||||
|
color: var(--ln-muted, #94a3b8);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-linked-col {
|
||||||
|
min-width: 88px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-linked-count-btn {
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--ln-primary, #32a06e);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-linked-count-btn:hover {
|
||||||
|
color: var(--ln-primary-focus, #2d8f62);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-linked-count-btn:focus-visible {
|
||||||
|
outline: 2px solid var(--ln-primary, #32a06e);
|
||||||
|
outline-offset: 2px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 关联账单子表:对齐租赁合同展开行 — 100cqw 贴齐主表可视区,并为右侧冻结操作列留白 */
|
||||||
|
.vm-page.lc-page.pr-page {
|
||||||
|
--pr-action-col-width: 108px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.pr-page .lc-list-table--nested .ant-table-expanded-row > td {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.pr-page .lc-list-table--nested .ant-table-expanded-row .pr-linked-bill-table-wrap {
|
||||||
|
box-sizing: border-box;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
@supports (width: 100cqw) {
|
||||||
|
.vm-page.lc-page.pr-page .lc-list-table--nested .ant-table-expanded-row .pr-linked-bill-table-wrap {
|
||||||
|
width: calc(100cqw - var(--pr-action-col-width, 108px));
|
||||||
|
max-width: calc(100cqw - var(--pr-action-col-width, 108px));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.pr-page .pr-linked-bill-native-table {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
table-layout: fixed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.pr-page .pr-linked-bill-native-table th,
|
||||||
|
.vm-page.lc-page.pr-page .pr-linked-bill-native-table td {
|
||||||
|
padding: 5px 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.pr-page .pr-linked-bill-native-table thead th {
|
||||||
|
padding-top: 6px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.pr-page .pr-linked-bill-native-table__type {
|
||||||
|
width: 9%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.pr-page .pr-linked-bill-native-table__info {
|
||||||
|
width: 36%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.pr-page .pr-linked-bill-native-table__period {
|
||||||
|
width: 10%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.pr-page .pr-linked-bill-native-table__money {
|
||||||
|
width: 15%;
|
||||||
|
text-align: right;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-bill-unreceived--negative {
|
||||||
|
color: #c2410c;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.pr-page .pr-linked-bill-native-table__info .vm-stack {
|
||||||
|
min-width: 0;
|
||||||
|
gap: 1px;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.pr-page .pr-linked-bill-native-table__info .primary,
|
||||||
|
.vm-page.lc-page.pr-page .pr-linked-bill-native-table__info .sub {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.pr-page .pr-linked-bill-native-table__info .primary {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.pr-page .pr-linked-bill-native-table__info .code {
|
||||||
|
display: block;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.pr-page .pr-linked-bill-native-table__period {
|
||||||
|
white-space: nowrap;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.pr-page .pr-linked-bill-native-table .pr-bill-type-tag {
|
||||||
|
padding: 0 4px;
|
||||||
|
font-size: 11px;
|
||||||
|
white-space: nowrap;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-bill-type-tag {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-bill-type-tag--lease {
|
||||||
|
color: #0f766e;
|
||||||
|
background: rgba(15, 118, 110, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-bill-type-tag--self-operated {
|
||||||
|
color: #1d4ed8;
|
||||||
|
background: rgba(29, 78, 216, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-bill-type-tag--clearing {
|
||||||
|
color: #b45309;
|
||||||
|
background: rgba(180, 83, 9, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-unlinked--pending,
|
||||||
|
.pr-unclaimed--pending {
|
||||||
|
color: #c2410c;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-bill-summary {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 20px;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: #f8fafc;
|
||||||
|
border: 1px solid var(--ln-hairline, #e2e8f0);
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
color: var(--ln-muted, #64748b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-bill-summary strong {
|
||||||
|
color: var(--ln-body, #334155);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-bill-hint {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--ln-muted, #64748b);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-form-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-form-field--full {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pr-required {
|
||||||
|
color: #dc2626;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 收款时间:单输入框 + 弹层双月历(与租赁合同 lc-filter-select 同高) */
|
||||||
|
.vm-page.lc-page.pr-page .pr-payment-range.ant-picker {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.pr-page .pr-payment-range.ant-picker .ant-picker-input > input {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.pr-page .pr-payment-range.ant-picker .ant-picker-separator {
|
||||||
|
color: var(--ln-muted, #64748b);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.pr-form-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
|
||||||
|
.vm-page.lc-page.pr-page .vm-kpi-row {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/prototypes/payment-records/types.ts
Normal file
75
src/prototypes/payment-records/types.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
export type BillType = 'lease' | 'self-operated' | 'clearing';
|
||||||
|
|
||||||
|
export interface LinkedBill {
|
||||||
|
id: string;
|
||||||
|
type: BillType;
|
||||||
|
label: string;
|
||||||
|
allocatedAmount: number;
|
||||||
|
period?: string;
|
||||||
|
customerName?: string;
|
||||||
|
projectName?: string;
|
||||||
|
contractCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnrichedLinkedBill extends LinkedBill {
|
||||||
|
receivable?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentRecord {
|
||||||
|
id: string;
|
||||||
|
paymentTime: string;
|
||||||
|
customerName: string;
|
||||||
|
amount: number;
|
||||||
|
remark: string;
|
||||||
|
linkedBills: LinkedBill[];
|
||||||
|
operatorName: string;
|
||||||
|
operatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BillCandidate {
|
||||||
|
id: string;
|
||||||
|
type: BillType;
|
||||||
|
customerName: string;
|
||||||
|
projectName: string;
|
||||||
|
contractCode: string;
|
||||||
|
label: string;
|
||||||
|
period: string;
|
||||||
|
receivable: number;
|
||||||
|
unreceived: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentRecordFilters {
|
||||||
|
customerName: string;
|
||||||
|
paymentFrom: string;
|
||||||
|
paymentTo: string;
|
||||||
|
operatorName: string;
|
||||||
|
unclaimed: '' | 'yes' | 'no';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_PAYMENT_FILTERS: PaymentRecordFilters = {
|
||||||
|
customerName: '',
|
||||||
|
paymentFrom: '',
|
||||||
|
paymentTo: '',
|
||||||
|
operatorName: '',
|
||||||
|
unclaimed: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface PaymentKpiDetailFilters {
|
||||||
|
customerName: string;
|
||||||
|
paymentFrom: string;
|
||||||
|
paymentTo: string;
|
||||||
|
billType: '' | BillType;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EMPTY_PAYMENT_KPI_DETAIL_FILTERS: PaymentKpiDetailFilters = {
|
||||||
|
customerName: '',
|
||||||
|
paymentFrom: '',
|
||||||
|
paymentTo: '',
|
||||||
|
billType: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
export const BILL_TYPE_LABELS: Record<BillType, string> = {
|
||||||
|
lease: '租赁账单',
|
||||||
|
'self-operated': '自营账单',
|
||||||
|
clearing: '氢费账单',
|
||||||
|
};
|
||||||
314
src/prototypes/payment-records/utils/payment.ts
Normal file
314
src/prototypes/payment-records/utils/payment.ts
Normal file
@@ -0,0 +1,314 @@
|
|||||||
|
import type { BillCandidate, BillType, EnrichedLinkedBill, LinkedBill, PaymentRecord, PaymentRecordFilters, PaymentKpiDetailFilters } from '../types';
|
||||||
|
|
||||||
|
export function formatMoney(value: number): string {
|
||||||
|
if (value == null || Number.isNaN(value)) return '—';
|
||||||
|
return value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calcBillUnreceivedAmount(receivable: number | null | undefined, allocatedAmount: number): number | null {
|
||||||
|
if (receivable == null || Number.isNaN(receivable)) return null;
|
||||||
|
const allocated = allocatedAmount || 0;
|
||||||
|
return Math.round((receivable - allocated) * 100) / 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calcLinkedTotal(record: PaymentRecord): number {
|
||||||
|
return (record.linkedBills || []).reduce((sum, bill) => sum + (bill.allocatedAmount || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calcUnclaimedAmount(record: PaymentRecord): number {
|
||||||
|
const linked = calcLinkedTotal(record);
|
||||||
|
return Math.max(0, Math.round((record.amount - linked) * 100) / 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function calcRecordBillUnreceivedAmount(record: PaymentRecord, billPool: BillCandidate[]): number {
|
||||||
|
const enriched = enrichLinkedBills(record.linkedBills || [], billPool, record.customerName);
|
||||||
|
return enriched.reduce((sum, bill) => {
|
||||||
|
const unreceived = calcBillUnreceivedAmount(bill.receivable, bill.allocatedAmount);
|
||||||
|
return sum + (unreceived ?? 0);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function applyPaymentFilters(
|
||||||
|
records: PaymentRecord[],
|
||||||
|
filters: PaymentRecordFilters,
|
||||||
|
billPool: BillCandidate[],
|
||||||
|
): PaymentRecord[] {
|
||||||
|
return records.filter((row) => {
|
||||||
|
if (filters.customerName.trim()) {
|
||||||
|
if (row.customerName !== filters.customerName.trim()) return false;
|
||||||
|
}
|
||||||
|
if (filters.operatorName.trim()) {
|
||||||
|
if (row.operatorName !== filters.operatorName.trim()) return false;
|
||||||
|
}
|
||||||
|
if (filters.paymentFrom && row.paymentTime.slice(0, 10) < filters.paymentFrom) return false;
|
||||||
|
if (filters.paymentTo && row.paymentTime.slice(0, 10) > filters.paymentTo) return false;
|
||||||
|
const unlinked = calcUnclaimedAmount(row);
|
||||||
|
if (filters.unclaimed === 'yes' && unlinked <= 0) return false;
|
||||||
|
if (filters.unclaimed === 'no' && unlinked > 0) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildOperatorOptions(records: PaymentRecord[]): string[] {
|
||||||
|
const set = new Set<string>();
|
||||||
|
records.forEach((row) => {
|
||||||
|
if (row.operatorName) set.add(row.operatorName);
|
||||||
|
});
|
||||||
|
return Array.from(set).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildCustomerOptions(records: PaymentRecord[]): Array<{ value: string; label: string }> {
|
||||||
|
const set = new Set<string>();
|
||||||
|
records.forEach((row) => {
|
||||||
|
if (row.customerName) set.add(row.customerName);
|
||||||
|
});
|
||||||
|
return Array.from(set).sort().map((name) => ({ value: name, label: name }));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sumPaymentAmount(records: PaymentRecord[]): number {
|
||||||
|
return records.reduce((sum, row) => sum + (row.amount || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sumLinkedAmount(records: PaymentRecord[]): number {
|
||||||
|
return records.reduce((sum, row) => sum + calcLinkedTotal(row), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sumUnclaimedAmount(records: PaymentRecord[]): number {
|
||||||
|
return records.reduce((sum, row) => sum + calcUnclaimedAmount(row), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sumBillUnreceivedAmount(records: PaymentRecord[], billPool: BillCandidate[]): number {
|
||||||
|
return records.reduce((sum, record) => sum + calcRecordBillUnreceivedAmount(record, billPool), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PaymentKpiKey = 'received' | 'linked' | 'unlinked';
|
||||||
|
|
||||||
|
export interface PaymentReceivedDetailRow {
|
||||||
|
key: string;
|
||||||
|
paymentTime: string;
|
||||||
|
customerName: string;
|
||||||
|
amount: number;
|
||||||
|
remark: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentLinkedDetailRow {
|
||||||
|
key: string;
|
||||||
|
paymentTime: string;
|
||||||
|
customerName: string;
|
||||||
|
amount: number;
|
||||||
|
linkedAmount: number;
|
||||||
|
remark: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PaymentUnlinkedDetailRow {
|
||||||
|
key: string;
|
||||||
|
paymentTime: string;
|
||||||
|
customerName: string;
|
||||||
|
amount: number;
|
||||||
|
linkedAmount: number;
|
||||||
|
unlinkedAmount: number;
|
||||||
|
remark: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildReceivedDetailRows(records: PaymentRecord[]): PaymentReceivedDetailRow[] {
|
||||||
|
return records.map((record) => ({
|
||||||
|
key: record.id,
|
||||||
|
paymentTime: record.paymentTime,
|
||||||
|
customerName: record.customerName,
|
||||||
|
amount: record.amount,
|
||||||
|
remark: record.remark || '—',
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildLinkedDetailRows(records: PaymentRecord[]): PaymentLinkedDetailRow[] {
|
||||||
|
return records
|
||||||
|
.map((record) => ({
|
||||||
|
key: record.id,
|
||||||
|
paymentTime: record.paymentTime,
|
||||||
|
customerName: record.customerName,
|
||||||
|
amount: record.amount,
|
||||||
|
linkedAmount: calcLinkedTotal(record),
|
||||||
|
remark: record.remark || '—',
|
||||||
|
}))
|
||||||
|
.filter((row) => row.linkedAmount > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildUnlinkedDetailRows(records: PaymentRecord[]): PaymentUnlinkedDetailRow[] {
|
||||||
|
return records
|
||||||
|
.map((record) => {
|
||||||
|
const linkedAmount = calcLinkedTotal(record);
|
||||||
|
return {
|
||||||
|
key: record.id,
|
||||||
|
paymentTime: record.paymentTime,
|
||||||
|
customerName: record.customerName,
|
||||||
|
amount: record.amount,
|
||||||
|
linkedAmount,
|
||||||
|
unlinkedAmount: calcUnclaimedAmount(record),
|
||||||
|
remark: record.remark || '—',
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((row) => row.unlinkedAmount > 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const BILL_TYPE_SHORT: Record<BillType, string> = {
|
||||||
|
lease: '租赁',
|
||||||
|
'self-operated': '自营',
|
||||||
|
clearing: '氢费',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function enrichLinkedBills(
|
||||||
|
linkedBills: LinkedBill[],
|
||||||
|
billPool: BillCandidate[],
|
||||||
|
paymentCustomerName?: string,
|
||||||
|
): EnrichedLinkedBill[] {
|
||||||
|
return (linkedBills || []).map((bill) => {
|
||||||
|
const candidate = billPool.find((item) => item.id === bill.id && item.type === bill.type);
|
||||||
|
return {
|
||||||
|
...bill,
|
||||||
|
period: bill.period || candidate?.period,
|
||||||
|
receivable: candidate?.receivable,
|
||||||
|
customerName: bill.customerName || candidate?.customerName || paymentCustomerName || '—',
|
||||||
|
projectName: bill.projectName || candidate?.projectName || '—',
|
||||||
|
contractCode: bill.contractCode || candidate?.contractCode || '—',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flattenBillPool(pool: Record<string, BillCandidate[]>): BillCandidate[] {
|
||||||
|
return Object.values(pool).flat();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterBillsByTypeAndCustomer(
|
||||||
|
bills: BillCandidate[],
|
||||||
|
type: BillType | 'all',
|
||||||
|
customerName: string,
|
||||||
|
): BillCandidate[] {
|
||||||
|
return bills.filter((bill) => {
|
||||||
|
if (type !== 'all' && bill.type !== type) return false;
|
||||||
|
if (customerName && bill.customerName !== customerName) return false;
|
||||||
|
return bill.unreceived > 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchKpiDetailTimeAndCustomer(
|
||||||
|
paymentTime: string,
|
||||||
|
customerName: string,
|
||||||
|
filters: PaymentKpiDetailFilters,
|
||||||
|
): boolean {
|
||||||
|
if (filters.customerName && customerName !== filters.customerName) return false;
|
||||||
|
if (filters.paymentFrom && paymentTime.slice(0, 10) < filters.paymentFrom) return false;
|
||||||
|
if (filters.paymentTo && paymentTime.slice(0, 10) > filters.paymentTo) return false;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterReceivedDetailRows(
|
||||||
|
rows: PaymentReceivedDetailRow[],
|
||||||
|
filters: PaymentKpiDetailFilters,
|
||||||
|
): PaymentReceivedDetailRow[] {
|
||||||
|
return rows.filter((row) => matchKpiDetailTimeAndCustomer(row.paymentTime, row.customerName, filters));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterLinkedDetailRows(
|
||||||
|
rows: PaymentLinkedDetailRow[],
|
||||||
|
filters: PaymentKpiDetailFilters,
|
||||||
|
): PaymentLinkedDetailRow[] {
|
||||||
|
return rows.filter((row) => matchKpiDetailTimeAndCustomer(row.paymentTime, row.customerName, filters));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function filterUnlinkedDetailRows(
|
||||||
|
rows: PaymentUnlinkedDetailRow[],
|
||||||
|
filters: PaymentKpiDetailFilters,
|
||||||
|
): PaymentUnlinkedDetailRow[] {
|
||||||
|
return rows.filter((row) => matchKpiDetailTimeAndCustomer(row.paymentTime, row.customerName, filters));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sumReceivedDetailAmount(rows: PaymentReceivedDetailRow[]): number {
|
||||||
|
return rows.reduce((sum, row) => sum + (row.amount || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sumLinkedDetailAmount(rows: PaymentLinkedDetailRow[]): number {
|
||||||
|
return rows.reduce((sum, row) => sum + (row.linkedAmount || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sumUnlinkedDetailAmount(rows: PaymentUnlinkedDetailRow[]): number {
|
||||||
|
return rows.reduce((sum, row) => sum + (row.unlinkedAmount || 0), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildDetailCustomerOptions(names: string[]): Array<{ value: string; label: string }> {
|
||||||
|
const set = new Set<string>();
|
||||||
|
names.forEach((name) => {
|
||||||
|
if (name) set.add(name);
|
||||||
|
});
|
||||||
|
return Array.from(set).sort().map((name) => ({ value: name, label: name }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeCsvCell(value: string | number): string {
|
||||||
|
const str = String(value ?? '');
|
||||||
|
if (/[",\n\r]/.test(str)) return `"${str.replace(/"/g, '""')}"`;
|
||||||
|
return str;
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadCsv(filename: string, headers: string[], rows: Array<Array<string | number>>) {
|
||||||
|
const bom = '\uFEFF';
|
||||||
|
const lines = [
|
||||||
|
headers.map(escapeCsvCell).join(','),
|
||||||
|
...rows.map((row) => row.map(escapeCsvCell).join(',')),
|
||||||
|
];
|
||||||
|
const blob = new Blob([bom + lines.join('\n')], { type: 'text/csv;charset=utf-8;' });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const anchor = document.createElement('a');
|
||||||
|
anchor.href = url;
|
||||||
|
anchor.download = filename;
|
||||||
|
anchor.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
const KPI_EXPORT_FILENAME: Record<PaymentKpiKey, string> = {
|
||||||
|
received: '到账总金额明细',
|
||||||
|
linked: '认领总金额明细',
|
||||||
|
unlinked: '未关联总金额明细',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function exportKpiDetailCsv(
|
||||||
|
kpiKey: PaymentKpiKey,
|
||||||
|
receivedRows: PaymentReceivedDetailRow[],
|
||||||
|
linkedRows: PaymentLinkedDetailRow[],
|
||||||
|
unlinkedRows: PaymentUnlinkedDetailRow[],
|
||||||
|
): number {
|
||||||
|
const stamp = new Date().getTime();
|
||||||
|
if (kpiKey === 'received') {
|
||||||
|
downloadCsv(
|
||||||
|
`${KPI_EXPORT_FILENAME.received}_${stamp}.csv`,
|
||||||
|
['收款时间', '客户名称', '到账金额', '备注'],
|
||||||
|
receivedRows.map((row) => [row.paymentTime, row.customerName, row.amount, row.remark]),
|
||||||
|
);
|
||||||
|
return receivedRows.length;
|
||||||
|
}
|
||||||
|
if (kpiKey === 'linked') {
|
||||||
|
downloadCsv(
|
||||||
|
`${KPI_EXPORT_FILENAME.linked}_${stamp}.csv`,
|
||||||
|
['收款时间', '客户名称', '到账金额', '认领金额', '备注'],
|
||||||
|
linkedRows.map((row) => [
|
||||||
|
row.paymentTime,
|
||||||
|
row.customerName,
|
||||||
|
row.amount,
|
||||||
|
row.linkedAmount,
|
||||||
|
row.remark,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
return linkedRows.length;
|
||||||
|
}
|
||||||
|
downloadCsv(
|
||||||
|
`${KPI_EXPORT_FILENAME.unlinked}_${stamp}.csv`,
|
||||||
|
['收款时间', '客户名称', '到账金额', '已认领', '未关联金额', '备注'],
|
||||||
|
unlinkedRows.map((row) => [
|
||||||
|
row.paymentTime,
|
||||||
|
row.customerName,
|
||||||
|
row.amount,
|
||||||
|
row.linkedAmount,
|
||||||
|
row.unlinkedAmount,
|
||||||
|
row.remark,
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
return unlinkedRows.length;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
# 自营业务台账 · 产品需求说明(PRD)
|
# 自营业务台账 · 产品需求说明(PRD)
|
||||||
|
|
||||||
> 参照《2026年业务二部运营台账总表》物流业务台账子表,按自营业务场景重新表达;行内编辑与导入为原型 Toast 演示。
|
> 参照《2026年业务二部运营台账总表》物流业务台账子表,按自营业务场景重新表达;行内编辑与导入为原型 Toast 演示。
|
||||||
|
> **界面设计规范**与租赁业务台账、租赁合同列表统一,见 `src/prototypes/ledger-shared/DESIGN.md`(`vm-page ldb-page`)。
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"version": 2,
|
"version": 2,
|
||||||
"prototypeName": "self-operated-business-ledger",
|
"prototypeName": "self-operated-business-ledger",
|
||||||
"pageId": "list",
|
"pageId": "list",
|
||||||
"updatedAt": 1782871095271,
|
"updatedAt": 1783328981489,
|
||||||
"nodes": [
|
"nodes": [
|
||||||
{
|
{
|
||||||
"id": "sob-filter",
|
"id": "sob-filter",
|
||||||
@@ -104,66 +104,10 @@
|
|||||||
"title": "ONE-OS 原型导航",
|
"title": "ONE-OS 原型导航",
|
||||||
"defaultExpanded": true,
|
"defaultExpanded": true,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"id": "nav-link-contract-template-management",
|
|
||||||
"title": "合同模板管理",
|
|
||||||
"href": "/prototypes/contract-template-management",
|
|
||||||
"target": "self"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"id": "nav-link-insurance-procurement",
|
|
||||||
"title": "保险采购",
|
|
||||||
"href": "/prototypes/insurance-procurement",
|
|
||||||
"target": "self"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"id": "nav-link-vehicle-management",
|
|
||||||
"title": "车辆管理",
|
|
||||||
"href": "/prototypes/vehicle-management",
|
|
||||||
"target": "self"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"id": "nav-link-lease-contract-management",
|
|
||||||
"title": "租赁合同管理",
|
|
||||||
"href": "/prototypes/lease-contract-management",
|
|
||||||
"target": "self"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"id": "nav-link-customer-management",
|
|
||||||
"title": "客户管理",
|
|
||||||
"href": "/prototypes/customer-management",
|
|
||||||
"target": "self"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"id": "nav-link-lease-business-ledger",
|
|
||||||
"title": "租赁业务台账",
|
|
||||||
"href": "/prototypes/lease-business-ledger",
|
|
||||||
"target": "self"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"id": "nav-link-vehicle-h2-fee-ledger",
|
|
||||||
"title": "车辆氢费明细",
|
|
||||||
"href": "/prototypes/vehicle-h2-fee-ledger",
|
|
||||||
"target": "self"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"id": "nav-link-self-operated-business-ledger",
|
|
||||||
"title": "自营业务台账(当前)",
|
|
||||||
"href": "/prototypes/self-operated-business-ledger",
|
|
||||||
"target": "self"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "folder",
|
"type": "folder",
|
||||||
"id": "nav-folder-prototypes-oneos-web",
|
"id": "nav-folder-1782874576229-r8atbu",
|
||||||
"title": "ONE-OS Web 端",
|
"title": "OneOS",
|
||||||
"defaultExpanded": true,
|
"defaultExpanded": true,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
@@ -172,7 +116,22 @@
|
|||||||
"title": "工作台",
|
"title": "工作台",
|
||||||
"href": "/prototypes/oneos-web-workbench",
|
"href": "/prototypes/oneos-web-workbench",
|
||||||
"target": "self"
|
"target": "self"
|
||||||
},
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-lease-business-line-overview",
|
||||||
|
"title": "业务条线说明",
|
||||||
|
"href": "/prototypes/lease-business-line-overview",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "folder",
|
||||||
|
"id": "nav-folder-prototypes-oneos-web",
|
||||||
|
"title": "ONE-OS Web 端",
|
||||||
|
"defaultExpanded": true,
|
||||||
|
"children": [
|
||||||
{
|
{
|
||||||
"type": "link",
|
"type": "link",
|
||||||
"id": "nav-link-oneos-web-login",
|
"id": "nav-link-oneos-web-login",
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export function FilterPanel({ records, filters, onChange, onReset, onSearch }: F
|
|||||||
control: (
|
control: (
|
||||||
<div className="ldb-date-range">
|
<div className="ldb-date-range">
|
||||||
<input type="date" className="vm-input" value={filters.dispatchFrom} aria-label="出车开始日期" onChange={(e) => onChange({ dispatchFrom: e.target.value })} />
|
<input type="date" className="vm-input" value={filters.dispatchFrom} aria-label="出车开始日期" onChange={(e) => onChange({ dispatchFrom: e.target.value })} />
|
||||||
<span aria-hidden>—</span>
|
<span className="ldb-date-range-sep" aria-hidden>至</span>
|
||||||
<input type="date" className="vm-input" value={filters.dispatchTo} aria-label="出车结束日期" onChange={(e) => onChange({ dispatchTo: e.target.value })} />
|
<input type="date" className="vm-input" value={filters.dispatchTo} aria-label="出车结束日期" onChange={(e) => onChange({ dispatchTo: e.target.value })} />
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -85,31 +85,45 @@ export function FilterPanel({ records, filters, onChange, onReset, onSearch }: F
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const visible = expanded ? fields : fields.slice(0, DEFAULT_VISIBLE);
|
const primaryFields = fields.slice(0, DEFAULT_VISIBLE);
|
||||||
|
const extraFields = fields.slice(DEFAULT_VISIBLE);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="vm-filter-card" data-annotation-id="sob-filter">
|
<section className="vm-filter-card ldb-filter-card" data-annotation-id="sob-filter" aria-label="筛选条件">
|
||||||
<header className="vm-filter-header">
|
<header className="vm-filter-header">
|
||||||
<h1 className="ldb-page-title">自营业务台账</h1>
|
<h1 className="ldb-page-title">自营业务台账</h1>
|
||||||
<p className="ldb-page-desc">业务服务组操作人维护各自台账;主管可查看全部。支持导入、行内编辑与盈亏自动计算(原型演示)。</p>
|
|
||||||
</header>
|
</header>
|
||||||
<div className="vm-filter-grid">
|
<div className="vm-filter-grid">
|
||||||
{visible.map((field) => (
|
{primaryFields.map((field) => (
|
||||||
<label key={field.label} className="vm-filter-field">
|
<label key={field.label} className="vm-filter-field">
|
||||||
<span>{field.label}</span>
|
<span>{field.label}</span>
|
||||||
{field.control}
|
{field.control}
|
||||||
</label>
|
</label>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
{extraFields.length > 0 && (
|
||||||
|
<div className={`ldb-filter-expand ${expanded ? 'is-expanded' : ''}`}>
|
||||||
|
<div className="ldb-filter-expand-inner">
|
||||||
|
<div className="vm-filter-grid ldb-filter-expand-grid">
|
||||||
|
{extraFields.map((field) => (
|
||||||
|
<label key={field.label} className="vm-filter-field">
|
||||||
|
<span>{field.label}</span>
|
||||||
|
{field.control}
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="vm-filter-actions">
|
<div className="vm-filter-actions">
|
||||||
{fields.length > DEFAULT_VISIBLE && (
|
{extraFields.length > 0 && (
|
||||||
<button type="button" className="vm-btn vm-btn-link" onClick={() => setExpanded(!expanded)}>
|
<button type="button" className="vm-btn vm-btn-link ldb-filter-toggle" onClick={() => setExpanded(!expanded)} aria-expanded={expanded}>
|
||||||
{expanded ? '收起' : '更多筛选'}
|
{expanded ? '收起' : '更多筛选'}
|
||||||
<ChevronDown size={14} aria-hidden style={{ transform: expanded ? 'rotate(180deg)' : undefined }} />
|
<ChevronDown size={14} aria-hidden className={`ldb-filter-toggle-icon ${expanded ? 'is-open' : ''}`} />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={onReset}>重置</button>
|
<button type="button" className="vm-btn vm-btn-ghost ldb-toolbar-btn" onClick={onReset}>重置</button>
|
||||||
<button type="button" className="vm-btn vm-btn-primary" onClick={onSearch}>
|
<button type="button" className="vm-btn vm-btn-primary ldb-toolbar-btn" onClick={onSearch}>
|
||||||
<Search size={16} aria-hidden />
|
<Search size={16} aria-hidden />
|
||||||
查询
|
查询
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,85 +1,236 @@
|
|||||||
import React from 'react';
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { ArrowDown, ArrowUp, ArrowUpDown } from 'lucide-react';
|
||||||
import type { SelfOperatedLedgerRow } from '../types';
|
import type { SelfOperatedLedgerRow } from '../types';
|
||||||
import { formatMoney } from '../utils/ledger';
|
import { displayText, formatMoney } from '../utils/ledger';
|
||||||
|
import {
|
||||||
|
DEFAULT_SELF_COLUMN_WIDTHS,
|
||||||
|
SELF_TABLE_COLUMNS,
|
||||||
|
type SelfTableColumn,
|
||||||
|
} from './tableColumns';
|
||||||
|
|
||||||
interface SelfLedgerTableProps {
|
interface SelfLedgerTableProps {
|
||||||
records: SelfOperatedLedgerRow[];
|
records: SelfOperatedLedgerRow[];
|
||||||
selectedIds: Set<string>;
|
selectedIds: Set<string>;
|
||||||
|
loading?: boolean;
|
||||||
onToggleRow: (id: string, checked: boolean) => void;
|
onToggleRow: (id: string, checked: boolean) => void;
|
||||||
onToggleAll: (checked: boolean) => void;
|
onToggleAll: (checked: boolean) => void;
|
||||||
onEdit: (record: SelfOperatedLedgerRow) => void;
|
onEdit: (record: SelfOperatedLedgerRow) => void;
|
||||||
onDelete: (record: SelfOperatedLedgerRow) => void;
|
onDelete: (record: SelfOperatedLedgerRow) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SortDir = 'asc' | 'desc';
|
||||||
|
|
||||||
|
const CHECK_COL_WIDTH = 48;
|
||||||
|
const ACTION_COL_WIDTH = 120;
|
||||||
|
|
||||||
|
function HeaderSort({
|
||||||
|
title,
|
||||||
|
sortActive,
|
||||||
|
sortDir,
|
||||||
|
onSort,
|
||||||
|
}: {
|
||||||
|
title: string;
|
||||||
|
sortActive?: boolean;
|
||||||
|
sortDir?: SortDir;
|
||||||
|
onSort: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button type="button" className={`ldb-th-sort ${sortActive ? 'is-active' : ''}`} onClick={onSort}>
|
||||||
|
<span className="ldb-th-label">{title}</span>
|
||||||
|
<span className="ldb-th-sort-icon" aria-hidden>
|
||||||
|
{sortActive
|
||||||
|
? (sortDir === 'asc' ? <ArrowUp size={14} /> : <ArrowDown size={14} />)
|
||||||
|
: <ArrowUpDown size={14} />}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCell(row: SelfOperatedLedgerRow, col: SelfTableColumn) {
|
||||||
|
if (col.key === 'profitLoss') {
|
||||||
|
return (
|
||||||
|
<span className={row.profitLoss >= 0 ? 'ldb-kpi-val--profit' : 'ldb-kpi-val--loss'}>
|
||||||
|
{formatMoney(row.profitLoss)}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const raw = row[col.key as keyof SelfOperatedLedgerRow];
|
||||||
|
if (col.money && typeof raw === 'number') {
|
||||||
|
if (col.key === 'quantity') return raw === 0 ? '' : String(raw);
|
||||||
|
return formatMoney(raw);
|
||||||
|
}
|
||||||
|
return displayText(raw);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TableSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="ldb-table-skeleton" aria-busy="true" aria-label="表格加载中">
|
||||||
|
{Array.from({ length: 6 }, (_, i) => (
|
||||||
|
<div key={i} className="ldb-skeleton-row" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function SelfLedgerTable({
|
export function SelfLedgerTable({
|
||||||
records,
|
records,
|
||||||
selectedIds,
|
selectedIds,
|
||||||
|
loading = false,
|
||||||
onToggleRow,
|
onToggleRow,
|
||||||
onToggleAll,
|
onToggleAll,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
}: SelfLedgerTableProps) {
|
}: SelfLedgerTableProps) {
|
||||||
const allSelected = records.length > 0 && records.every((r) => selectedIds.has(r.id));
|
const [sortKey, setSortKey] = useState<string | null>(null);
|
||||||
|
const [sortDir, setSortDir] = useState<SortDir>('asc');
|
||||||
|
const [columnWidths, setColumnWidths] = useState<Record<string, number>>(() => ({ ...DEFAULT_SELF_COLUMN_WIDTHS }));
|
||||||
|
const resizeRef = useRef<{ key: string; startX: number; startWidth: number } | null>(null);
|
||||||
|
|
||||||
if (!records.length) {
|
const sortedRecords = useMemo(() => {
|
||||||
|
if (!sortKey) return records;
|
||||||
|
const dir = sortDir === 'asc' ? 1 : -1;
|
||||||
|
return [...records].sort((a, b) => {
|
||||||
|
const av = a[sortKey as keyof SelfOperatedLedgerRow];
|
||||||
|
const bv = b[sortKey as keyof SelfOperatedLedgerRow];
|
||||||
|
if (typeof av === 'number' && typeof bv === 'number') return (av - bv) * dir;
|
||||||
|
return String(av ?? '').localeCompare(String(bv ?? ''), 'zh-CN') * dir;
|
||||||
|
});
|
||||||
|
}, [records, sortKey, sortDir]);
|
||||||
|
|
||||||
|
const allSelected = sortedRecords.length > 0 && sortedRecords.every((r) => selectedIds.has(r.id));
|
||||||
|
|
||||||
|
const handleSort = useCallback((key: string) => {
|
||||||
|
setSortKey((prev) => {
|
||||||
|
if (prev === key) {
|
||||||
|
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'));
|
||||||
|
return key;
|
||||||
|
}
|
||||||
|
setSortDir('asc');
|
||||||
|
return key;
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleResizeStart = useCallback((key: string, event: React.MouseEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
resizeRef.current = {
|
||||||
|
key,
|
||||||
|
startX: event.clientX,
|
||||||
|
startWidth: columnWidths[key] ?? DEFAULT_SELF_COLUMN_WIDTHS[key] ?? 100,
|
||||||
|
};
|
||||||
|
}, [columnWidths]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const onMove = (event: MouseEvent) => {
|
||||||
|
if (!resizeRef.current) return;
|
||||||
|
const delta = event.clientX - resizeRef.current.startX;
|
||||||
|
const next = Math.max(56, resizeRef.current.startWidth + delta);
|
||||||
|
setColumnWidths((prev) => ({ ...prev, [resizeRef.current!.key]: next }));
|
||||||
|
};
|
||||||
|
const onUp = () => {
|
||||||
|
resizeRef.current = null;
|
||||||
|
};
|
||||||
|
window.addEventListener('mousemove', onMove);
|
||||||
|
window.addEventListener('mouseup', onUp);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('mousemove', onMove);
|
||||||
|
window.removeEventListener('mouseup', onUp);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<div className="vm-table-wrap ldb-table-wrap" data-annotation-id="sob-table">
|
||||||
|
<TableSkeleton />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sortedRecords.length) {
|
||||||
return <div className="vm-empty" data-annotation-id="sob-table"><p>暂无符合条件的台账记录</p></div>;
|
return <div className="vm-empty" data-annotation-id="sob-table"><p>暂无符合条件的台账记录</p></div>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const tableMinWidth = CHECK_COL_WIDTH + ACTION_COL_WIDTH + SELF_TABLE_COLUMNS.reduce(
|
||||||
|
(sum, col) => sum + (columnWidths[col.key] ?? DEFAULT_SELF_COLUMN_WIDTHS[col.key] ?? 100),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="vm-table-wrap ldb-table-wrap" data-annotation-id="sob-table">
|
<div className="vm-table-wrap ldb-table-wrap" data-annotation-id="sob-table">
|
||||||
<table className="vm-table ldb-table sob-table">
|
<table className="vm-table ldb-table" style={{ minWidth: tableMinWidth }}>
|
||||||
|
<colgroup>
|
||||||
|
<col style={{ width: CHECK_COL_WIDTH }} />
|
||||||
|
{SELF_TABLE_COLUMNS.map((col) => (
|
||||||
|
<col key={col.key} style={{ width: columnWidths[col.key] ?? DEFAULT_SELF_COLUMN_WIDTHS[col.key] ?? 100 }} />
|
||||||
|
))}
|
||||||
|
<col style={{ width: ACTION_COL_WIDTH }} />
|
||||||
|
</colgroup>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th className="ldb-col-check sticky-col-left">
|
<th className="ldb-col-check sticky-col-left">
|
||||||
<input type="checkbox" checked={allSelected} onChange={(e) => onToggleAll(e.target.checked)} aria-label="全选" />
|
<input type="checkbox" checked={allSelected} onChange={(e) => onToggleAll(e.target.checked)} aria-label="全选" />
|
||||||
</th>
|
</th>
|
||||||
<th>月份</th>
|
{SELF_TABLE_COLUMNS.map((col) => (
|
||||||
<th>出车日期</th>
|
<th
|
||||||
<th>业务名称</th>
|
key={col.key}
|
||||||
<th>司机</th>
|
className={[
|
||||||
<th>电话</th>
|
col.money ? 'ldb-col-money' : '',
|
||||||
<th>车辆承接方</th>
|
col.className,
|
||||||
<th>车牌号码</th>
|
'ldb-col-resizable',
|
||||||
<th>品牌</th>
|
].filter(Boolean).join(' ')}
|
||||||
<th>型号</th>
|
>
|
||||||
<th className="ldb-col-money">单价</th>
|
<div className="ldb-th-inner">
|
||||||
<th className="ldb-col-money">数量</th>
|
<HeaderSort
|
||||||
<th className="ldb-col-money">金额</th>
|
title={col.title}
|
||||||
<th className="ldb-col-money">氢费</th>
|
sortActive={sortKey === col.key}
|
||||||
<th className="ldb-col-money">ETC费用</th>
|
sortDir={sortDir}
|
||||||
<th className="ldb-col-money">总成本</th>
|
onSort={() => handleSort(col.key)}
|
||||||
<th className="ldb-col-money">盈亏</th>
|
/>
|
||||||
<th>维护人</th>
|
<span
|
||||||
|
className="ldb-col-resize-handle"
|
||||||
|
role="separator"
|
||||||
|
aria-orientation="vertical"
|
||||||
|
aria-label={`调整${col.title}列宽`}
|
||||||
|
onMouseDown={(e) => handleResizeStart(col.key, e)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
<th className="sticky-col-right ldb-col-actions">操作</th>
|
<th className="sticky-col-right ldb-col-actions">操作</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{records.map((row) => (
|
{sortedRecords.map((row) => (
|
||||||
<tr key={row.id}>
|
<tr key={row.id}>
|
||||||
<td className="ldb-col-check sticky-col-left">
|
<td className="ldb-col-check sticky-col-left">
|
||||||
<input type="checkbox" checked={selectedIds.has(row.id)} onChange={(e) => onToggleRow(row.id, e.target.checked)} aria-label={`选择 ${row.plateNo}`} />
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedIds.has(row.id)}
|
||||||
|
onChange={(e) => onToggleRow(row.id, e.target.checked)}
|
||||||
|
aria-label={`选择 ${row.plateNo}`}
|
||||||
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="tabular-nums">{row.month}</td>
|
{SELF_TABLE_COLUMNS.map((col) => (
|
||||||
<td className="tabular-nums">{row.dispatchDate}</td>
|
<td
|
||||||
<td className="ldb-col-name" title={row.businessName}>{row.businessName}</td>
|
key={col.key}
|
||||||
<td>{row.driver}</td>
|
className={[
|
||||||
<td className="tabular-nums">{row.phone}</td>
|
col.money ? 'ldb-col-money tabular-nums' : '',
|
||||||
<td>{row.vehicleUndertaker}</td>
|
col.className,
|
||||||
<td className="tabular-nums">{row.plateNo}</td>
|
].filter(Boolean).join(' ')}
|
||||||
<td>{row.brand}</td>
|
title={col.key === 'businessName' || col.key === 'model' ? String(row[col.key as keyof SelfOperatedLedgerRow] ?? '') : undefined}
|
||||||
<td title={row.model}>{row.model}</td>
|
>
|
||||||
<td className="ldb-col-money tabular-nums">{formatMoney(row.unitPrice)}</td>
|
{renderCell(row, col)}
|
||||||
<td className="ldb-col-money tabular-nums">{row.quantity}</td>
|
</td>
|
||||||
<td className="ldb-col-money tabular-nums">{formatMoney(row.amount)}</td>
|
))}
|
||||||
<td className="ldb-col-money tabular-nums">{formatMoney(row.hydrogenFee)}</td>
|
|
||||||
<td className="ldb-col-money tabular-nums">{formatMoney(row.etcFee)}</td>
|
|
||||||
<td className="ldb-col-money tabular-nums">{formatMoney(row.totalCost)}</td>
|
|
||||||
<td className={`ldb-col-money tabular-nums ${row.profitLoss >= 0 ? 'ldb-kpi-val--profit' : 'ldb-kpi-val--loss'}`}>{formatMoney(row.profitLoss)}</td>
|
|
||||||
<td>{row.maintainerName}</td>
|
|
||||||
<td className="sticky-col-right ldb-col-actions">
|
<td className="sticky-col-right ldb-col-actions">
|
||||||
<button type="button" className="vm-btn vm-btn-link" onClick={() => onEdit(row)}>编辑</button>
|
<div className="ldb-row-actions">
|
||||||
<span className="ldb-action-sep" aria-hidden>|</span>
|
<button type="button" className="ldb-action-btn ldb-action-btn--edit" onClick={() => onEdit(row)}>
|
||||||
<button type="button" className="vm-btn vm-btn-link ldb-link-danger" onClick={() => onDelete(row)}>删除</button>
|
编辑
|
||||||
|
</button>
|
||||||
|
<button type="button" className="ldb-action-btn ldb-action-btn--delete" onClick={() => onDelete(row)}>
|
||||||
|
删除
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
export interface SelfTableColumn {
|
||||||
|
key: string;
|
||||||
|
title: string;
|
||||||
|
money?: boolean;
|
||||||
|
className?: string;
|
||||||
|
sortable?: boolean;
|
||||||
|
defaultWidth?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const SELF_TABLE_COLUMNS: SelfTableColumn[] = [
|
||||||
|
{ key: 'month', title: '月份', defaultWidth: 72, sortable: true },
|
||||||
|
{ key: 'dispatchDate', title: '出车日期', defaultWidth: 108, sortable: true },
|
||||||
|
{ key: 'businessName', title: '业务名称', className: 'ldb-col-name', defaultWidth: 140, sortable: true },
|
||||||
|
{ key: 'driver', title: '司机', defaultWidth: 88, sortable: true },
|
||||||
|
{ key: 'phone', title: '电话', defaultWidth: 112, sortable: true },
|
||||||
|
{ key: 'vehicleUndertaker', title: '车辆承接方', defaultWidth: 108, sortable: true },
|
||||||
|
{ key: 'plateNo', title: '车牌号码', defaultWidth: 108, sortable: true },
|
||||||
|
{ key: 'brand', title: '品牌', defaultWidth: 80, sortable: true },
|
||||||
|
{ key: 'model', title: '型号', className: 'ldb-col-name', defaultWidth: 160, sortable: true },
|
||||||
|
{ key: 'unitPrice', title: '单价', money: true, defaultWidth: 96, sortable: true },
|
||||||
|
{ key: 'quantity', title: '数量', money: true, defaultWidth: 72, sortable: true },
|
||||||
|
{ key: 'amount', title: '金额', money: true, defaultWidth: 104, sortable: true },
|
||||||
|
{ key: 'hydrogenFee', title: '氢费', money: true, defaultWidth: 96, sortable: true },
|
||||||
|
{ key: 'etcFee', title: 'ETC费用', money: true, defaultWidth: 96, sortable: true },
|
||||||
|
{ key: 'totalCost', title: '总成本', money: true, defaultWidth: 104, sortable: true },
|
||||||
|
{ key: 'profitLoss', title: '盈亏', money: true, defaultWidth: 104, sortable: true },
|
||||||
|
{ key: 'maintainerName', title: '维护人', defaultWidth: 88, sortable: true },
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_SELF_COLUMN_WIDTHS: Record<string, number> = Object.fromEntries(
|
||||||
|
SELF_TABLE_COLUMNS.map((col) => [col.key, col.defaultWidth ?? 100]),
|
||||||
|
);
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
/**
|
/**
|
||||||
* @name 自营业务台账
|
* @name 自营业务台账
|
||||||
|
* 设计规范:ledger-shared/DESIGN.md · vm-page + ldb-page(与租赁业务台账、租赁合同列表对齐)
|
||||||
*/
|
*/
|
||||||
import '../vehicle-management/style.css';
|
import '../vehicle-management/style.css';
|
||||||
import '../lease-business-ledger/styles/index.css';
|
import '../lease-business-ledger/styles/index.css';
|
||||||
import './styles/index.css';
|
|
||||||
import React, { useCallback, useMemo, useState } from 'react';
|
import React, { useCallback, useMemo, useState } from 'react';
|
||||||
import { Download, FileUp, Plus, Trash2 } from 'lucide-react';
|
import { Download, FileUp, Plus, Trash2 } from 'lucide-react';
|
||||||
import {
|
import {
|
||||||
@@ -17,6 +17,7 @@ import { FilterPanel } from './components/FilterPanel';
|
|||||||
import { LedgerKpiRow } from './components/LedgerKpiRow';
|
import { LedgerKpiRow } from './components/LedgerKpiRow';
|
||||||
import { SelfLedgerTable } from './components/SelfLedgerTable';
|
import { SelfLedgerTable } from './components/SelfLedgerTable';
|
||||||
import { TablePagination } from '../vehicle-management/components/TablePagination';
|
import { TablePagination } from '../vehicle-management/components/TablePagination';
|
||||||
|
import { DeleteConfirmModal } from '../lease-business-ledger/components/DeleteConfirmModal';
|
||||||
import {
|
import {
|
||||||
EMPTY_SELF_FILTERS,
|
EMPTY_SELF_FILTERS,
|
||||||
type SelfOperatedFilters,
|
type SelfOperatedFilters,
|
||||||
@@ -28,13 +29,16 @@ import annotationSourceDocument from './annotation-source.json';
|
|||||||
const PAGE_SIZE = 10;
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
export default function SelfOperatedBusinessLedgerApp() {
|
export default function SelfOperatedBusinessLedgerApp() {
|
||||||
const [records] = useState<SelfOperatedLedgerRow[]>(seedRows as SelfOperatedLedgerRow[]);
|
const [records, setRecords] = useState<SelfOperatedLedgerRow[]>(seedRows as SelfOperatedLedgerRow[]);
|
||||||
const [pendingFilters, setPendingFilters] = useState<SelfOperatedFilters>(EMPTY_SELF_FILTERS);
|
const [pendingFilters, setPendingFilters] = useState<SelfOperatedFilters>(EMPTY_SELF_FILTERS);
|
||||||
const [appliedFilters, setAppliedFilters] = useState<SelfOperatedFilters>(EMPTY_SELF_FILTERS);
|
const [appliedFilters, setAppliedFilters] = useState<SelfOperatedFilters>(EMPTY_SELF_FILTERS);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [pageSize, setPageSize] = useState(PAGE_SIZE);
|
const [pageSize, setPageSize] = useState(PAGE_SIZE);
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||||
const [toast, setToast] = useState('');
|
const [toast, setToast] = useState('');
|
||||||
|
const [tableLoading, setTableLoading] = useState(false);
|
||||||
|
const [deleteTarget, setDeleteTarget] = useState<SelfOperatedLedgerRow | null>(null);
|
||||||
|
const [bulkDeleteOpen, setBulkDeleteOpen] = useState(false);
|
||||||
|
|
||||||
const showToast = useCallback((msg: string) => {
|
const showToast = useCallback((msg: string) => {
|
||||||
setToast(msg);
|
setToast(msg);
|
||||||
@@ -50,9 +54,13 @@ export default function SelfOperatedBusinessLedgerApp() {
|
|||||||
}, [filtered, page, pageSize]);
|
}, [filtered, page, pageSize]);
|
||||||
|
|
||||||
const handleSearch = () => {
|
const handleSearch = () => {
|
||||||
setAppliedFilters(pendingFilters);
|
setTableLoading(true);
|
||||||
setPage(1);
|
window.setTimeout(() => {
|
||||||
setSelectedIds(new Set());
|
setAppliedFilters(pendingFilters);
|
||||||
|
setPage(1);
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
setTableLoading(false);
|
||||||
|
}, 320);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleReset = () => {
|
const handleReset = () => {
|
||||||
@@ -62,6 +70,14 @@ export default function SelfOperatedBusinessLedgerApp() {
|
|||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleBulkDeleteClick = () => {
|
||||||
|
if (selectedIds.size === 0) {
|
||||||
|
showToast('请先勾选需要删除的记录');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setBulkDeleteOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
const annotationOptions = useMemo<AnnotationViewerOptions>(
|
const annotationOptions = useMemo<AnnotationViewerOptions>(
|
||||||
() => ({
|
() => ({
|
||||||
showToolbar: true,
|
showToolbar: true,
|
||||||
@@ -89,28 +105,24 @@ export default function SelfOperatedBusinessLedgerApp() {
|
|||||||
<LedgerKpiRow summary={kpi} />
|
<LedgerKpiRow summary={kpi} />
|
||||||
|
|
||||||
<section className="vm-table-section">
|
<section className="vm-table-section">
|
||||||
<div className="vm-table-toolbar" data-annotation-id="sob-toolbar">
|
<div className="vm-table-toolbar ldb-table-toolbar" data-annotation-id="sob-toolbar">
|
||||||
<div className="vm-table-actions">
|
<div className="vm-table-actions">
|
||||||
<button type="button" className="vm-btn vm-btn-secondary" onClick={() => showToast('打开批量导入弹窗(原型演示)')}>
|
<button type="button" className="vm-btn vm-btn-secondary ldb-toolbar-btn" onClick={() => showToast('打开批量导入弹窗(原型演示)')}>
|
||||||
<FileUp size={16} aria-hidden />
|
<FileUp size={16} aria-hidden />
|
||||||
批量导入
|
批量导入
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={() => showToast(`导出 ${filtered.length} 条(原型演示)`)}>
|
<button type="button" className="vm-btn vm-btn-ghost ldb-toolbar-btn" onClick={() => showToast(`导出 ${filtered.length} 条(原型演示)`)}>
|
||||||
<Download size={16} aria-hidden />
|
<Download size={16} aria-hidden />
|
||||||
导出
|
导出
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="vm-btn vm-btn-ghost"
|
className="vm-btn vm-btn-ghost ldb-toolbar-btn"
|
||||||
onClick={() => showToast(`批量删除 ${selectedIds.size} 条(原型演示)`)}
|
onClick={handleBulkDeleteClick}
|
||||||
>
|
>
|
||||||
<Trash2 size={16} aria-hidden />
|
<Trash2 size={16} aria-hidden />
|
||||||
批量删除
|
批量删除
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="vm-btn vm-btn-primary" onClick={() => showToast('表格底部新增行(原型演示)')}>
|
|
||||||
<Plus size={16} aria-hidden />
|
|
||||||
添加行
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -118,6 +130,7 @@ export default function SelfOperatedBusinessLedgerApp() {
|
|||||||
<SelfLedgerTable
|
<SelfLedgerTable
|
||||||
records={paged}
|
records={paged}
|
||||||
selectedIds={selectedIds}
|
selectedIds={selectedIds}
|
||||||
|
loading={tableLoading}
|
||||||
onToggleRow={(id, checked) => {
|
onToggleRow={(id, checked) => {
|
||||||
setSelectedIds((prev) => {
|
setSelectedIds((prev) => {
|
||||||
const next = new Set(prev);
|
const next = new Set(prev);
|
||||||
@@ -130,23 +143,64 @@ export default function SelfOperatedBusinessLedgerApp() {
|
|||||||
setSelectedIds(checked ? new Set(paged.map((r) => r.id)) : new Set());
|
setSelectedIds(checked ? new Set(paged.map((r) => r.id)) : new Set());
|
||||||
}}
|
}}
|
||||||
onEdit={(row) => showToast(`编辑:${row.plateNo} · ${row.dispatchDate}(原型演示)`)}
|
onEdit={(row) => showToast(`编辑:${row.plateNo} · ${row.dispatchDate}(原型演示)`)}
|
||||||
onDelete={(row) => showToast(`删除:${row.plateNo}(原型演示)`)}
|
onDelete={(row) => setDeleteTarget(row)}
|
||||||
/>
|
/>
|
||||||
<div className="vm-table-footer ldb-table-footer">
|
<div className="vm-table-footer ldb-table-footer">
|
||||||
<span className="ldb-table-total">共 {filtered.length} 条</span>
|
<span className="ldb-table-total">共 {filtered.length} 条</span>
|
||||||
<TablePagination
|
<div className="ldb-table-footer-actions">
|
||||||
page={Math.min(page, totalPages)}
|
<TablePagination
|
||||||
pageSize={pageSize}
|
page={Math.min(page, totalPages)}
|
||||||
total={filtered.length}
|
pageSize={pageSize}
|
||||||
onPageChange={setPage}
|
total={filtered.length}
|
||||||
onPageSizeChange={(size) => { setPageSize(size); setPage(1); }}
|
onPageChange={setPage}
|
||||||
/>
|
onPageSizeChange={(size) => { setPageSize(size); setPage(1); }}
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="vm-btn vm-btn-secondary ldb-toolbar-btn"
|
||||||
|
data-annotation-id="sob-btn-add-row"
|
||||||
|
onClick={() => showToast('表格底部新增行(原型演示)')}
|
||||||
|
>
|
||||||
|
<Plus size={16} aria-hidden />
|
||||||
|
添加行
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{toast && <div className="vm-toast" role="status">{toast}</div>}
|
{toast && <div className="vm-toast" role="status">{toast}</div>}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<DeleteConfirmModal
|
||||||
|
open={Boolean(deleteTarget)}
|
||||||
|
plateNo={deleteTarget?.plateNo ?? ''}
|
||||||
|
onCancel={() => setDeleteTarget(null)}
|
||||||
|
onConfirm={() => {
|
||||||
|
if (!deleteTarget) return;
|
||||||
|
setRecords((prev) => prev.filter((row) => row.id !== deleteTarget.id));
|
||||||
|
setSelectedIds((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(deleteTarget.id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setDeleteTarget(null);
|
||||||
|
showToast(`已删除 ${deleteTarget.plateNo}(原型)`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<DeleteConfirmModal
|
||||||
|
open={bulkDeleteOpen}
|
||||||
|
meta={`已选 ${selectedIds.size} 条记录`}
|
||||||
|
onCancel={() => setBulkDeleteOpen(false)}
|
||||||
|
onConfirm={() => {
|
||||||
|
const ids = new Set(selectedIds);
|
||||||
|
setRecords((prev) => prev.filter((row) => !ids.has(row.id)));
|
||||||
|
setSelectedIds(new Set());
|
||||||
|
setBulkDeleteOpen(false);
|
||||||
|
showToast(`已批量删除 ${ids.size} 条(原型)`);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
<AnnotationViewer source={annotationSourceDocument as AnnotationSourceDocument} options={annotationOptions} />
|
<AnnotationViewer source={annotationSourceDocument as AnnotationSourceDocument} options={annotationOptions} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +0,0 @@
|
|||||||
.sob-table {
|
|
||||||
min-width: 1960px;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 工具栏与上方 KPI、下方表格卡片间距一致(各 16px) */
|
|
||||||
.vm-table-toolbar[data-annotation-id='sob-toolbar'] {
|
|
||||||
padding: 0 0 16px;
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,26 @@ export function formatMoney(value: number): string {
|
|||||||
return value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
return value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isBlankDisplayValue(value: unknown): boolean {
|
||||||
|
return value === null
|
||||||
|
|| value === undefined
|
||||||
|
|| value === ''
|
||||||
|
|| value === '—'
|
||||||
|
|| value === '-'
|
||||||
|
|| value === '/';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function displayText(value: unknown): string {
|
||||||
|
if (isBlankDisplayValue(value)) return '';
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function displayMoney(value: number | null | undefined, treatZeroAsEmpty = false): string {
|
||||||
|
if (value === null || value === undefined) return '';
|
||||||
|
if (treatZeroAsEmpty && value === 0) return '';
|
||||||
|
return formatMoney(value);
|
||||||
|
}
|
||||||
|
|
||||||
export function sumSelfKpi(rows: SelfOperatedLedgerRow[]): SelfOperatedKpiSummary {
|
export function sumSelfKpi(rows: SelfOperatedLedgerRow[]): SelfOperatedKpiSummary {
|
||||||
return rows.reduce(
|
return rows.reduce(
|
||||||
(acc, row) => ({
|
(acc, row) => ({
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"version": 2,
|
"version": 2,
|
||||||
"prototypeName": "supplier-management",
|
"prototypeName": "supplier-management",
|
||||||
"pageId": "list",
|
"pageId": "list",
|
||||||
"updatedAt": 1782697342692,
|
"updatedAt": 1783328981489,
|
||||||
"nodes": [
|
"nodes": [
|
||||||
{
|
{
|
||||||
"id": "sm-list-filter",
|
"id": "sm-list-filter",
|
||||||
@@ -13,7 +13,9 @@
|
|||||||
"title": "筛选条件",
|
"title": "筛选条件",
|
||||||
"pageId": "list",
|
"pageId": "list",
|
||||||
"locator": {
|
"locator": {
|
||||||
"selectors": ["[data-annotation-id=\"sm-list-filter\"]"],
|
"selectors": [
|
||||||
|
"[data-annotation-id=\"sm-list-filter\"]"
|
||||||
|
],
|
||||||
"fingerprint": "sm-list-filter",
|
"fingerprint": "sm-list-filter",
|
||||||
"path": []
|
"path": []
|
||||||
},
|
},
|
||||||
@@ -31,7 +33,9 @@
|
|||||||
"title": "统计卡片",
|
"title": "统计卡片",
|
||||||
"pageId": "list",
|
"pageId": "list",
|
||||||
"locator": {
|
"locator": {
|
||||||
"selectors": ["[data-annotation-id=\"sm-kpi-all\"]"],
|
"selectors": [
|
||||||
|
"[data-annotation-id=\"sm-kpi-all\"]"
|
||||||
|
],
|
||||||
"fingerprint": "sm-kpi-all",
|
"fingerprint": "sm-kpi-all",
|
||||||
"path": []
|
"path": []
|
||||||
},
|
},
|
||||||
@@ -49,7 +53,9 @@
|
|||||||
"title": "列表工具栏",
|
"title": "列表工具栏",
|
||||||
"pageId": "list",
|
"pageId": "list",
|
||||||
"locator": {
|
"locator": {
|
||||||
"selectors": ["[data-annotation-id=\"sm-list-toolbar\"]"],
|
"selectors": [
|
||||||
|
"[data-annotation-id=\"sm-list-toolbar\"]"
|
||||||
|
],
|
||||||
"fingerprint": "sm-list-toolbar",
|
"fingerprint": "sm-list-toolbar",
|
||||||
"path": []
|
"path": []
|
||||||
},
|
},
|
||||||
@@ -67,7 +73,9 @@
|
|||||||
"title": "供应商台账列表",
|
"title": "供应商台账列表",
|
||||||
"pageId": "list",
|
"pageId": "list",
|
||||||
"locator": {
|
"locator": {
|
||||||
"selectors": ["[data-annotation-id=\"sm-list-table\"]"],
|
"selectors": [
|
||||||
|
"[data-annotation-id=\"sm-list-table\"]"
|
||||||
|
],
|
||||||
"fingerprint": "sm-list-table",
|
"fingerprint": "sm-list-table",
|
||||||
"path": []
|
"path": []
|
||||||
},
|
},
|
||||||
@@ -85,7 +93,9 @@
|
|||||||
"title": "主体信息",
|
"title": "主体信息",
|
||||||
"pageId": "create",
|
"pageId": "create",
|
||||||
"locator": {
|
"locator": {
|
||||||
"selectors": ["[data-annotation-id=\"sm-create-entity\"]"],
|
"selectors": [
|
||||||
|
"[data-annotation-id=\"sm-create-entity\"]"
|
||||||
|
],
|
||||||
"fingerprint": "sm-create-entity",
|
"fingerprint": "sm-create-entity",
|
||||||
"path": []
|
"path": []
|
||||||
},
|
},
|
||||||
@@ -103,7 +113,9 @@
|
|||||||
"title": "开票信息",
|
"title": "开票信息",
|
||||||
"pageId": "create",
|
"pageId": "create",
|
||||||
"locator": {
|
"locator": {
|
||||||
"selectors": ["[data-annotation-id=\"sm-create-invoice\"]"],
|
"selectors": [
|
||||||
|
"[data-annotation-id=\"sm-create-invoice\"]"
|
||||||
|
],
|
||||||
"fingerprint": "sm-create-invoice",
|
"fingerprint": "sm-create-invoice",
|
||||||
"path": []
|
"path": []
|
||||||
},
|
},
|
||||||
@@ -121,7 +133,9 @@
|
|||||||
"title": "银行账户",
|
"title": "银行账户",
|
||||||
"pageId": "create",
|
"pageId": "create",
|
||||||
"locator": {
|
"locator": {
|
||||||
"selectors": ["[data-annotation-id=\"sm-create-bank\"]"],
|
"selectors": [
|
||||||
|
"[data-annotation-id=\"sm-create-bank\"]"
|
||||||
|
],
|
||||||
"fingerprint": "sm-create-bank",
|
"fingerprint": "sm-create-bank",
|
||||||
"path": []
|
"path": []
|
||||||
},
|
},
|
||||||
@@ -139,7 +153,9 @@
|
|||||||
"title": "资质证照",
|
"title": "资质证照",
|
||||||
"pageId": "create",
|
"pageId": "create",
|
||||||
"locator": {
|
"locator": {
|
||||||
"selectors": ["[data-annotation-id=\"sm-create-docs\"]"],
|
"selectors": [
|
||||||
|
"[data-annotation-id=\"sm-create-docs\"]"
|
||||||
|
],
|
||||||
"fingerprint": "sm-create-docs",
|
"fingerprint": "sm-create-docs",
|
||||||
"path": []
|
"path": []
|
||||||
},
|
},
|
||||||
@@ -157,7 +173,9 @@
|
|||||||
"title": "表单提交",
|
"title": "表单提交",
|
||||||
"pageId": "create",
|
"pageId": "create",
|
||||||
"locator": {
|
"locator": {
|
||||||
"selectors": ["[data-annotation-id=\"sm-create-footer\"]"],
|
"selectors": [
|
||||||
|
"[data-annotation-id=\"sm-create-footer\"]"
|
||||||
|
],
|
||||||
"fingerprint": "sm-create-footer",
|
"fingerprint": "sm-create-footer",
|
||||||
"path": []
|
"path": []
|
||||||
},
|
},
|
||||||
@@ -180,17 +198,154 @@
|
|||||||
"id": "list",
|
"id": "list",
|
||||||
"title": "列表页",
|
"title": "列表页",
|
||||||
"pageId": "list",
|
"pageId": "list",
|
||||||
"payload": { "pageId": "list" }
|
"payload": {
|
||||||
|
"pageId": "list"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "create",
|
"id": "create",
|
||||||
"title": "新增页",
|
"title": "新增页",
|
||||||
"pageId": "create",
|
"pageId": "create",
|
||||||
"payload": { "pageId": "create" }
|
"payload": {
|
||||||
|
"pageId": "create"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"directory": {
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"type": "folder",
|
||||||
|
"id": "oneos-project-nav",
|
||||||
|
"title": "ONE-OS 原型导航",
|
||||||
|
"defaultExpanded": true,
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "folder",
|
||||||
|
"id": "nav-folder-1782874576229-r8atbu",
|
||||||
|
"title": "OneOS",
|
||||||
|
"defaultExpanded": true,
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-workbench",
|
||||||
|
"title": "工作台",
|
||||||
|
"href": "/prototypes/oneos-web-workbench",
|
||||||
|
"target": "self"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-lease-business-line-overview",
|
||||||
|
"title": "业务条线说明",
|
||||||
|
"href": "/prototypes/lease-business-line-overview",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "folder",
|
||||||
|
"id": "nav-folder-prototypes-oneos-web",
|
||||||
|
"title": "ONE-OS Web 端",
|
||||||
|
"defaultExpanded": true,
|
||||||
|
"children": [
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-login",
|
||||||
|
"title": "登录",
|
||||||
|
"href": "/prototypes/oneos-web-login",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-vehicle-asset",
|
||||||
|
"title": "车辆管理",
|
||||||
|
"href": "/prototypes/oneos-web-vehicle-asset",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-contract-template",
|
||||||
|
"title": "合同模板管理",
|
||||||
|
"href": "/prototypes/oneos-web-contract-template",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-help-center",
|
||||||
|
"title": "帮助中心",
|
||||||
|
"href": "/prototypes/oneos-web-help-center",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-finance",
|
||||||
|
"title": "财务管理",
|
||||||
|
"href": "/prototypes/oneos-web-finance",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-procurement",
|
||||||
|
"title": "采购管理",
|
||||||
|
"href": "/prototypes/oneos-web-procurement",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-lease-contract",
|
||||||
|
"title": "车辆租赁合同",
|
||||||
|
"href": "/prototypes/oneos-web-lease-contract",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-h2-station",
|
||||||
|
"title": "加氢站管理",
|
||||||
|
"href": "/prototypes/oneos-web-h2-station",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-data-analysis",
|
||||||
|
"title": "数据分析",
|
||||||
|
"href": "/prototypes/oneos-web-data-analysis",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-ledger-data",
|
||||||
|
"title": "台账数据",
|
||||||
|
"href": "/prototypes/oneos-web-ledger-data",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-business",
|
||||||
|
"title": "业务管理",
|
||||||
|
"href": "/prototypes/oneos-web-business",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-ops",
|
||||||
|
"title": "运维管理",
|
||||||
|
"href": "/prototypes/oneos-web-ops",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-oneos-web-requirements",
|
||||||
|
"title": "需求说明",
|
||||||
|
"href": "/prototypes/oneos-web-requirements",
|
||||||
|
"target": "self"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"version": 2,
|
"version": 2,
|
||||||
"prototypeName": "vehicle-h2-fee-ledger",
|
"prototypeName": "vehicle-h2-fee-ledger",
|
||||||
"pageId": "list",
|
"pageId": "list",
|
||||||
"updatedAt": 1782871095271,
|
"updatedAt": 1783328981490,
|
||||||
"nodes": [
|
"nodes": [
|
||||||
{
|
{
|
||||||
"id": "vh2-header",
|
"id": "vh2-header",
|
||||||
@@ -258,66 +258,10 @@
|
|||||||
"title": "ONE-OS 原型导航",
|
"title": "ONE-OS 原型导航",
|
||||||
"defaultExpanded": true,
|
"defaultExpanded": true,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"id": "nav-link-contract-template-management",
|
|
||||||
"title": "合同模板管理",
|
|
||||||
"href": "/prototypes/contract-template-management",
|
|
||||||
"target": "self"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"id": "nav-link-insurance-procurement",
|
|
||||||
"title": "保险采购",
|
|
||||||
"href": "/prototypes/insurance-procurement",
|
|
||||||
"target": "self"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"id": "nav-link-vehicle-management",
|
|
||||||
"title": "车辆管理",
|
|
||||||
"href": "/prototypes/vehicle-management",
|
|
||||||
"target": "self"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"id": "nav-link-lease-contract-management",
|
|
||||||
"title": "租赁合同管理",
|
|
||||||
"href": "/prototypes/lease-contract-management",
|
|
||||||
"target": "self"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"id": "nav-link-customer-management",
|
|
||||||
"title": "客户管理",
|
|
||||||
"href": "/prototypes/customer-management",
|
|
||||||
"target": "self"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"id": "nav-link-lease-business-ledger",
|
|
||||||
"title": "租赁业务台账",
|
|
||||||
"href": "/prototypes/lease-business-ledger",
|
|
||||||
"target": "self"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"id": "nav-link-vehicle-h2-fee-ledger",
|
|
||||||
"title": "车辆氢费明细(当前)",
|
|
||||||
"href": "/prototypes/vehicle-h2-fee-ledger",
|
|
||||||
"target": "self"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"id": "nav-link-self-operated-business-ledger",
|
|
||||||
"title": "自营业务台账",
|
|
||||||
"href": "/prototypes/self-operated-business-ledger",
|
|
||||||
"target": "self"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "folder",
|
"type": "folder",
|
||||||
"id": "nav-folder-prototypes-oneos-web",
|
"id": "nav-folder-1782874576229-r8atbu",
|
||||||
"title": "ONE-OS Web 端",
|
"title": "OneOS",
|
||||||
"defaultExpanded": true,
|
"defaultExpanded": true,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
@@ -326,7 +270,22 @@
|
|||||||
"title": "工作台",
|
"title": "工作台",
|
||||||
"href": "/prototypes/oneos-web-workbench",
|
"href": "/prototypes/oneos-web-workbench",
|
||||||
"target": "self"
|
"target": "self"
|
||||||
},
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-lease-business-line-overview",
|
||||||
|
"title": "业务条线说明",
|
||||||
|
"href": "/prototypes/lease-business-line-overview",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "folder",
|
||||||
|
"id": "nav-folder-prototypes-oneos-web",
|
||||||
|
"title": "ONE-OS Web 端",
|
||||||
|
"defaultExpanded": true,
|
||||||
|
"children": [
|
||||||
{
|
{
|
||||||
"type": "link",
|
"type": "link",
|
||||||
"id": "nav-link-oneos-web-login",
|
"id": "nav-link-oneos-web-login",
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
"version": 2,
|
"version": 2,
|
||||||
"prototypeName": "vehicle-management",
|
"prototypeName": "vehicle-management",
|
||||||
"pageId": "list",
|
"pageId": "list",
|
||||||
"updatedAt": 1782871095272,
|
"updatedAt": 1783328981491,
|
||||||
"nodes": [
|
"nodes": [
|
||||||
{
|
{
|
||||||
"id": "vm-doc-toolbar",
|
"id": "vm-doc-toolbar",
|
||||||
@@ -674,66 +674,10 @@
|
|||||||
"title": "ONE-OS 原型导航",
|
"title": "ONE-OS 原型导航",
|
||||||
"defaultExpanded": true,
|
"defaultExpanded": true,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"id": "nav-link-contract-template-management",
|
|
||||||
"title": "合同模板管理",
|
|
||||||
"href": "/prototypes/contract-template-management",
|
|
||||||
"target": "self"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"id": "nav-link-insurance-procurement",
|
|
||||||
"title": "保险采购",
|
|
||||||
"href": "/prototypes/insurance-procurement",
|
|
||||||
"target": "self"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"id": "nav-link-vehicle-management",
|
|
||||||
"title": "车辆管理(当前)",
|
|
||||||
"href": "/prototypes/vehicle-management",
|
|
||||||
"target": "self"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"id": "nav-link-lease-contract-management",
|
|
||||||
"title": "租赁合同管理",
|
|
||||||
"href": "/prototypes/lease-contract-management",
|
|
||||||
"target": "self"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"id": "nav-link-customer-management",
|
|
||||||
"title": "客户管理",
|
|
||||||
"href": "/prototypes/customer-management",
|
|
||||||
"target": "self"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"id": "nav-link-lease-business-ledger",
|
|
||||||
"title": "租赁业务台账",
|
|
||||||
"href": "/prototypes/lease-business-ledger",
|
|
||||||
"target": "self"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"id": "nav-link-vehicle-h2-fee-ledger",
|
|
||||||
"title": "车辆氢费明细",
|
|
||||||
"href": "/prototypes/vehicle-h2-fee-ledger",
|
|
||||||
"target": "self"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "link",
|
|
||||||
"id": "nav-link-self-operated-business-ledger",
|
|
||||||
"title": "自营业务台账",
|
|
||||||
"href": "/prototypes/self-operated-business-ledger",
|
|
||||||
"target": "self"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "folder",
|
"type": "folder",
|
||||||
"id": "nav-folder-prototypes-oneos-web",
|
"id": "nav-folder-1782874576229-r8atbu",
|
||||||
"title": "ONE-OS Web 端",
|
"title": "OneOS",
|
||||||
"defaultExpanded": true,
|
"defaultExpanded": true,
|
||||||
"children": [
|
"children": [
|
||||||
{
|
{
|
||||||
@@ -742,7 +686,22 @@
|
|||||||
"title": "工作台",
|
"title": "工作台",
|
||||||
"href": "/prototypes/oneos-web-workbench",
|
"href": "/prototypes/oneos-web-workbench",
|
||||||
"target": "self"
|
"target": "self"
|
||||||
},
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "link",
|
||||||
|
"id": "nav-link-lease-business-line-overview",
|
||||||
|
"title": "业务条线说明",
|
||||||
|
"href": "/prototypes/lease-business-line-overview",
|
||||||
|
"target": "self"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "folder",
|
||||||
|
"id": "nav-folder-prototypes-oneos-web",
|
||||||
|
"title": "ONE-OS Web 端",
|
||||||
|
"defaultExpanded": true,
|
||||||
|
"children": [
|
||||||
{
|
{
|
||||||
"type": "link",
|
"type": "link",
|
||||||
"id": "nav-link-oneos-web-login",
|
"id": "nav-link-oneos-web-login",
|
||||||
|
|||||||
@@ -1,101 +1,6 @@
|
|||||||
import React, { useMemo } from 'react';
|
export {
|
||||||
import { ChevronLeft, ChevronRight } from 'lucide-react';
|
TablePagination,
|
||||||
|
DEFAULT_PAGE_SIZE,
|
||||||
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
|
PAGE_SIZE_OPTIONS,
|
||||||
|
type TablePaginationProps,
|
||||||
interface TablePaginationProps {
|
} from '../../../common/TablePagination';
|
||||||
page: number;
|
|
||||||
pageSize: number;
|
|
||||||
total: number;
|
|
||||||
onPageChange: (page: number) => void;
|
|
||||||
onPageSizeChange: (size: number) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPageItems(current: number, totalPages: number): Array<number | 'ellipsis'> {
|
|
||||||
if (totalPages <= 7) {
|
|
||||||
return Array.from({ length: totalPages }, (_, index) => index + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const items: Array<number | 'ellipsis'> = [1];
|
|
||||||
if (current > 3) items.push('ellipsis');
|
|
||||||
|
|
||||||
const start = Math.max(2, current - 1);
|
|
||||||
const end = Math.min(totalPages - 1, current + 1);
|
|
||||||
for (let page = start; page <= end; page += 1) {
|
|
||||||
items.push(page);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (current < totalPages - 2) items.push('ellipsis');
|
|
||||||
if (totalPages > 1) items.push(totalPages);
|
|
||||||
return items;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function TablePagination({
|
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
total,
|
|
||||||
onPageChange,
|
|
||||||
onPageSizeChange,
|
|
||||||
}: TablePaginationProps) {
|
|
||||||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
|
||||||
const pageItems = useMemo(() => buildPageItems(page, totalPages), [page, totalPages]);
|
|
||||||
|
|
||||||
if (total === 0) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="vm-pagination" role="navigation" aria-label="表格分页">
|
|
||||||
<label className="vm-pagination-size">
|
|
||||||
<span className="vm-pagination-size-label">每页</span>
|
|
||||||
<select
|
|
||||||
className="vm-pagination-select"
|
|
||||||
value={pageSize}
|
|
||||||
onChange={(event) => onPageSizeChange(Number(event.target.value))}
|
|
||||||
aria-label="每页显示条数"
|
|
||||||
>
|
|
||||||
{PAGE_SIZE_OPTIONS.map((size) => (
|
|
||||||
<option key={size} value={size}>{size}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
<span className="vm-pagination-size-label">条</span>
|
|
||||||
</label>
|
|
||||||
|
|
||||||
<div className="vm-pagination-pages">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="vm-pagination-nav"
|
|
||||||
disabled={page <= 1}
|
|
||||||
onClick={() => onPageChange(page - 1)}
|
|
||||||
aria-label="上一页"
|
|
||||||
>
|
|
||||||
<ChevronLeft size={16} aria-hidden />
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{pageItems.map((item, index) => (
|
|
||||||
item === 'ellipsis' ? (
|
|
||||||
<span key={`ellipsis-${index}`} className="vm-pagination-ellipsis" aria-hidden>…</span>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
key={item}
|
|
||||||
type="button"
|
|
||||||
className={`vm-pagination-page ${page === item ? 'active' : ''}`}
|
|
||||||
onClick={() => onPageChange(item)}
|
|
||||||
aria-current={page === item ? 'page' : undefined}
|
|
||||||
>
|
|
||||||
{item}
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
))}
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="vm-pagination-nav"
|
|
||||||
disabled={page >= totalPages}
|
|
||||||
onClick={() => onPageChange(page + 1)}
|
|
||||||
aria-label="下一页"
|
|
||||||
>
|
|
||||||
<ChevronRight size={16} aria-hidden />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
|
||||||
@import "tailwindcss";
|
@import "tailwindcss";
|
||||||
|
@import "../../common/modal-detail-table.css";
|
||||||
|
@import "../../common/vm-pagination.css";
|
||||||
|
|
||||||
/* Visual system aligned with src/themes/linear/DESIGN.md — light (inverse) mode */
|
/* Visual system aligned with src/themes/linear/DESIGN.md — light (inverse) mode */
|
||||||
:root {
|
:root {
|
||||||
@@ -754,58 +756,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.vm-empty { padding: 48px 24px; text-align: center; color: var(--ln-muted); }
|
.vm-empty { padding: 48px 24px; text-align: center; color: var(--ln-muted); }
|
||||||
.vm-pagination {
|
|
||||||
display: flex; justify-content: flex-end; align-items: center;
|
|
||||||
flex-wrap: wrap; gap: 12px 16px;
|
|
||||||
width: 100%; padding: 0;
|
|
||||||
}
|
|
||||||
.vm-pagination-size {
|
|
||||||
display: inline-flex; align-items: center; gap: 8px;
|
|
||||||
font-size: 0.875rem; color: var(--ln-muted);
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.vm-pagination-size-label { white-space: nowrap; }
|
|
||||||
.vm-pagination-select {
|
|
||||||
min-width: 72px; height: var(--vm-control-height);
|
|
||||||
padding: 0 28px 0 10px;
|
|
||||||
border: 1px solid var(--ln-hairline-strong); border-radius: var(--ln-radius-control);
|
|
||||||
background: var(--ln-surface-card); color: var(--ln-ink);
|
|
||||||
font-size: 0.875rem; cursor: pointer;
|
|
||||||
}
|
|
||||||
.vm-pagination-select:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--vm-focus-border-color);
|
|
||||||
}
|
|
||||||
.vm-pagination-pages {
|
|
||||||
display: inline-flex; align-items: center; gap: 4px;
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
.vm-pagination-nav,
|
|
||||||
.vm-pagination-page {
|
|
||||||
display: inline-flex; align-items: center; justify-content: center;
|
|
||||||
min-width: var(--vm-control-height); height: var(--vm-control-height);
|
|
||||||
padding: 0 8px;
|
|
||||||
border: 1px solid var(--ln-hairline-strong); border-radius: var(--ln-radius-control);
|
|
||||||
background: var(--ln-surface-card); color: var(--ln-ink);
|
|
||||||
font-size: 0.875rem; font-weight: 500; cursor: pointer;
|
|
||||||
transition: background 0.2s, border-color 0.2s, color 0.2s;
|
|
||||||
}
|
|
||||||
.vm-pagination-nav:disabled { opacity: 0.45; cursor: not-allowed; }
|
|
||||||
.vm-pagination-nav:hover:not(:disabled),
|
|
||||||
.vm-pagination-page:hover:not(.active) { background: var(--ln-canvas-soft); border-color: var(--ln-hairline-strong); }
|
|
||||||
.vm-pagination-page.active {
|
|
||||||
background: var(--ln-primary); border-color: var(--ln-primary); color: var(--ln-on-primary);
|
|
||||||
}
|
|
||||||
.vm-pagination-nav:focus-visible,
|
|
||||||
.vm-pagination-page:focus-visible {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--vm-focus-border-color);
|
|
||||||
}
|
|
||||||
.vm-pagination-ellipsis {
|
|
||||||
display: inline-flex; align-items: center; justify-content: center;
|
|
||||||
min-width: 28px; height: var(--vm-control-height);
|
|
||||||
color: var(--ln-muted); font-size: 0.875rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.vm-modal-backdrop {
|
.vm-modal-backdrop {
|
||||||
position: fixed; inset: 0; background: rgba(24, 24, 27, 0.4);
|
position: fixed; inset: 0; background: rgba(24, 24, 27, 0.4);
|
||||||
@@ -2181,35 +2131,6 @@
|
|||||||
font-size: 0.8125rem;
|
font-size: 0.8125rem;
|
||||||
line-height: 28px;
|
line-height: 28px;
|
||||||
}
|
}
|
||||||
.vm-table-footer--compact .vm-pagination {
|
|
||||||
width: auto;
|
|
||||||
flex: 0 1 auto;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: nowrap;
|
|
||||||
}
|
|
||||||
.vm-table-footer--compact .vm-pagination-size {
|
|
||||||
gap: 4px;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
}
|
|
||||||
.vm-table-footer--compact .vm-pagination-select {
|
|
||||||
min-width: 52px;
|
|
||||||
height: 28px;
|
|
||||||
padding: 0 22px 0 8px;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
}
|
|
||||||
.vm-table-footer--compact .vm-pagination-nav,
|
|
||||||
.vm-table-footer--compact .vm-pagination-page {
|
|
||||||
min-width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
padding: 0 6px;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
}
|
|
||||||
.vm-table-footer--compact .vm-pagination-ellipsis {
|
|
||||||
min-width: 20px;
|
|
||||||
height: 28px;
|
|
||||||
font-size: 0.8125rem;
|
|
||||||
}
|
|
||||||
.vm-table-total {
|
.vm-table-total {
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: var(--ln-muted);
|
color: var(--ln-muted);
|
||||||
|
|||||||
128
src/prototypes/vm-shared/DESIGN.md
Normal file
128
src/prototypes/vm-shared/DESIGN.md
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
# vm-page 列表页 · 全局设计规范
|
||||||
|
|
||||||
|
与 **vehicle-management**(Linear 主题 token + `vm-*` 类名)对齐的中后台列表页壳层规范。
|
||||||
|
**所有新建 `vm-page` 列表页必须遵循本规范**,台账类页面另见 `ledger-shared/DESIGN.md`。
|
||||||
|
|
||||||
|
## 适用范围
|
||||||
|
|
||||||
|
| 类型 | 页面类名示例 | 样式入口 |
|
||||||
|
|------|--------------|----------|
|
||||||
|
| 通用列表 | `vm-page` | `vehicle-management/style.css` |
|
||||||
|
| 台账列表 | `vm-page ldb-page` | 同上 + `ledger-shared/styles/ledger-page.css` |
|
||||||
|
| 合同列表 | `vm-page lc-page` | 同上 + `lease-contract-management/styles/lease-contract.css` |
|
||||||
|
|
||||||
|
## 样式与组件依赖
|
||||||
|
|
||||||
|
新建列表页 **必须** 引入:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import '../vehicle-management/style.css';
|
||||||
|
```
|
||||||
|
|
||||||
|
`vehicle-management/style.css` 已内联全局分页样式(`src/common/vm-pagination.css`)。
|
||||||
|
|
||||||
|
分页组件 **必须** 使用公共实现:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { TablePagination, DEFAULT_PAGE_SIZE } from '../../common/TablePagination';
|
||||||
|
```
|
||||||
|
|
||||||
|
禁止复制粘贴分页 DOM、手写 Ant Design Pagination、或自建另一套分页类名。
|
||||||
|
|
||||||
|
## 表格底部分页(强制)
|
||||||
|
|
||||||
|
凡带数据表格的列表页,**一律使用底部分页**,禁止「下拉加载更多」「无限滚动」替代分页(除非产品需求明确排除分页且已在 PRD 写明)。
|
||||||
|
|
||||||
|
### 布局结构
|
||||||
|
|
||||||
|
```text
|
||||||
|
vm-table-card
|
||||||
|
├── vm-table-wrap / ldb-table-wrap # 表格主体
|
||||||
|
└── vm-table-footer # 表格底栏(上边框分隔)
|
||||||
|
├── 左侧:总条数文案(可选)
|
||||||
|
└── 右侧:TablePagination
|
||||||
|
```
|
||||||
|
|
||||||
|
标准 DOM 示例:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
<div className="vm-table-footer">
|
||||||
|
<span className="vm-table-total">共 {total} 条</span>
|
||||||
|
<TablePagination
|
||||||
|
page={Math.min(page, totalPages)}
|
||||||
|
pageSize={pageSize}
|
||||||
|
total={total}
|
||||||
|
onPageChange={setPage}
|
||||||
|
onPageSizeChange={(size) => {
|
||||||
|
setPageSize(size);
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
台账页可在 footer 上加 `ldb-table-footer`,左侧用 `ldb-table-total`,分页仍用同一 `TablePagination` 组件。
|
||||||
|
|
||||||
|
### 视觉规格
|
||||||
|
|
||||||
|
| 元素 | 类名 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 分页容器 | `vm-pagination` | 右对齐,可换行;gap 12px / 16px |
|
||||||
|
| 每页条数 | `vm-pagination-size` + `vm-pagination-select` | 文案「每页 N 条」 |
|
||||||
|
| 页码区 | `vm-pagination-pages` | 上一页 / 页码 / 下一页 |
|
||||||
|
| 当前页 | `vm-pagination-page active` | 主色填充 |
|
||||||
|
| 省略 | `vm-pagination-ellipsis` | 超过 7 页时显示 … |
|
||||||
|
| 总条数 | `vm-table-total` | 0.875rem,muted 色 |
|
||||||
|
|
||||||
|
样式源文件:`src/common/vm-pagination.css`(由 `vehicle-management/style.css` 引入)。
|
||||||
|
|
||||||
|
### 交互与状态
|
||||||
|
|
||||||
|
| 项 | 规则 |
|
||||||
|
|----|------|
|
||||||
|
| 默认每页条数 | `DEFAULT_PAGE_SIZE`(20) |
|
||||||
|
| 可选每页条数 | 10 / 20 / 50 / 100(`PAGE_SIZE_OPTIONS`) |
|
||||||
|
| 筛选 / 重置 / KPI 切换 | 将 `page` 重置为 1 |
|
||||||
|
| 切换每页条数 | 将 `page` 重置为 1 |
|
||||||
|
| 无数据 | `TablePagination` 返回 `null`,不渲染分页 |
|
||||||
|
| 无障碍 | `role="navigation"`、`aria-label="表格分页"`、当前页 `aria-current="page"` |
|
||||||
|
|
||||||
|
### 数据切片
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
||||||
|
const paged = useMemo(() => {
|
||||||
|
const start = (page - 1) * pageSize;
|
||||||
|
return filtered.slice(start, start + pageSize);
|
||||||
|
}, [filtered, page, pageSize]);
|
||||||
|
```
|
||||||
|
|
||||||
|
表格 `records` 传入 `paged`,分页 `total` 传入筛选后总数 `filtered.length`。
|
||||||
|
|
||||||
|
### 紧凑变体(详情 Tab 内嵌表格)
|
||||||
|
|
||||||
|
详情页 Tab 等小空间场景,footer 追加:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="vm-table-footer vm-table-footer--split vm-table-footer--compact">
|
||||||
|
```
|
||||||
|
|
||||||
|
仍使用同一 `TablePagination` 组件;紧凑样式见 `vm-pagination.css` 内 `.vm-table-footer--compact` 规则。
|
||||||
|
|
||||||
|
参考:`vehicle-management/components/Detail*RecordsTab.tsx`。
|
||||||
|
|
||||||
|
## 禁止做法
|
||||||
|
|
||||||
|
- 不使用 `vm-pagination*` 类名自建分页 UI
|
||||||
|
- 不在列表主表格使用「下拉显示更多」代替分页
|
||||||
|
- 不引入与 Linear token 冲突的第三方分页皮肤
|
||||||
|
- 不在 footer 混用多种分页交互(分页 + 加载更多并存)
|
||||||
|
|
||||||
|
## 参考实现
|
||||||
|
|
||||||
|
| 页面 | 路径 |
|
||||||
|
|------|------|
|
||||||
|
| 车辆管理 | `src/prototypes/vehicle-management/index.tsx` |
|
||||||
|
| 租赁业务台账 | `src/prototypes/lease-business-ledger/index.tsx` |
|
||||||
|
| 客户管理 | `src/prototypes/customer-management/index.tsx` |
|
||||||
|
| 收款记录 | `src/prototypes/payment-records/index.tsx` |
|
||||||
Reference in New Issue
Block a user