feat: idCardHash匹配、Roster分页过滤、社保基数调整修复、Modal size支持、Termination版本对比、UI优化
This commit is contained in:
Generated
+340
@@ -12,12 +12,14 @@
|
||||
"@tanstack/react-query": "^5.51.0",
|
||||
"axios": "^1.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"jspdf": "^4.2.1",
|
||||
"lucide-react": "^0.428.0",
|
||||
"qrcode.react": "^4.0.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.52.0",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^3.23.0",
|
||||
"zustand": "^4.5.0"
|
||||
},
|
||||
@@ -279,6 +281,15 @@
|
||||
"@babel/core": "^7.0.0-0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/runtime": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmmirror.com/@babel/runtime/-/runtime-7.29.7.tgz",
|
||||
"integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmmirror.com/@babel/template/-/template-7.29.7.tgz",
|
||||
@@ -1298,6 +1309,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/pako": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmmirror.com/@types/pako/-/pako-2.0.4.tgz",
|
||||
"integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmmirror.com/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
@@ -1305,6 +1322,13 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/raf": {
|
||||
"version": "3.4.3",
|
||||
"resolved": "https://registry.npmmirror.com/@types/raf/-/raf-3.4.3.tgz",
|
||||
"integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.31",
|
||||
"resolved": "https://registry.npmmirror.com/@types/react/-/react-18.3.31.tgz",
|
||||
@@ -1326,6 +1350,13 @@
|
||||
"@types/react": "^18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/trusted-types": {
|
||||
"version": "2.0.7",
|
||||
"resolved": "https://registry.npmmirror.com/@types/trusted-types/-/trusted-types-2.0.7.tgz",
|
||||
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/@vitejs/plugin-react": {
|
||||
"version": "4.7.0",
|
||||
"resolved": "https://registry.npmmirror.com/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz",
|
||||
@@ -1347,6 +1378,15 @@
|
||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/adler-32": {
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/adler-32/-/adler-32-1.3.1.tgz",
|
||||
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/agent-base/-/agent-base-6.0.2.tgz",
|
||||
@@ -1442,6 +1482,16 @@
|
||||
"proxy-from-env": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/base64-arraybuffer": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.11.1",
|
||||
"resolved": "https://registry.npmmirror.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.1.tgz",
|
||||
@@ -1559,6 +1609,39 @@
|
||||
],
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/canvg": {
|
||||
"version": "3.0.11",
|
||||
"resolved": "https://registry.npmmirror.com/canvg/-/canvg-3.0.11.tgz",
|
||||
"integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"@types/raf": "^3.4.0",
|
||||
"core-js": "^3.8.3",
|
||||
"raf": "^3.4.1",
|
||||
"regenerator-runtime": "^0.13.7",
|
||||
"rgbcolor": "^1.0.1",
|
||||
"stackblur-canvas": "^2.0.0",
|
||||
"svg-pathdata": "^6.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cfb": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmmirror.com/cfb/-/cfb-1.2.2.tgz",
|
||||
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"crc-32": "~1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/chokidar": {
|
||||
"version": "3.6.0",
|
||||
"resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-3.6.0.tgz",
|
||||
@@ -1606,6 +1689,15 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/codepage": {
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmmirror.com/codepage/-/codepage-1.15.0.tgz",
|
||||
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/combined-stream": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
|
||||
@@ -1635,6 +1727,40 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/core-js": {
|
||||
"version": "3.49.0",
|
||||
"resolved": "https://registry.npmmirror.com/core-js/-/core-js-3.49.0.tgz",
|
||||
"integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==",
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/core-js"
|
||||
}
|
||||
},
|
||||
"node_modules/crc-32": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmmirror.com/crc-32/-/crc-32-1.2.2.tgz",
|
||||
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"crc32": "bin/crc32.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/css-line-break": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/css-line-break/-/css-line-break-2.1.0.tgz",
|
||||
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/cssesc": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmmirror.com/cssesc/-/cssesc-3.0.0.tgz",
|
||||
@@ -1695,6 +1821,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/dompurify": {
|
||||
"version": "3.4.12",
|
||||
"resolved": "https://registry.npmmirror.com/dompurify/-/dompurify-3.4.12.tgz",
|
||||
"integrity": "sha512-zQvGet8Z2sWbQhCmfFz/T5QWH2oBmjnqK3qvOjaqaNLrLEF912WamU+ohnTp0TCep/MFVHpdJuCZEdFOdTnEFg==",
|
||||
"license": "(MPL-2.0 OR Apache-2.0)",
|
||||
"optional": true,
|
||||
"optionalDependencies": {
|
||||
"@types/trusted-types": "^2.0.7"
|
||||
}
|
||||
},
|
||||
"node_modules/dunder-proto": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@@ -1840,6 +1976,17 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/fast-png": {
|
||||
"version": "6.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/fast-png/-/fast-png-6.4.0.tgz",
|
||||
"integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/pako": "^2.0.3",
|
||||
"iobuffer": "^5.3.2",
|
||||
"pako": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/fastq": {
|
||||
"version": "1.20.1",
|
||||
"resolved": "https://registry.npmmirror.com/fastq/-/fastq-1.20.1.tgz",
|
||||
@@ -1850,6 +1997,12 @@
|
||||
"reusify": "^1.0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/fflate": {
|
||||
"version": "0.8.3",
|
||||
"resolved": "https://registry.npmmirror.com/fflate/-/fflate-0.8.3.tgz",
|
||||
"integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz",
|
||||
@@ -1899,6 +2052,15 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/frac": {
|
||||
"version": "1.1.2",
|
||||
"resolved": "https://registry.npmmirror.com/frac/-/frac-1.1.2.tgz",
|
||||
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/fraction.js": {
|
||||
"version": "5.3.4",
|
||||
"resolved": "https://registry.npmmirror.com/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||
@@ -2048,6 +2210,20 @@
|
||||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/html2canvas": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmmirror.com/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"css-line-break": "^2.1.0",
|
||||
"text-segmentation": "^1.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/https-proxy-agent": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
|
||||
@@ -2061,6 +2237,12 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/iobuffer": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmmirror.com/iobuffer/-/iobuffer-5.4.0.tgz",
|
||||
"integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/is-binary-path": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/is-binary-path/-/is-binary-path-2.1.0.tgz",
|
||||
@@ -2165,6 +2347,23 @@
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/jspdf": {
|
||||
"version": "4.2.1",
|
||||
"resolved": "https://registry.npmmirror.com/jspdf/-/jspdf-4.2.1.tgz",
|
||||
"integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/runtime": "^7.28.6",
|
||||
"fast-png": "^6.2.0",
|
||||
"fflate": "^0.8.1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"canvg": "^3.0.11",
|
||||
"core-js": "^3.6.0",
|
||||
"dompurify": "^3.3.1",
|
||||
"html2canvas": "^1.0.0-rc.5"
|
||||
}
|
||||
},
|
||||
"node_modules/lilconfig": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmmirror.com/lilconfig/-/lilconfig-3.1.3.tgz",
|
||||
@@ -2347,6 +2546,22 @@
|
||||
"node": ">= 6"
|
||||
}
|
||||
},
|
||||
"node_modules/pako": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmmirror.com/pako/-/pako-2.2.0.tgz",
|
||||
"integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/puzrin"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/nodeca"
|
||||
}
|
||||
],
|
||||
"license": "(MIT AND Zlib)"
|
||||
},
|
||||
"node_modules/path-parse": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmmirror.com/path-parse/-/path-parse-1.0.7.tgz",
|
||||
@@ -2354,6 +2569,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/performance-now": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmmirror.com/performance-now/-/performance-now-2.1.0.tgz",
|
||||
"integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -2596,6 +2818,16 @@
|
||||
],
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/raf": {
|
||||
"version": "3.4.1",
|
||||
"resolved": "https://registry.npmmirror.com/raf/-/raf-3.4.1.tgz",
|
||||
"integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"performance-now": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "18.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/react/-/react-18.3.1.tgz",
|
||||
@@ -2702,6 +2934,13 @@
|
||||
"node": ">=8.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/regenerator-runtime": {
|
||||
"version": "0.13.11",
|
||||
"resolved": "https://registry.npmmirror.com/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz",
|
||||
"integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==",
|
||||
"license": "MIT",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/resolve": {
|
||||
"version": "1.22.12",
|
||||
"resolved": "https://registry.npmmirror.com/resolve/-/resolve-1.22.12.tgz",
|
||||
@@ -2735,6 +2974,16 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/rgbcolor": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmmirror.com/rgbcolor/-/rgbcolor-1.0.1.tgz",
|
||||
"integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==",
|
||||
"license": "MIT OR SEE LICENSE IN FEEL-FREE.md",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">= 0.8.15"
|
||||
}
|
||||
},
|
||||
"node_modules/rollup": {
|
||||
"version": "4.62.2",
|
||||
"resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.2.tgz",
|
||||
@@ -2833,6 +3082,28 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ssf": {
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmmirror.com/ssf/-/ssf-0.11.2.tgz",
|
||||
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"frac": "~1.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/stackblur-canvas": {
|
||||
"version": "2.7.0",
|
||||
"resolved": "https://registry.npmmirror.com/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz",
|
||||
"integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=0.1.14"
|
||||
}
|
||||
},
|
||||
"node_modules/sucrase": {
|
||||
"version": "3.35.1",
|
||||
"resolved": "https://registry.npmmirror.com/sucrase/-/sucrase-3.35.1.tgz",
|
||||
@@ -2869,6 +3140,16 @@
|
||||
"url": "https://github.com/sponsors/ljharb"
|
||||
}
|
||||
},
|
||||
"node_modules/svg-pathdata": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/svg-pathdata/-/svg-pathdata-6.0.3.tgz",
|
||||
"integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tailwindcss": {
|
||||
"version": "3.4.19",
|
||||
"resolved": "https://registry.npmmirror.com/tailwindcss/-/tailwindcss-3.4.19.tgz",
|
||||
@@ -2907,6 +3188,16 @@
|
||||
"node": ">=14.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/text-segmentation": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmmirror.com/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"utrie": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/thenify": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmmirror.com/thenify/-/thenify-3.3.1.tgz",
|
||||
@@ -3059,6 +3350,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/utrie": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/utrie/-/utrie-1.0.2.tgz",
|
||||
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"base64-arraybuffer": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.4.21",
|
||||
"resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz",
|
||||
@@ -3119,6 +3420,45 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/wmf": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmmirror.com/wmf/-/wmf-1.0.2.tgz",
|
||||
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/word": {
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://registry.npmmirror.com/word/-/word-0.3.0.tgz",
|
||||
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/xlsx": {
|
||||
"version": "0.18.5",
|
||||
"resolved": "https://registry.npmmirror.com/xlsx/-/xlsx-0.18.5.tgz",
|
||||
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"adler-32": "~1.3.0",
|
||||
"cfb": "~1.2.1",
|
||||
"codepage": "~1.15.0",
|
||||
"crc-32": "~1.2.1",
|
||||
"ssf": "~0.11.2",
|
||||
"wmf": "~1.0.1",
|
||||
"word": "~0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"xlsx": "bin/xlsx.njs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz",
|
||||
|
||||
+15
-13
@@ -9,27 +9,29 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"axios": "^1.7.0",
|
||||
"zustand": "^4.5.0",
|
||||
"@tanstack/react-query": "^5.51.0",
|
||||
"react-hook-form": "^7.52.0",
|
||||
"@hookform/resolvers": "^3.9.0",
|
||||
"zod": "^3.23.0",
|
||||
"@tanstack/react-query": "^5.51.0",
|
||||
"axios": "^1.7.0",
|
||||
"clsx": "^2.1.0",
|
||||
"jspdf": "^4.2.1",
|
||||
"lucide-react": "^0.428.0",
|
||||
"qrcode.react": "^4.0.1",
|
||||
"clsx": "^2.1.0"
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-hook-form": "^7.52.0",
|
||||
"react-router-dom": "^6.26.0",
|
||||
"xlsx": "^0.18.5",
|
||||
"zod": "^3.23.0",
|
||||
"zustand": "^4.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.0",
|
||||
"@types/react-dom": "^18.3.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^5.4.0",
|
||||
"tailwindcss": "^3.4.0",
|
||||
"autoprefixer": "^10.4.0",
|
||||
"postcss": "^8.4.0",
|
||||
"autoprefixer": "^10.4.0"
|
||||
"tailwindcss": "^3.4.0",
|
||||
"typescript": "^5.5.0",
|
||||
"vite": "^5.4.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Link, useLocation, useNavigate } from 'react-router-dom'
|
||||
import { Building2, AlertCircle, ChevronDown } from 'lucide-react'
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useAuthStore } from '../../store/authStore'
|
||||
import api from '../../lib/api'
|
||||
import clsx from 'clsx'
|
||||
|
||||
const tabs = [
|
||||
@@ -19,6 +21,16 @@ export default function TopNav() {
|
||||
const { user, logout } = useAuthStore()
|
||||
const [menuOpen, setMenuOpen] = useState(false)
|
||||
|
||||
const { data: dashboardData } = useQuery<any>({
|
||||
queryKey: ['dashboard'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/dashboard') as any
|
||||
return res.data
|
||||
},
|
||||
refetchInterval: 60000,
|
||||
})
|
||||
const riskCount = dashboardData?.riskSummary?.pending || 0
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-50 bg-white border-b border-gray-200">
|
||||
<div className="max-w-content mx-auto px-4 h-14 flex items-center gap-4">
|
||||
@@ -40,9 +52,9 @@ export default function TopNav() {
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
{tab.path === '/' && (
|
||||
<span className="absolute -top-1 -right-1 w-4 h-4 bg-danger text-white text-xs rounded-full flex items-center justify-center hidden">
|
||||
0
|
||||
{tab.path === '/' && riskCount > 0 && (
|
||||
<span className="absolute -top-1 -right-1 min-w-4 h-4 px-1 bg-danger text-white text-xs rounded-full flex items-center justify-center">
|
||||
{riskCount > 99 ? '99+' : riskCount}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
|
||||
@@ -8,9 +8,10 @@ interface ModalProps {
|
||||
title?: string
|
||||
children: ReactNode
|
||||
className?: string
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
}
|
||||
|
||||
export default function Modal({ open, onClose, title, children, className }: ModalProps) {
|
||||
export default function Modal({ open, onClose, title, children, className, size = 'md' }: ModalProps) {
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.body.style.overflow = 'hidden'
|
||||
@@ -27,7 +28,12 @@ export default function Modal({ open, onClose, title, children, className }: Mod
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div className="fixed inset-0 bg-black/40" onClick={onClose} />
|
||||
<div className={clsx('relative bg-white rounded-lg shadow-xl w-full max-w-lg max-h-[90vh] overflow-y-auto', className)}>
|
||||
<div className={clsx('relative bg-white rounded-lg shadow-xl w-full max-h-[90vh] overflow-y-auto', {
|
||||
'max-w-md': size === 'sm',
|
||||
'max-w-lg': size === 'md',
|
||||
'max-w-2xl': size === 'lg',
|
||||
'max-w-4xl': size === 'xl',
|
||||
}, className)}>
|
||||
{title && (
|
||||
<div className="flex items-center justify-between px-4 py-2.5 border-b border-gray-200">
|
||||
<h3 className="font-medium text-gray-900 text-sm">{title}</h3>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Bot, Send, FileSearch, Scale, Sparkles, Loader2 } from 'lucide-react'
|
||||
import { Bot, Send, FileSearch, Scale, Sparkles, Loader2, Mic } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
import { Input, Label, Select } from '../components/ui/Input'
|
||||
@@ -65,12 +66,40 @@ function ChatTab() {
|
||||
])
|
||||
const [input, setInput] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [recording, setRecording] = useState(false)
|
||||
const scrollRef = useRef<HTMLDivElement>(null)
|
||||
const recognitionRef = useRef<any>(null)
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo(0, scrollRef.current.scrollHeight)
|
||||
}, [messages])
|
||||
|
||||
const toggleVoice = () => {
|
||||
const SpeechRecognition = (window as any).SpeechRecognition || (window as any).webkitSpeechRecognition
|
||||
if (!SpeechRecognition) {
|
||||
alert('当前浏览器不支持语音输入,请使用 Chrome 或 Edge')
|
||||
return
|
||||
}
|
||||
if (recording) {
|
||||
recognitionRef.current?.stop()
|
||||
setRecording(false)
|
||||
return
|
||||
}
|
||||
const recognition = new SpeechRecognition()
|
||||
recognition.lang = 'zh-CN'
|
||||
recognition.continuous = false
|
||||
recognition.interimResults = false
|
||||
recognition.onresult = (event: any) => {
|
||||
const transcript = event.results[0]?.[0]?.transcript || ''
|
||||
setInput((prev) => prev + transcript)
|
||||
}
|
||||
recognition.onerror = () => setRecording(false)
|
||||
recognition.onend = () => setRecording(false)
|
||||
recognition.start()
|
||||
recognitionRef.current = recognition
|
||||
setRecording(true)
|
||||
}
|
||||
|
||||
const send = async (text?: string) => {
|
||||
const content = text || input.trim()
|
||||
if (!content || loading) return
|
||||
@@ -81,10 +110,55 @@ function ChatTab() {
|
||||
setLoading(true)
|
||||
|
||||
try {
|
||||
const res = await api.post('/ai/chat', { messages: newMessages }) as any
|
||||
setMessages([...newMessages, { role: 'assistant', content: res.data.reply }])
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const response = await fetch('/api/v1/ai/chat-stream', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ messages: newMessages }),
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errData = await response.json().catch(() => null)
|
||||
throw new Error(errData?.error?.message || '请求失败')
|
||||
}
|
||||
|
||||
const reader = response.body?.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
let accumulated = ''
|
||||
let buffer = ''
|
||||
|
||||
if (reader) {
|
||||
while (true) {
|
||||
const { done, value } = await reader.read()
|
||||
if (done) break
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
const lines = buffer.split('\n')
|
||||
buffer = lines.pop() || ''
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6).trim()
|
||||
if (data === '[DONE]') continue
|
||||
try {
|
||||
const parsed = JSON.parse(data)
|
||||
if (parsed.delta) {
|
||||
accumulated += parsed.delta
|
||||
setMessages([...newMessages, { role: 'assistant', content: accumulated }])
|
||||
}
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!accumulated) {
|
||||
setMessages([...newMessages, { role: 'assistant', content: '(无回复内容)' }])
|
||||
}
|
||||
} catch (err: any) {
|
||||
setMessages([...newMessages, { role: 'assistant', content: `抱歉,出错了:${err.response?.data?.error?.message || '请稍后重试'}` }])
|
||||
setMessages([...newMessages, { role: 'assistant', content: `抱歉,出错了:${err.message || '请稍后重试'}` }])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
@@ -128,6 +202,9 @@ function ChatTab() {
|
||||
placeholder="输入问题..."
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button variant="secondary" onClick={toggleVoice} disabled={loading} className={recording ? 'text-danger' : ''}>
|
||||
<Mic className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button onClick={() => send()} disabled={loading || !input.trim()}>
|
||||
{loading ? <Loader2 className="w-4 h-4 animate-spin" /> : <Send className="w-4 h-4" />}
|
||||
</Button>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Users, AlertTriangle, CheckSquare, DollarSign, ArrowRight, RefreshCw, FileText, Calendar, TrendingUp, Briefcase, Calculator, Wallet, Building2, Receipt, Check, X, Clock, LayoutDashboard, ListTodo, ShieldAlert, UserPlus } from 'lucide-react'
|
||||
import { Users, AlertTriangle, CheckSquare, DollarSign, ArrowRight, RefreshCw, FileText, Calendar, TrendingUp, Briefcase, Calculator, Wallet, Building2, Receipt, Check, X, Clock, LayoutDashboard, ListTodo, ShieldAlert, UserPlus, AlertCircle } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
@@ -44,6 +44,14 @@ export default function Dashboard() {
|
||||
},
|
||||
})
|
||||
|
||||
const { data: expiringContracts } = useQuery<any>({
|
||||
queryKey: ['expiring-contracts'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/roster/contracts/expiring') as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
|
||||
const resolveMutation = useMutation({
|
||||
mutationFn: (id: string) => api.patch(`/dashboard/todos/${id}/resolve`),
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['dashboard'] }),
|
||||
|
||||
@@ -54,6 +54,10 @@ export default function Money() {
|
||||
function BatchManager() {
|
||||
const queryClient = useQueryClient()
|
||||
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
|
||||
const [monthFrom, setMonthFrom] = useState('')
|
||||
const [monthTo, setMonthTo] = useState('')
|
||||
const [filterStatus, setFilterStatus] = useState<string>('')
|
||||
const [filterType, setFilterType] = useState<string>('')
|
||||
const [selectedBatchId, setSelectedBatchId] = useState<string | null>(null)
|
||||
const [showCreateModal, setShowCreateModal] = useState(false)
|
||||
const [createType, setCreateType] = useState<'REGULAR' | 'TERMINATION' | 'BONUS' | 'SEVERANCE'>('REGULAR')
|
||||
@@ -71,9 +75,15 @@ function BatchManager() {
|
||||
})
|
||||
|
||||
const { data: batches, isLoading } = useQuery<any[]>({
|
||||
queryKey: ['batches', month],
|
||||
queryKey: ['batches', month, monthFrom, monthTo, filterStatus, filterType],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/payroll2/batches', { params: { month } }) as any
|
||||
const params: any = {}
|
||||
if (month && !monthFrom && !monthTo) params.month = month
|
||||
if (monthFrom) params.monthFrom = monthFrom
|
||||
if (monthTo) params.monthTo = monthTo
|
||||
if (filterStatus) params.status = filterStatus
|
||||
if (filterType) params.type = filterType
|
||||
const res = await api.get('/payroll2/batches', { params }) as any
|
||||
return res.data
|
||||
},
|
||||
})
|
||||
@@ -104,6 +114,14 @@ function BatchManager() {
|
||||
},
|
||||
})
|
||||
|
||||
const renameBatchMutation = useMutation({
|
||||
mutationFn: ({ batchId, name }: { batchId: string; name: string }) =>
|
||||
api.put(`/payroll2/batches/${batchId}/name`, { name }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['batches'] })
|
||||
},
|
||||
})
|
||||
|
||||
if (selectedBatchId) {
|
||||
return <BatchDetail batchId={selectedBatchId} onBack={() => setSelectedBatchId(null)} />
|
||||
}
|
||||
@@ -118,9 +136,36 @@ function BatchManager() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="w-48" />
|
||||
<div className="flex items-center justify-between gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-gray-500">从</span>
|
||||
<Input type="month" value={monthFrom} onChange={(e) => { setMonthFrom(e.target.value); setMonth('') }} className="w-36" />
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-gray-500">至</span>
|
||||
<Input type="month" value={monthTo} onChange={(e) => { setMonthTo(e.target.value); setMonth('') }} className="w-36" />
|
||||
</div>
|
||||
{!monthFrom && !monthTo && (
|
||||
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="w-36" placeholder="单月" />
|
||||
)}
|
||||
<Select value={filterStatus} onChange={(e) => setFilterStatus(e.target.value)} className="w-28">
|
||||
<option value="">全部状态</option>
|
||||
<option value="DRAFT">草稿</option>
|
||||
<option value="ARCHIVED">已归档</option>
|
||||
</Select>
|
||||
<Select value={filterType} onChange={(e) => setFilterType(e.target.value)} className="w-28">
|
||||
<option value="">全部类型</option>
|
||||
<option value="REGULAR">常规发薪</option>
|
||||
<option value="TERMINATION">离职结算</option>
|
||||
<option value="BONUS">年终奖</option>
|
||||
<option value="SEVERANCE">补偿金</option>
|
||||
</Select>
|
||||
{(monthFrom || monthTo || filterStatus || filterType) && (
|
||||
<button onClick={() => { setMonthFrom(''); setMonthTo(''); setFilterStatus(''); setFilterType(''); setMonth(new Date().toISOString().slice(0, 7)) }} className="text-xs text-gray-400 hover:text-primary">
|
||||
清除筛选
|
||||
</button>
|
||||
)}
|
||||
{batches && batches.length > 0 && (
|
||||
<span className="text-xs text-gray-500 whitespace-nowrap flex-shrink-0">{batches.length} 个批次</span>
|
||||
)}
|
||||
@@ -219,6 +264,19 @@ function BatchManager() {
|
||||
) : (
|
||||
<>
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-amber-50 text-warning">草稿</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
const newName = prompt('请输入新的批次名称:', batch.name)
|
||||
if (newName && newName !== batch.name) {
|
||||
renameBatchMutation.mutate({ batchId: batch.id, name: newName })
|
||||
}
|
||||
}}
|
||||
className="p-1 rounded hover:bg-blue-50 text-gray-400 hover:text-primary transition-colors"
|
||||
title="重命名批次"
|
||||
>
|
||||
<SettingsIcon className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useRef } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus } from 'lucide-react'
|
||||
import { Users, FileText, AlertTriangle, Calendar, GraduationCap, TrendingUp, Scale, X, Plus, Paperclip, Trash2, Printer, Calculator, Shield, Info, Check, Eye, Download, UserX, UserPlus, QrCode } from 'lucide-react'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import api from '../lib/api'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
@@ -12,6 +13,14 @@ import Pagination from '../components/ui/Pagination'
|
||||
// 金额格式化:保留两位小数 + 千分位
|
||||
const fmt = (n: number) => (n || 0).toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
|
||||
|
||||
const terminateReasonMap: Record<string, string> = {
|
||||
NEGOTIATED: '协商解除',
|
||||
FAULT: '员工过错',
|
||||
NONFAULT: '非过错解除',
|
||||
LAYOFF: '经济性裁员',
|
||||
EXPIRED: '合同到期不续签',
|
||||
}
|
||||
|
||||
type DetailTab = 'basic' | 'contract' | 'payslip' | 'overtime' | 'disciplinary' | 'attendance' | 'training' | 'performance' | 'termination' | 'attachment' | 'evidence'
|
||||
|
||||
export default function Roster() {
|
||||
@@ -28,16 +37,34 @@ export default function Roster() {
|
||||
const [showDeptModal, setShowDeptModal] = useState(false)
|
||||
const [deptEmployee, setDeptEmployee] = useState<any>(null)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set())
|
||||
const [showBatchRenewModal, setShowBatchRenewModal] = useState(false)
|
||||
const [batchRenewYears, setBatchRenewYears] = useState(3)
|
||||
const [showRenewPreview, setShowRenewPreview] = useState(false)
|
||||
const [previewData, setPreviewData] = useState<any>(null)
|
||||
const [filterStatus, setFilterStatus] = useState('')
|
||||
const [filterContractStatus, setFilterContractStatus] = useState('')
|
||||
const [showBatchTerminateModal, setShowBatchTerminateModal] = useState(false)
|
||||
const [batchTerminateDate, setBatchTerminateDate] = useState(() => new Date().toISOString().slice(0, 10))
|
||||
const [batchTerminateReason, setBatchTerminateReason] = useState('NEGOTIATED')
|
||||
const [terminatePreviewData, setTerminatePreviewData] = useState<any>(null)
|
||||
|
||||
const { data: employees, isLoading } = useQuery<any[]>({
|
||||
queryKey: ['roster'],
|
||||
const { data: rosterData, isLoading } = useQuery<any>({
|
||||
queryKey: ['roster', page, pageSize, search, filterStatus, filterContractStatus],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/roster') as any
|
||||
return res.data
|
||||
const params: any = { page, pageSize }
|
||||
if (search) params.search = search
|
||||
if (filterStatus) params.status = filterStatus
|
||||
if (filterContractStatus) params.contractStatus = filterContractStatus
|
||||
const res = await api.get('/roster', { params }) as any
|
||||
return res
|
||||
},
|
||||
})
|
||||
|
||||
const employees = rosterData?.data || []
|
||||
const pagination = rosterData?.pagination || { page, pageSize, total: 0, totalPages: 0 }
|
||||
|
||||
const addMutation = useMutation({
|
||||
mutationFn: (data: any) => api.post('/employees', data),
|
||||
onSuccess: () => {
|
||||
@@ -95,6 +122,72 @@ export default function Roster() {
|
||||
},
|
||||
})
|
||||
|
||||
const batchRenewMutation = useMutation({
|
||||
mutationFn: (data: { contractIds: string[]; years: number }) => api.post('/employees/contracts/batch-renew', data),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['roster'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
setShowBatchRenewModal(false)
|
||||
setShowRenewPreview(false)
|
||||
setSelectedIds(new Set())
|
||||
setPreviewData(null)
|
||||
},
|
||||
})
|
||||
|
||||
const previewRenewMutation = useMutation({
|
||||
mutationFn: (contractIds: string[]) => api.post('/employees/contracts/preview-renew', { contractIds }),
|
||||
onSuccess: (data: any) => {
|
||||
setPreviewData(data.data)
|
||||
setShowRenewPreview(true)
|
||||
},
|
||||
})
|
||||
|
||||
const previewTerminateMutation = useMutation({
|
||||
mutationFn: (items: Array<{ employeeId: string; reason: string; terminationDate: string }>) =>
|
||||
api.post('/termination/batch/preview', { items }),
|
||||
onSuccess: (data: any) => {
|
||||
setTerminatePreviewData(data.data)
|
||||
},
|
||||
})
|
||||
|
||||
const batchTerminateMutation = useMutation({
|
||||
mutationFn: (items: Array<{ employeeId: string; reason: string; terminationDate: string }>) =>
|
||||
api.post('/termination/batch', { items }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['roster'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
setShowBatchTerminateModal(false)
|
||||
setTerminatePreviewData(null)
|
||||
setSelectedIds(new Set())
|
||||
},
|
||||
})
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(id)) next.delete(id)
|
||||
else next.add(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (selectedIds.size === employees.length) {
|
||||
setSelectedIds(new Set())
|
||||
} else {
|
||||
setSelectedIds(new Set(employees.map((e: any) => e.id)))
|
||||
}
|
||||
}
|
||||
|
||||
const clearFilters = () => {
|
||||
setSearch('')
|
||||
setFilterStatus('')
|
||||
setFilterContractStatus('')
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const hasActiveFilters = search || filterStatus || filterContractStatus
|
||||
|
||||
const filtered = employees?.filter((e: any) =>
|
||||
!search || e.name.includes(search) || e.department.includes(search)
|
||||
) || []
|
||||
@@ -112,9 +205,45 @@ export default function Roster() {
|
||||
<Input
|
||||
placeholder="搜索姓名/部门"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
|
||||
className="!w-64 shrink-0"
|
||||
/>
|
||||
<select
|
||||
value={filterStatus}
|
||||
onChange={(e) => { setFilterStatus(e.target.value); setPage(1) }}
|
||||
className="text-xs border rounded px-2 py-1.5"
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="ACTIVE">在职</option>
|
||||
<option value="PRE_HIRE">预入职</option>
|
||||
<option value="RESIGNED">离职</option>
|
||||
</select>
|
||||
<select
|
||||
value={filterContractStatus}
|
||||
onChange={(e) => { setFilterContractStatus(e.target.value); setPage(1) }}
|
||||
className="text-xs border rounded px-2 py-1.5"
|
||||
>
|
||||
<option value="">全部合同</option>
|
||||
<option value="active">正常</option>
|
||||
<option value="expiring">即将到期</option>
|
||||
<option value="expired">已到期</option>
|
||||
<option value="unsigned">未签合同</option>
|
||||
<option value="unsigned_over_30">未签合同(超30天)</option>
|
||||
<option value="unsigned_over_year">未签合同(已满一年)</option>
|
||||
</select>
|
||||
{hasActiveFilters && (
|
||||
<button onClick={clearFilters} className="text-xs text-gray-400 hover:text-gray-600">清除</button>
|
||||
)}
|
||||
{selectedIds.size > 0 && (
|
||||
<>
|
||||
<Button variant="secondary" onClick={() => setShowBatchRenewModal(true)} className="shrink-0">
|
||||
<Check className="w-4 h-4 mr-1" />批量续签({selectedIds.size})
|
||||
</Button>
|
||||
<Button variant="danger" onClick={() => setShowBatchTerminateModal(true)} className="shrink-0">
|
||||
<UserX className="w-4 h-4 mr-1" />批量解聘({selectedIds.size})
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button onClick={() => setShowAddModal(true)} className="shrink-0">
|
||||
<Plus className="w-4 h-4 mr-1" /> 添加员工
|
||||
</Button>
|
||||
@@ -127,11 +256,20 @@ export default function Roster() {
|
||||
<Card><div className="text-center py-8 text-gray-400">暂无员工</div></Card>
|
||||
) : (
|
||||
<Card>
|
||||
<Pagination page={page} pageSize={pageSize} total={filtered.length} onPageChange={setPage} onPageSizeChange={(s) => { setPageSize(s); setPage(1) }} />
|
||||
<Pagination
|
||||
page={pagination.page}
|
||||
pageSize={pagination.pageSize}
|
||||
total={pagination.total}
|
||||
onPageChange={(p) => setPage(p)}
|
||||
onPageSizeChange={(s) => { setPageSize(s); setPage(1) }}
|
||||
/>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs">
|
||||
<thead>
|
||||
<tr className="border-b text-gray-500">
|
||||
<th className="py-2 px-3 text-left w-8">
|
||||
<input type="checkbox" checked={employees.length > 0 && selectedIds.size === employees.length} onChange={toggleSelectAll} />
|
||||
</th>
|
||||
<th className="py-2 px-3 text-left">姓名</th>
|
||||
<th className="py-2 px-3 text-left">部门</th>
|
||||
<th className="py-2 px-3 text-left">状态</th>
|
||||
@@ -139,6 +277,7 @@ export default function Roster() {
|
||||
<th className="py-2 px-3 text-left">离职日期</th>
|
||||
<th className="py-2 px-3 text-right">月薪</th>
|
||||
<th className="py-2 px-3 text-left">合同状态</th>
|
||||
<th className="py-2 px-3 text-left">合同到期</th>
|
||||
<th className="py-2 px-3 text-center">违纪</th>
|
||||
<th className="py-2 px-3 text-center">考勤</th>
|
||||
<th className="py-2 px-3 text-center">培训</th>
|
||||
@@ -148,12 +287,15 @@ export default function Roster() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{paged.map((e: any) => (
|
||||
{employees.map((e: any) => (
|
||||
<tr
|
||||
key={e.id}
|
||||
className="border-b last:border-0 cursor-pointer hover:bg-gray-50"
|
||||
onClick={() => setSelectedId(e.id)}
|
||||
>
|
||||
<td className="py-2 px-3" onClick={(ev) => ev.stopPropagation()}>
|
||||
<input type="checkbox" checked={selectedIds.has(e.id)} onChange={() => toggleSelect(e.id)} />
|
||||
</td>
|
||||
<td className="py-2 px-3 font-medium">{e.name}</td>
|
||||
<td className="py-2 px-3 text-gray-500">{e.department}</td>
|
||||
<td className="py-2 px-3">
|
||||
@@ -192,6 +334,21 @@ export default function Roster() {
|
||||
return <span className={`px-2 py-0.5 rounded text-xs ${style}`}>{e.contractStatusText || '无合同'}</span>
|
||||
})()}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-gray-500">
|
||||
{(() => {
|
||||
const endDate = e.latestContract?.endDate
|
||||
if (!endDate) return <span className="text-gray-300">—</span>
|
||||
const end = new Date(endDate)
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
end.setHours(0, 0, 0, 0)
|
||||
const diffDays = Math.ceil((end.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
|
||||
if (diffDays < 0) return <span className="text-danger text-xs">已过期</span>
|
||||
if (diffDays <= 30) return <span className="text-danger font-medium text-xs">{end.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })} ({diffDays}天)</span>
|
||||
if (diffDays <= 90) return <span className="text-amber-600 text-xs">{end.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })} ({diffDays}天)</span>
|
||||
return <span className="text-xs">{end.toLocaleDateString('zh-CN', { month: '2-digit', day: '2-digit' })}</span>
|
||||
})()}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-center">
|
||||
{e.counts?.disciplinaryRecords ? (
|
||||
<span className="text-danger font-medium">{e.counts.disciplinaryRecords}</span>
|
||||
@@ -323,6 +480,201 @@ export default function Roster() {
|
||||
error={deptChangeMutation.error as any}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showBatchRenewModal && (
|
||||
<Modal open={showBatchRenewModal} onClose={() => { setShowBatchRenewModal(false); setPreviewData(null); }} title="批量续签">
|
||||
<div className="space-y-3">
|
||||
{!previewData ? (
|
||||
<>
|
||||
<p className="text-xs text-gray-500">已选择 {selectedIds.size} 名员工,将为其续签劳动合同。</p>
|
||||
<div>
|
||||
<Label>续签年限</Label>
|
||||
<Select value={String(batchRenewYears)} onChange={(e) => setBatchRenewYears(Number(e.target.value))}>
|
||||
<option value="1">1年</option>
|
||||
<option value="2">2年</option>
|
||||
<option value="3">3年</option>
|
||||
<option value="5">5年</option>
|
||||
</Select>
|
||||
</div>
|
||||
{previewRenewMutation.error && (
|
||||
<div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">
|
||||
{(previewRenewMutation.error as any)?.response?.data?.error?.message || '预检失败'}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={() => { setShowBatchRenewModal(false); setPreviewData(null); }}>取消</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const contractIds = employees
|
||||
.filter((e: any) => selectedIds.has(e.id) && e.latestContract?.id)
|
||||
.map((e: any) => e.latestContract.id)
|
||||
if (contractIds.length === 0) {
|
||||
alert('所选员工没有可续签的合同')
|
||||
return
|
||||
}
|
||||
previewRenewMutation.mutate(contractIds)
|
||||
}}
|
||||
disabled={previewRenewMutation.isPending}
|
||||
>
|
||||
{previewRenewMutation.isPending ? '预检中...' : '合规预检'}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium">合规预检结果</p>
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${previewData.warnings > 0 ? 'bg-amber-100 text-amber-700' : 'bg-green-100 text-green-700'}`}>
|
||||
{previewData.warnings > 0 ? `${previewData.warnings} 项风险提示` : '全部通过'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto space-y-2">
|
||||
{previewData.results.map((r: any) => (
|
||||
<div key={r.contractId} className={`p-2 rounded text-xs ${r.warning ? 'bg-amber-50 border border-amber-200' : 'bg-gray-50'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{r.employeeName}</span>
|
||||
<span className="text-gray-400">{r.department}</span>
|
||||
</div>
|
||||
<div className="text-gray-500 mt-1">
|
||||
第 {r.renewalCount} 次续签 · 在职 {r.yearsSinceHire} 年
|
||||
</div>
|
||||
{r.warning && (
|
||||
<div className="mt-1 text-amber-600">{r.warning}</div>
|
||||
)}
|
||||
<div className="mt-1 text-gray-500">{r.suggestion}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{previewData.warnings > 0 && (
|
||||
<div className="px-3 py-2 rounded-md bg-amber-50 text-amber-700 text-xs">
|
||||
存在法律风险,建议处理后再继续操作
|
||||
</div>
|
||||
)}
|
||||
{batchRenewMutation.error && (
|
||||
<div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">
|
||||
{(batchRenewMutation.error as any)?.response?.data?.error?.message || '续签失败'}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={() => setPreviewData(null)}>返回修改</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const contractIds = employees
|
||||
.filter((e: any) => selectedIds.has(e.id) && e.latestContract?.id)
|
||||
.map((e: any) => e.latestContract.id)
|
||||
batchRenewMutation.mutate({ contractIds, years: batchRenewYears })
|
||||
}}
|
||||
disabled={batchRenewMutation.isPending}
|
||||
>
|
||||
{batchRenewMutation.isPending ? '续签中...' : `确认续签 ${selectedIds.size} 人`}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{showBatchTerminateModal && (
|
||||
<Modal open={showBatchTerminateModal} onClose={() => { setShowBatchTerminateModal(false); setTerminatePreviewData(null); }} title="批量解聘" size="lg">
|
||||
<div className="space-y-3">
|
||||
{!terminatePreviewData ? (
|
||||
<>
|
||||
<p className="text-xs text-gray-500">已选择 {selectedIds.size} 名员工进行批量解聘。</p>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<Label>解聘日期</Label>
|
||||
<Input type="date" value={batchTerminateDate} onChange={(e) => setBatchTerminateDate(e.target.value)} />
|
||||
</div>
|
||||
<div>
|
||||
<Label>解聘原因</Label>
|
||||
<Select value={batchTerminateReason} onChange={(e) => setBatchTerminateReason(e.target.value)}>
|
||||
<option value="NEGOTIATED">协商解除</option>
|
||||
<option value="LAYOFF">经济性裁员</option>
|
||||
<option value="NONFAULT">非过错解除</option>
|
||||
<option value="FAULT">过错解除</option>
|
||||
<option value="EXPIRED">合同到期不续签</option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
{previewTerminateMutation.error && (
|
||||
<div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">
|
||||
{(previewTerminateMutation.error as any)?.response?.data?.error?.message || '预检失败'}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={() => { setShowBatchTerminateModal(false); setTerminatePreviewData(null); }}>取消</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
const items = Array.from(selectedIds).map(id => ({
|
||||
employeeId: id,
|
||||
reason: batchTerminateReason,
|
||||
terminationDate: batchTerminateDate,
|
||||
}))
|
||||
previewTerminateMutation.mutate(items)
|
||||
}}
|
||||
disabled={previewTerminateMutation.isPending}
|
||||
>
|
||||
{previewTerminateMutation.isPending ? '预检中...' : '合规预检'}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs font-medium">合规预检结果</p>
|
||||
<span className={`text-xs px-2 py-0.5 rounded ${terminatePreviewData.warnings > 0 ? 'bg-red-100 text-red-700' : 'bg-green-100 text-green-700'}`}>
|
||||
{terminatePreviewData.warnings > 0 ? `${terminatePreviewData.warnings} 项风险提示` : '全部通过'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-y-auto space-y-2">
|
||||
{terminatePreviewData.results.map((r: any) => (
|
||||
<div key={r.employeeId} className={`p-2 rounded text-xs ${r.warnings.length > 0 ? 'bg-red-50 border border-red-200' : 'bg-gray-50'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-medium">{r.employeeName}</span>
|
||||
<span className="text-gray-400">{r.department}</span>
|
||||
</div>
|
||||
<div className="text-gray-500 mt-1">
|
||||
{terminateReasonMap[r.reason] || r.reason} · {r.terminationDate}
|
||||
</div>
|
||||
{r.warnings.length > 0 && r.warnings.map((w: string, i: number) => (
|
||||
<div key={i} className="mt-1 text-red-600">{w}</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{terminatePreviewData.warnings > 0 && (
|
||||
<div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">
|
||||
存在法律风险,建议处理后再继续操作
|
||||
</div>
|
||||
)}
|
||||
{batchTerminateMutation.error && (
|
||||
<div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">
|
||||
{(batchTerminateMutation.error as any)?.response?.data?.error?.message || '解聘失败'}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button variant="secondary" onClick={() => setTerminatePreviewData(null)}>返回修改</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => {
|
||||
const items = Array.from(selectedIds).map(id => ({
|
||||
employeeId: id,
|
||||
reason: batchTerminateReason,
|
||||
terminationDate: batchTerminateDate,
|
||||
}))
|
||||
batchTerminateMutation.mutate(items)
|
||||
}}
|
||||
disabled={batchTerminateMutation.isPending}
|
||||
>
|
||||
{batchTerminateMutation.isPending ? '解聘中...' : `确认解聘 ${selectedIds.size} 人`}
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -583,6 +935,35 @@ function BasicInfo({ profile }: { profile: any }) {
|
||||
<p className="text-xs text-amber-600 mt-2">⚠️ 该员工处于特殊保护期,解聘操作将触发法律风险预警</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 员工端二维码 */}
|
||||
{!editing && profile.phone && (
|
||||
<div className="mt-4 pt-4 border-t">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-xs font-medium text-gray-600">员工端入口</h3>
|
||||
<Button size="sm" variant="secondary" onClick={() => {
|
||||
const url = `${window.location.origin}/portal/login`
|
||||
navigator.clipboard?.writeText(url)
|
||||
}}>
|
||||
复制链接
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="bg-white p-3 rounded-lg border">
|
||||
<QRCodeSVG
|
||||
value={`${window.location.origin}/portal/login`}
|
||||
size={120}
|
||||
level="M"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 space-y-1">
|
||||
<p>员工扫码进入员工端,使用手机号登录</p>
|
||||
<p>可查看工资条、合同信息、确认签署</p>
|
||||
<p className="text-gray-400">链接:{window.location.origin}/portal/login</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useState, useMemo } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { Building2, Users, CreditCard, Plus, Bell } from 'lucide-react'
|
||||
import { Building2, Users, CreditCard, Plus, Bell, Download, Upload, FileSpreadsheet } from 'lucide-react'
|
||||
import api from '../lib/api'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
import { Input, Label, Select } from '../components/ui/Input'
|
||||
@@ -9,7 +10,7 @@ import Modal from '../components/ui/Modal'
|
||||
|
||||
export default function Settings() {
|
||||
const queryClient = useQueryClient()
|
||||
const [activeSection, setActiveSection] = useState<'org' | 'users' | 'plan' | 'notifications'>('org')
|
||||
const [activeSection, setActiveSection] = useState<'org' | 'users' | 'plan' | 'notifications' | 'import'>('org')
|
||||
|
||||
const { data: orgData } = useQuery<any>({
|
||||
queryKey: ['org-settings'],
|
||||
@@ -37,6 +38,7 @@ export default function Settings() {
|
||||
{ key: 'users' as const, label: '用户管理', icon: Users },
|
||||
{ key: 'plan' as const, label: '套餐', icon: CreditCard },
|
||||
{ key: 'notifications' as const, label: '通知设置', icon: Bell },
|
||||
{ key: 'import' as const, label: '数据导入', icon: FileSpreadsheet },
|
||||
]
|
||||
|
||||
return (
|
||||
@@ -67,6 +69,7 @@ export default function Settings() {
|
||||
{activeSection === 'users' && <UserSettings usersData={usersData} />}
|
||||
{activeSection === 'plan' && <PlanSettings orgData={orgData} />}
|
||||
{activeSection === 'notifications' && <NotificationSettings />}
|
||||
{activeSection === 'import' && <ImportSettings />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -109,6 +112,34 @@ function OrgSettings({ orgData, onSave, saving }: { orgData: any; onSave: (data:
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 pt-4 border-t">
|
||||
<h3 className="text-xs font-medium text-gray-600 mb-3">数据导出</h3>
|
||||
<p className="text-xs text-gray-400 mb-3">导出全部员工、合同、薪税、社保等数据为 JSON 文件</p>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={async () => {
|
||||
try {
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const res = await fetch('/api/v1/export/all', {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `export-${new Date().toISOString().slice(0, 10)}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
alert('导出失败')
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Download className="w-4 h-4 mr-1" />导出全部数据
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -381,3 +412,275 @@ function NotificationSettings() {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ImportSettings() {
|
||||
const [importType, setImportType] = useState<'init' | 'monthly'>('init')
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-1 border-b">
|
||||
<button
|
||||
onClick={() => setImportType('init')}
|
||||
className={`px-3 py-1.5 text-xs font-medium border-b-2 transition-colors ${importType === 'init' ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
|
||||
>
|
||||
初始化导入
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setImportType('monthly')}
|
||||
className={`px-3 py-1.5 text-xs font-medium border-b-2 transition-colors ${importType === 'monthly' ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'}`}
|
||||
>
|
||||
月度导入
|
||||
</button>
|
||||
</div>
|
||||
{importType === 'init' ? <InitImport /> : <MonthlyImport />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function InitImport() {
|
||||
const queryClient = useQueryClient()
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [result, setResult] = useState<any>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file) return
|
||||
setUploading(true)
|
||||
setError('')
|
||||
setResult(null)
|
||||
try {
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
const res = await fetch('/api/v1/import/excel', {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: formData,
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!data.success) {
|
||||
setError(data.error?.message || '导入失败')
|
||||
} else {
|
||||
setResult(data.data)
|
||||
queryClient.invalidateQueries({ queryKey: ['roster'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e?.message || '上传失败')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadTemplate = async () => {
|
||||
try {
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const res = await fetch('/api/v1/import/template', {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'import-template.xlsx'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
setError('下载模板失败')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-xs font-medium text-gray-700 mb-1">初始化数据导入</h3>
|
||||
<p className="text-xs text-gray-400">首次使用系统时,批量导入已有员工、合同、历史考勤/加班/违纪记录</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button variant="secondary" size="sm" onClick={handleDownloadTemplate}>
|
||||
<Download className="w-4 h-4 mr-1" />下载导入模板
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="border-2 border-dashed border-gray-200 rounded-lg p-6 text-center">
|
||||
<FileSpreadsheet className="w-8 h-8 text-gray-300 mx-auto mb-2" />
|
||||
<input type="file" accept=".xlsx,.xls" onChange={(e) => { setFile(e.target.files?.[0] || null); setResult(null); setError('') }} className="hidden" id="import-file-init" />
|
||||
<label htmlFor="import-file-init" className="cursor-pointer text-xs text-primary hover:underline">
|
||||
{file ? file.name : '点击选择 Excel 文件'}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && <div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">{error}</div>}
|
||||
|
||||
{result && (
|
||||
<div className="px-3 py-2 rounded-md bg-green-50 text-green-700 text-xs space-y-1">
|
||||
<div className="font-medium">导入完成</div>
|
||||
<div>员工:{result.employees} 人</div>
|
||||
<div>合同:{result.contracts} 份</div>
|
||||
{result.overtime > 0 && <div>加班记录:{result.overtime} 条</div>}
|
||||
{result.disciplinary > 0 && <div>违纪记录:{result.disciplinary} 条</div>}
|
||||
{result.attendance > 0 && <div>考勤记录:{result.attendance} 条</div>}
|
||||
{result.errors?.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-green-200">
|
||||
<div className="font-medium text-amber-600">部分错误({result.errors.length}条):</div>
|
||||
{result.errors.slice(0, 10).map((e: string, i: number) => (<div key={i} className="text-amber-600">{e}</div>))}
|
||||
{result.errors.length > 10 && <div className="text-amber-600">...还有 {result.errors.length - 10} 条</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleUpload} disabled={!file || uploading}>
|
||||
<Upload className="w-4 h-4 mr-1" />{uploading ? '导入中...' : '开始导入'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="text-xs text-gray-500 space-y-1">
|
||||
<div className="font-medium text-gray-700 mb-1">Sheet 页说明</div>
|
||||
<div>- <b>员工信息</b>:姓名、部门、性别、手机号、身份证号、入职日期、月工资、社保基数、公积金基数等</div>
|
||||
<div>- <b>劳动合同</b>:身份证号(优先匹配)、姓名(备选)、合同类型、签订日期、起止日期、试用期等</div>
|
||||
<div>- <b>加班记录</b>:身份证号(优先匹配)、姓名(备选)、日期、加班时长、加班类型</div>
|
||||
<div>- <b>违纪记录</b>:身份证号(优先匹配)、姓名(备选)、日期、违纪类型、描述、处罚</div>
|
||||
<div>- <b>考勤记录</b>:身份证号(优先匹配)、姓名(备选)、日期、考勤状态、上下班时间</div>
|
||||
<div className="mt-2 text-gray-400">各 Sheet 优先用「身份证号」精确匹配员工,未填身份证号时用「姓名」兑底(重名可能匹配错误)</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MonthlyImport() {
|
||||
const queryClient = useQueryClient()
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [month, setMonth] = useState(new Date().toISOString().slice(0, 7))
|
||||
const [result, setResult] = useState<any>(null)
|
||||
const [uploading, setUploading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const handleUpload = async () => {
|
||||
if (!file) return
|
||||
setUploading(true)
|
||||
setError('')
|
||||
setResult(null)
|
||||
try {
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const formData = new FormData()
|
||||
formData.append('file', file)
|
||||
formData.append('month', month)
|
||||
const res = await fetch('/api/v1/import/monthly', {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: formData,
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!data.success) {
|
||||
setError(data.error?.message || '导入失败')
|
||||
} else {
|
||||
setResult(data.data)
|
||||
queryClient.invalidateQueries({ queryKey: ['roster'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['dashboard'] })
|
||||
}
|
||||
} catch (e: any) {
|
||||
setError(e?.message || '上传失败')
|
||||
} finally {
|
||||
setUploading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDownloadTemplate = async () => {
|
||||
try {
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const res = await fetch('/api/v1/import/monthly-template', {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
const blob = await res.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = 'monthly-import-template.xlsx'
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
} catch {
|
||||
setError('下载模板失败')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<h3 className="text-xs font-medium text-gray-700 mb-1">月度数据导入</h3>
|
||||
<p className="text-xs text-gray-400">每月定期导入当月考勤、加班、薪资调整、社保/公积金增减员变动</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<div>
|
||||
<Label>导入月份</Label>
|
||||
<Input type="month" value={month} onChange={(e) => setMonth(e.target.value)} className="!w-40" />
|
||||
</div>
|
||||
<div className="pt-5">
|
||||
<Button variant="secondary" size="sm" onClick={handleDownloadTemplate}>
|
||||
<Download className="w-4 h-4 mr-1" />下载月度模板
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-2 border-dashed border-gray-200 rounded-lg p-6 text-center">
|
||||
<FileSpreadsheet className="w-8 h-8 text-gray-300 mx-auto mb-2" />
|
||||
<input type="file" accept=".xlsx,.xls" onChange={(e) => { setFile(e.target.files?.[0] || null); setResult(null); setError('') }} className="hidden" id="import-file-monthly" />
|
||||
<label htmlFor="import-file-monthly" className="cursor-pointer text-xs text-primary hover:underline">
|
||||
{file ? file.name : '点击选择 Excel 文件'}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{error && <div className="px-3 py-2 rounded-md bg-red-50 text-red-700 text-xs">{error}</div>}
|
||||
|
||||
{result && (
|
||||
<div className="px-3 py-2 rounded-md bg-green-50 text-green-700 text-xs space-y-1">
|
||||
<div className="font-medium">{result.month} 月度导入完成</div>
|
||||
{result.attendance > 0 && <div>考勤记录:{result.attendance} 条</div>}
|
||||
{result.overtime > 0 && <div>加班记录:{result.overtime} 条</div>}
|
||||
{result.salaryChanges > 0 && <div>薪资调整:{result.salaryChanges} 人</div>}
|
||||
{result.socialInsChanges > 0 && <div>社保变动:{result.socialInsChanges} 人</div>}
|
||||
{result.housingFundChanges > 0 && <div>公积金变动:{result.housingFundChanges} 人</div>}
|
||||
{result.errors?.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-green-200">
|
||||
<div className="font-medium text-amber-600">部分错误({result.errors.length}条):</div>
|
||||
{result.errors.slice(0, 10).map((e: string, i: number) => (<div key={i} className="text-amber-600">{e}</div>))}
|
||||
{result.errors.length > 10 && <div className="text-amber-600">...还有 {result.errors.length - 10} 条</div>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<Button onClick={handleUpload} disabled={!file || uploading}>
|
||||
<Upload className="w-4 h-4 mr-1" />{uploading ? '导入中...' : '开始导入'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<div className="text-xs text-gray-500 space-y-1">
|
||||
<div className="font-medium text-gray-700 mb-1">Sheet 页说明</div>
|
||||
<div>- <b>考勤记录</b>:身份证号(优先匹配)、姓名(备选)、日期、考勤状态、上下班时间(同日重复导入会覆盖)</div>
|
||||
<div>- <b>加班记录</b>:身份证号(优先匹配)、姓名(备选)、日期、加班时长、加班类型(同月重复导入会累加)</div>
|
||||
<div>- <b>薪资调整</b>:身份证号(优先匹配)、姓名(备选)、调整后月薪、生效日期、调薪原因(自动关闭旧薪资记录)</div>
|
||||
<div>- <b>社保变动</b>:身份证号(优先匹配)、姓名(备选)、变动类型(增员/调基/减员)、缴费基数</div>
|
||||
<div>- <b>公积金变动</b>:身份证号(优先匹配)、姓名(备选)、变动类型(增员/调基/减员)、缴费基数</div>
|
||||
<div className="mt-2 text-gray-400">所有 Sheet 优先用「身份证号」精确匹配员工,未填时用「姓名」兑底(重名可能匹配错误)</div>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ export default function SocialInsurance() {
|
||||
const [showAdjust, setShowAdjust] = useState(false)
|
||||
const [adjustData, setAdjustData] = useState<any>(null)
|
||||
const [editItems, setEditItems] = useState<Record<string, number>>({})
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [monthlyMonth, setMonthlyMonth] = useState(new Date().toISOString().slice(0, 7))
|
||||
const [newVersion, setNewVersion] = useState<any>({
|
||||
effectiveFrom: new Date().toISOString().slice(0, 7),
|
||||
@@ -153,6 +154,7 @@ export default function SocialInsurance() {
|
||||
setShowAdjust(false)
|
||||
setAdjustData(null)
|
||||
setEditItems({})
|
||||
setEditingId(null)
|
||||
alert(`调整完成,共调整 ${res.data?.adjusted || 0} 名员工的社保基数`)
|
||||
},
|
||||
})
|
||||
@@ -166,10 +168,29 @@ export default function SocialInsurance() {
|
||||
setShowAdjust(false)
|
||||
setAdjustData(null)
|
||||
setEditItems({})
|
||||
setEditingId(null)
|
||||
alert(`调整完成,共调整 ${res.data?.adjusted || 0} 名员工的公积金基数`)
|
||||
},
|
||||
})
|
||||
|
||||
const resetAdjustMutation = useMutation({
|
||||
mutationFn: () => api.post(`/social/config/${config?.id}/reset-adjustment`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['social-config'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['social-config-versions'] })
|
||||
alert('社保基数调整已重置,可以重新调整')
|
||||
},
|
||||
})
|
||||
|
||||
const resetHousingAdjustMutation = useMutation({
|
||||
mutationFn: () => api.post(`/social/housing-config/${housingConfig?.id}/reset-adjustment`),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['housing-config'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['housing-config-versions'] })
|
||||
alert('公积金基数调整已重置,可以重新调整')
|
||||
},
|
||||
})
|
||||
|
||||
const handleExportCSV = (type: 'social' | 'housing', data: any) => {
|
||||
if (!data?.items?.length) return
|
||||
const headers = type === 'social'
|
||||
@@ -224,7 +245,7 @@ export default function SocialInsurance() {
|
||||
className={`px-4 py-2 text-xs font-medium border-b-2 transition-colors ${
|
||||
tab === t ? 'border-primary text-primary' : 'border-transparent text-gray-500 hover:text-gray-700'
|
||||
}`}
|
||||
onClick={() => { setTab(t); setShowVersions(false); setShowNewVersion(false); setShowAdjust(false); setAdjustData(null); setEditItems({}) }}
|
||||
onClick={() => { setTab(t); setShowVersions(false); setShowNewVersion(false); setShowAdjust(false); setAdjustData(null); setEditItems({}); setEditingId(null) }}
|
||||
>
|
||||
{t === 'social' ? '社保' : t === 'housing' ? '公积金' : '月度办理'}
|
||||
</button>
|
||||
@@ -243,15 +264,32 @@ export default function SocialInsurance() {
|
||||
<span className="px-2 py-0.5 rounded text-xs bg-gray-100 text-gray-400">已调整员工基数</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => activePreviewMut.mutate()}
|
||||
disabled={activeConfig.adjustmentDone || activePreviewMut.isPending}
|
||||
>
|
||||
<SettingsIcon className="w-4 h-4 mr-1" />
|
||||
{activeConfig.adjustmentDone ? '已调整' : activePreviewMut.isPending ? '加载中...' : `调整员工${isHousing ? '公积金' : '社保'}基数`}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
{activeConfig.adjustmentDone && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
if (confirm(`确定要重置${isHousing ? '公积金' : '社保'}基数调整吗?重置后可重新调整。`)) {
|
||||
isHousing ? resetHousingAdjustMutation.mutate() : resetAdjustMutation.mutate()
|
||||
}
|
||||
}}
|
||||
disabled={isHousing ? resetHousingAdjustMutation.isPending : resetAdjustMutation.isPending}
|
||||
>
|
||||
<SettingsIcon className="w-4 h-4 mr-1" />
|
||||
{isHousing ? resetHousingAdjustMutation.isPending ? '重置中...' : '重置调整' : resetAdjustMutation.isPending ? '重置中...' : '重置调整'}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={() => activePreviewMut.mutate()}
|
||||
disabled={activeConfig.adjustmentDone || activePreviewMut.isPending}
|
||||
>
|
||||
<SettingsIcon className="w-4 h-4 mr-1" />
|
||||
{activeConfig.adjustmentDone ? '已调整' : activePreviewMut.isPending ? '加载中...' : `调整员工${isHousing ? '公积金' : '社保'}基数`}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{isHousing ? (
|
||||
<div className="grid md:grid-cols-4 gap-3 text-xs">
|
||||
@@ -329,8 +367,17 @@ export default function SocialInsurance() {
|
||||
<td className="py-1.5 text-right text-gray-400">¥{fmt(item.oldBase)}</td>
|
||||
<td className="py-1.5 text-right text-gray-500">¥{fmt(item.suggestedBase)}</td>
|
||||
<td className="py-1.5 text-right">
|
||||
<Input type="number" className="w-24 text-right text-xs" value={newBase}
|
||||
onChange={(e) => setEditItems({ ...editItems, [item.employeeId]: Number(e.target.value) || 0 })} />
|
||||
{editingId === item.employeeId ? (
|
||||
<Input type="number" step="0.01" min="0" className="!w-28 text-right text-xs" value={newBase}
|
||||
onChange={(e) => setEditItems({ ...editItems, [item.employeeId]: Number(e.target.value) || 0 })}
|
||||
onBlur={() => setEditingId(null)}
|
||||
autoFocus />
|
||||
) : (
|
||||
<span className="cursor-text inline-block !w-28 text-right"
|
||||
onClick={() => setEditingId(item.employeeId)}>
|
||||
¥{fmt(newBase)}
|
||||
</span>
|
||||
)}
|
||||
{changed && <span className="text-warning ml-1">●</span>}
|
||||
</td>
|
||||
</tr>
|
||||
@@ -346,7 +393,7 @@ export default function SocialInsurance() {
|
||||
}} disabled={activeApplyMut.isPending}>
|
||||
{activeApplyMut.isPending ? '保存中...' : '确认保存'}
|
||||
</Button>
|
||||
<Button variant="secondary" onClick={() => { setShowAdjust(false); setAdjustData(null); setEditItems({}) }}>取消</Button>
|
||||
<Button variant="secondary" onClick={() => { setShowAdjust(false); setAdjustData(null); setEditItems({}); setEditingId(null) }}>取消</Button>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useMemo, useEffect } from 'react'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { AlertTriangle, Check, ChevronRight, ChevronLeft, Shield, Info, Calculator, FileText, Printer, Trash2, List } from 'lucide-react'
|
||||
import { AlertTriangle, Check, ChevronRight, ChevronLeft, Shield, Info, Calculator, FileText, Printer, Trash2, List, Download } from 'lucide-react'
|
||||
import jsPDF from 'jspdf'
|
||||
import api from '../lib/api'
|
||||
import Card from '../components/ui/Card'
|
||||
import Button from '../components/ui/Button'
|
||||
@@ -62,6 +63,7 @@ export default function Termination() {
|
||||
const [acknowledgeRisk, setAcknowledgeRisk] = useState(false)
|
||||
const [socialAvgWage, setSocialAvgWage] = useState(0)
|
||||
const [savedItems, setSavedItems] = useState<Array<{
|
||||
id: string
|
||||
employeeId: string
|
||||
name: string
|
||||
department: string
|
||||
@@ -75,7 +77,14 @@ export default function Termination() {
|
||||
years: number
|
||||
remainingMonths: number
|
||||
compMonths: number
|
||||
version: number
|
||||
isSimulated: boolean
|
||||
createdAt: string
|
||||
}>>([])
|
||||
// 对比模式:选中的版本ID列表
|
||||
const [compareVersions, setCompareVersions] = useState<string[]>([])
|
||||
// 是否展开对比
|
||||
const [showCompare, setShowCompare] = useState(false)
|
||||
|
||||
const { data: employees } = useQuery<RosterEmployee[]>({
|
||||
queryKey: ['roster-for-termination'],
|
||||
@@ -299,34 +308,19 @@ export default function Termination() {
|
||||
})
|
||||
}
|
||||
|
||||
// 模拟计算:只计算并暂存,不实际保存解聘记录
|
||||
// 模拟计算:追加新版本,支持参数对比
|
||||
const handleSimulate = () => {
|
||||
if (!costResult || !selectedEmployee) return
|
||||
setSavedItems((prev) => {
|
||||
if (prev.some((item) => item.employeeId === employeeId)) {
|
||||
return prev.map((item) =>
|
||||
item.employeeId === employeeId
|
||||
? {
|
||||
employeeId,
|
||||
name: selectedEmployee.name,
|
||||
department: selectedEmployee.department,
|
||||
reason,
|
||||
reasonLabel,
|
||||
terminationDate,
|
||||
severancePay: costResult.severancePay,
|
||||
noticePay: costResult.noticePay,
|
||||
doublePay: costResult.doublePay,
|
||||
grandTotal: costResult.grandTotal,
|
||||
years: costResult.years,
|
||||
remainingMonths: costResult.remainingMonths,
|
||||
compMonths: costResult.compMonths,
|
||||
}
|
||||
: item
|
||||
)
|
||||
}
|
||||
// 该员工的最新版本号
|
||||
const sameEmployee = prev.filter(item => item.employeeId === employeeId)
|
||||
const maxVersion = sameEmployee.reduce((max, item) => Math.max(max, item.version), 0)
|
||||
const newVersion = maxVersion + 1
|
||||
// 追加新版本(不覆盖旧版本)
|
||||
return [
|
||||
...prev,
|
||||
{
|
||||
id: `sim-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
employeeId,
|
||||
name: selectedEmployee.name,
|
||||
department: selectedEmployee.department,
|
||||
@@ -340,40 +334,29 @@ export default function Termination() {
|
||||
years: costResult.years,
|
||||
remainingMonths: costResult.remainingMonths,
|
||||
compMonths: costResult.compMonths,
|
||||
version: newVersion,
|
||||
isSimulated: true,
|
||||
createdAt: new Date().toISOString().slice(0, 19).replace('T', ' '),
|
||||
},
|
||||
]
|
||||
})
|
||||
handleReset()
|
||||
}
|
||||
|
||||
// 保存成功后暂存到右侧列表
|
||||
// 保存成功后追加正式版本(isSimulated=false)
|
||||
useEffect(() => {
|
||||
if (saveMutation.isSuccess && costResult && selectedEmployee) {
|
||||
setSavedItems((prev) => {
|
||||
if (prev.some((item) => item.employeeId === employeeId)) {
|
||||
return prev.map((item) =>
|
||||
item.employeeId === employeeId
|
||||
? {
|
||||
employeeId,
|
||||
name: selectedEmployee.name,
|
||||
department: selectedEmployee.department,
|
||||
reason,
|
||||
reasonLabel,
|
||||
terminationDate,
|
||||
severancePay: costResult.severancePay,
|
||||
noticePay: costResult.noticePay,
|
||||
doublePay: costResult.doublePay,
|
||||
grandTotal: costResult.grandTotal,
|
||||
years: costResult.years,
|
||||
remainingMonths: costResult.remainingMonths,
|
||||
compMonths: costResult.compMonths,
|
||||
}
|
||||
: item
|
||||
)
|
||||
}
|
||||
// 该员工的最新正式版本
|
||||
const sameEmp = prev.filter(item => item.employeeId === employeeId && !item.isSimulated)
|
||||
const maxVer = sameEmp.reduce((max, item) => Math.max(max, item.version), 0)
|
||||
const newVer = maxVer + 1
|
||||
// 替换该员工的旧正式版本(如有),追加新版本
|
||||
const filtered = prev.filter(item => !(item.employeeId === employeeId && !item.isSimulated))
|
||||
return [
|
||||
...prev,
|
||||
...filtered,
|
||||
{
|
||||
id: `saved-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
employeeId,
|
||||
name: selectedEmployee.name,
|
||||
department: selectedEmployee.department,
|
||||
@@ -387,6 +370,9 @@ export default function Termination() {
|
||||
years: costResult.years,
|
||||
remainingMonths: costResult.remainingMonths,
|
||||
compMonths: costResult.compMonths,
|
||||
version: newVer,
|
||||
isSimulated: false,
|
||||
createdAt: new Date().toISOString().slice(0, 19).replace('T', ' '),
|
||||
},
|
||||
]
|
||||
})
|
||||
@@ -529,6 +515,54 @@ export default function Termination() {
|
||||
<Label>解聘日期</Label>
|
||||
<Input type="date" value={terminationDate} onChange={(e) => setTerminationDate(e.target.value)} />
|
||||
</div>
|
||||
{/* 实时费用预览 - 当有足够数据时在解聘方式下方显示 */}
|
||||
{step === 1 && costResult && (
|
||||
<div className="border border-primary/30 bg-primary/5 rounded-md p-4 space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs font-medium text-primary">
|
||||
<Calculator className="w-4 h-4" />
|
||||
实时费用预览
|
||||
</div>
|
||||
<div className="text-xs text-gray-500">工作年限:{costResult.years}年{costResult.remainingMonths}个月 · 月工资:¥{fmt(costResult.wage)}</div>
|
||||
{costResult.capped && (
|
||||
<div className="text-xs text-warning">⚠️ 工资超社平3倍,按三倍封顶且最多12个月</div>
|
||||
)}
|
||||
{costResult.noComp ? (
|
||||
<div className="text-xs text-gray-600">员工过错解除,无需支付经济补偿金</div>
|
||||
) : (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs">
|
||||
{costResult.isIllegal ? '违法解除赔偿金(×2)' : '经济补偿金'}
|
||||
<span className="text-gray-400 ml-1">({costResult.cappedMonths}个月 × ¥{fmt(costResult.cappedWage)})</span>
|
||||
</span>
|
||||
<span className={`text-sm font-bold ${costResult.isIllegal ? 'text-danger' : 'text-primary'}`}>
|
||||
¥{fmt(costResult.severancePay)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{costResult.noticePay > 0 && (
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className="text-gray-500">代通知金</span>
|
||||
<span>¥{fmt(costResult.noticePay)}</span>
|
||||
</div>
|
||||
)}
|
||||
{costResult.isIllegal && (
|
||||
<div className="text-xs text-red-600 bg-red-50 px-2 py-1 rounded">
|
||||
违法解除赔偿金 = 经济补偿金 × 2(《劳动合同法》第87条)
|
||||
</div>
|
||||
)}
|
||||
{!costResult.hasContract && costResult.doubleMonths > 0 && (
|
||||
<div className="flex items-center justify-between text-xs border-t pt-2">
|
||||
<span className="text-warning">未签合同双倍工资({costResult.doubleMonths}个月)</span>
|
||||
<span className="text-warning font-medium">¥{fmt(costResult.doublePay)}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between border-t pt-2">
|
||||
<span className="text-xs font-medium">预估合计</span>
|
||||
<span className="text-base font-bold text-danger">¥{fmt(costResult.grandTotal)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="border-t pt-3">
|
||||
<Label>社保公积金截止缴费年月</Label>
|
||||
<div className="text-xs text-gray-400 mb-2">默认与解聘日期同月,可手动修改</div>
|
||||
@@ -905,7 +939,6 @@ export default function Termination() {
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
</div>
|
||||
|
||||
{/* 右侧:暂存列表 */}
|
||||
@@ -915,17 +948,115 @@ export default function Termination() {
|
||||
<h2 className="text-xs font-medium flex items-center gap-1.5">
|
||||
<List className="w-4 h-4" />已计算列表
|
||||
</h2>
|
||||
{savedItems.length > 0 && (
|
||||
<Button variant="secondary" size="sm" onClick={() => setSavedItems([])}>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex gap-1">
|
||||
{savedItems.length > 0 && (
|
||||
<Button
|
||||
variant={showCompare ? 'primary' : 'secondary'}
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setShowCompare(!showCompare)
|
||||
if (!showCompare) setCompareVersions([])
|
||||
}}
|
||||
>
|
||||
<List className="w-3.5 h-3.5 mr-1" />
|
||||
{showCompare ? '收起对比' : '版本对比'}
|
||||
</Button>
|
||||
)}
|
||||
{savedItems.length > 0 && (
|
||||
<>
|
||||
<Button variant="secondary" size="sm" onClick={() => {
|
||||
const doc = new jsPDF()
|
||||
doc.setFontSize(16)
|
||||
doc.text('解聘补偿记录', 14, 20)
|
||||
doc.setFontSize(10)
|
||||
doc.text(`生成日期: ${new Date().toLocaleDateString('zh-CN')}`, 14, 28)
|
||||
let y = 40
|
||||
savedItems.forEach((item, i) => {
|
||||
if (y > 270) { doc.addPage(); y = 20 }
|
||||
doc.setFontSize(11)
|
||||
doc.text(`${i + 1}. ${item.name} (${item.department})`, 14, y)
|
||||
y += 6
|
||||
doc.setFontSize(9)
|
||||
doc.text(`解聘原因: ${item.reason || '-'}`, 20, y); y += 5
|
||||
doc.text(`解聘日期: ${item.terminationDate || '-'}`, 20, y); y += 5
|
||||
doc.text(`工作年限: ${item.years?.toFixed(1) || 0} 年`, 20, y); y += 5
|
||||
if (item.severancePay > 0) { doc.text(`补偿金: ¥${fmt(item.severancePay)}`, 20, y); y += 5 }
|
||||
if (item.noticePay > 0) { doc.text(`代通知金: ¥${fmt(item.noticePay)}`, 20, y); y += 5 }
|
||||
if (item.doublePay > 0) { doc.text(`双倍工资: ¥${fmt(item.doublePay)}`, 20, y); y += 5 }
|
||||
doc.text(`合计: ¥${fmt(item.grandTotal)}`, 20, y); y += 8
|
||||
})
|
||||
if (y > 260) { doc.addPage(); y = 20 }
|
||||
doc.setFontSize(11)
|
||||
doc.text(`总计: ¥${fmt(totalGrand)}`, 14, y)
|
||||
doc.save(`解聘补偿记录-${new Date().toISOString().slice(0, 10)}.pdf`)
|
||||
}}>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
<Button variant="secondary" size="sm" onClick={() => setSavedItems([])}>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{savedItems.length === 0 ? (
|
||||
{savedItems.length === 0 && !showCompare ? (
|
||||
<div className="text-center py-6 text-gray-400 text-xs">
|
||||
计算完一人后<br />暂存结果将显示在此
|
||||
</div>
|
||||
) : showCompare ? (
|
||||
<div className="space-y-2">
|
||||
{savedItems.map((item, i) => (
|
||||
<div key={i} className="border rounded-md p-2 space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="text-xs font-medium">{item.name}</div>
|
||||
<div className="text-xs text-gray-400">{item.department}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSavedItems(savedItems.filter((_, idx) => idx !== i))}
|
||||
className="text-gray-400 hover:text-danger"
|
||||
>
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 flex justify-between">
|
||||
<span>{item.reasonLabel}</span>
|
||||
<span>{item.years}年{item.remainingMonths}月</span>
|
||||
</div>
|
||||
<div className="text-xs space-y-0.5">
|
||||
{item.severancePay > 0 && (
|
||||
<div className="flex justify-between"><span className="text-gray-400">补偿金</span><span>¥{fmt(item.severancePay)}</span></div>
|
||||
)}
|
||||
{item.noticePay > 0 && (
|
||||
<div className="flex justify-between"><span className="text-gray-400">代通知金</span><span>¥{fmt(item.noticePay)}</span></div>
|
||||
)}
|
||||
{item.doublePay > 0 && (
|
||||
<div className="flex justify-between"><span className="text-gray-400">双倍工资</span><span>¥{fmt(item.doublePay)}</span></div>
|
||||
)}
|
||||
<div className="flex justify-between font-bold border-t pt-0.5"><span>合计</span><span className="text-danger">¥{fmt(item.grandTotal)}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 合计 */}
|
||||
<div className="border-t-2 pt-2 space-y-0.5">
|
||||
<div className="text-xs font-medium text-gray-600">合计({savedItems.length}人)</div>
|
||||
{totalSeverance > 0 && (
|
||||
<div className="flex justify-between text-xs"><span className="text-gray-400">补偿金</span><span>¥{fmt(totalSeverance)}</span></div>
|
||||
)}
|
||||
{totalNotice > 0 && (
|
||||
<div className="flex justify-between text-xs"><span className="text-gray-400">代通知金</span><span>¥{fmt(totalNotice)}</span></div>
|
||||
)}
|
||||
{totalDouble > 0 && (
|
||||
<div className="flex justify-between text-xs"><span className="text-gray-400">双倍工资</span><span>¥{fmt(totalDouble)}</span></div>
|
||||
)}
|
||||
<div className="flex justify-between text-sm font-bold">
|
||||
<span>总计</span>
|
||||
<span className="text-danger">¥{fmt(totalGrand)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{savedItems.map((item, i) => (
|
||||
|
||||
@@ -1,38 +1,52 @@
|
||||
import { useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { Building2, Eye, EyeOff } from 'lucide-react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import api from '../../lib/api'
|
||||
import { Input, Label } from '../../components/ui/Input'
|
||||
import Button from '../../components/ui/Button'
|
||||
|
||||
const schema = z.object({
|
||||
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
|
||||
newPassword: z.string().min(8, '密码至少8位').max(32, '密码最多32位'),
|
||||
}).refine((data) => data.newPassword.length >= 8, {
|
||||
message: '密码至少8位',
|
||||
path: ['newPassword'],
|
||||
})
|
||||
|
||||
type FormData = z.infer<typeof schema>
|
||||
|
||||
export default function ForgotPassword() {
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const [success, setSuccess] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [step, setStep] = useState<1 | 2>(1)
|
||||
const [phone, setPhone] = useState('')
|
||||
const [code, setCode] = useState('')
|
||||
const [newPassword, setNewPassword] = useState('')
|
||||
const [sentCode, setSentCode] = useState('')
|
||||
|
||||
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
|
||||
resolver: zodResolver(schema),
|
||||
})
|
||||
|
||||
const onSubmit = async (data: FormData) => {
|
||||
const sendCode = async () => {
|
||||
setError('')
|
||||
if (!/^1[3-9]\d{9}$/.test(phone)) {
|
||||
setError('手机号格式不正确')
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
await api.post('/auth/reset-password', data)
|
||||
const res = await api.post('/auth/forgot-password/send-code', { phone }) as any
|
||||
setSentCode(res.data?.code || '')
|
||||
setStep(2)
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error?.message || '发送失败,请稍后重试')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const resetPwd = async () => {
|
||||
setError('')
|
||||
if (code.length !== 6) {
|
||||
setError('请输入6位验证码')
|
||||
return
|
||||
}
|
||||
if (newPassword.length < 8) {
|
||||
setError('密码至少8位')
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
try {
|
||||
await api.post('/auth/forgot-password/verify', { phone, code, newPassword })
|
||||
setSuccess(true)
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.error?.message || '重置失败,请稍后重试')
|
||||
@@ -63,41 +77,73 @@ export default function ForgotPassword() {
|
||||
<div className="mb-4 px-3 py-2 rounded-md bg-red-50 text-red-700 text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||
<div>
|
||||
<Label>手机号</Label>
|
||||
<Input
|
||||
type="tel"
|
||||
placeholder="请输入注册手机号"
|
||||
{...register('phone')}
|
||||
maxLength={11}
|
||||
/>
|
||||
{errors.phone && <p className="text-xs text-red-500 mt-1">{errors.phone.message}</p>}
|
||||
{sentCode && step === 2 && (
|
||||
<div className="mb-4 px-3 py-2 rounded-md bg-blue-50 text-blue-700 text-sm">
|
||||
验证码:{sentCode}(开发阶段直接显示,生产环境将发送短信)
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<Label>新密码</Label>
|
||||
<div className="relative">
|
||||
{step === 1 ? (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>手机号</Label>
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="至少8位"
|
||||
{...register('newPassword')}
|
||||
type="tel"
|
||||
placeholder="请输入注册手机号"
|
||||
value={phone}
|
||||
onChange={(e) => setPhone(e.target.value)}
|
||||
maxLength={11}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
{errors.newPassword && <p className="text-xs text-red-500 mt-1">{errors.newPassword.message}</p>}
|
||||
<Button className="w-full" disabled={loading} onClick={sendCode}>
|
||||
{loading ? '发送中...' : '获取验证码'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? '重置中...' : '重置密码'}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<Label>手机号</Label>
|
||||
<Input type="tel" value={phone} disabled />
|
||||
</div>
|
||||
<div>
|
||||
<Label>验证码</Label>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="6位验证码"
|
||||
value={code}
|
||||
onChange={(e) => setCode(e.target.value)}
|
||||
maxLength={6}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>新密码</Label>
|
||||
<div className="relative">
|
||||
<Input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="至少8位"
|
||||
value={newPassword}
|
||||
onChange={(e) => setNewPassword(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-gray-400"
|
||||
>
|
||||
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<Button className="w-full" disabled={loading} onClick={resetPwd}>
|
||||
{loading ? '重置中...' : '重置密码'}
|
||||
</Button>
|
||||
<button
|
||||
className="w-full text-xs text-gray-500 hover:text-gray-700"
|
||||
onClick={() => { setStep(1); setCode(''); setSentCode('') }}
|
||||
>
|
||||
重新获取验证码
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 text-center text-sm">
|
||||
<Link to="/login" className="text-primary hover:underline">返回登录</Link>
|
||||
|
||||
@@ -21,5 +21,6 @@
|
||||
}
|
||||
},
|
||||
"include": ["src"],
|
||||
"references": [{ "path": "./tsconfig.node.json" }]
|
||||
}
|
||||
"references": [{ "path": "./tsconfig.node.json" }],
|
||||
"ignoreDeprecations": "6.0"
|
||||
}
|
||||
Reference in New Issue
Block a user