feat(supplier-management): 同步供应商管理最新原型迭代
补齐新增表单校验、角色切换、删除/离开确认等交互组件,并更新列表 KPI、样例数据与需求评审文档。
This commit is contained in:
94
src/prototypes/supplier-management/.spec/prototype-review.md
Normal file
94
src/prototypes/supplier-management/.spec/prototype-review.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# Prototype Review
|
||||
|
||||
- 审查目标:`src/prototypes/supplier-management`(列表页 `list` + 新增页 `create`)
|
||||
- 用户资料/参考资料:
|
||||
- 本轮用户说明:按 `rules/prototype-review-guide.md` 对供应商管理做需求评审
|
||||
- `.spec/prototype-comments.json`(供应商名称筛选项、联系人必填与电话位数校验等近期批注)
|
||||
- `src/resources/oneos-web-legacy/需求说明/业务管理/供应商管理`
|
||||
- `src/resources/oneos-web-legacy/业务管理/供应商管理-新增.jsx`、`供应商管理-查看.jsx`、`供应商管理-编辑.jsx`
|
||||
- 源码:`index.tsx`、`pages/CreatePage.tsx`、`components/*`、`utils/*`、`types.ts`、`data/suppliers.json`
|
||||
- `annotation-source.json`、`canvas.excalidraw`(未逐元素核对画布)
|
||||
- `.axhub/make/project.json`(入口 `supplier-management` → `/prototypes/supplier-management`)
|
||||
- 生成时间:2026-07-03
|
||||
|
||||
## 总体点评
|
||||
|
||||
供应商管理原型在**列表台账展示**方面与 Legacy 需求高度对齐:筛选区(含近期批注要求的供应商名称模糊搜索)、8 张 KPI 统计卡片、宽表列(编码至创建时间)、分页与导入/导出入口均已具备,样例数据 `suppliers.json` 字段口径与表格列一致,列表侧可作为「供应商主数据台账」的有效演示。
|
||||
|
||||
当前最影响需求评审签字的问题,集中在**主流程出口与新增表单数据模型**。Legacy 与资源文档明确要求查看页、编辑页、删除二次确认;源码中三类操作均为 Toast(`index.tsx`),无法完成「查—改—删」闭环。新增页已按加氢站/客户管理风格扩展为主体信息、联系人、开票、银行账户、资质证照五段式,但**缺少列表与 Legacy 新增页共有的合作状态、供应商类型、城市/区域、信用代码/身份证、电子邮箱、备注等字段**,提交后也不写回 `records` 状态,导致「新增 → 列表可见」链路在业务上断裂。
|
||||
|
||||
近期 `.spec/prototype-comments.json` 与 `annotation-source.json` 已记录筛选区名称搜索、联系人校验等决策,与实现基本一致;但 `.spec` 下**无独立 PRD Markdown**,与 Legacy 2026-03-12 版需求在新增表单结构、列表名称搜索交互(联动 vs 点查询)上存在明显分歧,评审时需产品确认以哪套为准。
|
||||
|
||||
## P0-P3 优先级问题
|
||||
|
||||
### P1 - 查看 / 编辑 / 删除主流程未实现,Legacy 核心任务出口缺失
|
||||
|
||||
- 证据:Legacy `需求说明/业务管理/供应商管理` §2.15–§2.16 要求查看页、编辑页跳转及删除二次确认;`供应商管理.jsx` 含删除确认文案(历史数据保留规则);源码 `index.tsx` 中 `onView` / `onEdit` / `onDelete` 均仅 `showToast('…原型演示')`,`SupplierPageId` 仅 `'list' | 'create'`,无 view/edit 路由。
|
||||
- 影响:无法验证编辑态字段只读/可改规则、删除后对合同/账单选供应商的约束;与 OneOS 业务管理模块能力不对齐,需求评审无法确认主流程完整性。
|
||||
- 修复方向:至少补充查看页(只读回显)与编辑页(编码不可改等 Legacy 规则)原型;删除增加确认弹窗并 Mock 从可选列表移除、历史保留说明。
|
||||
|
||||
### P1 - 新增表单字段与列表台账 / Legacy 新增页严重不对齐
|
||||
|
||||
- 证据:Legacy `供应商管理-新增.jsx` 必填含合作状态、供应商名称、类型、城市(省-市级联)、供应商地址、联系人、联系人手机、信用代码/身份证;列表列含类型、区域、城市、信用代码、邮箱、备注(`SupplierTable.tsx`)。当前 `CreatePage.tsx` / `SupplierCreateForm` 含签约公司、地址解析、联系人、开票、银行、证照,但**无** `coopStatus`、`type`、`city`/`region`、`creditCodeOrId`、`email`、`remark`;校验(`validateCreateForm`)亦未覆盖上述字段。
|
||||
- 影响:即使用户完整填写并提交,也无法生成与表格列一致的台账记录;与加氢站「新建供应商」区块(`站点信息.jsx`)部分对齐,但与 Legacy 供应商模块口径冲突,下游合同/对账场景无法演示字段来源。
|
||||
- 修复方向:产品确认新增页以 Legacy 两卡片为准,还是以当前五段式(对齐加氢站)为准;若保留五段式,需补全台账关键字段及区域自动匹配逻辑,并在标注/PRD 写明与 Legacy 差异。
|
||||
|
||||
### P1 - 提交 / 保存不写回列表,新增主流程闭环断裂
|
||||
|
||||
- 证据:`index.tsx` 中 `records` 为 `useState` 初始 seed 且只读;`handleSubmitCreate` 校验通过后仅 Toast 并 `setView('list')`,不追加 `SupplierRecord`;`handleSaveCreate` 同样不落库。
|
||||
- 影响:无法演示「新增供应商后在列表/KPI 中可见」;筛选、KPI 计数、分页与新增联调失效;测试无法覆盖提交后编码生成、创建人/时间写入等规则。
|
||||
- 修复方向:提交时将表单 Mock 映射为 `SupplierRecord` 写入本地 state(含自动生成编码、创建人/时间);保存草稿可写入 session 或单独 drafts 列表。
|
||||
|
||||
### P2 - 删除缺少二次确认与业务规则表达
|
||||
|
||||
- 证据:Legacy §2.16.3 要求删除确认及「已删除供应商无法在合同或账单中选择,历史数据仍显示」;`index.tsx` `onDelete` 直接 Toast,无 Modal。
|
||||
- 影响:业务约束(软删 vs 硬删、下游引用)无法在原型中感知,评审易遗漏合规讨论。
|
||||
- 修复方向:增加确认对话框,文案对齐 Legacy;Mock 删除可标记 `coopStatus` 或 `deleted` 标志并过滤可选列表。
|
||||
|
||||
### P2 - KPI 卡片与筛选区口径分裂,且超出 Legacy 列表范围
|
||||
|
||||
- 证据:`KpiCards.tsx` + `utils/constants.ts` 定义 8 张卡片(含「资质异常」`licenseAbnormal`),点击即时过滤(`kpiTab`);筛选区 `pendingFilters` / `appliedFilters` 需点「查询」才生效(`FilterPanel.tsx` + `index.tsx`);Legacy 需求说明无 KPI 卡片章节。`licenseStatus` 仅存在于 seed 数据,新增页无法维护。
|
||||
- 影响:KPI 与高级筛选组合时用户难以理解最终列表范围;「资质异常」卡片无对应新增/编辑维护路径,业务含义难验收。
|
||||
- 修复方向:在 PRD/标注补充 KPI 与筛选优先级;或 KPI 激活时联动写入 `appliedFilters`;明确「资质异常」判定来源(证照上传/OCR/人工标记)。
|
||||
|
||||
## 完整性与项目对齐
|
||||
|
||||
| 维度 | 结论 |
|
||||
|---|---|
|
||||
| 核心用户 | Legacy 未细分角色;原型无权限模型 — **可接受为原型简化** |
|
||||
| 主流程 | 列表筛选 → KPI → 表格 → 分页:**可演示**;新增 → 校验 → 返回列表:**部分可演示**(校验有、落库无);查看/编辑/删除:**未实现** |
|
||||
| 入口/出口 | 入口:`project.json` → `/prototypes/supplier-management` — **对齐**;新增页返回列表 — **有**;缺查看/编辑出口 |
|
||||
| 范围边界 | 导入/导出 Toast 演示 — **符合原型惯例**;8 KPI 卡片 — **实现有、Legacy 无**;五段式新增表单 — **与 Legacy 两卡片不一致** |
|
||||
| 近期批注对齐 | 筛选区供应商名称(`FilterPanel.tsx` `nameKeyword` + 模糊匹配)— **已实现**;联系人必填 + 手机/座机至少一项 + 位数校验(`utils/phone.ts`)— **已实现** |
|
||||
| 资料冲突/待确认 | ① 列表名称搜索:Legacy §2 要求工具栏左上方**输入即联动**;批注与实现放在筛选区且需点「查询」— **以批注为准,Legacy 待产品确认是否废弃**;② 新增表单结构:Legacy 基本信息+开票 vs 当前主体+联系人+开票+银行+证照 — **待确认**;③ 联系人手机:Legacy 新增页手机必填;批注与实现为手机/座机二选一 — **以实现+批注为准,Legacy 待确认**;④ `.spec` 无 `requirements-prd-*.md` — **规格缺口** |
|
||||
|
||||
## 业务逻辑连贯性
|
||||
|
||||
**流程顺序**:列表加载 →(可选)KPI 切换 → 筛选填写 → 查询 → 表格浏览 → 新增 — 顺序合理。名称链接与「查看」均指向 Toast,未形成真实导航。
|
||||
|
||||
**状态迁移**:合作状态枚举(已合作/终止合作/洽谈中/合约过期)在列表、`CoopStatusBadge`、筛选与 KPI 间一致 — **自洽**。新增页不采集合作状态,新记录无法表达 KPI「已合作/洽谈中」等维度 — **断档**。
|
||||
|
||||
**区域与城市**:Legacy 要求城市级联后自动匹配区域;列表展示 `region` + `city`(省-市);筛选区区域多选 + 城市单选(扁平 `FilterPickerField`),新增页仅地址解析出 `provinceCity`,不写入 `SupplierRecord.region/city` — **新增与列表字段链路不完整**。
|
||||
|
||||
**跨模块一致性**:加氢站新建供应商含主体、开票、银行、证照(`oneos-web-legacy/加氢站管理/站点信息.jsx`),与当前新增页结构相近;Legacy 独立「供应商管理-新增」字段集更偏台账全字段 — **项目内存在两套供应商表单范式,需统一**。
|
||||
|
||||
**数据生命周期**:seed 数据含 `licenseStatus` 驱动 KPI「资质异常」,但无编辑入口维护 — **只读演示**。
|
||||
|
||||
## 状态、异常、边界与恢复
|
||||
|
||||
| 类别 | 检查结论 |
|
||||
|---|---|
|
||||
| 输入校验 | 新增:签约公司、名称、地址解析、联系人、电话格式 — **有**(Toast 提示);缺类型/城市/信用代码等 — **见 P1** |
|
||||
| 空/无数据 | 表格空态「暂无符合条件的供应商」— **有** |
|
||||
| 权限不足 | 未建模 — **N/A** |
|
||||
| 筛选边界 | 名称模糊匹配、编码精确、多选合作状态/类型/区域 — **有**;城市非级联 — **与 Legacy 差异** |
|
||||
| KPI + 筛选重置 | 「重置」清空筛选并 KPI 回「全部供应商」— **有** |
|
||||
| 删除/并发 | 无确认、无防抖 — **见 P2** |
|
||||
| 网络/集成 | OCR、导入校验均为 Mock Toast — **原型范围内可接受** |
|
||||
| 恢复路径 | 校验失败 Toast 可重填 — **有**;提交成功后列表无新数据 — **无业务恢复感知** |
|
||||
|
||||
## 证据与评估说明
|
||||
|
||||
- **用户资料优先级**:本轮无额外用户口头需求;`.spec/prototype-comments.json` 批注权重高于 Legacy 冲突点(名称筛选位置、联系人电话规则),已在「完整性与项目对齐」标为待确认处。
|
||||
- **读取范围**:`rules/prototype-review-guide.md`;`.spec/prototype-comments.json`;`src/resources/oneos-web-legacy/需求说明/业务管理/供应商管理`;Legacy JSX 新增/查看/编辑;`index.tsx`、`CreatePage.tsx`、`FilterPanel.tsx`、`SupplierTable.tsx`、`KpiCards.tsx`、`CoopStatusBadge.tsx`;`utils/filters.ts`、`constants.ts`、`address.ts`、`phone.ts`;`types.ts`、`data/suppliers.json`;`annotation-source.json`;`.axhub/make/project.json`(摘要)。未引用 Impeccable 产物。
|
||||
- **独立评估**:`degraded` — 未拆分子代理;业务基线(Legacy + 批注)与源码证据在同一轮完成对照。
|
||||
@@ -36,7 +36,7 @@
|
||||
"path": []
|
||||
},
|
||||
"aiPrompt": "KPI 卡片点击切换列表数据范围。",
|
||||
"annotationText": "参照车辆管理列表页,8 张统计卡片可点击筛选。",
|
||||
"annotationText": "6 张统计卡片:全部、已合作、洽谈中、终止合作、合约过期、资质异常(未上传营业执照);已移除备件供应商/保险公司类型卡片。",
|
||||
"hasMarkdown": true,
|
||||
"color": "#0f766e",
|
||||
"images": [],
|
||||
@@ -82,24 +82,42 @@
|
||||
{
|
||||
"id": "sm-create-entity",
|
||||
"index": 5,
|
||||
"title": "主体信息",
|
||||
"title": "地址信息",
|
||||
"pageId": "create",
|
||||
"locator": {
|
||||
"selectors": ["[data-annotation-id=\"sm-create-entity\"]"],
|
||||
"fingerprint": "sm-create-entity",
|
||||
"path": []
|
||||
},
|
||||
"aiPrompt": "签约公司、供应商名称、地址解析字段。",
|
||||
"annotationText": "签约公司下拉默认展示置灰占位「请选择羚牛签约主体」,不可作为有效选项。地址解析为必填项,填写完整地址后自动反写省市与详细地址。",
|
||||
"aiPrompt": "签约公司、供应商名称、区域、城市、详细地址。",
|
||||
"annotationText": "签约公司下拉默认展示置灰占位「请选择羚牛签约主体」。含签约公司、供应商名称、区域、城市(只读自动解析)、详细地址(无必填星号);填写详细地址后自动解析区域与城市。",
|
||||
"hasMarkdown": true,
|
||||
"color": "#2563eb",
|
||||
"images": [],
|
||||
"createdAt": 1740787200000,
|
||||
"updatedAt": 1740787200000
|
||||
"updatedAt": 1783052400000
|
||||
},
|
||||
{
|
||||
"id": "sm-create-contact",
|
||||
"index": 6,
|
||||
"title": "联系人信息",
|
||||
"pageId": "create",
|
||||
"locator": {
|
||||
"selectors": ["[data-annotation-id=\"sm-create-contact\"]"],
|
||||
"fingerprint": "sm-create-contact",
|
||||
"path": []
|
||||
},
|
||||
"aiPrompt": "联系人、联系人手机、联系人座机。",
|
||||
"annotationText": "联系人为必填;联系人手机(11位)与联系人座机(7-12位)至少填写一项,保存/提交时校验位数与格式。",
|
||||
"hasMarkdown": true,
|
||||
"color": "#2563eb",
|
||||
"images": [],
|
||||
"createdAt": 1740787200000,
|
||||
"updatedAt": 1783052400000
|
||||
},
|
||||
{
|
||||
"id": "sm-create-invoice",
|
||||
"index": 6,
|
||||
"index": 7,
|
||||
"title": "开票信息",
|
||||
"pageId": "create",
|
||||
"locator": {
|
||||
@@ -108,7 +126,7 @@
|
||||
"path": []
|
||||
},
|
||||
"aiPrompt": "纳税人识别号、注册电话、注册地址。",
|
||||
"annotationText": "",
|
||||
"annotationText": "纳税人识别号、注册电话、注册地址;所有有编辑权限的用户均可维护(不限于财务)。",
|
||||
"hasMarkdown": true,
|
||||
"color": "#2563eb",
|
||||
"images": [],
|
||||
@@ -117,7 +135,7 @@
|
||||
},
|
||||
{
|
||||
"id": "sm-create-bank",
|
||||
"index": 7,
|
||||
"index": 8,
|
||||
"title": "银行账户",
|
||||
"pageId": "create",
|
||||
"locator": {
|
||||
@@ -126,7 +144,7 @@
|
||||
"path": []
|
||||
},
|
||||
"aiPrompt": "开户行与银行账号。",
|
||||
"annotationText": "",
|
||||
"annotationText": "开户行、银行账号;所有有编辑权限的用户均可维护(不限于财务)。",
|
||||
"hasMarkdown": true,
|
||||
"color": "#2563eb",
|
||||
"images": [],
|
||||
@@ -135,7 +153,7 @@
|
||||
},
|
||||
{
|
||||
"id": "sm-create-docs",
|
||||
"index": 8,
|
||||
"index": 9,
|
||||
"title": "资质证照",
|
||||
"pageId": "create",
|
||||
"locator": {
|
||||
@@ -144,7 +162,7 @@
|
||||
"path": []
|
||||
},
|
||||
"aiPrompt": "营业执照 OCR 与其他证件上传。",
|
||||
"annotationText": "营业执照上传后自动反写名称、税号、通讯地址。",
|
||||
"annotationText": "营业执照与其他证件上传;所有有编辑权限的用户均可维护(不限于财务)。",
|
||||
"hasMarkdown": true,
|
||||
"color": "#2563eb",
|
||||
"images": [],
|
||||
@@ -153,7 +171,7 @@
|
||||
},
|
||||
{
|
||||
"id": "sm-create-footer",
|
||||
"index": 9,
|
||||
"index": 10,
|
||||
"title": "表单提交",
|
||||
"pageId": "create",
|
||||
"locator": {
|
||||
@@ -162,7 +180,7 @@
|
||||
"path": []
|
||||
},
|
||||
"aiPrompt": "返回、保存与提交操作。",
|
||||
"annotationText": "底部固定操作栏:左侧「返回」回到列表;右侧「保存」暂存草稿,「提交」校验通过后提交并返回列表。",
|
||||
"annotationText": "底部操作栏按钮右对齐:「返回」「保存」「提交」。未保存时点击「返回」或面包屑离开,需二次确认。",
|
||||
"hasMarkdown": true,
|
||||
"color": "#0f766e",
|
||||
"images": [],
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import React from 'react';
|
||||
import { ShieldAlert, X } from 'lucide-react';
|
||||
|
||||
interface BankAccountConfirmModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function BankAccountConfirmModal({
|
||||
open,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: BankAccountConfirmModalProps) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="vm-modal-backdrop" role="presentation" onClick={onClose}>
|
||||
<div
|
||||
className="vm-modal vm-modal-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="sm-bank-title"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header className="vm-modal-header">
|
||||
<h2 id="sm-bank-title">确认变更银行账户</h2>
|
||||
<button type="button" className="vm-modal-close" onClick={onClose} aria-label="关闭">
|
||||
<X size={18} aria-hidden />
|
||||
</button>
|
||||
</header>
|
||||
<div className="sm-modal-body">
|
||||
<div className="sm-modal-alert">
|
||||
<ShieldAlert size={18} aria-hidden />
|
||||
<div>
|
||||
<p>银行账户属于关键主数据字段,变更后将写入变更记录并需财务复核(原型演示)。</p>
|
||||
<p className="sm-modal-sub">请确认开户行与银行账号信息准确无误。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer className="vm-modal-footer">
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose}>取消</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={onConfirm}>确认变更</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import type { ChangeLogEntry } from '../types';
|
||||
|
||||
interface ChangeLogDrawerProps {
|
||||
open: boolean;
|
||||
supplierId: string;
|
||||
supplierName: string;
|
||||
entries: ChangeLogEntry[];
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function ChangeLogDrawer({
|
||||
open,
|
||||
supplierId,
|
||||
supplierName,
|
||||
entries,
|
||||
onClose,
|
||||
}: ChangeLogDrawerProps) {
|
||||
if (!open) return null;
|
||||
|
||||
const filtered = entries.filter((item) => item.supplierId === supplierId);
|
||||
|
||||
return (
|
||||
<div className="sm-drawer-backdrop" role="presentation" onClick={onClose}>
|
||||
<aside
|
||||
className="sm-drawer"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="sm-changelog-title"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
data-annotation-id="sm-changelog-drawer"
|
||||
>
|
||||
<header className="sm-drawer-header">
|
||||
<div>
|
||||
<h2 id="sm-changelog-title">变更记录</h2>
|
||||
<p className="sm-drawer-sub">{supplierName}</p>
|
||||
</div>
|
||||
<button type="button" className="vm-modal-close" onClick={onClose} aria-label="关闭">
|
||||
<X size={18} aria-hidden />
|
||||
</button>
|
||||
</header>
|
||||
<div className="sm-drawer-body">
|
||||
{filtered.length === 0 ? (
|
||||
<div className="vm-empty">
|
||||
<p>暂无变更记录</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul className="sm-changelog-list">
|
||||
{filtered.map((entry) => (
|
||||
<li key={entry.id} className="sm-changelog-item">
|
||||
<div className="sm-changelog-meta">
|
||||
<span>{entry.fieldLabel}</span>
|
||||
<time>{entry.operatedAt}</time>
|
||||
</div>
|
||||
<div className="sm-changelog-diff">
|
||||
<span className="sm-changelog-old">{entry.oldValue}</span>
|
||||
<span className="sm-changelog-arrow" aria-hidden>→</span>
|
||||
<span className="sm-changelog-new">{entry.newValue}</span>
|
||||
</div>
|
||||
<div className="sm-changelog-operator">操作人:{entry.operator}</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import React from 'react';
|
||||
import { AlertTriangle, X } from 'lucide-react';
|
||||
import type { SupplierRecord } from '../types';
|
||||
import { canDeleteSupplier } from '../utils/permissions';
|
||||
|
||||
interface DeleteConfirmModalProps {
|
||||
open: boolean;
|
||||
record: SupplierRecord | null;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function DeleteConfirmModal({
|
||||
open,
|
||||
record,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: DeleteConfirmModalProps) {
|
||||
if (!open || !record) return null;
|
||||
|
||||
const deletable = canDeleteSupplier(record);
|
||||
|
||||
return (
|
||||
<div className="vm-modal-backdrop" role="presentation" onClick={onClose}>
|
||||
<div
|
||||
className="vm-modal vm-modal-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="sm-delete-title"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header className="vm-modal-header">
|
||||
<h2 id="sm-delete-title">{deletable ? '确认删除供应商' : '确认停用供应商'}</h2>
|
||||
<button type="button" className="vm-modal-close" onClick={onClose} aria-label="关闭">
|
||||
<X size={18} aria-hidden />
|
||||
</button>
|
||||
</header>
|
||||
<div className="sm-modal-body">
|
||||
<div className="sm-modal-alert">
|
||||
<AlertTriangle size={18} aria-hidden />
|
||||
<div>
|
||||
{deletable ? (
|
||||
<p>
|
||||
确定删除供应商「{record.supplierName}」吗?删除后不可恢复(原型演示)。
|
||||
</p>
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
供应商「{record.supplierName}」已关联
|
||||
{record.hasLinkedContract && '合同'}
|
||||
{record.hasLinkedContract && record.hasLinkedPayment && '、'}
|
||||
{record.hasLinkedPayment && '付款记录'}
|
||||
,不可删除,仅可停用。
|
||||
</p>
|
||||
<p className="sm-modal-sub">停用后该供应商将不再参与新业务,历史数据保留。</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer className="vm-modal-footer">
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose}>取消</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={onConfirm}>
|
||||
{deletable ? '确认删除' : '确认停用'}
|
||||
</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,8 +6,6 @@ import {
|
||||
MessageSquare,
|
||||
Ban,
|
||||
Clock,
|
||||
Package,
|
||||
Shield,
|
||||
FileWarning,
|
||||
} from 'lucide-react';
|
||||
import type { SupplierKpiKey } from '../types';
|
||||
@@ -19,8 +17,6 @@ const ICONS: Record<SupplierKpiKey, React.ReactNode> = {
|
||||
negotiating: <MessageSquare size={18} aria-hidden />,
|
||||
terminated: <Ban size={18} aria-hidden />,
|
||||
expired: <Clock size={18} aria-hidden />,
|
||||
parts: <Package size={18} aria-hidden />,
|
||||
insurance: <Shield size={18} aria-hidden />,
|
||||
licenseAbnormal: <FileWarning size={18} aria-hidden />,
|
||||
};
|
||||
|
||||
@@ -32,7 +28,7 @@ interface KpiCardsProps {
|
||||
|
||||
export function KpiCards({ counts, active, onChange }: KpiCardsProps) {
|
||||
return (
|
||||
<div className="vm-kpi-row">
|
||||
<div className="vm-kpi-row sm-kpi-row">
|
||||
{KPI_CARDS.map((card) => (
|
||||
<button
|
||||
key={card.key}
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from 'react';
|
||||
import { AlertTriangle, X } from 'lucide-react';
|
||||
|
||||
interface LeaveConfirmModalProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onConfirm: () => void;
|
||||
}
|
||||
|
||||
export function LeaveConfirmModal({ open, onClose, onConfirm }: LeaveConfirmModalProps) {
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className="vm-modal-backdrop" role="presentation" onClick={onClose}>
|
||||
<div
|
||||
className="vm-modal vm-modal-sm"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="sm-leave-title"
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<header className="vm-modal-header">
|
||||
<h2 id="sm-leave-title">确认离开</h2>
|
||||
<button type="button" className="vm-modal-close" onClick={onClose} aria-label="关闭">
|
||||
<X size={18} aria-hidden />
|
||||
</button>
|
||||
</header>
|
||||
<div className="sm-modal-body">
|
||||
<div className="sm-modal-alert">
|
||||
<AlertTriangle size={18} aria-hidden />
|
||||
<div>
|
||||
<p>当前表单尚未保存,确定要离开吗?</p>
|
||||
<p className="sm-modal-sub">离开后已填写内容将不会保留。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<footer className="vm-modal-footer">
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={onClose}>继续编辑</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={onConfirm}>确认离开</button>
|
||||
</footer>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import React from 'react';
|
||||
import type { SupplierRole } from '../types';
|
||||
import { SUPPLIER_ROLE_OPTIONS } from '../utils/constants';
|
||||
import { canCreateSupplier } from '../utils/permissions';
|
||||
|
||||
interface RoleSwitcherProps {
|
||||
role: SupplierRole;
|
||||
onChange: (role: SupplierRole) => void;
|
||||
}
|
||||
|
||||
export function RoleSwitcher({ role, onChange }: RoleSwitcherProps) {
|
||||
const current = SUPPLIER_ROLE_OPTIONS.find((item) => item.value === role);
|
||||
|
||||
return (
|
||||
<div className="sm-role-bar" data-annotation-id="sm-role-switch">
|
||||
<span className="sm-role-bar-label">当前操作角色(原型演示):</span>
|
||||
<select
|
||||
className="vm-input sm-role-select"
|
||||
value={role}
|
||||
onChange={(event) => onChange(event.target.value as SupplierRole)}
|
||||
aria-label="当前操作角色"
|
||||
>
|
||||
{SUPPLIER_ROLE_OPTIONS.map((item) => (
|
||||
<option key={item.value} value={item.value}>{item.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<span className="sm-role-hint">{current?.desc}</span>
|
||||
{!canCreateSupplier(role) && (
|
||||
<span className="sm-role-hint sm-role-hint-warn">当前角色仅可查看主数据</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -16,7 +16,8 @@
|
||||
"remark": "",
|
||||
"creator": "李四",
|
||||
"createTime": "2025-01-10 09:30",
|
||||
"licenseStatus": "正常"
|
||||
"licenseStatus": "正常",
|
||||
"businessLicenseUploaded": true
|
||||
},
|
||||
{
|
||||
"id": "2",
|
||||
@@ -35,7 +36,8 @@
|
||||
"remark": "意向供应商",
|
||||
"creator": "李专员",
|
||||
"createTime": "2025-02-15 14:20",
|
||||
"licenseStatus": "正常"
|
||||
"licenseStatus": "正常",
|
||||
"businessLicenseUploaded": true
|
||||
},
|
||||
{
|
||||
"id": "3",
|
||||
@@ -54,7 +56,8 @@
|
||||
"remark": "",
|
||||
"creator": "张经理",
|
||||
"createTime": "2025-01-20 11:00",
|
||||
"licenseStatus": "异常"
|
||||
"licenseStatus": "异常",
|
||||
"businessLicenseUploaded": true
|
||||
},
|
||||
{
|
||||
"id": "4",
|
||||
@@ -73,7 +76,8 @@
|
||||
"remark": "已终止",
|
||||
"creator": "李四",
|
||||
"createTime": "2024-06-01 10:00",
|
||||
"licenseStatus": "正常"
|
||||
"licenseStatus": "正常",
|
||||
"businessLicenseUploaded": true
|
||||
},
|
||||
{
|
||||
"id": "5",
|
||||
@@ -92,7 +96,8 @@
|
||||
"remark": "待续约",
|
||||
"creator": "王专员",
|
||||
"createTime": "2025-03-01 08:45",
|
||||
"licenseStatus": "异常"
|
||||
"licenseStatus": "异常",
|
||||
"businessLicenseUploaded": false
|
||||
},
|
||||
{
|
||||
"id": "6",
|
||||
@@ -111,7 +116,8 @@
|
||||
"remark": "",
|
||||
"creator": "张经理",
|
||||
"createTime": "2025-03-12 16:10",
|
||||
"licenseStatus": "正常"
|
||||
"licenseStatus": "正常",
|
||||
"businessLicenseUploaded": true
|
||||
},
|
||||
{
|
||||
"id": "7",
|
||||
@@ -130,7 +136,8 @@
|
||||
"remark": "",
|
||||
"creator": "李四",
|
||||
"createTime": "2025-04-02 10:20",
|
||||
"licenseStatus": "正常"
|
||||
"licenseStatus": "正常",
|
||||
"businessLicenseUploaded": true
|
||||
},
|
||||
{
|
||||
"id": "8",
|
||||
@@ -149,6 +156,7 @@
|
||||
"remark": "评估中",
|
||||
"creator": "李专员",
|
||||
"createTime": "2025-04-18 11:35",
|
||||
"licenseStatus": "正常"
|
||||
"licenseStatus": "正常",
|
||||
"businessLicenseUploaded": false
|
||||
}
|
||||
]
|
||||
|
||||
@@ -17,6 +17,7 @@ import { FilterPanel } from './components/FilterPanel';
|
||||
import { KpiCards } from './components/KpiCards';
|
||||
import { SupplierTable } from './components/SupplierTable';
|
||||
import { CreatePage } from './pages/CreatePage';
|
||||
import { LeaveConfirmModal } from './components/LeaveConfirmModal';
|
||||
import { ImportModal } from '../vehicle-management/components/ImportModal';
|
||||
import { TablePagination } from '../vehicle-management/components/TablePagination';
|
||||
import {
|
||||
@@ -29,6 +30,8 @@ import {
|
||||
type SupplierRecord,
|
||||
} from './types';
|
||||
import { applyFilters, countKpi } from './utils/filters';
|
||||
import { validateContactPhones } from './utils/phone';
|
||||
import { cloneCreateForm, isCreateFormDirty } from './utils/createForm';
|
||||
import annotationSourceDocument from './annotation-source.json';
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 10;
|
||||
@@ -41,8 +44,8 @@ type NavigationRef = {
|
||||
function validateCreateForm(form: SupplierCreateForm): string | null {
|
||||
if (!form.contractingCompany) return '请选择签约公司';
|
||||
if (!form.supplierName.trim()) return '请输入供应商名称';
|
||||
if (!form.fullAddress.trim()) return '请填写地址解析';
|
||||
return null;
|
||||
if (!form.contact.trim()) return '请输入联系人';
|
||||
return validateContactPhones(form.contactMobile, form.contactPhone);
|
||||
}
|
||||
|
||||
export default function SupplierManagementApp() {
|
||||
@@ -54,6 +57,8 @@ export default function SupplierManagementApp() {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
|
||||
const [createForm, setCreateForm] = useState<SupplierCreateForm>(EMPTY_CREATE_FORM);
|
||||
const [createFormBaseline, setCreateFormBaseline] = useState<SupplierCreateForm>(EMPTY_CREATE_FORM);
|
||||
const [leaveConfirmOpen, setLeaveConfirmOpen] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [toast, setToast] = useState('');
|
||||
const navigationRef = useRef<NavigationRef>({});
|
||||
@@ -66,6 +71,7 @@ export default function SupplierManagementApp() {
|
||||
navigationRef.current = {
|
||||
openCreate: () => {
|
||||
setCreateForm(EMPTY_CREATE_FORM);
|
||||
setCreateFormBaseline(cloneCreateForm(EMPTY_CREATE_FORM));
|
||||
setView('create');
|
||||
},
|
||||
backToList: () => setView('list'),
|
||||
@@ -77,8 +83,6 @@ export default function SupplierManagementApp() {
|
||||
negotiating: countKpi(records, 'negotiating'),
|
||||
terminated: countKpi(records, 'terminated'),
|
||||
expired: countKpi(records, 'expired'),
|
||||
parts: countKpi(records, 'parts'),
|
||||
insurance: countKpi(records, 'insurance'),
|
||||
licenseAbnormal: countKpi(records, 'licenseAbnormal'),
|
||||
}), [records]);
|
||||
|
||||
@@ -112,15 +116,32 @@ export default function SupplierManagementApp() {
|
||||
|
||||
const handleOpenCreate = () => {
|
||||
setCreateForm(EMPTY_CREATE_FORM);
|
||||
setCreateFormBaseline(cloneCreateForm(EMPTY_CREATE_FORM));
|
||||
setView('create');
|
||||
};
|
||||
|
||||
const leaveCreatePage = () => {
|
||||
setLeaveConfirmOpen(false);
|
||||
setCreateForm(EMPTY_CREATE_FORM);
|
||||
setCreateFormBaseline(cloneCreateForm(EMPTY_CREATE_FORM));
|
||||
setView('list');
|
||||
};
|
||||
|
||||
const handleBackFromCreate = () => {
|
||||
if (isCreateFormDirty(createForm, createFormBaseline)) {
|
||||
setLeaveConfirmOpen(true);
|
||||
return;
|
||||
}
|
||||
leaveCreatePage();
|
||||
};
|
||||
|
||||
const handleSaveCreate = () => {
|
||||
const error = validateCreateForm(createForm);
|
||||
if (error) {
|
||||
showToast(error);
|
||||
return;
|
||||
}
|
||||
setCreateFormBaseline(cloneCreateForm(createForm));
|
||||
showToast('已保存草稿(原型演示)');
|
||||
};
|
||||
|
||||
@@ -131,7 +152,7 @@ export default function SupplierManagementApp() {
|
||||
return;
|
||||
}
|
||||
showToast('供应商已提交(原型演示)');
|
||||
setView('list');
|
||||
leaveCreatePage();
|
||||
};
|
||||
|
||||
const handleImport = (file: File) => {
|
||||
@@ -171,7 +192,7 @@ export default function SupplierManagementApp() {
|
||||
onChange={(patch) => setCreateForm((prev) => ({ ...prev, ...patch }))}
|
||||
onSave={handleSaveCreate}
|
||||
onSubmit={handleSubmitCreate}
|
||||
onBack={() => setView('list')}
|
||||
onBack={handleBackFromCreate}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
@@ -241,6 +262,11 @@ export default function SupplierManagementApp() {
|
||||
</div>
|
||||
|
||||
<ImportModal open={importOpen} onClose={() => setImportOpen(false)} onImport={handleImport} />
|
||||
<LeaveConfirmModal
|
||||
open={leaveConfirmOpen}
|
||||
onClose={() => setLeaveConfirmOpen(false)}
|
||||
onConfirm={leaveCreatePage}
|
||||
/>
|
||||
|
||||
<AnnotationViewer
|
||||
source={annotationSourceDocument as AnnotationSourceDocument}
|
||||
@@ -296,19 +322,6 @@ if (typeof window !== 'undefined') {
|
||||
}
|
||||
}
|
||||
|
||||
const firstSection = document.querySelector('section:nth-of-type(1)');
|
||||
if (firstSection && !firstSection.getAttribute('data-address-parse-required-applied')) {
|
||||
const walker = document.createTreeWalker(firstSection, NodeFilter.SHOW_TEXT);
|
||||
let node = walker.nextNode();
|
||||
while (node) {
|
||||
if (node.nodeValue?.trim() === '地址解析') {
|
||||
node.nodeValue = node.nodeValue.replace('地址解析', '地址解析*');
|
||||
firstSection.setAttribute('data-address-parse-required-applied', 'true');
|
||||
break;
|
||||
}
|
||||
node = walker.nextNode();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener('DOMContentLoaded', applySupplierManagementAnnotationFixes);
|
||||
|
||||
@@ -2,7 +2,7 @@ import React, { useRef } from 'react';
|
||||
import { Upload } from 'lucide-react';
|
||||
import type { SupplierCreateForm } from '../types';
|
||||
import { CONTRACTING_COMPANY_OPTIONS } from '../utils/constants';
|
||||
import { parseFullAddress } from '../utils/address';
|
||||
import { resolveAddressFromDetail } from '../utils/address';
|
||||
|
||||
interface CreatePageProps {
|
||||
form: SupplierCreateForm;
|
||||
@@ -92,12 +92,13 @@ export function CreatePage({
|
||||
onSubmit,
|
||||
onBack,
|
||||
}: CreatePageProps) {
|
||||
const handleAddressParse = (fullAddress: string) => {
|
||||
const parsed = parseFullAddress(fullAddress);
|
||||
const handleDetailAddressChange = (value: string) => {
|
||||
const resolved = resolveAddressFromDetail(value);
|
||||
onChange({
|
||||
fullAddress,
|
||||
provinceCity: parsed.provinceCity,
|
||||
detailAddress: parsed.detailAddress,
|
||||
detailAddress: value,
|
||||
region: resolved.region,
|
||||
provinceCity: resolved.provinceCity,
|
||||
cityDisplay: resolved.cityDisplay,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -129,7 +130,8 @@ export function CreatePage({
|
||||
</nav>
|
||||
|
||||
<section className="sm-form-card" data-annotation-id="sm-create-entity">
|
||||
<h2 className="sm-form-card-title">主体信息</h2>
|
||||
<h2 className="sm-form-card-title">地址信息</h2>
|
||||
<p className="sm-section-hint">填写详细地址后自动解析所属区域与城市(直辖市显示为「xx市xx区」)</p>
|
||||
<div className="sm-form-grid">
|
||||
<FormField label="签约公司" required>
|
||||
<select
|
||||
@@ -151,30 +153,60 @@ export function CreatePage({
|
||||
onChange={(event) => onChange({ supplierName: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="省市">
|
||||
<FormField label="区域">
|
||||
<input
|
||||
className="vm-input sm-input-disabled"
|
||||
value={form.provinceCity}
|
||||
placeholder="自动从地址解析"
|
||||
disabled
|
||||
value={form.region}
|
||||
placeholder="自动从详细地址解析"
|
||||
readOnly
|
||||
aria-readonly
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="详细地址">
|
||||
<FormField label="城市">
|
||||
<input
|
||||
className="vm-input sm-input-disabled"
|
||||
value={form.detailAddress}
|
||||
placeholder="自动从地址解析"
|
||||
disabled
|
||||
value={form.cityDisplay}
|
||||
placeholder="自动从详细地址解析"
|
||||
readOnly
|
||||
aria-readonly
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="地址解析" required fullWidth>
|
||||
<FormField label="详细地址" fullWidth>
|
||||
<input
|
||||
className="vm-input"
|
||||
value={form.fullAddress}
|
||||
placeholder="填写完整地址自动解析省市和详细地址"
|
||||
onChange={(event) => handleAddressParse(event.target.value)}
|
||||
value={form.detailAddress}
|
||||
placeholder="请输入完整地址,如:浙江省嘉兴市南湖区科技大道1号"
|
||||
onChange={(event) => handleDetailAddressChange(event.target.value)}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="sm-form-card" data-annotation-id="sm-create-contact">
|
||||
<h2 className="sm-form-card-title">联系人信息</h2>
|
||||
<div className="sm-form-grid">
|
||||
<FormField label="联系人姓名" required>
|
||||
<input
|
||||
className="vm-input"
|
||||
value={form.contact}
|
||||
placeholder="请输入联系人"
|
||||
onChange={(event) => onChange({ contact: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="联系人手机" hint="手机与座机至少填一项">
|
||||
<input
|
||||
className="vm-input"
|
||||
value={form.contactMobile}
|
||||
placeholder="11位手机号"
|
||||
onChange={(event) => onChange({ contactMobile: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label="联系人座机">
|
||||
<input
|
||||
className="vm-input"
|
||||
value={form.contactPhone}
|
||||
placeholder="手机号或固定电话"
|
||||
onChange={(event) => onChange({ contactPhone: event.target.value })}
|
||||
/>
|
||||
</FormField>
|
||||
</div>
|
||||
@@ -254,8 +286,8 @@ export function CreatePage({
|
||||
</section>
|
||||
|
||||
<footer className="sm-create-footer" data-annotation-id="sm-create-footer">
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={onBack}>返回</button>
|
||||
<div className="sm-create-footer-actions">
|
||||
<button type="button" className="vm-btn vm-btn-ghost" onClick={onBack}>返回</button>
|
||||
<button type="button" className="vm-btn vm-btn-secondary" onClick={onSave}>保存</button>
|
||||
<button type="button" className="vm-btn vm-btn-primary" onClick={onSubmit}>提交</button>
|
||||
</div>
|
||||
|
||||
@@ -88,6 +88,258 @@
|
||||
min-width: 168px;
|
||||
}
|
||||
|
||||
.sm-inactive-tag {
|
||||
display: inline-block;
|
||||
margin-left: 6px;
|
||||
padding: 0 6px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--ln-muted);
|
||||
background: var(--ln-canvas-soft);
|
||||
border-radius: var(--ln-radius-pill);
|
||||
}
|
||||
|
||||
.sm-kpi-row {
|
||||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.sm-role-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
margin-bottom: 16px;
|
||||
padding: 12px 16px;
|
||||
background: var(--ln-surface-card);
|
||||
border: 1px solid var(--ln-hairline);
|
||||
border-radius: var(--ln-radius-card);
|
||||
}
|
||||
|
||||
.sm-role-bar-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--ln-body);
|
||||
}
|
||||
|
||||
.sm-role-select {
|
||||
min-width: 160px;
|
||||
}
|
||||
|
||||
.sm-role-hint {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ln-muted);
|
||||
}
|
||||
|
||||
.sm-role-hint-warn {
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.sm-date-range {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sm-date-sep {
|
||||
color: var(--ln-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.sm-permission-banner {
|
||||
padding: 10px 14px;
|
||||
font-size: 0.8125rem;
|
||||
color: #1e40af;
|
||||
background: #eff6ff;
|
||||
border: 1px solid #bfdbfe;
|
||||
border-radius: var(--ln-radius-card);
|
||||
}
|
||||
|
||||
.sm-section-hint {
|
||||
margin: -12px 0 16px;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ln-muted);
|
||||
}
|
||||
|
||||
.sm-lock-tag {
|
||||
margin-left: 6px;
|
||||
padding: 0 6px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--ln-muted);
|
||||
background: var(--ln-canvas-soft);
|
||||
border-radius: var(--ln-radius-pill);
|
||||
}
|
||||
|
||||
.sm-textarea {
|
||||
min-height: 88px;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.sm-license-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.sm-license-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.sm-license-item {
|
||||
padding: 16px;
|
||||
border: 1px solid var(--ln-hairline);
|
||||
border-radius: var(--ln-radius-card);
|
||||
background: var(--ln-surface-pearl);
|
||||
}
|
||||
|
||||
.sm-license-item-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.sm-license-empty {
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.sm-upload-zone-compact {
|
||||
min-height: 88px;
|
||||
}
|
||||
|
||||
.sm-modal-body {
|
||||
padding: 0 24px 8px;
|
||||
}
|
||||
|
||||
.sm-modal-alert {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border-radius: var(--ln-radius-card);
|
||||
background: #fffbeb;
|
||||
color: #92400e;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.sm-modal-sub {
|
||||
margin-top: 6px;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--ln-muted);
|
||||
}
|
||||
|
||||
.sm-drawer-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 100;
|
||||
background: rgba(15, 23, 42, 0.35);
|
||||
}
|
||||
|
||||
.sm-drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
z-index: 101;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(480px, 100vw);
|
||||
height: 100vh;
|
||||
background: var(--ln-surface-card);
|
||||
box-shadow: -4px 0 24px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.sm-drawer-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 20px 24px;
|
||||
border-bottom: 1px solid var(--ln-hairline);
|
||||
}
|
||||
|
||||
.sm-drawer-header h2 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.sm-drawer-sub {
|
||||
margin: 4px 0 0;
|
||||
font-size: 0.875rem;
|
||||
color: var(--ln-muted);
|
||||
}
|
||||
|
||||
.sm-drawer-body {
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 16px 24px 24px;
|
||||
}
|
||||
|
||||
.sm-changelog-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sm-changelog-item {
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--ln-hairline);
|
||||
border-radius: var(--ln-radius-card);
|
||||
background: var(--ln-surface-pearl);
|
||||
}
|
||||
|
||||
.sm-changelog-meta {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sm-changelog-meta time {
|
||||
font-weight: 400;
|
||||
color: var(--ln-muted);
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.sm-changelog-diff {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.sm-changelog-old {
|
||||
color: var(--ln-muted);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.sm-changelog-new {
|
||||
color: var(--ln-ink);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sm-changelog-operator {
|
||||
margin-top: 6px;
|
||||
font-size: 0.75rem;
|
||||
color: var(--ln-muted);
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.sm-kpi-row {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.sm-kpi-row {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.sticky-col-left {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
@@ -289,7 +541,7 @@
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 16px 24px;
|
||||
margin: 0 -32px -32px;
|
||||
@@ -301,7 +553,9 @@
|
||||
.sm-create-footer-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
|
||||
@@ -16,8 +16,6 @@ export type SupplierKpiKey =
|
||||
| 'negotiating'
|
||||
| 'terminated'
|
||||
| 'expired'
|
||||
| 'parts'
|
||||
| 'insurance'
|
||||
| 'licenseAbnormal';
|
||||
|
||||
export interface SupplierRecord {
|
||||
@@ -38,6 +36,7 @@ export interface SupplierRecord {
|
||||
creator: string;
|
||||
createTime: string;
|
||||
licenseStatus: '正常' | '异常';
|
||||
businessLicenseUploaded: boolean;
|
||||
}
|
||||
|
||||
export interface SupplierFilters {
|
||||
@@ -63,9 +62,13 @@ export const EMPTY_FILTERS: SupplierFilters = {
|
||||
export interface SupplierCreateForm {
|
||||
contractingCompany: string;
|
||||
supplierName: string;
|
||||
region: string;
|
||||
provinceCity: string;
|
||||
cityDisplay: string;
|
||||
detailAddress: string;
|
||||
fullAddress: string;
|
||||
contact: string;
|
||||
contactMobile: string;
|
||||
contactPhone: string;
|
||||
taxId: string;
|
||||
regPhone: string;
|
||||
regAddress: string;
|
||||
@@ -78,9 +81,13 @@ export interface SupplierCreateForm {
|
||||
export const EMPTY_CREATE_FORM: SupplierCreateForm = {
|
||||
contractingCompany: '',
|
||||
supplierName: '',
|
||||
region: '',
|
||||
provinceCity: '',
|
||||
cityDisplay: '',
|
||||
detailAddress: '',
|
||||
fullAddress: '',
|
||||
contact: '',
|
||||
contactMobile: '',
|
||||
contactPhone: '',
|
||||
taxId: '',
|
||||
regPhone: '',
|
||||
regAddress: '',
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { getRegionFromProvinceCity } from './region';
|
||||
|
||||
const PROVINCES = [
|
||||
'北京市', '天津市', '河北省', '山西省', '内蒙古自治区',
|
||||
'上海市', '江苏省', '浙江省', '安徽省', '福建省', '江西省', '山东省',
|
||||
@@ -8,6 +10,8 @@ const PROVINCES = [
|
||||
'陕西省', '甘肃省', '青海省', '宁夏回族自治区', '新疆维吾尔自治区',
|
||||
];
|
||||
|
||||
const MUNICIPALITIES = new Set(['北京市', '天津市', '上海市', '重庆市']);
|
||||
|
||||
export function parseFullAddress(fullAddress: string): {
|
||||
provinceCity: string;
|
||||
detailAddress: string;
|
||||
@@ -30,3 +34,30 @@ export function parseFullAddress(fullAddress: string): {
|
||||
detailAddress: detailAddress || text,
|
||||
};
|
||||
}
|
||||
|
||||
export function formatCityDisplay(provinceCity: string): string {
|
||||
if (!provinceCity.trim()) return '';
|
||||
const [province, cityPart] = provinceCity.split('-');
|
||||
if (!province) return '';
|
||||
if (MUNICIPALITIES.has(province)) {
|
||||
return cityPart ? `${province}${cityPart}` : province;
|
||||
}
|
||||
return cityPart ? `${province}${cityPart}` : province;
|
||||
}
|
||||
|
||||
export function resolveAddressFromDetail(detailAddress: string): {
|
||||
region: string;
|
||||
provinceCity: string;
|
||||
cityDisplay: string;
|
||||
detailAddress: string;
|
||||
} {
|
||||
const parsed = parseFullAddress(detailAddress);
|
||||
const region = getRegionFromProvinceCity(parsed.provinceCity);
|
||||
const cityDisplay = formatCityDisplay(parsed.provinceCity);
|
||||
return {
|
||||
region,
|
||||
provinceCity: parsed.provinceCity,
|
||||
cityDisplay,
|
||||
detailAddress: parsed.detailAddress || detailAddress.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -32,7 +32,5 @@ export const KPI_CARDS: Array<{
|
||||
{ key: 'negotiating', title: '洽谈中', desc: '合作状态为洽谈中的供应商' },
|
||||
{ key: 'terminated', title: '终止合作', desc: '合作状态为终止合作的供应商' },
|
||||
{ key: 'expired', title: '合约过期', desc: '合作状态为合约过期的供应商' },
|
||||
{ key: 'parts', title: '备件供应商', desc: '类型为备件供应商的记录' },
|
||||
{ key: 'insurance', title: '保险公司', desc: '类型为保险公司的记录' },
|
||||
{ key: 'licenseAbnormal', title: '资质异常', desc: '资质证照状态异常的供应商' },
|
||||
{ key: 'licenseAbnormal', title: '资质异常', desc: '未上传供应商营业执照的记录' },
|
||||
];
|
||||
|
||||
16
src/prototypes/supplier-management/utils/createForm.ts
Normal file
16
src/prototypes/supplier-management/utils/createForm.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import type { SupplierCreateForm } from '../types';
|
||||
import { EMPTY_CREATE_FORM } from '../types';
|
||||
|
||||
export function cloneCreateForm(form: SupplierCreateForm): SupplierCreateForm {
|
||||
return {
|
||||
...form,
|
||||
otherDocFiles: [...form.otherDocFiles],
|
||||
};
|
||||
}
|
||||
|
||||
export function isCreateFormDirty(
|
||||
form: SupplierCreateForm,
|
||||
baseline: SupplierCreateForm = EMPTY_CREATE_FORM,
|
||||
): boolean {
|
||||
return JSON.stringify(form) !== JSON.stringify(baseline);
|
||||
}
|
||||
@@ -10,9 +10,7 @@ export function applyFilters(
|
||||
if (kpiTab === 'negotiating' && record.coopStatus !== '洽谈中') return false;
|
||||
if (kpiTab === 'terminated' && record.coopStatus !== '终止合作') return false;
|
||||
if (kpiTab === 'expired' && record.coopStatus !== '合约过期') return false;
|
||||
if (kpiTab === 'parts' && record.type !== '备件供应商') return false;
|
||||
if (kpiTab === 'insurance' && record.type !== '保险公司') return false;
|
||||
if (kpiTab === 'licenseAbnormal' && record.licenseStatus !== '异常') return false;
|
||||
if (kpiTab === 'licenseAbnormal' && record.businessLicenseUploaded !== false) return false;
|
||||
|
||||
if (filters.code && record.supplierCode !== filters.code) return false;
|
||||
if (filters.coopStatuses.length > 0 && !filters.coopStatuses.includes(record.coopStatus)) {
|
||||
@@ -52,8 +50,6 @@ export function countKpi(records: SupplierRecord[], key: SupplierKpiKey): number
|
||||
if (key === 'negotiating') return records.filter((r) => r.coopStatus === '洽谈中').length;
|
||||
if (key === 'terminated') return records.filter((r) => r.coopStatus === '终止合作').length;
|
||||
if (key === 'expired') return records.filter((r) => r.coopStatus === '合约过期').length;
|
||||
if (key === 'parts') return records.filter((r) => r.type === '备件供应商').length;
|
||||
if (key === 'insurance') return records.filter((r) => r.type === '保险公司').length;
|
||||
if (key === 'licenseAbnormal') return records.filter((r) => r.licenseStatus === '异常').length;
|
||||
if (key === 'licenseAbnormal') return records.filter((r) => !r.businessLicenseUploaded).length;
|
||||
return 0;
|
||||
}
|
||||
|
||||
41
src/prototypes/supplier-management/utils/permissions.ts
Normal file
41
src/prototypes/supplier-management/utils/permissions.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import type { SupplierRecord, SupplierRole } from '../types';
|
||||
|
||||
export type FormSection = 'basic' | 'address' | 'contact' | 'tax' | 'bank' | 'license' | 'remark';
|
||||
|
||||
export function canCreateSupplier(role: SupplierRole): boolean {
|
||||
return role === 'businessAdmin';
|
||||
}
|
||||
|
||||
export function canEditSection(role: SupplierRole, section: FormSection): boolean {
|
||||
if (role === 'viewer') return false;
|
||||
if (role === 'businessAdmin') {
|
||||
return (
|
||||
section === 'basic'
|
||||
|| section === 'address'
|
||||
|| section === 'contact'
|
||||
|| section === 'remark'
|
||||
|| section === 'license'
|
||||
|| section === 'tax'
|
||||
|| section === 'bank'
|
||||
);
|
||||
}
|
||||
if (role === 'finance') {
|
||||
return section === 'tax' || section === 'bank';
|
||||
}
|
||||
if (role === 'compliance') {
|
||||
return section === 'license';
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function canReviewLicense(role: SupplierRole): boolean {
|
||||
return role === 'compliance';
|
||||
}
|
||||
|
||||
export function canDeleteSupplier(record: SupplierRecord): boolean {
|
||||
return !record.hasLinkedContract && !record.hasLinkedPayment;
|
||||
}
|
||||
|
||||
export function getDeleteActionLabel(record: SupplierRecord): '删除' | '停用' {
|
||||
return canDeleteSupplier(record) ? '删除' : '停用';
|
||||
}
|
||||
32
src/prototypes/supplier-management/utils/phone.ts
Normal file
32
src/prototypes/supplier-management/utils/phone.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
export function validateMobile(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
if (!/^1\d{10}$/.test(trimmed)) return '手机号格式不正确(11位)';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateLandline(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || trimmed === '-') return null;
|
||||
const digits = trimmed.replace(/\D/g, '');
|
||||
if (digits.length < 7 || digits.length > 12) return '座机号码格式不正确';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateEmail(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(trimmed)) return '邮箱格式不正确';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateContactPhones(mobile: string, phone: string): string | null {
|
||||
if (!mobile.trim() && (!phone.trim() || phone.trim() === '-')) {
|
||||
return '联系人手机与座机至少填写一项';
|
||||
}
|
||||
const mobileError = validateMobile(mobile);
|
||||
if (mobileError) return mobileError;
|
||||
const phoneError = validateLandline(phone);
|
||||
if (phoneError) return phoneError;
|
||||
return null;
|
||||
}
|
||||
31
src/prototypes/supplier-management/utils/region.ts
Normal file
31
src/prototypes/supplier-management/utils/region.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
export function getRegionByProvince(province: string): string {
|
||||
const area = province.trim();
|
||||
if (!area) return '';
|
||||
if (['北京市', '天津市', '河北省', '山西省', '内蒙古', '内蒙古自治区'].some((item) => area.startsWith(item))) {
|
||||
return '华北';
|
||||
}
|
||||
if (['上海市', '江苏省', '浙江省', '安徽省', '福建省', '江西省', '山东省', '台湾省'].some((item) => area.startsWith(item))) {
|
||||
return '华东';
|
||||
}
|
||||
if (['广东省', '广西', '海南省', '澳门', '香港'].some((item) => area.startsWith(item))) {
|
||||
return '华南';
|
||||
}
|
||||
if (['河南省', '湖北省', '湖南省'].some((item) => area.startsWith(item))) {
|
||||
return '华中';
|
||||
}
|
||||
if (['辽宁省', '吉林省', '黑龙江省'].some((item) => area.startsWith(item))) {
|
||||
return '东北';
|
||||
}
|
||||
if (['四川省', '云南省', '贵州省', '重庆市', '西藏'].some((item) => area.startsWith(item))) {
|
||||
return '西南';
|
||||
}
|
||||
if (['陕西省', '甘肃省', '青海省', '宁夏', '新疆'].some((item) => area.startsWith(item))) {
|
||||
return '西北';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
export function getRegionFromProvinceCity(provinceCity: string): string {
|
||||
const province = provinceCity.split('-')[0]?.trim() ?? provinceCity.trim();
|
||||
return getRegionByProvince(province);
|
||||
}
|
||||
127
src/prototypes/supplier-management/utils/supplierFields.ts
Normal file
127
src/prototypes/supplier-management/utils/supplierFields.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import type { ChangeLogEntry, SupplierCreateForm, SupplierRecord } from '../types';
|
||||
import { KEY_FIELD_LABELS } from './constants';
|
||||
|
||||
export function recordToForm(record: SupplierRecord): SupplierCreateForm {
|
||||
return {
|
||||
contractingCompany: record.contractingCompany,
|
||||
supplierName: record.supplierName,
|
||||
type: record.type,
|
||||
coopStatus: record.coopStatus,
|
||||
region: record.region,
|
||||
provinceCity: record.city,
|
||||
cityDisplay: record.cityDisplay,
|
||||
detailAddress: record.detailAddress,
|
||||
contact: record.contact,
|
||||
contactMobile: record.contactMobile,
|
||||
contactPhone: record.contactPhone,
|
||||
email: record.email,
|
||||
creditCodeOrId: record.creditCodeOrId,
|
||||
taxId: record.taxId,
|
||||
regPhone: record.regPhone,
|
||||
regAddress: record.regAddress,
|
||||
bankName: record.bankName,
|
||||
bankAccount: record.bankAccount,
|
||||
remark: record.remark,
|
||||
licenses: record.licenses.map((item) => ({ ...item })),
|
||||
};
|
||||
}
|
||||
|
||||
export function formToRecord(
|
||||
form: SupplierCreateForm,
|
||||
base: Partial<SupplierRecord> & Pick<SupplierRecord, 'id' | 'supplierCode'>,
|
||||
): SupplierRecord {
|
||||
const creditCode = form.creditCodeOrId.trim() || base.creditCodeOrId || '';
|
||||
return {
|
||||
id: base.id,
|
||||
supplierCode: base.supplierCode,
|
||||
contractingCompany: form.contractingCompany,
|
||||
supplierName: form.supplierName.trim(),
|
||||
type: form.type as SupplierRecord['type'],
|
||||
coopStatus: form.coopStatus as SupplierRecord['coopStatus'],
|
||||
region: form.region,
|
||||
city: form.provinceCity,
|
||||
cityDisplay: form.cityDisplay,
|
||||
detailAddress: form.detailAddress,
|
||||
creditCodeOrId: creditCode,
|
||||
contact: form.contact.trim(),
|
||||
contactMobile: form.contactMobile.trim(),
|
||||
contactPhone: form.contactPhone.trim() || '-',
|
||||
email: form.email.trim(),
|
||||
taxId: form.taxId.trim(),
|
||||
regPhone: form.regPhone.trim(),
|
||||
regAddress: form.regAddress.trim(),
|
||||
bankName: form.bankName.trim(),
|
||||
bankAccount: form.bankAccount.trim(),
|
||||
remark: form.remark.trim(),
|
||||
qualStatus: base.qualStatus ?? '正常',
|
||||
riskStatus: base.riskStatus ?? '无风险',
|
||||
licenseStatus: base.licenseStatus ?? '正常',
|
||||
hasLinkedContract: base.hasLinkedContract ?? false,
|
||||
hasLinkedPayment: base.hasLinkedPayment ?? false,
|
||||
status: base.status ?? 'active',
|
||||
creator: base.creator ?? '系统',
|
||||
createTime: base.createTime ?? new Date().toISOString().slice(0, 16).replace('T', ' '),
|
||||
updatedAt: new Date().toISOString().slice(0, 16).replace('T', ' '),
|
||||
licenses: form.licenses.map((item) => ({ ...item })),
|
||||
};
|
||||
}
|
||||
|
||||
const TRACKED_FIELDS: Array<keyof SupplierCreateForm> = [
|
||||
'contractingCompany',
|
||||
'supplierName',
|
||||
'type',
|
||||
'coopStatus',
|
||||
'region',
|
||||
'cityDisplay',
|
||||
'detailAddress',
|
||||
'contact',
|
||||
'contactMobile',
|
||||
'contactPhone',
|
||||
'email',
|
||||
'creditCodeOrId',
|
||||
'taxId',
|
||||
'regPhone',
|
||||
'regAddress',
|
||||
'bankName',
|
||||
'bankAccount',
|
||||
'remark',
|
||||
];
|
||||
|
||||
export function buildChangeLogs(
|
||||
before: SupplierCreateForm,
|
||||
after: SupplierCreateForm,
|
||||
supplier: Pick<SupplierRecord, 'id' | 'supplierName'>,
|
||||
operator: string,
|
||||
): ChangeLogEntry[] {
|
||||
const now = new Date().toISOString().slice(0, 16).replace('T', ' ');
|
||||
const entries: ChangeLogEntry[] = [];
|
||||
|
||||
TRACKED_FIELDS.forEach((field) => {
|
||||
const oldValue = String(before[field] ?? '').trim();
|
||||
const newValue = String(after[field] ?? '').trim();
|
||||
if (oldValue === newValue) return;
|
||||
entries.push({
|
||||
id: `${supplier.id}-${field}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
supplierId: supplier.id,
|
||||
supplierName: supplier.supplierName,
|
||||
fieldLabel: KEY_FIELD_LABELS[field] ?? field,
|
||||
oldValue: oldValue || '—',
|
||||
newValue: newValue || '—',
|
||||
operator,
|
||||
operatedAt: now,
|
||||
});
|
||||
});
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
export function nextSupplierCode(records: SupplierRecord[]): string {
|
||||
const year = new Date().getFullYear();
|
||||
const prefix = `GYS-${year}-`;
|
||||
const maxSeq = records.reduce((max, record) => {
|
||||
if (!record.supplierCode.startsWith(prefix)) return max;
|
||||
const seq = Number.parseInt(record.supplierCode.slice(prefix.length), 10);
|
||||
return Number.isNaN(seq) ? max : Math.max(max, seq);
|
||||
}, 0);
|
||||
return `${prefix}${String(maxSeq + 1).padStart(3, '0')}`;
|
||||
}
|
||||
100
src/prototypes/supplier-management/utils/validation.ts
Normal file
100
src/prototypes/supplier-management/utils/validation.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import type { LicenseRecord, SupplierCreateForm, SupplierRecord } from '../types';
|
||||
import { validateContactPhones, validateEmail, validateMobile } from './phone';
|
||||
|
||||
const TAX_ID_PATTERN = /^[0-9A-HJ-NPQRTUWXY]{15}$|^[0-9A-HJ-NPQRTUWXY]{18}$/i;
|
||||
const USCC_PATTERN = /^[0-9A-HJ-NPQRTUWXY]{18}$/i;
|
||||
|
||||
export function validateTaxId(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
if (!TAX_ID_PATTERN.test(trimmed)) return '纳税人识别号格式不正确';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateCreditCode(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
if (!USCC_PATTERN.test(trimmed)) return '统一社会信用代码格式不正确(18位)';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateBankAccount(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) return null;
|
||||
if (!/^\d+$/.test(trimmed)) return '银行账号仅允许数字';
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateLicenses(licenses: LicenseRecord[]): string | null {
|
||||
const businessLicense = licenses.find((item) => item.docType === '营业执照');
|
||||
if (!businessLicense?.fileName.trim()) return '请上传营业执照';
|
||||
|
||||
for (const license of licenses) {
|
||||
if (!license.validFrom || !license.validTo) continue;
|
||||
if (license.validTo < license.validFrom) {
|
||||
return `${license.docType}有效期结束时间不得早于开始时间`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateSupplierForm(
|
||||
form: SupplierCreateForm,
|
||||
records: SupplierRecord[],
|
||||
editingId?: string,
|
||||
): string | null {
|
||||
if (!form.contractingCompany) return '请选择签约公司';
|
||||
if (!form.supplierName.trim()) return '请输入供应商名称';
|
||||
if (!form.type) return '请选择供应商类型';
|
||||
if (!form.coopStatus) return '请选择合作状态';
|
||||
|
||||
const duplicateName = records.some(
|
||||
(r) => r.supplierName.trim() === form.supplierName.trim() && r.id !== editingId,
|
||||
);
|
||||
if (duplicateName) return '供应商名称不可重复';
|
||||
|
||||
if (!form.contact.trim()) return '请输入联系人姓名';
|
||||
|
||||
const phoneError = validateContactPhones(form.contactMobile, form.contactPhone);
|
||||
if (phoneError) return phoneError;
|
||||
|
||||
if (form.contactMobile.trim()) {
|
||||
const mobileError = validateMobile(form.contactMobile);
|
||||
if (mobileError) return mobileError;
|
||||
}
|
||||
|
||||
if (form.email.trim()) {
|
||||
const emailError = validateEmail(form.email);
|
||||
if (emailError) return emailError;
|
||||
}
|
||||
|
||||
const creditError = validateCreditCode(form.creditCodeOrId);
|
||||
if (creditError) return creditError;
|
||||
|
||||
const taxError = validateTaxId(form.taxId);
|
||||
if (taxError) return taxError;
|
||||
|
||||
const bankError = validateBankAccount(form.bankAccount);
|
||||
if (bankError) return bankError;
|
||||
|
||||
const licenseError = validateLicenses(form.licenses);
|
||||
if (licenseError) return licenseError;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function validateCreditCodeUnique(
|
||||
creditCode: string,
|
||||
records: SupplierRecord[],
|
||||
editingId?: string,
|
||||
): string | null {
|
||||
const trimmed = creditCode.trim();
|
||||
if (!trimmed) return null;
|
||||
const formatError = validateCreditCode(trimmed);
|
||||
if (formatError) return formatError;
|
||||
const duplicate = records.some(
|
||||
(r) => r.creditCodeOrId.trim().toUpperCase() === trimmed.toUpperCase() && r.id !== editingId,
|
||||
);
|
||||
if (duplicate) return '统一社会信用代码不可重复';
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user