feat: tab导航、recharts图表、库存统计、出勤率里程、区域城市下钻、数据一致性修复
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
- 新增tab导航(总览/按部门/按区域/按客户)+ 移动端底部导航 - 新增recharts柱状图(区域分布)和饼图(客户分布) - 新增库存统计模块(按区域/按车型,筛选面板) - 对接ln_vehicle_day_mileage表实现出勤率和日均里程 - 区域运营支持区域→城市→车型三级下钻 - 修复ownership取字段错误(改用truck_rent_status) - 修复部门统计闲置定义(当日无行驶里程) - 修复区域图表"其他"重复问题(后端Top N合并) - 修复城市名空值降级(resolveCity取province) - 修复下钻数据不一致(统一category/vehicleType参数) - 扩展/list端点支持大区过滤和未分配匹配 - 所有筛选改为searchable datalist Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
446
package-lock.json
generated
446
package-lock.json
generated
@@ -15,7 +15,9 @@
|
||||
"motion": "^12.23.24",
|
||||
"mysql2": "^3.11.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
"react-dom": "^19.0.0",
|
||||
"recharts": "^3.8.1",
|
||||
"tsx": "^4.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
@@ -25,7 +27,6 @@
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"concurrently": "^9.1.0",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
@@ -319,7 +320,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -336,7 +336,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -353,7 +352,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -370,7 +368,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -387,7 +384,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -404,7 +400,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -421,7 +416,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -438,7 +432,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -455,7 +448,6 @@
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -472,7 +464,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -489,7 +480,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -506,7 +496,6 @@
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -523,7 +512,6 @@
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -540,7 +528,6 @@
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -557,7 +544,6 @@
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -574,7 +560,6 @@
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -591,7 +576,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -608,7 +592,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -625,7 +608,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -642,7 +624,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -659,7 +640,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -676,7 +656,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -693,7 +672,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -710,7 +688,6 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -727,7 +704,6 @@
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -744,7 +720,6 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -816,6 +791,42 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit": {
|
||||
"version": "2.11.2",
|
||||
"resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz",
|
||||
"integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/utils": "^0.3.0",
|
||||
"immer": "^11.0.0",
|
||||
"redux": "^5.0.1",
|
||||
"redux-thunk": "^3.1.0",
|
||||
"reselect": "^5.1.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
|
||||
"react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"react": {
|
||||
"optional": true
|
||||
},
|
||||
"react-redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||
"version": "11.1.4",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz",
|
||||
"integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.0-rc.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz",
|
||||
@@ -1173,6 +1184,18 @@
|
||||
"win32"
|
||||
]
|
||||
},
|
||||
"node_modules/@standard-schema/spec": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@standard-schema/utils": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
|
||||
"integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tailwindcss/node": {
|
||||
"version": "4.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.2.2.tgz",
|
||||
@@ -1490,6 +1513,69 @@
|
||||
"@babel/types": "^7.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-array": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz",
|
||||
"integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-color": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz",
|
||||
"integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-ease": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz",
|
||||
"integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-interpolate": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz",
|
||||
"integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-color": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-path": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz",
|
||||
"integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-scale": {
|
||||
"version": "4.0.9",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz",
|
||||
"integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-time": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-shape": {
|
||||
"version": "3.1.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz",
|
||||
"integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/d3-path": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/d3-time": {
|
||||
"version": "3.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz",
|
||||
"integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/d3-timer": {
|
||||
"version": "3.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz",
|
||||
"integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/estree": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@@ -1510,7 +1596,7 @@
|
||||
"version": "19.2.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz",
|
||||
"integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"csstype": "^3.2.2"
|
||||
@@ -1526,6 +1612,12 @@
|
||||
"@types/react": "^19.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/use-sync-external-store": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
|
||||
"integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "5.2.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-5.2.0.tgz",
|
||||
@@ -1695,6 +1787,15 @@
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/clsx": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
@@ -1751,9 +1852,130 @@
|
||||
"version": "3.2.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"dev": true,
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/d3-array": {
|
||||
"version": "3.2.4",
|
||||
"resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz",
|
||||
"integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"internmap": "1 - 2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-color": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz",
|
||||
"integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-ease": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz",
|
||||
"integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==",
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-format": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz",
|
||||
"integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-interpolate": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz",
|
||||
"integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-color": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-path": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz",
|
||||
"integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-scale": {
|
||||
"version": "4.0.2",
|
||||
"resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz",
|
||||
"integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2.10.0 - 3",
|
||||
"d3-format": "1 - 3",
|
||||
"d3-interpolate": "1.2.0 - 3",
|
||||
"d3-time": "2.1.1 - 3",
|
||||
"d3-time-format": "2 - 4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-shape": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz",
|
||||
"integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-path": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz",
|
||||
"integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-array": "2 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-time-format": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz",
|
||||
"integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"d3-time": "1 - 3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/d3-timer": {
|
||||
"version": "3.0.1",
|
||||
"resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz",
|
||||
"integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||
@@ -1772,6 +1994,12 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decimal.js-light": {
|
||||
"version": "2.5.1",
|
||||
"resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz",
|
||||
"integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/denque": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
|
||||
@@ -1831,11 +2059,20 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/es-toolkit": {
|
||||
"version": "1.45.1",
|
||||
"resolved": "https://registry.npmjs.org/es-toolkit/-/es-toolkit-1.45.1.tgz",
|
||||
"integrity": "sha512-/jhoOj/Fx+A+IIyDNOvO3TItGmlMKhtX8ISAHKE90c4b/k1tqaqEZ+uUqfpU8DMnW5cgNJv606zS55jGvza0Xw==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"docs",
|
||||
"benchmarks"
|
||||
]
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.27.4",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.4.tgz",
|
||||
"integrity": "sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
@@ -1883,6 +2120,12 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/eventemitter3": {
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
|
||||
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
@@ -1932,7 +2175,6 @@
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
@@ -1976,7 +2218,6 @@
|
||||
"version": "4.13.7",
|
||||
"resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.13.7.tgz",
|
||||
"integrity": "sha512-7tN6rFgBlMgpBML5j8typ92BKFi2sFQvIdpAqLA2beia5avZDrMs0FLZiM5etShWq5irVyGcGMEA1jcDaK7A/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"resolve-pkg-maps": "^1.0.0"
|
||||
@@ -2027,6 +2268,25 @@
|
||||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/internmap": {
|
||||
"version": "2.0.3",
|
||||
"resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz",
|
||||
"integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
@@ -2575,6 +2835,36 @@
|
||||
"react": "^19.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/react-is": {
|
||||
"version": "19.2.4",
|
||||
"resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.4.tgz",
|
||||
"integrity": "sha512-W+EWGn2v0ApPKgKKCy/7s7WHXkboGcsrXE+2joLyVxkbyVQfO3MUEaUQDHoSmb8TFFrSKYa9mw64WZHNHSDzYA==",
|
||||
"license": "MIT",
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/react-redux": {
|
||||
"version": "9.2.0",
|
||||
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
|
||||
"integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/use-sync-external-store": "^0.0.6",
|
||||
"use-sync-external-store": "^1.4.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/react": "^18.2.25 || ^19",
|
||||
"react": "^18.0 || ^19",
|
||||
"redux": "^5.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/react": {
|
||||
"optional": true
|
||||
},
|
||||
"redux": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/react-refresh": {
|
||||
"version": "0.18.0",
|
||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.18.0.tgz",
|
||||
@@ -2585,6 +2875,51 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts": {
|
||||
"version": "3.8.1",
|
||||
"resolved": "https://registry.npmjs.org/recharts/-/recharts-3.8.1.tgz",
|
||||
"integrity": "sha512-mwzmO1s9sFL0TduUpwndxCUNoXsBw3u3E/0+A+cLcrSfQitSG62L32N69GhqUrrT5qKcAE3pCGVINC6pqkBBQg==",
|
||||
"license": "MIT",
|
||||
"workspaces": [
|
||||
"www"
|
||||
],
|
||||
"dependencies": {
|
||||
"@reduxjs/toolkit": "^1.9.0 || 2.x.x",
|
||||
"clsx": "^2.1.1",
|
||||
"decimal.js-light": "^2.5.1",
|
||||
"es-toolkit": "^1.39.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"immer": "^10.1.1",
|
||||
"react-redux": "8.x.x || 9.x.x",
|
||||
"reselect": "5.1.1",
|
||||
"tiny-invariant": "^1.3.3",
|
||||
"use-sync-external-store": "^1.2.2",
|
||||
"victory-vendor": "^37.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
"integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/redux-thunk": {
|
||||
"version": "3.1.0",
|
||||
"resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
|
||||
"integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"redux": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
@@ -2595,11 +2930,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/reselect": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
|
||||
"integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/resolve-pkg-maps": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz",
|
||||
"integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1"
|
||||
@@ -2785,6 +3125,12 @@
|
||||
"url": "https://opencollective.com/webpack"
|
||||
}
|
||||
},
|
||||
"node_modules/tiny-invariant": {
|
||||
"version": "1.3.3",
|
||||
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
|
||||
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@@ -2822,7 +3168,6 @@
|
||||
"version": "4.21.0",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz",
|
||||
"integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.27.0",
|
||||
@@ -2889,6 +3234,37 @@
|
||||
"browserslist": ">= 4.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/use-sync-external-store": {
|
||||
"version": "1.6.0",
|
||||
"resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz",
|
||||
"integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/victory-vendor": {
|
||||
"version": "37.3.6",
|
||||
"resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz",
|
||||
"integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==",
|
||||
"license": "MIT AND ISC",
|
||||
"dependencies": {
|
||||
"@types/d3-array": "^3.0.3",
|
||||
"@types/d3-ease": "^3.0.0",
|
||||
"@types/d3-interpolate": "^3.0.1",
|
||||
"@types/d3-scale": "^4.0.2",
|
||||
"@types/d3-shape": "^3.1.0",
|
||||
"@types/d3-time": "^3.0.0",
|
||||
"@types/d3-timer": "^3.0.0",
|
||||
"d3-array": "^3.1.6",
|
||||
"d3-ease": "^3.0.1",
|
||||
"d3-interpolate": "^3.0.1",
|
||||
"d3-scale": "^4.0.2",
|
||||
"d3-shape": "^3.1.0",
|
||||
"d3-time": "^3.0.0",
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
|
||||
17
package.json
17
package.json
@@ -12,25 +12,26 @@
|
||||
"lint": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"dotenv": "^16.4.0",
|
||||
"hono": "^4.7.0",
|
||||
"lucide-react": "^0.546.0",
|
||||
"motion": "^12.23.24",
|
||||
"hono": "^4.7.0",
|
||||
"@hono/node-server": "^1.13.0",
|
||||
"mysql2": "^3.11.0",
|
||||
"dotenv": "^16.4.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"recharts": "^3.8.1",
|
||||
"tsx": "^4.21.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"@types/node": "^22.14.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^5.0.4",
|
||||
"@tailwindcss/vite": "^4.1.14",
|
||||
"concurrently": "^9.1.0",
|
||||
"tailwindcss": "^4.1.14",
|
||||
"typescript": "~5.8.2",
|
||||
"vite": "^6.2.0",
|
||||
"concurrently": "^9.1.0"
|
||||
"vite": "^6.2.0"
|
||||
}
|
||||
}
|
||||
|
||||
248
src/App.tsx
248
src/App.tsx
@@ -32,7 +32,7 @@ import {
|
||||
LabelList,
|
||||
} from 'recharts';
|
||||
import type { SummaryData, TypeSummary, VehicleListItem, DeptGroup, RegionGroup, CustomerStats, RegionalInventoryStats } from './types';
|
||||
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats } from './api';
|
||||
import { fetchSummary, fetchByType, fetchVehicleList, fetchWeeklyDetail, fetchDeptStats, fetchRegionStats, fetchCustomerStats, fetchInventoryStats, fetchRegionChart } from './api';
|
||||
import type { WeeklyDetailItem } from './api';
|
||||
|
||||
// --- Constants ---
|
||||
@@ -86,6 +86,7 @@ export default function App() {
|
||||
|
||||
// Region section state
|
||||
const [expandedRegions, setExpandedRegions] = useState<Set<string>>(new Set());
|
||||
const [expandedRegionCities, setExpandedRegionCities] = useState<Set<string>>(new Set());
|
||||
const [regionFilters, setRegionFilters] = useState({ region: '', city: '', customer: '' });
|
||||
const [isRegionFilterOpen, setIsRegionFilterOpen] = useState(false);
|
||||
|
||||
@@ -105,6 +106,7 @@ export default function App() {
|
||||
// Chart view states
|
||||
const [customerChartView, setCustomerChartView] = useState<'region' | 'city'>('region');
|
||||
const [regionChartView, setRegionChartView] = useState<'region' | 'city'>('region');
|
||||
const [regionChartData, setRegionChartData] = useState<{ name: string; value: number }[]>([]);
|
||||
|
||||
// Modal filter state
|
||||
const [modalFilters, setModalFilters] = useState({ plateNumber: '', model: '', brand: '', location: '' });
|
||||
@@ -149,6 +151,11 @@ export default function App() {
|
||||
return () => clearInterval(interval);
|
||||
}, [loadData]);
|
||||
|
||||
// Fetch region chart data when view changes
|
||||
useEffect(() => {
|
||||
fetchRegionChart(regionChartView, regionChartView === 'city' ? 5 : 8).then(setRegionChartData).catch(() => setRegionChartData([]));
|
||||
}, [regionChartView]);
|
||||
|
||||
// Load modal vehicles
|
||||
useEffect(() => {
|
||||
if (!showPlateNumbers) {
|
||||
@@ -185,24 +192,17 @@ export default function App() {
|
||||
if (showPlateNumbers.isColdChain !== undefined) params.isColdChain = String(showPlateNumbers.isColdChain);
|
||||
if (showPlateNumbers.isTrailer !== undefined) params.isTrailer = String(showPlateNumbers.isTrailer);
|
||||
}
|
||||
// Map prototype's type field to backend vehicleType
|
||||
// Map type field to backend vehicleType
|
||||
if (showPlateNumbers.type) {
|
||||
if (showPlateNumbers.type === '4.5T') {
|
||||
if (showPlateNumbers.isColdChain === true) {
|
||||
params.vehicleType = '4.5T冷链';
|
||||
} else if (showPlateNumbers.isColdChain === false) {
|
||||
params.vehicleType = '4.5T普货';
|
||||
}
|
||||
} else if (showPlateNumbers.type === '18T') {
|
||||
params.vehicleType = '18T';
|
||||
} else if (showPlateNumbers.type === '49T') {
|
||||
params.vehicleType = '49T';
|
||||
} else if (showPlateNumbers.type === '其他车型') {
|
||||
if (showPlateNumbers.isTrailer === true) {
|
||||
params.isTrailer = 'true';
|
||||
} else if (showPlateNumbers.isTrailer === false) {
|
||||
params.vehicleType = '其他';
|
||||
}
|
||||
const t = showPlateNumbers.type;
|
||||
if (t === '4.5T') {
|
||||
if (showPlateNumbers.isColdChain === true) params.vehicleType = '4.5T冷链';
|
||||
else if (showPlateNumbers.isColdChain === false) params.vehicleType = '4.5T普货';
|
||||
} else if (t === '4.5T普货' || t === '4.5T冷链' || t === '18T' || t === '49T' || t === '挂车' || t === '其他') {
|
||||
params.vehicleType = t;
|
||||
} else if (t === '其他车型') {
|
||||
if (showPlateNumbers.isTrailer === true) params.isTrailer = 'true';
|
||||
else if (showPlateNumbers.isTrailer === false) params.vehicleType = '其他';
|
||||
}
|
||||
}
|
||||
fetchVehicleList(params)
|
||||
@@ -256,6 +256,13 @@ export default function App() {
|
||||
setExpandedRegions(newSet);
|
||||
};
|
||||
|
||||
const toggleRegionCity = (key: string) => {
|
||||
const newSet = new Set(expandedRegionCities);
|
||||
if (newSet.has(key)) newSet.delete(key);
|
||||
else newSet.add(key);
|
||||
setExpandedRegionCities(newSet);
|
||||
};
|
||||
|
||||
const toggleCustomer = (customer: string) => {
|
||||
const newSet = new Set(expandedCustomers);
|
||||
if (newSet.has(customer)) newSet.delete(customer);
|
||||
@@ -477,7 +484,7 @@ export default function App() {
|
||||
<div className="text-[9px] text-gray-400 font-medium uppercase leading-none mb-0.5">总运营</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-base font-bold text-blue-600 leading-none">{SUMMARY.operating.total}</span>
|
||||
<span className="text-[8px] text-gray-400 leading-none">自{SUMMARY.operating.self} 租{SUMMARY.operating.leased}</span>
|
||||
<span className="text-[8px] text-gray-400 leading-none">自{SUMMARY.operating.self} 租{SUMMARY.operating.leased}{SUMMARY.operating.hanging > 0 ? ` 挂${SUMMARY.operating.hanging}` : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1542,7 +1549,7 @@ export default function App() {
|
||||
<div className="flex flex-col">
|
||||
<span className="text-[9px] opacity-50 uppercase font-bold tracking-widest mb-0.5 text-blue-400">平均出勤</span>
|
||||
<span className="text-xl font-black text-blue-400">
|
||||
{'—'}
|
||||
{deptData.length > 0 ? (deptData.reduce((acc, d) => acc + d.attendanceRate, 0) / deptData.length).toFixed(1) : 0}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1561,7 +1568,7 @@ export default function App() {
|
||||
onClick={() => setDeptViewMode('manager')}
|
||||
className={`px-4 py-1.5 text-xs font-bold rounded-md transition-all ${deptViewMode === 'manager' ? 'bg-white text-blue-600 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}
|
||||
>
|
||||
按业务员
|
||||
按业务负责人
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -1578,7 +1585,7 @@ export default function App() {
|
||||
className="w-full pl-9 pr-8 py-1.5 bg-white border border-gray-200 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-blue-500/20 focus:border-blue-500 transition-all shadow-sm font-bold text-gray-700"
|
||||
/>
|
||||
<datalist id="dl-dept-manager">
|
||||
<option value="All">所有业务员</option>
|
||||
<option value="All">所有业务负责人</option>
|
||||
{allManagersList.map(m => (
|
||||
<option key={m} value={m} />
|
||||
))}
|
||||
@@ -1593,7 +1600,7 @@ export default function App() {
|
||||
<table className="w-full text-left border-collapse min-w-[900px]">
|
||||
<thead>
|
||||
<tr className="bg-gray-100/50 text-[11px] text-gray-500 uppercase tracking-wider border-b border-gray-200">
|
||||
<th className="p-2 font-bold border-r border-gray-100 w-48">{deptViewMode === 'department' ? '部门名称' : '业务员'}</th>
|
||||
<th className="p-2 font-bold border-r border-gray-100 w-48">{deptViewMode === 'department' ? '部门名称' : '业务负责人'}</th>
|
||||
{deptViewMode === 'manager' && <th className="p-2 font-bold border-r border-gray-100 w-32">所属部门</th>}
|
||||
<th className="p-2 font-bold border-r border-gray-100 text-center w-24">{deptViewMode === 'department' ? '出勤率' : '合计资产'}</th>
|
||||
{deptViewMode === 'department' && (
|
||||
@@ -1634,7 +1641,7 @@ export default function App() {
|
||||
</td>
|
||||
<td className="p-2 border-r border-gray-100 text-center">
|
||||
<span className="bg-blue-50 text-blue-600 text-[10px] font-bold px-2 py-0.5 rounded-full">
|
||||
{'—'}
|
||||
{dept.attendanceRate}%
|
||||
</span>
|
||||
</td>
|
||||
<td className="p-2 border-r border-gray-100 text-center font-black text-gray-800 text-sm">
|
||||
@@ -1642,7 +1649,7 @@ export default function App() {
|
||||
</td>
|
||||
<td className="p-2 border-r border-gray-100 text-center">
|
||||
<div className="flex items-baseline justify-center gap-1">
|
||||
<span className="font-black text-gray-800 text-sm">{'—'}</span>
|
||||
<span className="font-black text-gray-800 text-sm">{dept.avgMileage}</span>
|
||||
<span className="text-[9px] text-gray-400 font-bold">km</span>
|
||||
</div>
|
||||
</td>
|
||||
@@ -1688,42 +1695,42 @@ export default function App() {
|
||||
<div className="grid grid-cols-3 gap-1 mt-2">
|
||||
<div
|
||||
className="text-center bg-white p-1 rounded cursor-pointer hover:bg-blue-50 transition-colors border border-gray-100"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: false, source: 'department' })}
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: false, category: 'Operating', source: 'department' })}
|
||||
>
|
||||
<div className="text-[8px] text-gray-400 uppercase">4.5T</div>
|
||||
<div className="text-[10px] font-bold text-gray-600">{m.t4_5}</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-center bg-white p-1 rounded cursor-pointer hover:bg-blue-50 transition-colors border border-gray-100"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: true, source: 'department' })}
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: true, category: 'Operating', source: 'department' })}
|
||||
>
|
||||
<div className="text-[8px] text-gray-400 uppercase">冷链</div>
|
||||
<div className="text-[10px] font-bold text-gray-600">{m.t4_5c}</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-center bg-white p-1 rounded cursor-pointer hover:bg-blue-50 transition-colors border border-gray-100"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '18T', source: 'department' })}
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '18T', category: 'Operating', source: 'department' })}
|
||||
>
|
||||
<div className="text-[8px] text-gray-400 uppercase">18T</div>
|
||||
<div className="text-[10px] font-bold text-gray-600">{m.t18}</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-center bg-white p-1 rounded cursor-pointer hover:bg-blue-50 transition-colors border border-gray-100"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '49T', source: 'department' })}
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '49T', category: 'Operating', source: 'department' })}
|
||||
>
|
||||
<div className="text-[8px] text-gray-400 uppercase">49T</div>
|
||||
<div className="text-[10px] font-bold text-gray-600">{m.t49}</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-center bg-white p-1 rounded cursor-pointer hover:bg-blue-50 transition-colors border border-gray-100"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: true, source: 'department' })}
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: true, category: 'Operating', source: 'department' })}
|
||||
>
|
||||
<div className="text-[8px] text-gray-400 uppercase">挂车</div>
|
||||
<div className="text-[10px] font-bold text-gray-600">{m.trailer}</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-center bg-white p-1 rounded cursor-pointer hover:bg-blue-50 transition-colors border border-gray-100"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: false, source: 'department' })}
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: false, category: 'Operating', source: 'department' })}
|
||||
>
|
||||
<div className="text-[8px] text-gray-400 uppercase">其他</div>
|
||||
<div className="text-[10px] font-bold text-gray-600">{m.other}</div>
|
||||
@@ -1759,7 +1766,7 @@ export default function App() {
|
||||
className="p-2 border-r border-gray-100 text-center font-black text-blue-600 text-sm cursor-pointer hover:bg-blue-50"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, source: 'department' });
|
||||
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, category: 'Operating', source: 'department' });
|
||||
}}
|
||||
>
|
||||
{m.total}
|
||||
@@ -1786,27 +1793,27 @@ export default function App() {
|
||||
<tr className="bg-gray-50/50 border-b border-gray-100">
|
||||
<td colSpan={10} className="p-0">
|
||||
<div className="grid grid-cols-6 text-[10px] bg-white/50">
|
||||
<div className="p-2 border-r border-gray-100 flex flex-col items-center cursor-pointer hover:bg-blue-50" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: false, source: 'department' })}>
|
||||
<div className="p-2 border-r border-gray-100 flex flex-col items-center cursor-pointer hover:bg-blue-50" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: false, category: 'Operating', source: 'department' })}>
|
||||
<span className="text-gray-400 uppercase mb-1">4.5T</span>
|
||||
<span className="font-bold text-gray-600">{m.t4_5}</span>
|
||||
</div>
|
||||
<div className="p-2 border-r border-gray-100 flex flex-col items-center cursor-pointer hover:bg-blue-50" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: true, source: 'department' })}>
|
||||
<div className="p-2 border-r border-gray-100 flex flex-col items-center cursor-pointer hover:bg-blue-50" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: true, category: 'Operating', source: 'department' })}>
|
||||
<span className="text-gray-400 uppercase mb-1">冷链</span>
|
||||
<span className="font-bold text-gray-600">{m.t4_5c}</span>
|
||||
</div>
|
||||
<div className="p-2 border-r border-gray-100 flex flex-col items-center cursor-pointer hover:bg-blue-50" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '18T', source: 'department' })}>
|
||||
<div className="p-2 border-r border-gray-100 flex flex-col items-center cursor-pointer hover:bg-blue-50" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '18T', category: 'Operating', source: 'department' })}>
|
||||
<span className="text-gray-400 uppercase mb-1">18T</span>
|
||||
<span className="font-bold text-gray-600">{m.t18}</span>
|
||||
</div>
|
||||
<div className="p-2 border-r border-gray-100 flex flex-col items-center cursor-pointer hover:bg-blue-50" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '49T', source: 'department' })}>
|
||||
<div className="p-2 border-r border-gray-100 flex flex-col items-center cursor-pointer hover:bg-blue-50" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '49T', category: 'Operating', source: 'department' })}>
|
||||
<span className="text-gray-400 uppercase mb-1">49T</span>
|
||||
<span className="font-bold text-gray-600">{m.t49}</span>
|
||||
</div>
|
||||
<div className="p-2 border-r border-gray-100 flex flex-col items-center cursor-pointer hover:bg-blue-50" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: true, source: 'department' })}>
|
||||
<div className="p-2 border-r border-gray-100 flex flex-col items-center cursor-pointer hover:bg-blue-50" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: true, category: 'Operating', source: 'department' })}>
|
||||
<span className="text-gray-400 uppercase mb-1">挂车</span>
|
||||
<span className="font-bold text-gray-600">{m.trailer}</span>
|
||||
</div>
|
||||
<div className="p-2 flex flex-col items-center cursor-pointer hover:bg-blue-50" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: false, source: 'department' })}>
|
||||
<div className="p-2 flex flex-col items-center cursor-pointer hover:bg-blue-50" onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: false, category: 'Operating', source: 'department' })}>
|
||||
<span className="text-gray-400 uppercase mb-1">其他</span>
|
||||
<span className="font-bold text-gray-600">{m.other}</span>
|
||||
</div>
|
||||
@@ -1836,7 +1843,7 @@ export default function App() {
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<h3 className="text-sm font-bold text-gray-800">{dept.department}</h3>
|
||||
<span className="bg-blue-50 text-blue-600 text-[9px] font-bold px-2 py-0.5 rounded-full">
|
||||
出勤率: —
|
||||
出勤率: {dept.attendanceRate}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
@@ -1846,7 +1853,7 @@ export default function App() {
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-[8px] text-gray-400 uppercase font-bold mb-0.5">里程</div>
|
||||
<div className="text-xs font-black text-gray-800">{'—'}</div>
|
||||
<div className="text-xs font-black text-gray-800">{dept.avgMileage}</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<div className="text-[8px] text-green-500 uppercase font-bold mb-0.5">运营</div>
|
||||
@@ -1878,7 +1885,7 @@ export default function App() {
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, source: 'department' });
|
||||
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, category: 'Operating', source: 'department' });
|
||||
}}
|
||||
className="text-[10px] font-bold text-blue-600 bg-blue-50 px-2 py-0.5 rounded"
|
||||
>
|
||||
@@ -1890,42 +1897,42 @@ export default function App() {
|
||||
<div className="p-2 border-t border-gray-50 bg-gray-50/30 grid grid-cols-3 gap-1">
|
||||
<div
|
||||
className="text-center bg-white p-1 rounded border border-gray-100 cursor-pointer"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: false, source: 'department' })}
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: false, category: 'Operating', source: 'department' })}
|
||||
>
|
||||
<div className="text-[8px] text-gray-400 uppercase">4.5T</div>
|
||||
<div className="text-[10px] font-bold text-gray-600">{m.t4_5}</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-center bg-white p-1 rounded border border-gray-100 cursor-pointer"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: true, source: 'department' })}
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: true, category: 'Operating', source: 'department' })}
|
||||
>
|
||||
<div className="text-[8px] text-gray-400 uppercase">冷链</div>
|
||||
<div className="text-[10px] font-bold text-gray-600">{m.t4_5c}</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-center bg-white p-1 rounded border border-gray-100 cursor-pointer"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '18T', source: 'department' })}
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '18T', category: 'Operating', source: 'department' })}
|
||||
>
|
||||
<div className="text-[8px] text-gray-400 uppercase">18T</div>
|
||||
<div className="text-[10px] font-bold text-gray-600">{m.t18}</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-center bg-white p-1 rounded border border-gray-100 cursor-pointer"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '49T', source: 'department' })}
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '49T', category: 'Operating', source: 'department' })}
|
||||
>
|
||||
<div className="text-[8px] text-gray-400 uppercase">49T</div>
|
||||
<div className="text-[10px] font-bold text-gray-600">{m.t49}</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-center bg-white p-1 rounded border border-gray-100 cursor-pointer"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: true, source: 'department' })}
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: true, category: 'Operating', source: 'department' })}
|
||||
>
|
||||
<div className="text-[8px] text-gray-400 uppercase">挂车</div>
|
||||
<div className="text-[10px] font-bold text-gray-600">{m.trailer}</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-center bg-white p-1 rounded border border-gray-100 cursor-pointer"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: false, source: 'department' })}
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: false, category: 'Operating', source: 'department' })}
|
||||
>
|
||||
<div className="text-[8px] text-gray-400 uppercase">其他</div>
|
||||
<div className="text-[10px] font-bold text-gray-600">{m.other}</div>
|
||||
@@ -1958,7 +1965,7 @@ export default function App() {
|
||||
className="text-[11px] font-bold text-blue-600 whitespace-nowrap"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, source: 'department' });
|
||||
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, category: 'Operating', source: 'department' });
|
||||
}}
|
||||
>
|
||||
资产: {m.total}
|
||||
@@ -1981,42 +1988,42 @@ export default function App() {
|
||||
<div className="p-2 border-t border-gray-50 bg-gray-50/30 grid grid-cols-3 gap-1">
|
||||
<div
|
||||
className="text-center bg-white p-1 rounded border border-gray-100 cursor-pointer"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: false, source: 'department' })}
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: false, category: 'Operating', source: 'department' })}
|
||||
>
|
||||
<div className="text-[8px] text-gray-400 uppercase">4.5T</div>
|
||||
<div className="text-[10px] font-bold text-gray-600">{m.t4_5}</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-center bg-white p-1 rounded border border-gray-100 cursor-pointer"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: true, source: 'department' })}
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '4.5T', isColdChain: true, category: 'Operating', source: 'department' })}
|
||||
>
|
||||
<div className="text-[8px] text-gray-400 uppercase">冷链</div>
|
||||
<div className="text-[10px] font-bold text-gray-600">{m.t4_5c}</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-center bg-white p-1 rounded border border-gray-100 cursor-pointer"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '18T', source: 'department' })}
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '18T', category: 'Operating', source: 'department' })}
|
||||
>
|
||||
<div className="text-[8px] text-gray-400 uppercase">18T</div>
|
||||
<div className="text-[10px] font-bold text-gray-600">{m.t18}</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-center bg-white p-1 rounded border border-gray-100 cursor-pointer"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '49T', source: 'department' })}
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '49T', category: 'Operating', source: 'department' })}
|
||||
>
|
||||
<div className="text-[8px] text-gray-400 uppercase">49T</div>
|
||||
<div className="text-[10px] font-bold text-gray-600">{m.t49}</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-center bg-white p-1 rounded border border-gray-100 cursor-pointer"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: true, source: 'department' })}
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: true, category: 'Operating', source: 'department' })}
|
||||
>
|
||||
<div className="text-[8px] text-gray-400 uppercase">挂车</div>
|
||||
<div className="text-[10px] font-bold text-gray-600">{m.trailer}</div>
|
||||
</div>
|
||||
<div
|
||||
className="text-center bg-white p-1 rounded border border-gray-100 cursor-pointer"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: false, source: 'department' })}
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', manager: m.manager, type: '其他车型', isTrailer: false, category: 'Operating', source: 'department' })}
|
||||
>
|
||||
<div className="text-[8px] text-gray-400 uppercase">其他</div>
|
||||
<div className="text-[10px] font-bold text-gray-600">{m.other}</div>
|
||||
@@ -2052,25 +2059,9 @@ export default function App() {
|
||||
>按城市</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="h-64 w-full">
|
||||
<div className="h-72 w-full">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={(() => {
|
||||
if (regionChartView === 'region') {
|
||||
const regions: { [key: string]: number } = {};
|
||||
customerData.forEach(item => {
|
||||
regions[item.region] = (regions[item.region] || 0) + item.total;
|
||||
});
|
||||
return Object.entries(regions).map(([name, value]) => ({ name, value }));
|
||||
} else {
|
||||
const cities: { [key: string]: number } = {};
|
||||
customerData.forEach(item => {
|
||||
cities[item.city] = (cities[item.city] || 0) + item.total;
|
||||
});
|
||||
return Object.entries(cities)
|
||||
.map(([name, value]) => ({ name, value }))
|
||||
.sort((a, b) => b.value - a.value);
|
||||
}
|
||||
})()}>
|
||||
<BarChart data={regionChartData} margin={{ top: 20 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" vertical={false} stroke="#f0f0f0" />
|
||||
<XAxis dataKey="name" axisLine={false} tickLine={false} tick={{ fill: '#94a3b8', fontSize: 12 }} />
|
||||
<YAxis axisLine={false} tickLine={false} tick={{ fill: '#94a3b8', fontSize: 12 }} />
|
||||
@@ -2212,90 +2203,55 @@ export default function App() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="text-xs">
|
||||
{uniqueRegions.filter(r => !regionFilters.region || r === regionFilters.region).map((region) => {
|
||||
const regionStats = customerData.filter(s => {
|
||||
const matchRegion = s.region === region;
|
||||
const matchCity = !regionFilters.city || s.city === regionFilters.city;
|
||||
const matchCustomer = !regionFilters.customer || s.customer.toLowerCase().includes(regionFilters.customer.toLowerCase());
|
||||
return matchRegion && matchCity && matchCustomer;
|
||||
});
|
||||
const totalAssets = regionStats.reduce((acc, s) => acc + s.total, 0);
|
||||
if (totalAssets === 0) return null;
|
||||
|
||||
const isExpanded = expandedRegions.has(region);
|
||||
|
||||
{regionData.filter(r => !regionFilters.region || r.region === regionFilters.region).map((r) => {
|
||||
const isExpanded = expandedRegions.has(r.region);
|
||||
return (
|
||||
<React.Fragment key={region}>
|
||||
<React.Fragment key={r.region}>
|
||||
<tr
|
||||
className={`border-b border-slate-100 cursor-pointer transition-colors ${isExpanded ? 'bg-slate-50' : 'bg-white hover:bg-slate-50/50'}`}
|
||||
onClick={() => toggleRegion(region)}
|
||||
onClick={() => toggleRegion(r.region)}
|
||||
>
|
||||
<td className="p-2 font-bold text-slate-700 flex items-center gap-2">
|
||||
{isExpanded ? <ChevronDown size={14} className="text-slate-400" /> : <ChevronRight size={14} className="text-slate-400" />}
|
||||
<Truck size={14} className="text-slate-400" />
|
||||
{region}区域
|
||||
</td>
|
||||
<td className="p-2 text-center font-bold text-slate-600">{totalAssets}</td>
|
||||
<td
|
||||
className="p-2 text-center text-green-600 font-bold cursor-pointer hover:bg-green-50"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Operating', source: 'asset' });
|
||||
}}
|
||||
>
|
||||
{Math.floor(totalAssets * 0.8)}
|
||||
</td>
|
||||
<td
|
||||
className="p-2 text-center text-orange-600 font-bold cursor-pointer hover:bg-orange-50"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', category: 'Pending', source: 'asset' });
|
||||
}}
|
||||
>
|
||||
{Math.floor(totalAssets * 0.05)}
|
||||
</td>
|
||||
<td className="p-2 text-center text-slate-500 font-medium">
|
||||
{regionStats.slice(0, 2).map(s => s.customer).join(', ')}
|
||||
{r.region}区域
|
||||
</td>
|
||||
<td className="p-2 text-center font-bold text-slate-600">{r.totalAssets}</td>
|
||||
<td className="p-2 text-center text-green-600 font-bold">{r.operatingCount}</td>
|
||||
<td className="p-2 text-center text-orange-600 font-bold">{r.pendingCount || ''}</td>
|
||||
<td className="p-2 text-center text-slate-500 font-medium">{r.customers.slice(0, 2).join(', ')}</td>
|
||||
</tr>
|
||||
{isExpanded && ['4.5T', '18T', '49T'].map(type => {
|
||||
const typeTotal = regionStats.reduce((acc, s) => {
|
||||
if (type === '4.5T') return acc + s.t4_5 + s.t4_5c;
|
||||
if (type === '18T') return acc + s.t18;
|
||||
if (type === '49T') return acc + s.t49;
|
||||
return acc;
|
||||
}, 0);
|
||||
if (typeTotal === 0) return null;
|
||||
|
||||
{isExpanded && r.cities.map((city) => {
|
||||
const cityKey = `${r.region}-${city.city}`;
|
||||
const isCityExpanded = expandedRegionCities.has(cityKey);
|
||||
return (
|
||||
<React.Fragment key={type}>
|
||||
<tr className="border-b border-gray-50 hover:bg-gray-50">
|
||||
<td className="p-2 pl-8 text-gray-500 flex items-center gap-2">
|
||||
<div className="w-1 h-1 bg-slate-300 rounded-full"></div>
|
||||
{type} 车型
|
||||
</td>
|
||||
<td className="p-2 text-center text-gray-600">{typeTotal}</td>
|
||||
<td
|
||||
className="p-2 text-center text-green-600 cursor-pointer hover:bg-green-50"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', type, category: 'Operating', source: 'asset' })}
|
||||
>
|
||||
{Math.floor(typeTotal * 0.8)}
|
||||
</td>
|
||||
<td
|
||||
className="p-2 text-center text-orange-600 cursor-pointer hover:bg-orange-50"
|
||||
onClick={() => setShowPlateNumbers({ batch: 'All', model: 'All', location: 'All', type, category: 'Pending', source: 'asset' })}
|
||||
>
|
||||
{Math.floor(typeTotal * 0.05)}
|
||||
</td>
|
||||
<td className="p-2 text-center text-gray-400 italic">
|
||||
{regionStats.filter(s => {
|
||||
if (type === '4.5T') return (s.t4_5 + s.t4_5c) > 0;
|
||||
if (type === '18T') return s.t18 > 0;
|
||||
if (type === '49T') return s.t49 > 0;
|
||||
return false;
|
||||
}).map(s => s.customer).join(', ')}
|
||||
<React.Fragment key={city.city}>
|
||||
<tr
|
||||
className="border-b border-gray-50 hover:bg-gray-50 cursor-pointer"
|
||||
onClick={() => toggleRegionCity(cityKey)}
|
||||
>
|
||||
<td className="p-2 pl-8 text-slate-600 flex items-center gap-2">
|
||||
{isCityExpanded ? <ChevronDown size={12} className="text-slate-400" /> : <ChevronRight size={12} className="text-slate-400" />}
|
||||
<MapPin size={12} className="text-slate-300" />
|
||||
<span className="font-medium">{city.city}</span>
|
||||
</td>
|
||||
<td className="p-2 text-center text-slate-600 font-medium">{city.totalAssets}</td>
|
||||
<td className="p-2 text-center text-green-600">{city.operatingCount}</td>
|
||||
<td className="p-2 text-center text-orange-600">{city.pendingCount || ''}</td>
|
||||
<td className="p-2 text-center text-slate-400 text-[10px] italic">{city.customers.slice(0, 2).join(', ')}</td>
|
||||
</tr>
|
||||
{isCityExpanded && city.typeBreakdown.map(tb => (
|
||||
<tr key={tb.type} className="border-b border-gray-50/50 bg-slate-50/30">
|
||||
<td className="p-2 pl-14 text-gray-400 flex items-center gap-2">
|
||||
<div className="w-1 h-1 bg-slate-300 rounded-full"></div>
|
||||
{tb.type} 车型
|
||||
</td>
|
||||
<td className="p-2 text-center text-gray-500">{tb.total}</td>
|
||||
<td className="p-2 text-center text-green-500">{tb.operating}</td>
|
||||
<td className="p-2 text-center text-orange-500">{tb.inventory || ''}</td>
|
||||
<td className="p-2 text-center text-gray-400 text-[10px] italic">{tb.customers.slice(0, 2).join(', ')}</td>
|
||||
</tr>
|
||||
))}
|
||||
</React.Fragment>
|
||||
);
|
||||
})}
|
||||
@@ -2645,7 +2601,7 @@ export default function App() {
|
||||
<tr className="bg-emerald-700 text-white text-[11px] uppercase tracking-wider border-b border-emerald-800">
|
||||
<th className="p-2 font-semibold border-r border-emerald-600 w-40">客户名称</th>
|
||||
<th className="p-2 font-semibold border-r border-emerald-600 w-24">所在区域</th>
|
||||
<th className="p-2 font-semibold border-r border-emerald-600 w-24">关联业务员</th>
|
||||
<th className="p-2 font-semibold border-r border-emerald-600 w-24">关联业务负责人</th>
|
||||
<th className="p-2 font-semibold border-r border-emerald-600 text-center w-20">4.5T</th>
|
||||
<th className="p-2 font-semibold border-r border-emerald-600 text-center w-20">4.5T冷链</th>
|
||||
<th className="p-2 font-semibold border-r border-emerald-600 text-center w-20">18T</th>
|
||||
@@ -2689,7 +2645,7 @@ export default function App() {
|
||||
<div className="bg-white p-2 rounded border border-gray-100 shadow-sm">
|
||||
<div className="text-[10px] text-gray-400 uppercase mb-1">客户详情</div>
|
||||
<div className="text-sm font-bold text-gray-700">{cust.customer}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">主责业务员: {cust.manager}</div>
|
||||
<div className="text-xs text-gray-500 mt-1">主责业务负责人: {cust.manager}</div>
|
||||
</div>
|
||||
<div className="bg-white p-2 rounded border border-gray-100 shadow-sm">
|
||||
<div className="text-[10px] text-gray-400 uppercase mb-1">主要车型</div>
|
||||
|
||||
@@ -74,6 +74,10 @@ export async function fetchInventoryStats(): Promise<RegionalInventoryStats[]> {
|
||||
return fetchJson<RegionalInventoryStats[]>(`${BASE}/inventory-stats`);
|
||||
}
|
||||
|
||||
export async function fetchRegionChart(groupBy: string, top = 8): Promise<{ name: string; value: number }[]> {
|
||||
return fetchJson<{ name: string; value: number }[]>(`${BASE}/region-chart?groupBy=${groupBy}&top=${top}`);
|
||||
}
|
||||
|
||||
export async function fetchWeeklyDetail(type: string): Promise<WeeklyDetailItem[]> {
|
||||
return fetchJson<WeeklyDetailItem[]>(`${BASE}/weekly-detail?type=${type}`);
|
||||
}
|
||||
|
||||
@@ -156,15 +156,23 @@ function mapStatus(rentStatus: string | null): 'Operating' | 'Inventory' | 'Pend
|
||||
return 'Inventory';
|
||||
}
|
||||
|
||||
// Map ownership status
|
||||
// Actual DB values: 自有(0), 外租(1), 挂靠(2)
|
||||
function mapOwnership(ascriptionLabel: string | null): string {
|
||||
if (!ascriptionLabel) return 'Self';
|
||||
const s = ascriptionLabel.trim();
|
||||
if (s === '自有') return 'Self';
|
||||
if (s === '外租') return 'Leased';
|
||||
// Map ownership from truck_rent_status (rentStatusLabel)
|
||||
// DB values: 自营(1), 租赁(2), 挂靠(8) → these are the operating subtypes
|
||||
function mapOwnership(rentStatusLabel: string | null): string {
|
||||
if (!rentStatusLabel) return 'Unknown';
|
||||
const s = rentStatusLabel.trim();
|
||||
if (s === '自营') return 'Self';
|
||||
if (s === '租赁') return 'Leased';
|
||||
if (s === '挂靠') return 'Hanging';
|
||||
return 'Self';
|
||||
return 'Unknown';
|
||||
}
|
||||
|
||||
// Resolve city name: clean brackets, fallback to province for municipalities
|
||||
function resolveCity(city: string | null, province: string | null): string {
|
||||
const c = (city || '').replace(/[\[\]]/g, '').trim();
|
||||
if (c) return c;
|
||||
const p = (province || '').replace(/[\[\]]/g, '').trim();
|
||||
return p || '其他';
|
||||
}
|
||||
|
||||
// Derive vehicle type category from model label
|
||||
@@ -268,7 +276,7 @@ function transformRow(row: VehicleRow): Vehicle {
|
||||
province: row.省,
|
||||
city: row.市,
|
||||
status: mapStatus(row.车辆租赁状态Label),
|
||||
ownership: mapOwnership(row.车辆归属状态Label),
|
||||
ownership: mapOwnership(row.车辆租赁状态Label),
|
||||
rentCompany: row.租赁公司 || '',
|
||||
contractNo: row.合同编码,
|
||||
customerName: row.客户名称,
|
||||
@@ -639,68 +647,168 @@ app.get('/inventory-analysis', async (c) => {
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
// GET /api/vehicles/dept-stats — department & manager breakdown
|
||||
// GET /api/vehicles/dept-stats — department & manager breakdown with mileage/attendance
|
||||
app.get('/dept-stats', async (c) => {
|
||||
const vehicles = await getVehicles();
|
||||
const withManager = vehicles.filter((v) => v.customerManager && v.status === 'Operating');
|
||||
const withManager = vehicles.filter((v) => v.status === 'Operating');
|
||||
|
||||
// Query mileage data: last 30 days attendance & avg mileage per plate
|
||||
// + today's mileage for idle detection
|
||||
const [[mileageRows], [todayRows]] = await Promise.all([
|
||||
pool.query<any[]>(`
|
||||
SELECT plateNumber,
|
||||
COUNT(CASE WHEN dayMileage > 0 THEN 1 END) AS activeDays,
|
||||
COUNT(*) AS totalDays,
|
||||
AVG(dayMileage) AS avgMileage
|
||||
FROM ln_vehicle_day_mileage
|
||||
WHERE dates >= DATE_SUB(CURDATE(), INTERVAL 30 DAY)
|
||||
GROUP BY plateNumber
|
||||
`),
|
||||
pool.query<any[]>(`
|
||||
SELECT plateNumber, dayMileage
|
||||
FROM ln_vehicle_day_mileage
|
||||
WHERE dates = CURDATE()
|
||||
`),
|
||||
]);
|
||||
const mileageMap = new Map<string, { activeDays: number; totalDays: number; avgMileage: number }>();
|
||||
for (const row of mileageRows as any[]) {
|
||||
mileageMap.set(row.plateNumber, {
|
||||
activeDays: Number(row.activeDays),
|
||||
totalDays: Number(row.totalDays),
|
||||
avgMileage: Number(row.avgMileage),
|
||||
});
|
||||
}
|
||||
const todayMileageMap = new Map<string, number>();
|
||||
for (const row of todayRows as any[]) {
|
||||
todayMileageMap.set(row.plateNumber, Number(row.dayMileage));
|
||||
}
|
||||
|
||||
const deptMap = new Map<string, Map<string, Vehicle[]>>();
|
||||
for (const v of withManager) {
|
||||
const dept = v.departmentName || '未分配';
|
||||
const mgr = v.customerManager!;
|
||||
const mgr = v.customerManager || '未分配';
|
||||
if (!deptMap.has(dept)) deptMap.set(dept, new Map());
|
||||
const mgrMap = deptMap.get(dept)!;
|
||||
if (!mgrMap.has(mgr)) mgrMap.set(mgr, []);
|
||||
mgrMap.get(mgr)!.push(v);
|
||||
}
|
||||
|
||||
// Compute attendance & mileage for a set of vehicles
|
||||
const getMileageStats = (vList: Vehicle[]) => {
|
||||
let totalActive = 0;
|
||||
let totalDays = 0;
|
||||
let totalMileage = 0;
|
||||
let count = 0;
|
||||
for (const v of vList) {
|
||||
const m = mileageMap.get(v.plateNumber);
|
||||
if (m) {
|
||||
totalActive += m.activeDays;
|
||||
totalDays += m.totalDays;
|
||||
totalMileage += m.avgMileage;
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return {
|
||||
attendanceRate: totalDays > 0 ? Math.round((totalActive / totalDays) * 1000) / 10 : 0,
|
||||
avgMileage: count > 0 ? Math.round(totalMileage / count) : 0,
|
||||
};
|
||||
};
|
||||
|
||||
const result = Array.from(deptMap.entries()).map(([department, mgrMap]) => {
|
||||
const allDeptVehicles = Array.from(mgrMap.values()).flat();
|
||||
const deptMileage = getMileageStats(allDeptVehicles);
|
||||
const managers = Array.from(mgrMap.entries())
|
||||
.map(([manager, mvs]) => ({ manager, department, ...countByType(mvs) }))
|
||||
.sort((a, b) => b.total - a.total);
|
||||
return {
|
||||
department,
|
||||
totalAssets: allDeptVehicles.length,
|
||||
operatingCount: allDeptVehicles.filter((v) => v.status === 'Operating').length,
|
||||
idleCount: allDeptVehicles.filter((v) => v.status !== 'Operating').length,
|
||||
operatingCount: allDeptVehicles.filter((v) => (todayMileageMap.get(v.plateNumber) || 0) > 0).length,
|
||||
idleCount: allDeptVehicles.filter((v) => (todayMileageMap.get(v.plateNumber) || 0) === 0).length,
|
||||
attendanceRate: deptMileage.attendanceRate,
|
||||
avgMileage: deptMileage.avgMileage,
|
||||
managers,
|
||||
};
|
||||
}).sort((a, b) => b.totalAssets - a.totalAssets);
|
||||
}).sort((a, b) => {
|
||||
// 按部门名中的数字排序(业务一部=1, 业务二部=2, ...)
|
||||
const numMap: Record<string, number> = { '一': 1, '二': 2, '三': 3, '四': 4, '五': 5, '六': 6, '七': 7, '八': 8, '九': 9, '十': 10 };
|
||||
const getNum = (name: string) => {
|
||||
const m = name.match(/[一二三四五六七八九十]/);
|
||||
return m ? (numMap[m[0]] || 99) : 99;
|
||||
};
|
||||
return getNum(a.department) - getNum(b.department);
|
||||
});
|
||||
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
// GET /api/vehicles/region-stats — macro-region breakdown for operating vehicles
|
||||
// GET /api/vehicles/region-stats — macro-region with city drill-down
|
||||
app.get('/region-stats', async (c) => {
|
||||
const vehicles = await getVehicles();
|
||||
const operating = vehicles.filter((v) => v.status === 'Operating');
|
||||
const operating = vehicles.filter((v) => v.status === 'Operating' || v.status === 'Pending');
|
||||
|
||||
const regionMap = new Map<string, Vehicle[]>();
|
||||
const regionCityMap = new Map<string, Map<string, Vehicle[]>>();
|
||||
for (const v of operating) {
|
||||
const region = mapMacroRegion(v.province, v.city);
|
||||
if (!regionMap.has(region)) regionMap.set(region, []);
|
||||
regionMap.get(region)!.push(v);
|
||||
const city = resolveCity(v.city, v.province);
|
||||
if (!regionCityMap.has(region)) regionCityMap.set(region, new Map());
|
||||
const cityMap = regionCityMap.get(region)!;
|
||||
if (!cityMap.has(city)) cityMap.set(city, []);
|
||||
cityMap.get(city)!.push(v);
|
||||
}
|
||||
|
||||
const getTypeBreakdown = (vList: Vehicle[]) =>
|
||||
['4.5T', '18T', '49T'].map((type) => {
|
||||
const tv = vList.filter((v) => v.type === type);
|
||||
return { type, total: tv.length, operating: tv.filter((v) => v.status === 'Operating').length, inventory: tv.filter((v) => v.status === 'Inventory').length, customers: Array.from(new Set(tv.map((v) => v.customerName).filter(Boolean))) as string[] };
|
||||
}).filter((t) => t.total > 0);
|
||||
|
||||
const regionOrder = ['华东', '华南', '华北', '华中', '西南', '西北', '其他'];
|
||||
const result = regionOrder
|
||||
.filter((r) => regionMap.has(r))
|
||||
.filter((r) => regionCityMap.has(r))
|
||||
.map((region) => {
|
||||
const rv = regionMap.get(region)!;
|
||||
const customers = Array.from(new Set(rv.map((v) => v.customerName).filter(Boolean))) as string[];
|
||||
const typeBreakdown = ['4.5T', '18T', '49T'].map((type) => {
|
||||
const typeVehicles = rv.filter((v) => v.type === type);
|
||||
return {
|
||||
type,
|
||||
total: typeVehicles.length,
|
||||
operating: typeVehicles.filter((v) => v.status === 'Operating').length,
|
||||
inventory: typeVehicles.filter((v) => v.status === 'Inventory').length,
|
||||
customers: Array.from(new Set(typeVehicles.map((v) => v.customerName).filter(Boolean))) as string[],
|
||||
};
|
||||
}).filter((t) => t.total > 0);
|
||||
const cityMap = regionCityMap.get(region)!;
|
||||
const allVehicles = Array.from(cityMap.values()).flat();
|
||||
const customers = Array.from(new Set(allVehicles.map((v) => v.customerName).filter(Boolean))) as string[];
|
||||
const allCities = Array.from(cityMap.entries())
|
||||
.map(([city, cv]) => ({
|
||||
city,
|
||||
totalAssets: cv.length,
|
||||
operatingCount: cv.filter((v) => v.status === 'Operating').length,
|
||||
pendingCount: cv.filter((v) => v.status === 'Pending').length,
|
||||
customers: Array.from(new Set(cv.map((v) => v.customerName).filter(Boolean))) as string[],
|
||||
typeBreakdown: getTypeBreakdown(cv),
|
||||
}))
|
||||
.sort((a, b) => b.totalAssets - a.totalAssets);
|
||||
|
||||
return { region, totalAssets: rv.length, operatingCount: rv.filter((v) => v.status === 'Operating').length, inventoryCount: rv.filter((v) => v.status === 'Inventory').length, customers, typeBreakdown };
|
||||
// Top 8 cities + merge rest into "其他"
|
||||
const topCities = allCities.slice(0, 8);
|
||||
const restCities = allCities.slice(8);
|
||||
if (restCities.length > 0) {
|
||||
const restVehicles = restCities.flatMap((c) => {
|
||||
const key = c.city;
|
||||
return cityMap.get(key) || [];
|
||||
});
|
||||
topCities.push({
|
||||
city: '其他',
|
||||
totalAssets: restCities.reduce((s, c) => s + c.totalAssets, 0),
|
||||
operatingCount: restCities.reduce((s, c) => s + c.operatingCount, 0),
|
||||
pendingCount: restCities.reduce((s, c) => s + (c.pendingCount || 0), 0),
|
||||
customers: Array.from(new Set(restVehicles.map((v) => v.customerName).filter(Boolean))) as string[],
|
||||
typeBreakdown: getTypeBreakdown(restVehicles),
|
||||
});
|
||||
}
|
||||
const cities = topCities;
|
||||
|
||||
return {
|
||||
region,
|
||||
totalAssets: allVehicles.length,
|
||||
operatingCount: allVehicles.filter((v) => v.status === 'Operating').length,
|
||||
pendingCount: allVehicles.filter((v) => v.status === 'Pending').length,
|
||||
customers,
|
||||
typeBreakdown: getTypeBreakdown(allVehicles),
|
||||
cities,
|
||||
};
|
||||
});
|
||||
|
||||
return c.json(result);
|
||||
@@ -709,11 +817,11 @@ app.get('/region-stats', async (c) => {
|
||||
// GET /api/vehicles/customer-stats — per-customer breakdown for operating vehicles
|
||||
app.get('/customer-stats', async (c) => {
|
||||
const vehicles = await getVehicles();
|
||||
const operating = vehicles.filter((v) => v.status === 'Operating' && v.customerName);
|
||||
const operating = vehicles.filter((v) => v.status === 'Operating');
|
||||
|
||||
const custMap = new Map<string, Vehicle[]>();
|
||||
for (const v of operating) {
|
||||
const cust = v.customerName!;
|
||||
const cust = v.customerName || '未分配客户';
|
||||
if (!custMap.has(cust)) custMap.set(cust, []);
|
||||
custMap.get(cust)!.push(v);
|
||||
}
|
||||
@@ -742,7 +850,8 @@ const VEHICLE_TYPE_FILTERS: Record<string, (v: Vehicle) => boolean> = {
|
||||
'4.5T冷链': (v) => v.type === '4.5T' && v.model.includes('冷链'),
|
||||
'18T': (v) => v.type === '18T',
|
||||
'49T': (v) => v.type === '49T',
|
||||
'其他': (v) => !['4.5T', '18T', '49T'].includes(v.type),
|
||||
'挂车': (v) => v.type === '挂车' || v.model.includes('挂车'),
|
||||
'其他': (v) => classifyVehicleType(v) === 'other',
|
||||
};
|
||||
|
||||
// GET /api/vehicles/list — flat list with optional filters
|
||||
@@ -761,10 +870,15 @@ app.get('/list', async (c) => {
|
||||
filtered = filtered.filter((v) => v.model === model);
|
||||
}
|
||||
if (location && location !== 'All') {
|
||||
// Support both display region names and inventory region names
|
||||
const inventoryRegionMap: Record<string, string> = { '江浙沪': '嘉兴', '其它': '其他' };
|
||||
const mappedLocation = inventoryRegionMap[location] || location;
|
||||
filtered = filtered.filter((v) => v.location === mappedLocation);
|
||||
// Support: display regions (嘉兴/广东), inventory regions (江浙沪), cities (嘉兴市), macro regions (华东/华南)
|
||||
const macroRegions = ['华东', '华南', '华北', '华中', '西南', '西北'];
|
||||
if (macroRegions.includes(location) || location === '其他') {
|
||||
filtered = filtered.filter((v) => mapMacroRegion(v.province, v.city) === location);
|
||||
} else {
|
||||
const inventoryRegionMap: Record<string, string> = { '江浙沪': '嘉兴', '其它': '其他' };
|
||||
const mappedLocation = inventoryRegionMap[location] || location;
|
||||
filtered = filtered.filter((v) => v.location === mappedLocation || v.city === location || resolveCity(v.city, v.province) === location);
|
||||
}
|
||||
}
|
||||
if (status && status !== 'All') {
|
||||
filtered = filtered.filter((v) => v.status === status);
|
||||
@@ -777,10 +891,10 @@ app.get('/list', async (c) => {
|
||||
}
|
||||
}
|
||||
if (manager) {
|
||||
filtered = filtered.filter((v) => v.customerManager === manager);
|
||||
filtered = filtered.filter((v) => manager === '未分配' ? !v.customerManager : v.customerManager === manager);
|
||||
}
|
||||
if (customer) {
|
||||
filtered = filtered.filter((v) => v.customerName === customer);
|
||||
filtered = filtered.filter((v) => customer === '未分配客户' ? !v.customerName : v.customerName === customer);
|
||||
}
|
||||
if (isColdChain !== undefined) {
|
||||
const wantCold = isColdChain === 'true';
|
||||
@@ -795,6 +909,7 @@ app.get('/list', async (c) => {
|
||||
filtered.map((v) => ({
|
||||
id: v.id,
|
||||
plateNumber: v.plateNumber,
|
||||
vin: v.vin,
|
||||
type: v.type,
|
||||
model: v.model,
|
||||
location: v.location,
|
||||
@@ -832,7 +947,7 @@ app.get('/inventory-stats', async (c) => {
|
||||
const typeCategory = classifyVehicleType(v);
|
||||
const typeName = TYPE_NAME_MAP[typeCategory];
|
||||
const region = mapMacroRegion(v.province, v.city);
|
||||
const city = v.city || '其他';
|
||||
const city = resolveCity(v.city, v.province);
|
||||
const brand = v.brandLabel || '未知';
|
||||
const model = v.model;
|
||||
const batch = v.contractNo || 'N/A';
|
||||
@@ -907,4 +1022,31 @@ app.get('/debug', async (c) => {
|
||||
});
|
||||
});
|
||||
|
||||
// GET /api/vehicles/region-chart — aggregated chart data with top N + "其他"
|
||||
app.get('/region-chart', async (c) => {
|
||||
const vehicles = await getVehicles();
|
||||
const operating = vehicles.filter((v) => v.status === 'Operating');
|
||||
const groupBy = c.req.query('groupBy') || 'region'; // 'region' | 'city'
|
||||
const top = Number(c.req.query('top')) || 8;
|
||||
|
||||
const counts = new Map<string, number>();
|
||||
for (const v of operating) {
|
||||
const key = groupBy === 'city' ? resolveCity(v.city, v.province) : mapMacroRegion(v.province, v.city);
|
||||
counts.set(key, (counts.get(key) || 0) + 1);
|
||||
}
|
||||
|
||||
// 分离"其他",对非"其他"排序取 Top N,剩余全部合入"其他"
|
||||
const otherCount = counts.get('其他') || 0;
|
||||
counts.delete('其他');
|
||||
|
||||
const sorted = Array.from(counts.entries())
|
||||
.map(([name, value]) => ({ name, value }))
|
||||
.sort((a, b) => b.value - a.value);
|
||||
|
||||
const result = sorted.slice(0, top);
|
||||
const restTotal = sorted.slice(top).reduce((s, item) => s + item.value, 0) + otherCount;
|
||||
if (restTotal > 0) result.push({ name: '其他', value: restTotal });
|
||||
return c.json(result);
|
||||
});
|
||||
|
||||
export default app;
|
||||
|
||||
25
src/types.ts
25
src/types.ts
@@ -98,6 +98,7 @@ export interface InventoryTypeSummary {
|
||||
export interface VehicleListItem {
|
||||
id: number;
|
||||
plateNumber: string;
|
||||
vin: string;
|
||||
type: string;
|
||||
model: string;
|
||||
location: string;
|
||||
@@ -141,16 +142,36 @@ export interface DeptGroup {
|
||||
totalAssets: number;
|
||||
operatingCount: number;
|
||||
idleCount: number;
|
||||
attendanceRate: number;
|
||||
avgMileage: number;
|
||||
managers: ManagerStats[];
|
||||
}
|
||||
|
||||
export interface RegionTypeBreakdown {
|
||||
type: string;
|
||||
total: number;
|
||||
operating: number;
|
||||
inventory: number;
|
||||
customers: string[];
|
||||
}
|
||||
|
||||
export interface RegionCityGroup {
|
||||
city: string;
|
||||
totalAssets: number;
|
||||
operatingCount: number;
|
||||
pendingCount: number;
|
||||
customers: string[];
|
||||
typeBreakdown: RegionTypeBreakdown[];
|
||||
}
|
||||
|
||||
export interface RegionGroup {
|
||||
region: string;
|
||||
totalAssets: number;
|
||||
operatingCount: number;
|
||||
inventoryCount: number;
|
||||
pendingCount: number;
|
||||
customers: string[];
|
||||
typeBreakdown: { type: string; total: number; operating: number; inventory: number; customers: string[] }[];
|
||||
typeBreakdown: RegionTypeBreakdown[];
|
||||
cities: RegionCityGroup[];
|
||||
}
|
||||
|
||||
export interface CustomerStats {
|
||||
|
||||
Reference in New Issue
Block a user