定开项目:改名(华智下单/进行中)+ 表格排序与筛选 + 列序/按钮调整

项目归属「新签订单」→「华智下单」(含数据迁移 83 条,导入脚本自动映射)
项目状态「交付中」→「进行中」(含数据迁移 10 条,导入脚本自动映射);项目归属去掉「其他」选项
本周进展卡片:本周新增项目→新增项目、交付项目→进行中项目
清空早于 2026-09-18 的「更新时间」(111 条),后续编辑按提交时间重新写入
表格:备注与更新时间列对调;下单时间/订单金额/更新时间 表头支持升/降序
去掉「删除本周更新」按钮(后端接口保留)
新增「推动验收计划」筛选,导出同步(export_custom 新增 accept_plan)
develop-chezhiqi
chezhiqi 2026-09-20 22:08:00 +08:00
parent a77cd7bc81
commit 07e1b0ae7b
6 changed files with 106 additions and 78 deletions

View File

@ -60,3 +60,37 @@
- 定开第一个 tab **「项目明细」→「项目总览」**(chip 与卡片标题「定开项目总览」都改)。 - 定开第一个 tab **「项目明细」→「项目总览」**(chip 与卡片标题「定开项目总览」都改)。
- `customTableHtml` 列序:把**「项目归属」移到「下单时间」之后**(新序:项目名称 / 项目ID / 下单时间 / 项目归属 / 办事处 / …)。项目总览与本周进展两个 tab 共用,故同时生效。 - `customTableHtml` 列序:把**「项目归属」移到「下单时间」之后**(新序:项目名称 / 项目ID / 下单时间 / 项目归属 / 办事处 / …)。项目总览与本周进展两个 tab 共用,故同时生效。
- 后端导出标题仍是「定开项目明细」(未改,用户只说 tab 名)。纯前端改动,无需重启。 - 后端导出标题仍是「定开项目明细」(未改,用户只说 tab 名)。纯前端改动,无需重启。
## 推送到 develop-chezhiqi
- 仓库此前**未配置 git 身份**(唯一提交 e9854eb 作者是 mula.liu <mula.liu@163.com>)。按用户选择,设**本仓库** `user.name=chezhiqi` / `user.email=chezhiqi@unissense.tech`(邮箱取远端域名 unissense.tech,如不对可 amend)。
- 提交 **a77cd7b**「定开项目:台账化改造 + 统计/导出/搜索调整,新增本周进展与年度任务 tab」,包含 app/**、web/**、scripts/**(新增 `import_custom_ledger.py`)、README.md、run.sh、`data/board.db`、`.workbuddy/memory/**`(含被 git 跟踪的 `__pycache__/*.pyc`)。
- 已 push 到 `origin/develop-chezhiqi`(`e9854eb..a77cd7b`),本地与远端一致(远端还提示可发 PR main...develop-chezhiqi)。
- **有意未提交**:`data/board.db.bak-*` 4 个本地备份(保持 untracked)。
## 「项目归属」新签订单 → 华智下单
- 前端 `web/app.js`:`CUSTOM_ORIGINS` / `CUSTOM_PLACED` 里 `新签订单` → **`华智下单`**(连同新建对话框默认值、统计说明文案,全文替换 6 处,现 app.js 内已无「新签订单」)。
- **数据迁移**:`UPDATE custom_projects SET origin='华智下单' WHERE origin='新签订单'`(83 行);备份 `data/board.db.bak-before-origin-rename-20260920_213304`。现 origin 分布:华智下单 83 / 存量项目 27 / 汇智直签 10 / 提前交付 2。
- `scripts/import_custom_ledger.py`:读入行时把 `新签订单` 归一化为 `华智下单`(防再导入回退),并更新文档注释;`app/models.py` 注释同步。
- 纯前端 + 数据 + 脚本改动,**无需重启**(models.py 仅注释)。**本次未 push**(用户本轮只要求改名)。
## 三项改名/调整(项目状态 / 本周卡片 / 归属选项)
1. **项目状态「交付中」→「进行中」**(全量改名):
- `web/app.js` 全文 `交付中`→`进行中`(共 12 处:状态色映射、STATUS_OPTIONS、进度列判定、本周「交付项目」= 进行中+交付完成、新建/编辑 statusOpts、placeholder、注释)。
- **数据迁移** `UPDATE custom_projects SET project_status='进行中' WHERE project_status='交付中'`(10 行);备份 `data/board.db.bak-before-status-rename-20260920_213707`。现状态:已验收 64 / 待开发 29 / 交付完成 17 / **进行中 10** / 退单 2。
- `import_custom_ledger.py` 读入时 `交付中`→`进行中` 归一化;`models.py` 注释同步。
2. **本周进展卡片「本周新增项目」→「新增项目」**(`web/app.js` 卡片标题 + 说明文案)。
3. **项目归属去掉「其他」选项**:`CUSTOM_ORIGINS = [华智下单, 存量项目, 汇智直签, 提前交付]`(新建/筛选下拉都不再有「其他」);`models.py` / 导入脚本文档注释同步。数据中本来就没有 origin=其他 的行。
- 纯前端 + 数据 + 脚本改动,**无需重启**;`node --check` / `ast.parse` 通过。**本轮未 push**。
## 本周卡片改名 + 清空历史「更新时间」
1. **本周进展卡片「交付项目」→「进行中项目」**(`web/app.js` 卡片标题/说明/标题栏摘要;口径未变,仍是 本周更新 ∩ 进行中+交付完成)。
2. **清空早于 2026-09-18 的「更新时间」**:`UPDATE custom_projects SET update_date=NULL WHERE update_date IS NOT NULL AND update_date!='' AND update_date < '2026-09-18'` → 清空 **111** 行;保留 11 行(均为 2026-09-18 及以后编辑的,含 "YYYY-MM-DD HH:MM" 提交时间)。备份 `data/board.db.bak-before-clearing-updatedate-20260920_214254`。
- 影响:`update_date` 仅剩 11 行有值;「删除本周更新」现在只会命中这 11 行(都在本周)。后续「修改/新建」会按提交时间重新写入。
## 表格排序 + 计划筛选 + 列序/按钮调整
1. **列序**:`customTableHtml` 里把「备注」与「更新时间」**对调**(… 风险值 / **更新时间** / **备注** / 操作)。
2. **去掉「删除本周更新」按钮**:删除 `renderCustomList` 卡片头里的按钮、`bindCustomList` 里的绑定与整个 `purgeWeek()` 函数(后端 `/custom/weekly-preview`、`/custom/purge-week` 接口保留未删)。
3. **表头排序**:`下单时间`(sign_date) / `订单金额`(amount, 数值) / `更新时间`(update_date) 三个表头可点击排序,`state.customSort = {key, dir}`;点不同列默认「降序」,再点同列切换升/降;表头显示 ▲/▼,卡片副标题显示当前排序。
4. **新增「推动验收计划」筛选**:工具栏加 `#cAcceptPlan`(`state.customAcceptPlan`),选项取数据中实际出现过的值(Q3/Q4/27年/-- 等);导出也带 `accept_plan` 参数(后端 `export_custom` 新增 `accept_plan`)。
- `state` 新增 `customAcceptPlan` / `customSort`,均加入 `UI_FIELDS` 持久化。
- 后端 `export.py` 改动 → 重启 uvicorn(PID 23436);`node --check` / `ast.parse` 通过。实测导出 `accept_plan=Q3` 生效。**本轮未 push**。

View File

@ -414,7 +414,7 @@ class CustomProject(Base, TimestampMixin):
source: Mapped[str] = mapped_column(String(16), default="import", nullable=False) source: Mapped[str] = mapped_column(String(16), default="import", nullable=False)
# ---- 市场/销售侧台账字段(来源《定开项目.xlsx》,2026-09 起)---- # ---- 市场/销售侧台账字段(来源《定开项目.xlsx》,2026-09 起)----
origin: Mapped[str | None] = mapped_column(String(32), nullable=True) # 项目归属:新签订单/存量项目/汇智直签 origin: Mapped[str | None] = mapped_column(String(32), nullable=True) # 项目归属:华智下单/存量项目/汇智直签/提前交付
project_code: Mapped[str | None] = mapped_column( project_code: Mapped[str | None] = mapped_column(
String(64), nullable=True, index=True String(64), nullable=True, index=True
) # 项目ID(市场编号,导入时用于去重) ) # 项目ID(市场编号,导入时用于去重)
@ -426,7 +426,7 @@ class CustomProject(Base, TimestampMixin):
) # 市场责任人(销售侧;多人用 / 分隔,与花名册无关) ) # 市场责任人(销售侧;多人用 / 分隔,与花名册无关)
project_status: Mapped[str | None] = mapped_column( project_status: Mapped[str | None] = mapped_column(
String(32), nullable=True String(32), nullable=True
) # 项目状态:已验收/待开发/交付完成/交付中/退单 ) # 项目状态:已验收/待开发/交付完成/进行中/退单
accept_plan: Mapped[str | None] = mapped_column( accept_plan: Mapped[str | None] = mapped_column(
String(32), nullable=True String(32), nullable=True
) # 推动验收计划(Q1..Q4 / 27年) ) # 推动验收计划(Q1..Q4 / 27年)

View File

@ -48,6 +48,7 @@ def export_custom(
region: str | None = None, region: str | None = None,
origin: str | None = None, origin: str | None = None,
status: str | None = None, status: str | None = None,
accept_plan: str | None = None,
keyword: str | None = None, keyword: str | None = None,
archived: str = Query("false", pattern="^(true|false|all)$"), archived: str = Query("false", pattern="^(true|false|all)$"),
db: Session = Depends(get_db), db: Session = Depends(get_db),
@ -63,6 +64,8 @@ def export_custom(
q = q.filter(CustomProject.origin == origin) q = q.filter(CustomProject.origin == origin)
if status: if status:
q = q.filter(CustomProject.project_status == status) q = q.filter(CustomProject.project_status == status)
if accept_plan:
q = q.filter(CustomProject.accept_plan == accept_plan)
if keyword: if keyword:
q = q.filter(CustomProject.name.contains(keyword)) q = q.filter(CustomProject.name.contains(keyword))
projects = _sort_by_update(q.all()) projects = _sort_by_update(q.all())

Binary file not shown.

View File

@ -4,7 +4,7 @@
| 表格列 | 数据库列 | 说明 | | 表格列 | 数据库列 | 说明 |
|--------------|-----------------|------| |--------------|-----------------|------|
| 项目归属 | origin | 新签订单 / 存量项目 / 汇智直签 | | 项目归属 | origin | 华智下单 / 存量项目 / 汇智直签 / 提前交付(「新签订单」自动映射为「华智下单」) |
| 项目名称 | name | 匹配键之一 | | 项目名称 | name | 匹配键之一 |
| 项目ID | project_code | 匹配键(优先) | | 项目ID | project_code | 匹配键(优先) |
| 下单时间 | sign_date | 日期取 YYYY-MM-DD,像「2024年3月前」这种文本原样保留 | | 下单时间 | sign_date | 日期取 YYYY-MM-DD,像「2024年3月前」这种文本原样保留 |
@ -13,7 +13,7 @@
| 市场下单人天 | man_days | | | 市场下单人天 | man_days | |
| 订单金额 | amount | | | 订单金额 | amount | |
| 市场责任人 | market_owner | 销售侧人员,与花名册无关,可「张宁/叶镇」这种多人写法 | | 市场责任人 | market_owner | 销售侧人员,与花名册无关,可「张宁/叶镇」这种多人写法 |
| 项目状态 | project_status | 已验收 / 待开发 / 交付完成 / 交付中 / 退单 | | 项目状态 | project_status | 已验收 / 待开发 / 交付完成 / 进行中 / 退单(「交付中」自动映射为「进行中」) |
| 验收时间 | accept_date | 存的是季度文本(Q1..Q4),不是日期 | | 验收时间 | accept_date | 存的是季度文本(Q1..Q4),不是日期 |
| 推动验收计划 | accept_plan | Q1..Q4 / 27年 | | 推动验收计划 | accept_plan | Q1..Q4 / 27年 |
| 风险值 | risk_level | 1.高风险 / 2.低风险 | | 风险值 | risk_level | 1.高风险 / 2.低风险 |
@ -137,6 +137,11 @@ def read_rows(xlsx: str) -> list[dict]:
continue continue
row["region"] = normalize_region(row.get("office")) row["region"] = normalize_region(row.get("office"))
# 历史取值改名(2026-09-20):项目归属 新签订单 → 华智下单;项目状态 交付中 → 进行中
if row.get("origin") == "新签订单":
row["origin"] = "华智下单"
if row.get("project_status") == "交付中":
row["project_status"] = "进行中"
# 验收日期(真实日期,区别于 accept_date 季度文本):仅对「已验收」且验收时间为季度文本的项目, # 验收日期(真实日期,区别于 accept_date 季度文本):仅对「已验收」且验收时间为季度文本的项目,
# 补一个当年季度末占位,使「本年/本月验收」统计有数可算。用户后续在界面上改了真日期不会被覆盖。 # 补一个当年季度末占位,使「本年/本月验收」统计有数可算。用户后续在界面上改了真日期不会被覆盖。
row["accept_date_real"] = None row["accept_date_real"] = None

View File

@ -31,6 +31,8 @@
customStage: "", customStage: "",
customStatus: "", customStatus: "",
customOrigin: "", customOrigin: "",
customAcceptPlan: "",
customSort: null, // { key: amount | sign_date | update_date, dir: asc | desc }
customArchived: "false", customArchived: "false",
customRows: [], customRows: [],
intakeType: "general", intakeType: "general",
@ -1472,12 +1474,12 @@
const CUSTOM_STATUS_COLOR = { const CUSTOM_STATUS_COLOR = {
已验收: "#16a34a", 已验收: "#16a34a",
交付完成: "#0891b2", 交付完成: "#0891b2",
交付中: "#2563eb", 进行中: "#2563eb",
待开发: "#b45309", 待开发: "#b45309",
退单: "#7a8394", 退单: "#7a8394",
}; };
/** 项目状态:已验收/交付完成/交付中/待开发/退单 */ /** 项目状态:已验收/交付完成/进行中/待开发/退单 */
function statusPill(code) { function statusPill(code) {
if (!code) return `<span style="color:var(--muted)">—</span>`; if (!code) return `<span style="color:var(--muted)">—</span>`;
const color = CUSTOM_STATUS_COLOR[code] || "#7a8394"; const color = CUSTOM_STATUS_COLOR[code] || "#7a8394";
@ -1496,8 +1498,8 @@
let keywordFocus = false; let keywordFocus = false;
/** 定开「项目归属」取值:前三项视为「已下单」,提前交付为下单前状态 */ /** 定开「项目归属」取值:前三项视为「已下单」,提前交付为下单前状态 */
const CUSTOM_ORIGINS = ["新签订单", "存量项目", "汇智直签", "提前交付", "其他"]; const CUSTOM_ORIGINS = ["华智下单", "存量项目", "汇智直签", "提前交付"];
const CUSTOM_PLACED = ["新签订单", "存量项目", "汇智直签"]; const CUSTOM_PLACED = ["华智下单", "存量项目", "汇智直签"];
const isPlaced = (p) => CUSTOM_PLACED.indexOf(p.origin) >= 0; const isPlaced = (p) => CUSTOM_PLACED.indexOf(p.origin) >= 0;
/** 年度计收目标(万元),来源《年度任务.xlsx》(Q1 35 / Q2 225 / Q3 160 / Q4 260,合计 680) */ /** 年度计收目标(万元),来源《年度任务.xlsx》(Q1 35 / Q2 225 / Q3 160 / Q4 260,合计 680) */
const CUSTOM_ANNUAL_TARGETS = { 1: 35, 2: 225, 3: 160, 4: 260 }; const CUSTOM_ANNUAL_TARGETS = { 1: 35, 2: 225, 3: 160, 4: 260 };
@ -1532,11 +1534,15 @@
`</optgroup>`; `</optgroup>`;
} }
const STATUS_OPTIONS = ["已验收", "待开发", "交付完成", "交付中", "退单"]; const STATUS_OPTIONS = ["已验收", "待开发", "交付完成", "进行中", "退单"];
const statusFilterHtml = `<option value="">全部状态</option>` + const statusFilterHtml = `<option value="">全部状态</option>` +
STATUS_OPTIONS.map((s) => `<option value="${esc(s)}" ${s === state.customStatus ? "selected" : ""}>${esc(s)}</option>`).join(""); STATUS_OPTIONS.map((s) => `<option value="${esc(s)}" ${s === state.customStatus ? "selected" : ""}>${esc(s)}</option>`).join("");
const originFilterHtml = `<option value="">全部归属</option>` + const originFilterHtml = `<option value="">全部归属</option>` +
CUSTOM_ORIGINS.map((o) => `<option value="${esc(o)}" ${o === state.customOrigin ? "selected" : ""}>${esc(o)}</option>`).join(""); CUSTOM_ORIGINS.map((o) => `<option value="${esc(o)}" ${o === state.customOrigin ? "selected" : ""}>${esc(o)}</option>`).join("");
// 推动验收计划:取数据里实际出现过的值
const planVals = Array.from(new Set(allRows.map((p) => String(p.accept_plan || "").trim()).filter(Boolean))).sort();
const planFilterHtml = `<option value="">全部计划</option>` +
planVals.map((v) => `<option value="${esc(v)}" ${v === state.customAcceptPlan ? "selected" : ""}>${esc(v)}</option>`).join("");
const isList = state.customTab !== "weekly" && state.customTab !== "annual"; const isList = state.customTab !== "weekly" && state.customTab !== "annual";
const toolbar = ` const toolbar = `
@ -1550,6 +1556,7 @@
<select id="cOrigin" class="sel" style="width:130px">${originFilterHtml}</select> <select id="cOrigin" class="sel" style="width:130px">${originFilterHtml}</select>
<select id="cRegion" class="sel" style="width:132px">${regionFilterHtml}</select> <select id="cRegion" class="sel" style="width:132px">${regionFilterHtml}</select>
<select id="cStatus" class="sel" style="width:140px">${statusFilterHtml}</select> <select id="cStatus" class="sel" style="width:140px">${statusFilterHtml}</select>
<select id="cAcceptPlan" class="sel" style="width:120px" title="推动验收计划">${planFilterHtml}</select>
<button class="btn sm" id="btnExportCustom">导出 Excel</button> <button class="btn sm" id="btnExportCustom">导出 Excel</button>
<button class="btn sm primary" id="btnNewCustom">${Ico.svg("plus")}新建定开项目</button> <button class="btn sm primary" id="btnNewCustom">${Ico.svg("plus")}新建定开项目</button>
<span class="period-hint">全部 ${allRows.length} 个项目</span>` <span class="period-hint">全部 ${allRows.length} 个项目</span>`
@ -1572,6 +1579,8 @@
if (rs) rs.addEventListener("change", () => { state.customRegion = rs.value; renderCustom(); }); if (rs) rs.addEventListener("change", () => { state.customRegion = rs.value; renderCustom(); });
const st = $("#cStatus"); const st = $("#cStatus");
if (st) st.addEventListener("change", () => { state.customStatus = st.value; renderCustom(); }); if (st) st.addEventListener("change", () => { state.customStatus = st.value; renderCustom(); });
const ap = $("#cAcceptPlan");
if (ap) ap.addEventListener("change", () => { state.customAcceptPlan = ap.value; renderCustom(); });
if (isList) bindKeyword("cKeyword", { enter: true }); if (isList) bindKeyword("cKeyword", { enter: true });
const bn = $("#btnNewCustom"); const bn = $("#btnNewCustom");
if (bn) bn.addEventListener("click", () => openNewCustomDialog(() => afterWrite())); if (bn) bn.addEventListener("click", () => openNewCustomDialog(() => afterWrite()));
@ -1582,6 +1591,7 @@
if (state.customOrigin) q.push("origin=" + encodeURIComponent(state.customOrigin)); if (state.customOrigin) q.push("origin=" + encodeURIComponent(state.customOrigin));
if (state.customRegion) q.push("region=" + encodeURIComponent(state.customRegion)); if (state.customRegion) q.push("region=" + encodeURIComponent(state.customRegion));
if (state.customStatus) q.push("status=" + encodeURIComponent(state.customStatus)); if (state.customStatus) q.push("status=" + encodeURIComponent(state.customStatus));
if (state.customAcceptPlan) q.push("accept_plan=" + encodeURIComponent(state.customAcceptPlan));
if (state.keyword) q.push("keyword=" + encodeURIComponent(state.keyword)); if (state.keyword) q.push("keyword=" + encodeURIComponent(state.keyword));
download("/api/export/custom?" + q.join("&")); download("/api/export/custom?" + q.join("&"));
}); });
@ -1589,19 +1599,24 @@
} }
function renderCustomList(allRows) { function renderCustomList(allRows) {
// 列表:按 关键字 + 归属 + 区域 + 状态 在本地筛选;并按「更新时间」倒序 // 列表:按 关键字 + 归属 + 区域 + 状态 + 推动验收计划 在本地筛选;再按表头排序
const kw = (state.keyword || "").trim(); const kw = (state.keyword || "").trim();
let list = allRows; let list = allRows;
if (kw) list = list.filter((p) => String(p.name || "").includes(kw)); if (kw) list = list.filter((p) => String(p.name || "").includes(kw));
if (state.customOrigin) list = list.filter((p) => (p.origin || "") === state.customOrigin); if (state.customOrigin) list = list.filter((p) => (p.origin || "") === state.customOrigin);
if (state.customRegion) list = list.filter((p) => (p.region || "") === state.customRegion); if (state.customRegion) list = list.filter((p) => (p.region || "") === state.customRegion);
if (state.customStatus) list = list.filter((p) => (p.project_status || "") === state.customStatus); if (state.customStatus) list = list.filter((p) => (p.project_status || "") === state.customStatus);
const view = list.slice().sort((a, b) => if (state.customAcceptPlan) list = list.filter((p) => String(p.accept_plan || "").trim() === state.customAcceptPlan);
String(b.update_date || "").localeCompare(String(a.update_date || "")) const s = state.customSort || { key: "update_date", dir: "desc" };
); const sv = (p, k) => (k === "amount" ? Number(p.amount || 0) : String(p[k] || ""));
const view = list.slice().sort((a, b) => {
const va = sv(a, s.key), vb = sv(b, s.key);
const c = va < vb ? -1 : va > vb ? 1 : 0;
return s.dir === "asc" ? c : -c;
});
// ---- 统计条:基于「全部已下单项目」,不随搜索 / 筛选变化 ---- // ---- 统计条:基于「全部已下单项目」,不随搜索 / 筛选变化 ----
const placed = allRows.filter(isPlaced); // 已下单 = 新签订单 / 存量项目 / 汇智直签 const placed = allRows.filter(isPlaced); // 已下单 = 华智下单 / 存量项目 / 汇智直签
const today = new Date(); const today = new Date();
const Y = today.getFullYear(); const Y = today.getFullYear();
const M = today.getMonth() + 1; const M = today.getMonth() + 1;
@ -1634,47 +1649,48 @@
${stat(`本季度验收(${Y}年Q${Q})`, accQuarter.length, sum(accQuarter, "amount"), "var(--amber)")} ${stat(`本季度验收(${Y}年Q${Q})`, accQuarter.length, sum(accQuarter, "amount"), "var(--amber)")}
${stat(`本年验收项目(${Y}年)`, accYear.length, sum(accYear, "amount"), "var(--amber)")} ${stat(`本年验收项目(${Y}年)`, accYear.length, sum(accYear, "amount"), "var(--amber)")}
</div> </div>
<div class="stat-note">「已下单项目 / 合同额」与「本季度·本年下单」只统计 <b>新签订单 / 存量项目 / 汇智直签</b>(提前交付不计入下单);统计基于全部项目,不受搜索 / 筛选影响。</div>`; <div class="stat-note">「已下单项目 / 合同额」与「本季度·本年下单」只统计 <b>华智下单 / 存量项目 / 汇智直签</b>(提前交付不计入下单);统计基于全部项目,不受搜索 / 筛选影响。</div>`;
const manDays = view.reduce((a, p) => a + Number(p.man_days || 0), 0); const manDays = view.reduce((a, p) => a + Number(p.man_days || 0), 0);
const highRisk = view.filter((p) => String(p.risk_level || "").includes("高")).length; const highRisk = view.filter((p) => String(p.risk_level || "").includes("高")).length;
const accepted = view.filter((p) => p.project_status === "已验收").length; const accepted = view.filter((p) => p.project_status === "已验收").length;
const sortLabel = { sign_date: "下单时间", amount: "订单金额", update_date: "更新时间" }[s.key] || "更新时间";
return `<div class="card"> return `<div class="card">
<div class="card-head"> <div class="card-head">
<h4>定开项目总览<span class="sub">按「更新时间」倒序 · 共 ${view.length} 个${state.customStatus ? "(" + esc(state.customStatus) + ")" : ""} · 列表只读,改数据请点「修改」</span></h4> <h4>定开项目总览<span class="sub">按「${sortLabel}」${s.dir === "asc" ? "升序" : "降序"} · 共 ${view.length} 个${state.customStatus ? "(" + esc(state.customStatus) + ")" : ""} · 点表头(下单时间 / 订单金额 / 更新时间)可排序;列表只读,改数据请点「修改」</span></h4>
<span class="period-hint">人天 ${manDays.toLocaleString()} · 高风险 ${highRisk} 个 · 已验收 ${accepted} 个</span> <span class="period-hint">人天 ${manDays.toLocaleString()} · 高风险 ${highRisk} 个 · 已验收 ${accepted} 个</span>
<div class="inline-fields">
<button class="btn sm danger" id="btnPurgeWeek" title="删除「更新时间」在本周(周一~周日)内的定开项目">删除本周更新</button>
</div>
</div> </div>
${statBar} ${statBar}
${view.length ? customTableHtml(view) : `<div class="empty">${state.keyword ? "没有匹配「" + esc(state.keyword) + "」的定开项目" : "当前筛选条件下没有定开项目"}</div>`} ${view.length ? customTableHtml(view) : `<div class="empty">${state.keyword ? "没有匹配「" + esc(state.keyword) + "」的定开项目" : "当前筛选条件下没有定开项目"}</div>`}
</div>`; </div>`;
} }
/** 定开项目表格(「项目明细」与「本周进展」两个 tab 共用) */ /** 定开项目表格(「项目总览」与「本周进展」两个 tab 共用);下单时间 / 订单金额 / 更新时间 表头可排序 */
function customTableHtml(view, withOps) { function customTableHtml(view, withOps) {
const ops = withOps !== false; const ops = withOps !== false;
const s = state.customSort || { key: "update_date", dir: "desc" };
const sth = (label, k, cls) =>
`<th class="${cls ? cls + " " : ""}sortable" data-sort="${k}" style="cursor:pointer;user-select:none" title="点击排序">${label}${s.key === k ? (s.dir === "asc" ? " ▲" : " ▼") : ""}</th>`;
return `<div class="tbl-wrap"> return `<div class="tbl-wrap">
<table class="tbl"> <table class="tbl">
<thead><tr> <thead><tr>
<th style="min-width:200px">项目名称</th> <th style="min-width:200px">项目名称</th>
<th>项目ID</th> <th>项目ID</th>
<th>下单时间</th> ${sth("下单时间", "sign_date")}
<th>项目归属</th> <th>项目归属</th>
<th>办事处</th> <th>办事处</th>
<th>行业</th> <th>行业</th>
<th class="num">市场下单人天</th> <th class="num">市场下单人天</th>
<th class="num">订单金额</th> ${sth("订单金额", "amount", "num")}
<th>市场责任人</th> <th>市场责任人</th>
<th>项目状态</th> <th>项目状态</th>
<th class="num">进度</th> <th class="num">进度</th>
<th>验收时间</th> <th>验收时间</th>
<th>推动验收计划</th> <th>推动验收计划</th>
<th>风险值</th> <th>风险值</th>
${sth("更新时间", "update_date")}
<th>备注</th> <th>备注</th>
<th>更新时间</th>
${ops ? `<th class="col-ops">操作</th>` : ""} ${ops ? `<th class="col-ops">操作</th>` : ""}
</tr></thead> </tr></thead>
<tbody> <tbody>
@ -1696,12 +1712,12 @@
<td class="num">${money(p.amount)}</td> <td class="num">${money(p.amount)}</td>
<td class="nowrap">${esc(p.market_owner || "—")}</td> <td class="nowrap">${esc(p.market_owner || "—")}</td>
<td class="nowrap">${statusPill(p.project_status)}</td> <td class="nowrap">${statusPill(p.project_status)}</td>
<td class="num">${(p.project_status === "交付中" || Number(p.progress || 0) > 0) ? Math.round(p.progress || 0) + "%" : "—"}</td> <td class="num">${(p.project_status === "进行中" || Number(p.progress || 0) > 0) ? Math.round(p.progress || 0) + "%" : "—"}</td>
<td class="nowrap">${p.accept_date ? esc(p.accept_date) : "—"}</td> <td class="nowrap">${p.accept_date ? esc(p.accept_date) : "—"}</td>
<td>${esc(p.accept_plan || "—")}</td> <td>${esc(p.accept_plan || "—")}</td>
<td class="nowrap">${riskPill(p.risk_level)}</td> <td class="nowrap">${riskPill(p.risk_level)}</td>
<td>${esc((p.remark || "").slice(0, 40)) || "—"}</td>
<td class="nowrap">${esc(p.update_date || "—")}</td> <td class="nowrap">${esc(p.update_date || "—")}</td>
<td>${esc((p.remark || "").slice(0, 40)) || "—"}</td>
${ops ? `<td class="nowrap col-ops"><button class="btn sm" data-cedit="${p.id}">修改</button></td>` : ""} ${ops ? `<td class="nowrap col-ops"><button class="btn sm" data-cedit="${p.id}">修改</button></td>` : ""}
</tr>`).join("")} </tr>`).join("")}
</tbody> </tbody>
@ -1723,25 +1739,25 @@
const sum = (arr, f) => arr.reduce((a, p) => a + Number(p[f] || 0), 0); const sum = (arr, f) => arr.reduce((a, p) => a + Number(p[f] || 0), 0);
const acc = week.filter((p) => p.project_status === "已验收"); const acc = week.filter((p) => p.project_status === "已验收");
// 本周新增项目:按「下单时间」在本周的新下单 / 提前交付项目(不是「本周更新」) // 新增项目:按「下单时间」在本周的新下单 / 提前进行中项目(不是「本周更新」)
const newDeal = allRows.filter((p) => inWeek(p.sign_date) && (isPlaced(p) || p.origin === "提前交付")); const newDeal = allRows.filter((p) => inWeek(p.sign_date) && (isPlaced(p) || p.origin === "提前交付"));
// 交付项目:本周更新的「交付中 + 交付完成」 // 进行中项目:本周更新的「进行中 + 交付完成」
const doing = week.filter((p) => p.project_status === "交付中" || p.project_status === "交付完成"); const doing = week.filter((p) => p.project_status === "进行中" || p.project_status === "交付完成");
const stat = (label, rows, color) => const stat = (label, rows, color) =>
`<div class="stat" style="--c:${color}"><div class="stat-num">${rows.length}<span class="stat-unit">个</span> · <span class="stat-money">${fen(sum(rows, "amount"))}</span><span class="stat-unit">万</span></div><div class="stat-label">${label}</div></div>`; `<div class="stat" style="--c:${color}"><div class="stat-num">${rows.length}<span class="stat-unit">个</span> · <span class="stat-money">${fen(sum(rows, "amount"))}</span><span class="stat-unit">万</span></div><div class="stat-label">${label}</div></div>`;
const statBar = `<div class="stat-row"> const statBar = `<div class="stat-row">
${stat("验收情况", acc, "var(--amber)")} ${stat("验收情况", acc, "var(--amber)")}
${stat("本周新增项目", newDeal, "var(--violet)")} ${stat("新增项目", newDeal, "var(--violet)")}
${stat("交付项目", doing, "var(--primary)")} ${stat("进行中项目", doing, "var(--primary)")}
</div> </div>
<div class="stat-note">本周 = <b>${start} ~ ${end}</b>;<b>本周新增项目</b> = 按「下单时间」在本周的新下单 / 提前交付项目(已下单 + 提前交付);验收情况 / 交付项目 为「更新时间」在本周的项目(交付项目 = 交付中 + 交付完成)。</div>`; <div class="stat-note">本周 = <b>${start} ~ ${end}</b>;<b>新增项目</b> = 按「下单时间」在本周的新下单 / 提前进行中项目(已下单 + 提前交付);验收情况 / 进行中项目 为「更新时间」在本周的项目(进行中项目 = 进行中 + 交付完成)。</div>`;
return `<div class="card"> return `<div class="card">
<div class="card-head"> <div class="card-head">
<h4>本周进展<span class="sub">更新时间在本周(${start} ~ ${end})· 共 ${week.length} 个项目 · 列表只读,改数据请点项目名称</span></h4> <h4>本周进展<span class="sub">更新时间在本周(${start} ~ ${end})· 共 ${week.length} 个项目 · 列表只读,改数据请点项目名称</span></h4>
<span class="period-hint">验收 ${acc.length} · 新增 ${newDeal.length} · 交付 ${doing.length}</span> <span class="period-hint">验收 ${acc.length} · 新增 ${newDeal.length} · 进行中 ${doing.length}</span>
</div> </div>
${statBar} ${statBar}
${week.length ${week.length
@ -1808,11 +1824,11 @@
</tbody> </tbody>
</table> </table>
</div> </div>
<div class="stat-note">计收项目 = 按「验收时间」归属季度的项目;新增签单 = 按「下单时间」归属本年度季度、且项目归属为已下单(新签订单 / 存量项目 / 汇智直签)的项目;金额由「元」换算为「万元」;计收完成率 = 计收项目金额 ÷ 计收目标。</div> <div class="stat-note">计收项目 = 按「验收时间」归属季度的项目;新增签单 = 按「下单时间」归属本年度季度、且项目归属为已下单(华智下单 / 存量项目 / 汇智直签)的项目;金额由「元」换算为「万元」;计收完成率 = 计收项目金额 ÷ 计收目标。</div>
</div>`; </div>`;
} }
/** 项目明细表:列表只读,行内只有「修改」;名称可点开详情 */ /** 项目总览表:列表只读,行内只有「修改」;名称可点开详情;表头(下单时间 / 订单金额 / 更新时间)可排序 */
function bindCustomList(host) { function bindCustomList(host) {
host.querySelectorAll("[data-copen]").forEach((a) => host.querySelectorAll("[data-copen]").forEach((a) =>
a.addEventListener("click", () => openCustomDrawer(Number(a.dataset.copen), "view")) a.addEventListener("click", () => openCustomDrawer(Number(a.dataset.copen), "view"))
@ -1820,44 +1836,14 @@
host.querySelectorAll("[data-cedit]").forEach((b) => host.querySelectorAll("[data-cedit]").forEach((b) =>
b.addEventListener("click", () => openCustomDrawer(Number(b.dataset.cedit), "edit")) b.addEventListener("click", () => openCustomDrawer(Number(b.dataset.cedit), "edit"))
); );
const bp = $("#btnPurgeWeek"); host.querySelectorAll("[data-sort]").forEach((th) =>
if (bp) bp.addEventListener("click", purgeWeek); th.addEventListener("click", () => {
} const k = th.dataset.sort;
const cur = state.customSort || { key: "update_date", dir: "desc" };
/** 删除本周(周一~周日)更新的定开项目:先预览数量,二次确认后执行。 */ state.customSort = { key: k, dir: cur.key === k && cur.dir === "desc" ? "asc" : "desc" };
async function purgeWeek() { renderCustom();
let pv; })
try { );
pv = await api("/custom/weekly-preview");
} catch (e) {
return toast("读取本周更新失败:" + e.message);
}
if (!pv.count) {
return toast("本周(" + pv.start + " ~ " + pv.end + ")没有更新的定开项目");
}
const ok = await confirmDialog({
title: "删除本周更新的定开项目",
okText: "确认删除 " + pv.count + " 个",
danger: true,
requireText: "删除",
requirePlaceholder: "输入「删除」以确认",
body:
`<p style="margin:0 0 10px">将删除<b>更新时间在本周(${esc(pv.start)} ~ ${esc(pv.end)})</b>内的定开项目:` +
`<b>${pv.count} 个</b>,合同额合计 <b>${fen(pv.amount)} 万</b>。</p>` +
`<div class="notice warn">此操作会连同项目的更新记录一起删除,且<b>不可撤销</b>。` +
`若只是想标记,请在详情里改状态。</div>` +
(pv.names && pv.names.length
? `<div class="notice info" style="max-height:160px;overflow:auto">${pv.names.map((n) => esc(n)).join("、")}${pv.count > pv.names.length ? " …" : ""}</div>`
: ""),
});
if (!ok) return;
try {
const r = await post("/custom/purge-week", {});
toast("已删除本周更新的 " + r.deleted + " 个定开项目");
afterWrite();
} catch (e) {
toast("删除失败:" + e.message);
}
} }
/** /**
@ -1873,9 +1859,9 @@
// 选项直接对齐《定开项目.xlsx》台账字段取值 // 选项直接对齐《定开项目.xlsx》台账字段取值
const opts = (list, sel) => const opts = (list, sel) =>
list.map((v) => `<option value="${esc(v)}" ${v === sel ? "selected" : ""}>${esc(v)}</option>`).join(""); list.map((v) => `<option value="${esc(v)}" ${v === sel ? "selected" : ""}>${esc(v)}</option>`).join("");
const originOpts = opts(CUSTOM_ORIGINS, "新签订单"); const originOpts = opts(CUSTOM_ORIGINS, "华智下单");
const industryOpts = opts(["安平", "政府", "企业", "教育", "交通", "医疗", "高速", "行业", "其他"], ""); const industryOpts = opts(["安平", "政府", "企业", "教育", "交通", "医疗", "高速", "行业", "其他"], "");
const statusOpts = opts(["待开发", "交付中", "交付完成", "已验收", "退单"], "待开发"); const statusOpts = opts(["待开发", "进行中", "交付完成", "已验收", "退单"], "待开发");
const riskOpts = opts(["1.高风险", "2.低风险"], "2.低风险"); const riskOpts = opts(["1.高风险", "2.低风险"], "2.低风险");
const numVal = (sel) => ($(sel).value === "" ? null : Number($(sel).value)); const numVal = (sel) => ($(sel).value === "" ? null : Number($(sel).value));
const txtVal = (sel) => $(sel).value.trim() || null; const txtVal = (sel) => $(sel).value.trim() || null;
@ -1895,7 +1881,7 @@
<div class="form-row"><label>订单金额(元)</label><input class="inp" id="ncAmount" type="number" min="0" style="width:100%" /></div> <div class="form-row"><label>订单金额(元)</label><input class="inp" id="ncAmount" type="number" min="0" style="width:100%" /></div>
<div class="form-row"><label>市场责任人</label><input class="inp" id="ncMarketOwner" placeholder="多人用 / 分隔" style="width:100%" /></div> <div class="form-row"><label>市场责任人</label><input class="inp" id="ncMarketOwner" placeholder="多人用 / 分隔" style="width:100%" /></div>
<div class="form-row"><label>项目状态</label><select class="sel" id="ncStatus" style="width:100%">${statusOpts}</select></div> <div class="form-row"><label>项目状态</label><select class="sel" id="ncStatus" style="width:100%">${statusOpts}</select></div>
<div class="form-row"><label>进度(%)</label><input class="inp" id="ncProgress" type="number" min="0" max="100" step="5" placeholder="交付中项目填 0~100" style="width:100%" /></div> <div class="form-row"><label>进度(%)</label><input class="inp" id="ncProgress" type="number" min="0" max="100" step="5" placeholder="进行中项目填 0~100" style="width:100%" /></div>
<div class="form-row"><label>验收时间</label><input class="inp" id="ncAcceptQuarter" placeholder="季度文本,如 Q3" style="width:100%" /></div> <div class="form-row"><label>验收时间</label><input class="inp" id="ncAcceptQuarter" placeholder="季度文本,如 Q3" style="width:100%" /></div>
<div class="form-row"><label>推动验收计划</label><input class="inp" id="ncAcceptPlan" placeholder="如 Q4 / 27年" style="width:100%" /></div> <div class="form-row"><label>推动验收计划</label><input class="inp" id="ncAcceptPlan" placeholder="如 Q4 / 27年" style="width:100%" /></div>
<div class="form-row"><label>风险值</label><select class="sel" id="ncRisk" style="width:100%">${riskOpts}</select></div> <div class="form-row"><label>风险值</label><select class="sel" id="ncRisk" style="width:100%">${riskOpts}</select></div>
@ -2030,7 +2016,7 @@
const optList = (list, sel) => list.map((v) => `<option value="${esc(v)}" ${v === sel ? "selected" : ""}>${esc(v)}</option>`).join(""); const optList = (list, sel) => list.map((v) => `<option value="${esc(v)}" ${v === sel ? "selected" : ""}>${esc(v)}</option>`).join("");
const originOpts = optList(CUSTOM_ORIGINS, p.origin); const originOpts = optList(CUSTOM_ORIGINS, p.origin);
const industryOpts = optList(["安平", "政府", "企业", "教育", "交通", "医疗", "高速", "行业", "其他"], p.industry); const industryOpts = optList(["安平", "政府", "企业", "教育", "交通", "医疗", "高速", "行业", "其他"], p.industry);
const statusOpts = optList(["待开发", "交付中", "交付完成", "已验收", "退单"], p.project_status); const statusOpts = optList(["待开发", "进行中", "交付完成", "已验收", "退单"], p.project_status);
const riskOpts = optList(["1.高风险", "2.低风险"], p.risk_level); const riskOpts = optList(["1.高风险", "2.低风险"], p.risk_level);
// 市场台账(来源《定开项目.xlsx》):详情只读,修改模式给输入框 // 市场台账(来源《定开项目.xlsx》):详情只读,修改模式给输入框
@ -2065,7 +2051,7 @@
${ro("市场下单人天", numOrDash(p.man_days))} ${ro("市场下单人天", numOrDash(p.man_days))}
${ro("市场责任人", esc(p.market_owner || "—"))} ${ro("市场责任人", esc(p.market_owner || "—"))}
${ro("项目状态", statusPill(p.project_status))} ${ro("项目状态", statusPill(p.project_status))}
${ro("进度", (p.project_status === "交付中" || Number(p.progress || 0) > 0) ? Math.round(p.progress || 0) + "%" : "—")} ${ro("进度", (p.project_status === "进行中" || Number(p.progress || 0) > 0) ? Math.round(p.progress || 0) + "%" : "—")}
${ro("验收时间", esc(p.accept_date || "—"))} ${ro("验收时间", esc(p.accept_date || "—"))}
${ro("推动验收计划", esc(p.accept_plan || "—"))} ${ro("推动验收计划", esc(p.accept_plan || "—"))}
${ro("风险值", riskPill(p.risk_level))} ${ro("风险值", riskPill(p.risk_level))}
@ -3342,7 +3328,7 @@
const UI_KEY = "unis.ui.v1"; const UI_KEY = "unis.ui.v1";
const UI_FIELDS = ["view", "gran", "weekId", "monthIdx", "categoryId", "keyOnly", "keyword", const UI_FIELDS = ["view", "gran", "weekId", "monthIdx", "categoryId", "keyOnly", "keyword",
"projOwner", "projIdleOnly", "showArchived", "focusFilter", "focusArchived", "projOwner", "projIdleOnly", "showArchived", "focusFilter", "focusArchived",
"customTab", "customArchived", "customRegion", "customOrigin", "staffArchived", "staffGroup"]; "customTab", "customArchived", "customRegion", "customOrigin", "customAcceptPlan", "customSort", "staffArchived", "staffGroup"];
function saveUI() { function saveUI() {
const o = {}; const o = {};