Initial commit: 风影 OA 全栈源码(API/Web/Flutter/IM)
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。 排除 node_modules、构建产物、安装包与 .env 密钥。 Co-authored-by: Cursor <cursoragent@cursor.com>
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,50 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
||||
|
||||
- Configure the top-level `parserOptions` property like this:
|
||||
|
||||
```js
|
||||
export default tseslint.config({
|
||||
languageOptions: {
|
||||
// other options...
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked`
|
||||
- Optionally add `...tseslint.configs.stylisticTypeChecked`
|
||||
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import react from 'eslint-plugin-react'
|
||||
|
||||
export default tseslint.config({
|
||||
// Set the react version
|
||||
settings: { react: { version: '18.3' } },
|
||||
plugins: {
|
||||
// Add the react plugin
|
||||
react,
|
||||
},
|
||||
rules: {
|
||||
// other rules...
|
||||
// Enable its recommended rules
|
||||
...react.configs.recommended.rules,
|
||||
...react.configs['jsx-runtime'].rules,
|
||||
},
|
||||
})
|
||||
```
|
||||
@@ -0,0 +1,28 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>风影智慧办公综合平台</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,47 @@
|
||||
{
|
||||
"name": "web",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons": "^5.5.2",
|
||||
"@tanstack/react-query": "^5.102.8",
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@wangeditor/editor-for-react": "^1.0.6",
|
||||
"antd": "^5.22.6",
|
||||
"axios": "^1.20.0",
|
||||
"dayjs": "^1.11.23",
|
||||
"livekit-client": "^2.22.1",
|
||||
"mammoth": "^1.12.2",
|
||||
"pdfjs-dist": "4.10.38",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react-router-dom": "^6.28.0",
|
||||
"xlsx": "^0.18.5",
|
||||
"zustand": "^5.0.15"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.13.0",
|
||||
"@types/react": "^18.3.12",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@vitejs/plugin-react": "^4.3.3",
|
||||
"eslint": "^9.13.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.14",
|
||||
"globals": "^15.11.0",
|
||||
"typescript": "~5.6.2",
|
||||
"typescript-eslint": "^8.11.0",
|
||||
"vite": "^5.4.10"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"esbuild"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 56 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg width="1024" height="1024" viewBox="0 0 80 80" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="fy-bg" x1="10" y1="0" x2="70" y2="80" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#5b4fe3"/>
|
||||
<stop offset="1" stop-color="#7c3aed"/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="1" y="1" width="78" height="78" rx="20" fill="url(#fy-bg)"/>
|
||||
<path d="M22 20h28c5 0 9 4 9 9v6" stroke="#fff" stroke-width="6.5" stroke-linecap="round" fill="none"/>
|
||||
<path d="M22 20v40" stroke="#fff" stroke-width="6.5" stroke-linecap="round"/>
|
||||
<path d="M28 34h22" stroke="#fff" stroke-width="5.5" stroke-linecap="round"/>
|
||||
<path d="M34 26l16 28" stroke="#fff" stroke-width="5.5" stroke-linecap="round"/>
|
||||
<path d="M50 26L34 54" stroke="#fff" stroke-width="5.5" stroke-linecap="round"/>
|
||||
<path d="M20 58c14 10 32 10 44-6" stroke="#fff" stroke-width="5" stroke-linecap="round" fill="none"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 931 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,405 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Navigate, Route, Routes } from 'react-router-dom';
|
||||
import { useEffect } from 'react';
|
||||
import { Spin } from 'antd';
|
||||
import { useAuth } from './store/auth';
|
||||
import AppLayout from './layouts/AppLayout';
|
||||
import LoginPage from './pages/LoginPage';
|
||||
import DesktopLoginPage from './pages/DesktopLoginPage';
|
||||
import LegalPage from './pages/LegalPage';
|
||||
import OverviewPage from './pages/OverviewPage';
|
||||
import BidCreatePage from './pages/BidCreatePage';
|
||||
import BidListPage from './pages/BidListPage';
|
||||
import BidDetailPage from './pages/BidDetailPage';
|
||||
import ContractListPage from './pages/ContractListPage';
|
||||
import ContractDetailPage from './pages/ContractDetailPage';
|
||||
import LaborContractPage from './pages/LaborContractPage';
|
||||
import OnboardPage from './pages/OnboardPage';
|
||||
import WorkAssignPage from './pages/WorkAssignPage';
|
||||
import { AssetRegistryPage } from './pages/AssetRegistryPage';
|
||||
import OfficeApprovalsPage from './pages/OfficeApprovalsPage';
|
||||
import OfficeTodosPage from './pages/OfficeTodosPage';
|
||||
import OfficeApplyPage from './pages/OfficeApplyPage';
|
||||
import MyFlowPage from './pages/MyFlowPage';
|
||||
import NoticePage from './pages/NoticePage';
|
||||
import NoticeDetailPage from './pages/NoticeDetailPage';
|
||||
import HrNoticePage from './pages/HrNoticePage';
|
||||
import DirectoryPage from './pages/DirectoryPage';
|
||||
import ImPage from './pages/im/ImPage';
|
||||
import MyExpensesPage from './pages/MyExpensesPage';
|
||||
import ExpenseDetailPage from './pages/ExpenseDetailPage';
|
||||
import ExpenseListPage, { LoanListPage } from './pages/ExpenseListPage';
|
||||
import LoanDetailPage from './pages/LoanDetailPage';
|
||||
import AssetPurchaseLedgerPage from './pages/AssetPurchaseLedgerPage';
|
||||
import BondListPage from './pages/BondListPage';
|
||||
import InvoiceListPage from './pages/InvoiceListPage';
|
||||
import ProjectListPage from './pages/ProjectListPage';
|
||||
import ProjectDetailPage from './pages/ProjectDetailPage';
|
||||
import TaskListPage, { TimesheetPage } from './pages/TaskListPage';
|
||||
import QualificationListPage, { BorrowListPage } from './pages/QualificationListPage';
|
||||
import SealRequestListPage from './pages/SealRequestListPage';
|
||||
import CalendarPage from './pages/CalendarPage';
|
||||
import OrgPage from './pages/OrganizationPage';
|
||||
import EmployeeListPage from './pages/EmployeePage';
|
||||
import HrApplyPage from './pages/HrApplyPage';
|
||||
import HrLeaveDetailPage from './pages/HrLeaveDetailPage';
|
||||
import {
|
||||
AttendancePage,
|
||||
LifecyclePage,
|
||||
PayrollPage,
|
||||
PerformancePage,
|
||||
} from './pages/HrPages';
|
||||
import { AttendanceRulesPage, HrParamsPage } from './pages/HrSettingsPage';
|
||||
import {
|
||||
AclPage,
|
||||
AclPermsPage,
|
||||
AssetPage,
|
||||
CashLockPage,
|
||||
ConversionPage,
|
||||
EfficiencyPage,
|
||||
LogsPage,
|
||||
LostBidsPage,
|
||||
NumberRulesPage,
|
||||
PaymentPage,
|
||||
ProfitPage,
|
||||
PurchasePage,
|
||||
ResourceBoardPage,
|
||||
SealRegistryPage,
|
||||
WorkReportPage,
|
||||
} from './pages/OpsPages';
|
||||
import { ForbiddenPage, NotFoundPage } from './pages/StatusPages';
|
||||
import SmsPage from './pages/SmsPage';
|
||||
import AppReleasePage from './pages/AppReleasePage';
|
||||
import MessagesChannelPage from './pages/MessagesChannelPage';
|
||||
import OfficeSmsPage from './pages/OfficeSmsPage';
|
||||
import {
|
||||
ContactDirectoryPage,
|
||||
DictsPage,
|
||||
LegalEntityPage,
|
||||
TenderPartyPage,
|
||||
} from './pages/PartyPages';
|
||||
|
||||
const NOTICE_PUBLISH_ROLES = [
|
||||
'hr',
|
||||
'biz_director',
|
||||
'tech_director',
|
||||
'rd_director',
|
||||
'3d_director',
|
||||
'video_director',
|
||||
'material_director',
|
||||
];
|
||||
|
||||
function canSee(permissions: string[], roles: string[], code: string) {
|
||||
if (roles.includes('admin') || roles.includes('owner')) return true;
|
||||
if ((code === 'hr:notices' || code === 'office:sms') && NOTICE_PUBLISH_ROLES.some((r) => roles.includes(r))) return true;
|
||||
return permissions.includes(code) || permissions.some((p) => p.startsWith(`${code}:`));
|
||||
}
|
||||
|
||||
function Require({ code, children }: { code: string; children: ReactNode }) {
|
||||
const roles = useAuth((s) => s.roles);
|
||||
const permissions = useAuth((s) => s.permissions);
|
||||
if (!canSee(permissions, roles, code)) return <Navigate to="/403" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function Guard({ children, allowOnboard }: { children: ReactNode; allowOnboard?: boolean }) {
|
||||
const { loaded, username, loadSession, needsOnboard } = useAuth();
|
||||
useEffect(() => {
|
||||
void loadSession();
|
||||
}, [loadSession]);
|
||||
if (!loaded) {
|
||||
return (
|
||||
<div style={{ display: 'grid', placeItems: 'center', minHeight: '100vh' }}>
|
||||
<Spin />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!username) return <Navigate to="/login" replace />;
|
||||
if (needsOnboard && !allowOnboard) return <Navigate to="/office/onboard" replace />;
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route path="/login/desktop" element={<DesktopLoginPage />} />
|
||||
<Route path="/legal/:kind" element={<LegalPage />} />
|
||||
<Route
|
||||
path="/office/onboard"
|
||||
element={
|
||||
<Guard allowOnboard>
|
||||
<OnboardPage />
|
||||
</Guard>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/"
|
||||
element={
|
||||
<Guard>
|
||||
<AppLayout />
|
||||
</Guard>
|
||||
}
|
||||
>
|
||||
<Route index element={<Navigate to="/workbench" replace />} />
|
||||
<Route path="workbench" element={<OverviewPage />} />
|
||||
<Route path="office/overview" element={<Navigate to="/workbench" replace />} />
|
||||
<Route path="office/todos" element={<OfficeTodosPage />} />
|
||||
<Route path="office/bid-open/:id" element={<BidDetailPage officeOpen />} />
|
||||
<Route
|
||||
path="office/approvals"
|
||||
element={
|
||||
<Require code="office:approvals">
|
||||
<OfficeApprovalsPage />
|
||||
</Require>
|
||||
}
|
||||
/>
|
||||
<Route path="office/flow" element={<MyFlowPage />} />
|
||||
<Route path="office/calendar" element={<CalendarPage />} />
|
||||
<Route path="office/reports" element={<WorkReportPage />} />
|
||||
<Route path="office/hr-apply" element={<HrApplyPage />} />
|
||||
<Route path="office/leaves/:id" element={<HrLeaveDetailPage />} />
|
||||
<Route path="office/seal-apply" element={<SealRequestListPage mode="apply" />} />
|
||||
<Route path="office/apply" element={<OfficeApplyPage />} />
|
||||
<Route path="office/notices" element={<NoticePage />} />
|
||||
<Route path="office/notices/:id" element={<NoticeDetailPage />} />
|
||||
<Route
|
||||
path="office/sms"
|
||||
element={
|
||||
<Require code="office:sms">
|
||||
<OfficeSmsPage />
|
||||
</Require>
|
||||
}
|
||||
/>
|
||||
<Route path="office/policies" element={<Navigate to="/office/notices" replace />} />
|
||||
<Route path="office/directory" element={<DirectoryPage />} />
|
||||
<Route path="office/im" element={<ImPage />} />
|
||||
<Route
|
||||
path="office/assign"
|
||||
element={
|
||||
<Require code="office:assign">
|
||||
<WorkAssignPage />
|
||||
</Require>
|
||||
}
|
||||
/>
|
||||
<Route path="office/attendance" element={<Navigate to="/office/hr-apply" replace />} />
|
||||
<Route path="office/expenses" element={<MyExpensesPage />}>
|
||||
<Route path="new" element={<ExpenseDetailPage />} />
|
||||
<Route path=":id" element={<ExpenseDetailPage />} />
|
||||
</Route>
|
||||
<Route path="office/loans" element={<MyExpensesPage />}>
|
||||
<Route path="new" element={<LoanDetailPage />} />
|
||||
<Route path=":id" element={<LoanDetailPage />} />
|
||||
</Route>
|
||||
<Route path="party/tenders" element={<Require code="party"><TenderPartyPage /></Require>} />
|
||||
<Route path="party/entities" element={<Require code="party"><LegalEntityPage /></Require>} />
|
||||
<Route path="party/contacts" element={<Require code="party"><ContactDirectoryPage /></Require>} />
|
||||
<Route path="seal/qualifications" element={<Require code="seal"><QualificationListPage /></Require>} />
|
||||
<Route path="seal/borrows" element={<Require code="seal"><BorrowListPage /></Require>} />
|
||||
<Route path="seal/requests" element={<Require code="seal"><SealRequestListPage mode="register" /></Require>} />
|
||||
<Route path="seal/registry" element={<Require code="seal"><SealRegistryPage /></Require>} />
|
||||
<Route path="bid/screening" element={<Require code="bid"><BidListPage title="项目筛选" bucket="screening" extra /></Require>} />
|
||||
<Route path="bid/screening/new" element={<Require code="bid"><BidCreatePage /></Require>} />
|
||||
<Route
|
||||
path="bid/special-screening"
|
||||
element={<Require code="bid"><BidListPage title="特殊筛选" extra special /></Require>}
|
||||
/>
|
||||
<Route path="bid/special-screening/new" element={<Require code="bid"><BidCreatePage special /></Require>} />
|
||||
<Route path="bid/cases/:id" element={<Require code="bid"><BidDetailPage /></Require>} />
|
||||
<Route path="bid/making" element={<Navigate to="/bid/drafting" replace />} />
|
||||
<Route path="bid/drafting" element={<Require code="bid"><BidListPage title="标书制作" bucket="drafting" /></Require>} />
|
||||
<Route
|
||||
path="bid/special-drafting"
|
||||
element={<Require code="bid"><BidListPage title="特殊制标" bucket="drafting" special /></Require>}
|
||||
/>
|
||||
<Route path="bid/execution" element={<Require code="bid"><BidListPage title="投标执行" bucket="execution" /></Require>} />
|
||||
<Route path="bid/result" element={<Require code="bid"><BidListPage title="开标结果" bucket="result" /></Require>} />
|
||||
<Route
|
||||
path="bid/terminated"
|
||||
element={<Require code="bid"><BidListPage title="终止投标" bucket="terminated" /></Require>}
|
||||
/>
|
||||
<Route path="contract/list" element={<Navigate to="/contract/incremental" replace />} />
|
||||
<Route path="contract/approval" element={<Navigate to="/contract/tender" replace />} />
|
||||
<Route
|
||||
path="contract/tender"
|
||||
element={
|
||||
<Require code="contract">
|
||||
<ContractListPage
|
||||
title="投标合同"
|
||||
description="中标后自动生成草稿,商务完善编号和条款后提交审批。也可在本页查看已有合同。不与直签、其他合同重复。"
|
||||
bucket="tender"
|
||||
/>
|
||||
</Require>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="contract/direct"
|
||||
element={
|
||||
<Require code="contract">
|
||||
<ContractListPage
|
||||
title="直签合同"
|
||||
description="不经过投标、直接签订的合同。在本页新建。"
|
||||
bucket="direct"
|
||||
/>
|
||||
</Require>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="contract/incremental"
|
||||
element={
|
||||
<Require code="contract">
|
||||
<ContractListPage
|
||||
title="其他合同"
|
||||
description="除投标合同、直签合同以外的合同。在本页新建。"
|
||||
bucket="incremental"
|
||||
/>
|
||||
</Require>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="contract/hr"
|
||||
element={
|
||||
<Require code="contract:hr">
|
||||
<LaborContractPage />
|
||||
</Require>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="contract/milestones"
|
||||
element={<Navigate to="/contract/incremental" replace />}
|
||||
/>
|
||||
<Route path="contract/:id" element={<Require code="contract"><ContractDetailPage /></Require>} />
|
||||
<Route
|
||||
path="asset/digital"
|
||||
element={
|
||||
<Require code="asset">
|
||||
<AssetRegistryPage kind="DIGITAL" title="数字资产" />
|
||||
</Require>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="asset/fixed"
|
||||
element={
|
||||
<Require code="asset">
|
||||
<AssetRegistryPage kind="FIXED" title="固定资产" />
|
||||
</Require>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="project/list"
|
||||
element={
|
||||
<Require code="project:list">
|
||||
<ProjectListPage
|
||||
title="项目台账"
|
||||
description="中标后自动写入。未开始和执行中的项目也可在此补登直签。预算、报销和花费都在办理里看:商务差旅进商务,技术部门差旅、工时和采购进技术。"
|
||||
bucket="ledger"
|
||||
/>
|
||||
</Require>
|
||||
}
|
||||
/>
|
||||
<Route path="project/tasks" element={<TaskListPage />} />
|
||||
<Route path="project/timesheets" element={<TimesheetPage />} />
|
||||
<Route path="project/resources" element={<ResourceBoardPage />} />
|
||||
<Route path="project/budgets" element={<Navigate to="/project/list" replace />} />
|
||||
<Route
|
||||
path="project/acceptance"
|
||||
element={
|
||||
<ProjectListPage
|
||||
title="项目验收"
|
||||
description="台账里加入验收材料后,项目会出现在这里。点操作只看出验收材料,不必在本页新增项目。"
|
||||
bucket="acceptance"
|
||||
mode="acceptance"
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Route path="project/changes" element={<Navigate to="/project/list" replace />} />
|
||||
<Route path="project/purchases" element={<PurchasePage />} />
|
||||
<Route path="project/assets" element={<AssetPage />} />
|
||||
<Route path="project/:id" element={<ProjectDetailPage />} />
|
||||
<Route path="finance/expenses" element={<ExpenseListPage />}>
|
||||
<Route path=":id" element={<ExpenseDetailPage />} />
|
||||
</Route>
|
||||
<Route path="finance/loans" element={<LoanListPage />}>
|
||||
<Route path=":id" element={<LoanDetailPage />} />
|
||||
</Route>
|
||||
<Route
|
||||
path="finance/asset-purchases"
|
||||
element={
|
||||
<Require code="finance:asset-purchases">
|
||||
<AssetPurchaseLedgerPage />
|
||||
</Require>
|
||||
}
|
||||
/>
|
||||
<Route path="finance/bonds" element={<Navigate to="/finance/bonds/tender" replace />} />
|
||||
<Route path="finance/bonds/tender" element={<BondListPage bondType="TENDER" />} />
|
||||
<Route path="finance/bonds/performance" element={<BondListPage bondType="PERFORMANCE" />} />
|
||||
<Route path="finance/bonds/warranty" element={<BondListPage bondType="WARRANTY" />} />
|
||||
<Route path="finance/invoices" element={<InvoiceListPage />} />
|
||||
<Route path="finance/payments" element={<PaymentPage />} />
|
||||
<Route path="hr/org" element={<OrgPage />} />
|
||||
<Route path="hr/employees" element={<EmployeeListPage />} />
|
||||
<Route path="hr/lifecycle" element={<LifecyclePage />} />
|
||||
<Route path="hr/attendance" element={<AttendancePage />} />
|
||||
<Route path="hr/payroll" element={<PayrollPage />} />
|
||||
<Route path="hr/performance" element={<PerformancePage />} />
|
||||
<Route
|
||||
path="hr/notices"
|
||||
element={
|
||||
<Require code="hr:notices">
|
||||
<HrNoticePage />
|
||||
</Require>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="hr/notices/:id"
|
||||
element={
|
||||
<Require code="hr:notices">
|
||||
<HrNoticePage />
|
||||
</Require>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="hr/policies"
|
||||
element={
|
||||
<Require code="hr:policies">
|
||||
<HrNoticePage />
|
||||
</Require>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="hr/policies/:id"
|
||||
element={
|
||||
<Require code="hr:policies">
|
||||
<HrNoticePage />
|
||||
</Require>
|
||||
}
|
||||
/>
|
||||
<Route path="report/conversion" element={<Require code="report"><ConversionPage /></Require>} />
|
||||
<Route path="report/lost" element={<Require code="report"><LostBidsPage /></Require>} />
|
||||
<Route path="report/profit" element={<Require code="report"><ProfitPage /></Require>} />
|
||||
<Route path="report/efficiency" element={<Require code="report"><EfficiencyPage /></Require>} />
|
||||
<Route path="report/cash" element={<Require code="report"><CashLockPage /></Require>} />
|
||||
<Route path="system/acl" element={<AclPage />} />
|
||||
<Route
|
||||
path="system/perms"
|
||||
element={
|
||||
<Require code="system:perms">
|
||||
<AclPermsPage />
|
||||
</Require>
|
||||
}
|
||||
/>
|
||||
<Route path="system/hr-params" element={<HrParamsPage />} />
|
||||
<Route path="system/org" element={<Navigate to="/hr/org" replace />} />
|
||||
<Route path="system/attendance-rules" element={<AttendanceRulesPage />} />
|
||||
<Route path="system/dicts" element={<DictsPage />} />
|
||||
<Route path="system/numbers" element={<NumberRulesPage />} />
|
||||
<Route path="system/sms" element={<SmsPage />} />
|
||||
<Route path="system/app-release" element={<AppReleasePage />} />
|
||||
<Route path="system/messages" element={<MessagesChannelPage />} />
|
||||
<Route path="system/logs" element={<LogsPage />} />
|
||||
<Route path="403" element={<ForbiddenPage />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Tag } from 'antd';
|
||||
import { ZH_COLOR, zhCtx } from './labels';
|
||||
|
||||
export function StatusTag({ value, context }: { value?: string | number | null; context?: string }) {
|
||||
if (value === undefined || value === null || value === '') return <span>—</span>;
|
||||
const key = String(value);
|
||||
return <Tag color={ZH_COLOR[key] || 'default'}>{zhCtx(key, context)}</Tag>;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
const ADMIN = ['admin', 'owner'];
|
||||
|
||||
export function canHr(roles: string[]) {
|
||||
return [...ADMIN, 'hr', 'biz_director'].some((r) => roles.includes(r));
|
||||
}
|
||||
|
||||
export function canConfirmPay(roles: string[]) {
|
||||
return [...ADMIN, 'hr', 'biz_director'].some((r) => roles.includes(r));
|
||||
}
|
||||
|
||||
export function canApproveFin(roles: string[]) {
|
||||
return [...ADMIN, 'finance', 'biz_director'].some((r) => roles.includes(r));
|
||||
}
|
||||
|
||||
const ACTIONS = new Set(['view', 'create', 'edit', 'delete', 'approve']);
|
||||
|
||||
/** 菜单下的具体操作。还没配操作项时,有该菜单就视为能做。 */
|
||||
export function canDo(
|
||||
permissions: string[],
|
||||
roles: string[],
|
||||
menuCode: string,
|
||||
action: 'view' | 'create' | 'edit' | 'delete' | 'approve',
|
||||
) {
|
||||
if (ADMIN.some((r) => roles.includes(r))) return true;
|
||||
if (permissions.includes(`${menuCode}:${action}`)) return true;
|
||||
const hasActions = permissions.some((p) => {
|
||||
const tail = p.slice(menuCode.length + 1);
|
||||
return p.startsWith(`${menuCode}:`) && ACTIONS.has(tail);
|
||||
});
|
||||
if (hasActions) return false;
|
||||
return permissions.includes(menuCode) || permissions.some((p) => p.startsWith(`${menuCode}:`));
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
import axios from 'axios';
|
||||
|
||||
function requestId() {
|
||||
const c = globalThis.crypto as Crypto | undefined;
|
||||
if (c && typeof c.randomUUID === 'function') {
|
||||
return c.randomUUID();
|
||||
}
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (ch) => {
|
||||
const n = (Math.random() * 16) | 0;
|
||||
const v = ch === 'x' ? n : (n & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
const mem = { access: '', refresh: '', kind: 'web' };
|
||||
const tabCh = typeof BroadcastChannel !== 'undefined' ? new BroadcastChannel('fy-web-session') : null;
|
||||
|
||||
function persistKind(kind?: string | null) {
|
||||
const v = kind === 'desktop' || kind === 'mobile' ? kind : 'web';
|
||||
mem.kind = v;
|
||||
sessionStorage.setItem('clientKind', v);
|
||||
}
|
||||
|
||||
function applyTokens(access?: string | null, refresh?: string | null, kind?: string | null, broadcast = true) {
|
||||
if (access) {
|
||||
mem.access = access;
|
||||
sessionStorage.setItem('accessToken', access);
|
||||
}
|
||||
if (refresh) {
|
||||
mem.refresh = refresh;
|
||||
sessionStorage.setItem('refreshToken', refresh);
|
||||
}
|
||||
if (kind) persistKind(kind);
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
if (broadcast) {
|
||||
tabCh?.postMessage({ type: 'set', access: mem.access, refresh: mem.refresh, kind: mem.kind });
|
||||
}
|
||||
}
|
||||
|
||||
tabCh?.addEventListener('message', (e: MessageEvent) => {
|
||||
const data = e.data as { type?: string; access?: string; refresh?: string; kind?: string } | undefined;
|
||||
if (data?.type === 'ask' && mem.access) {
|
||||
tabCh.postMessage({ type: 'set', access: mem.access, refresh: mem.refresh, kind: mem.kind });
|
||||
return;
|
||||
}
|
||||
if (data?.type === 'set' && data.access) {
|
||||
applyTokens(data.access, data.refresh, data.kind, false);
|
||||
return;
|
||||
}
|
||||
if (data?.type === 'clear') {
|
||||
mem.access = '';
|
||||
mem.refresh = '';
|
||||
sessionStorage.removeItem('accessToken');
|
||||
sessionStorage.removeItem('refreshToken');
|
||||
sessionStorage.removeItem('clientKind');
|
||||
}
|
||||
});
|
||||
|
||||
export const tokenStore = {
|
||||
getAccess() {
|
||||
return mem.access || sessionStorage.getItem('accessToken') || '';
|
||||
},
|
||||
getRefresh() {
|
||||
return mem.refresh || sessionStorage.getItem('refreshToken') || '';
|
||||
},
|
||||
getKind() {
|
||||
return mem.kind || sessionStorage.getItem('clientKind') || 'web';
|
||||
},
|
||||
set(access?: string | null, refresh?: string | null, kind?: string | null) {
|
||||
applyTokens(access, refresh, kind);
|
||||
},
|
||||
clear() {
|
||||
mem.access = '';
|
||||
mem.refresh = '';
|
||||
mem.kind = 'web';
|
||||
sessionStorage.removeItem('accessToken');
|
||||
sessionStorage.removeItem('refreshToken');
|
||||
sessionStorage.removeItem('clientKind');
|
||||
localStorage.removeItem('accessToken');
|
||||
localStorage.removeItem('refreshToken');
|
||||
tabCh?.postMessage({ type: 'clear' });
|
||||
},
|
||||
/** 同浏览器多标签共用会话;关浏览器后 sessionStorage 清空,需重新登录。 */
|
||||
waitShare() {
|
||||
if (this.getAccess() || !tabCh) return Promise.resolve();
|
||||
return new Promise<void>((resolve) => {
|
||||
const timer = window.setTimeout(() => {
|
||||
tabCh.removeEventListener('message', onMsg);
|
||||
resolve();
|
||||
}, 160);
|
||||
const onMsg = (e: MessageEvent) => {
|
||||
if (e.data?.type === 'set' && e.data.access) {
|
||||
window.clearTimeout(timer);
|
||||
tabCh.removeEventListener('message', onMsg);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
tabCh.addEventListener('message', onMsg);
|
||||
tabCh.postMessage({ type: 'ask' });
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
let refreshing: Promise<boolean> | null = null;
|
||||
|
||||
export const api = axios.create({
|
||||
baseURL: '/api/v1',
|
||||
timeout: 30000,
|
||||
});
|
||||
|
||||
api.interceptors.request.use((config) => {
|
||||
const token = tokenStore.getAccess();
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
config.headers['X-Request-Id'] = requestId();
|
||||
return config;
|
||||
});
|
||||
|
||||
api.interceptors.response.use(
|
||||
(res) => res.data,
|
||||
async (error) => {
|
||||
const original = error.config;
|
||||
if (error.response?.status === 401 && !original._retry) {
|
||||
original._retry = true;
|
||||
const refreshToken = tokenStore.getRefresh();
|
||||
if (refreshToken && !original.url?.includes('/auth/refresh')) {
|
||||
try {
|
||||
if (!refreshing) {
|
||||
refreshing = api
|
||||
.post('/auth/refresh', { refreshToken, clientKind: tokenStore.getKind() || 'web' })
|
||||
.then((data: { data?: { accessToken?: string; refreshToken?: string } }) => {
|
||||
const body = (data?.data || data) as { accessToken?: string; refreshToken?: string };
|
||||
tokenStore.set(body.accessToken, body.refreshToken, tokenStore.getKind());
|
||||
return Boolean(body.accessToken);
|
||||
})
|
||||
.catch(() => false)
|
||||
.finally(() => {
|
||||
refreshing = null;
|
||||
});
|
||||
}
|
||||
const ok = await refreshing;
|
||||
if (ok) {
|
||||
original.headers.Authorization = `Bearer ${tokenStore.getAccess()}`;
|
||||
return api(original);
|
||||
}
|
||||
} catch {
|
||||
/* fallthrough */
|
||||
}
|
||||
tokenStore.clear();
|
||||
window.location.href = '/login';
|
||||
} else {
|
||||
tokenStore.clear();
|
||||
if (window.location.pathname !== '/login' && !window.location.pathname.startsWith('/login/')) {
|
||||
window.location.href = '/login';
|
||||
}
|
||||
}
|
||||
}
|
||||
const message = error.response?.data?.message || error.message || '请求失败';
|
||||
return Promise.reject(new Error(message));
|
||||
},
|
||||
);
|
||||
|
||||
export type ApiOk<T> = { code: number; message: string; data: T; requestId: string };
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 4.0 KiB |
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
export default function AmbientBackground() {
|
||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
let frame = 0;
|
||||
let width = (canvas.width = window.innerWidth);
|
||||
let height = (canvas.height = window.innerHeight);
|
||||
const onResize = () => {
|
||||
width = canvas.width = window.innerWidth;
|
||||
height = canvas.height = window.innerHeight;
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
|
||||
const particles = Array.from({ length: 32 }, () => ({
|
||||
x: Math.random() * width,
|
||||
y: Math.random() * height,
|
||||
size: Math.random() * 3 + 1.2,
|
||||
speedY: -(Math.random() * 0.4 + 0.15),
|
||||
speedX: (Math.random() - 0.5) * 0.2,
|
||||
opacity: Math.random() * 0.5 + 0.2,
|
||||
pulse: Math.random() * Math.PI,
|
||||
pulseSpeed: Math.random() * 0.03 + 0.01,
|
||||
}));
|
||||
|
||||
const render = () => {
|
||||
ctx.clearRect(0, 0, width, height);
|
||||
for (const p of particles) {
|
||||
p.y += p.speedY;
|
||||
p.x += p.speedX;
|
||||
p.pulse += p.pulseSpeed;
|
||||
if (p.y < -10) {
|
||||
p.y = height + 10;
|
||||
p.x = Math.random() * width;
|
||||
}
|
||||
if (p.x < -10) p.x = width + 10;
|
||||
if (p.x > width + 10) p.x = -10;
|
||||
const a = p.opacity * (0.7 + 0.3 * Math.sin(p.pulse));
|
||||
ctx.beginPath();
|
||||
ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2);
|
||||
ctx.fillStyle = `rgba(107, 56, 212, ${a * 0.45})`;
|
||||
ctx.fill();
|
||||
}
|
||||
frame = requestAnimationFrame(render);
|
||||
};
|
||||
render();
|
||||
return () => {
|
||||
window.removeEventListener('resize', onResize);
|
||||
cancelAnimationFrame(frame);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="yzl-ambient" aria-hidden>
|
||||
<div className="yzl-orb yzl-orb-a" />
|
||||
<div className="yzl-orb yzl-orb-b" />
|
||||
<div className="yzl-orb yzl-orb-c" />
|
||||
<canvas ref={canvasRef} className="yzl-particles" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export function BrandLogo({ size = 36, className }: { size?: number; className?: string }) {
|
||||
const id = `fy-${size}`;
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 80 80"
|
||||
fill="none"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
aria-hidden
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={`${id}-bg`} x1="10" y1="0" x2="70" y2="80" gradientUnits="userSpaceOnUse">
|
||||
<stop stopColor="#5b4fe3" />
|
||||
<stop offset="1" stopColor="#7c3aed" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<rect x="1" y="1" width="78" height="78" rx="20" fill={`url(#${id}-bg)`} />
|
||||
<path
|
||||
d="M22 20h28c5 0 9 4 9 9v6"
|
||||
stroke="#fff"
|
||||
strokeWidth="6.5"
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>
|
||||
<path d="M22 20v40" stroke="#fff" strokeWidth="6.5" strokeLinecap="round" />
|
||||
<path d="M28 34h22" stroke="#fff" strokeWidth="5.5" strokeLinecap="round" />
|
||||
<path d="M34 26l16 28" stroke="#fff" strokeWidth="5.5" strokeLinecap="round" />
|
||||
<path d="M50 26L34 54" stroke="#fff" strokeWidth="5.5" strokeLinecap="round" />
|
||||
<path
|
||||
d="M20 58c14 10 32 10 44-6"
|
||||
stroke="#fff"
|
||||
strokeWidth="5"
|
||||
strokeLinecap="round"
|
||||
fill="none"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Button, Popover, Typography } from 'antd';
|
||||
import { DownloadOutlined, LaptopOutlined } from '@ant-design/icons';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../api/client';
|
||||
|
||||
export function DesktopDownload() {
|
||||
const q = useQuery({
|
||||
queryKey: ['desktop-release-public'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/system/app-release');
|
||||
return ((res as { data?: { windowsUrl?: string; version?: string } }).data ?? res) as {
|
||||
windowsUrl?: string;
|
||||
version?: string;
|
||||
};
|
||||
},
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const url = q.data?.windowsUrl || '';
|
||||
const version = q.data?.version || '';
|
||||
const content = url ? (
|
||||
<div className="client-dl-card">
|
||||
<Typography.Text type="secondary">Windows 桌面端{version ? ` v${version}` : ''}</Typography.Text>
|
||||
<Button type="primary" icon={<DownloadOutlined />} href={url} download className="client-dl-btn">
|
||||
下载安装包
|
||||
</Button>
|
||||
<Typography.Text type="secondary" className="client-dl-hint">
|
||||
下载后运行 .exe 安装
|
||||
</Typography.Text>
|
||||
</div>
|
||||
) : (
|
||||
<div className="client-dl-card">
|
||||
<Typography.Text type="secondary">Windows 桌面版暂未发布</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Popover placement="bottomRight" trigger={['hover', 'click']} content={content} overlayClassName="mobile-dl-pop">
|
||||
<Button type="text" icon={<LaptopOutlined />}>
|
||||
Windows端
|
||||
</Button>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Button, Popover, Typography } from 'antd';
|
||||
import { CodeOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../api/client';
|
||||
|
||||
export function LinuxDownload() {
|
||||
const q = useQuery({
|
||||
queryKey: ['linux-release-public'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/system/app-release');
|
||||
return ((res as { data?: { linuxDebUrl?: string; linuxUrl?: string; version?: string } }).data ?? res) as {
|
||||
linuxDebUrl?: string;
|
||||
linuxUrl?: string;
|
||||
version?: string;
|
||||
};
|
||||
},
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const debUrl = q.data?.linuxDebUrl || '';
|
||||
const tarUrl = q.data?.linuxUrl || '';
|
||||
const version = q.data?.version || '';
|
||||
const content = debUrl || tarUrl ? (
|
||||
<div className="client-dl-card">
|
||||
<Typography.Text type="secondary">Linux 桌面端{version ? ` v${version}` : ''}</Typography.Text>
|
||||
{debUrl ? (
|
||||
<Button type="primary" icon={<DownloadOutlined />} href={debUrl} download className="client-dl-btn">
|
||||
下载 deb 安装包
|
||||
</Button>
|
||||
) : null}
|
||||
{tarUrl ? (
|
||||
<Button type={debUrl ? 'default' : 'primary'} icon={<DownloadOutlined />} href={tarUrl} download className="client-dl-btn">
|
||||
下载 tar.gz
|
||||
</Button>
|
||||
) : null}
|
||||
<Typography.Text type="secondary" className="client-dl-hint">
|
||||
{debUrl
|
||||
? 'deb 适用于 Ubuntu / Debian / Deepin 25:sudo dpkg -i fengying-oa_amd64.deb'
|
||||
: '解压 tar.gz 后运行 fengying_oa'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
) : (
|
||||
<div className="client-dl-card">
|
||||
<Typography.Text type="secondary">Linux 桌面版暂未发布</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Popover placement="bottomRight" trigger={['hover', 'click']} content={content} overlayClassName="mobile-dl-pop">
|
||||
<Button type="text" icon={<CodeOutlined />}>
|
||||
Linux端
|
||||
</Button>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { Button, Popover, QRCode, Typography } from 'antd';
|
||||
import type { TooltipPlacement } from 'antd/es/tooltip';
|
||||
import { DownloadOutlined, MobileOutlined } from '@ant-design/icons';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../api/client';
|
||||
|
||||
function fallbackApk() {
|
||||
if (typeof window === 'undefined') return 'https://oa.fysxkj.com/fengying-oa.apk';
|
||||
return `${window.location.origin}/fengying-oa.apk`;
|
||||
}
|
||||
|
||||
export function MobileDownload({
|
||||
compact = false,
|
||||
placement = 'bottomRight',
|
||||
}: {
|
||||
compact?: boolean;
|
||||
placement?: TooltipPlacement;
|
||||
}) {
|
||||
const q = useQuery({
|
||||
queryKey: ['app-release-public'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/system/app-release');
|
||||
return ((res as { data?: { url?: string; version?: string } }).data ?? res) as {
|
||||
url?: string;
|
||||
version?: string;
|
||||
};
|
||||
},
|
||||
staleTime: 60_000,
|
||||
});
|
||||
const url = q.data?.url || fallbackApk();
|
||||
const version = q.data?.version || '';
|
||||
const card = (
|
||||
<div className="mobile-dl-card">
|
||||
<QRCode value={url} size={148} bordered={false} />
|
||||
<Typography.Text type="secondary">手机扫码安装 Android 版{version ? ` v${version}` : ''}</Typography.Text>
|
||||
<Button type="link" icon={<DownloadOutlined />} href={url} download="fengying-oa.apk" className="mobile-dl-link-btn">
|
||||
下载 APK
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
return (
|
||||
<Popover content={card} trigger={['hover', 'click']} placement={placement} overlayClassName="mobile-dl-pop">
|
||||
<Button type="text" icon={<MobileOutlined />} className={compact ? 'mobile-dl-btn' : undefined}>
|
||||
安卓端
|
||||
</Button>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
const ICP = '苏ICP备2026017371号-2';
|
||||
const COMPANY = '江苏风影随行科技有限公司';
|
||||
const PHONE = '13142801138';
|
||||
|
||||
export function SiteFooter({ compact = false }: { compact?: boolean }) {
|
||||
return (
|
||||
<footer className={compact ? 'site-beian site-beian-compact' : 'site-beian'}>
|
||||
<span>{COMPANY}</span>
|
||||
<span className="site-beian-sep">|</span>
|
||||
<a href="https://beian.miit.gov.cn/" target="_blank" rel="noreferrer">
|
||||
{ICP}
|
||||
</a>
|
||||
<span className="site-beian-sep">|</span>
|
||||
<a href={`tel:${PHONE}`}>联系电话 {PHONE}</a>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
export type DeptNode = {
|
||||
id: string;
|
||||
name: string;
|
||||
code?: string | null;
|
||||
parentId?: string | null;
|
||||
};
|
||||
|
||||
function companyRootId(depts: DeptNode[]) {
|
||||
const roots = depts.filter((d) => !d.parentId);
|
||||
const named = roots.find((d) => d.name?.includes('公司') || d.code === 'ROOT' || d.code === 'OA_1');
|
||||
return named?.id || roots[0]?.id;
|
||||
}
|
||||
|
||||
export function isTopLevelDept(depts: DeptNode[], dept: DeptNode) {
|
||||
const rootId = companyRootId(depts);
|
||||
if (!rootId) return !dept.parentId;
|
||||
if (dept.id === rootId) return false;
|
||||
return dept.parentId === rootId || !dept.parentId;
|
||||
}
|
||||
|
||||
export function topLevelDepartments(depts: DeptNode[]) {
|
||||
return depts.filter((d) => isTopLevelDept(depts, d)).sort((a, b) => a.name.localeCompare(b.name, 'zh'));
|
||||
}
|
||||
|
||||
export function topLevelOf(depts: DeptNode[], deptId?: string | null): DeptNode | undefined {
|
||||
if (!deptId) return undefined;
|
||||
const byId = new Map(depts.map((d) => [d.id, d]));
|
||||
const rootId = companyRootId(depts);
|
||||
let cur = byId.get(deptId);
|
||||
const seen = new Set<string>();
|
||||
while (cur && !seen.has(cur.id)) {
|
||||
seen.add(cur.id);
|
||||
if (isTopLevelDept(depts, cur)) return cur;
|
||||
if (!cur.parentId || cur.parentId === rootId) return cur.id === rootId ? undefined : cur;
|
||||
cur = byId.get(cur.parentId);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function topLevelNameOf(depts: DeptNode[], deptId?: string | null, fallback?: string | null) {
|
||||
return topLevelOf(depts, deptId)?.name || fallback || '';
|
||||
}
|
||||
|
||||
export function topLevelNameByLeafName(depts: DeptNode[], name?: string | null) {
|
||||
if (!name) return '';
|
||||
const leaf = depts.find((d) => d.name === name);
|
||||
if (!leaf) return name;
|
||||
return topLevelOf(depts, leaf.id)?.name || name;
|
||||
}
|
||||
|
||||
export function descendantIds(depts: DeptNode[], root?: string) {
|
||||
if (!root) return null;
|
||||
const kids = new Map<string, string[]>();
|
||||
for (const d of depts) {
|
||||
if (!d.parentId) continue;
|
||||
const arr = kids.get(d.parentId) || [];
|
||||
arr.push(d.id);
|
||||
kids.set(d.parentId, arr);
|
||||
}
|
||||
const out = new Set<string>([root]);
|
||||
const stack = [root];
|
||||
while (stack.length) {
|
||||
const id = stack.pop()!;
|
||||
for (const c of kids.get(id) || []) {
|
||||
if (!out.has(c)) {
|
||||
out.add(c);
|
||||
stack.push(c);
|
||||
}
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function topLevelSelectOptions(depts: DeptNode[]) {
|
||||
return topLevelDepartments(depts).map((d) => ({ value: d.id, label: d.name }));
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/** 浏览器/卫星 GPS 是 WGS-84,腾讯地图是 GCJ-02。国内不转换会偏约 300–600 米。 */
|
||||
const A = 6378245.0;
|
||||
const EE = 0.00669342162296594323;
|
||||
const PI = Math.PI;
|
||||
|
||||
function outOfChina(lat: number, lng: number) {
|
||||
return lng < 72.004 || lng > 137.8347 || lat < 0.8293 || lat > 55.8271;
|
||||
}
|
||||
|
||||
function transformLat(x: number, y: number) {
|
||||
let ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x));
|
||||
ret += ((20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0) / 3.0;
|
||||
ret += ((20.0 * Math.sin(y * PI) + 40.0 * Math.sin((y / 3.0) * PI)) * 2.0) / 3.0;
|
||||
ret += ((160.0 * Math.sin((y / 12.0) * PI) + 320 * Math.sin((y * PI) / 30.0)) * 2.0) / 3.0;
|
||||
return ret;
|
||||
}
|
||||
|
||||
function transformLng(x: number, y: number) {
|
||||
let ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x));
|
||||
ret += ((20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0) / 3.0;
|
||||
ret += ((20.0 * Math.sin(x * PI) + 40.0 * Math.sin((x / 3.0) * PI)) * 2.0) / 3.0;
|
||||
ret += ((150.0 * Math.sin((x / 12.0) * PI) + 300.0 * Math.sin((x / 30.0) * PI)) * 2.0) / 3.0;
|
||||
return ret;
|
||||
}
|
||||
|
||||
export function wgs84ToGcj02(lat: number, lng: number) {
|
||||
if (outOfChina(lat, lng)) return { lat, lng };
|
||||
let dLat = transformLat(lng - 105.0, lat - 35.0);
|
||||
let dLng = transformLng(lng - 105.0, lat - 35.0);
|
||||
const radLat = (lat / 180.0) * PI;
|
||||
let magic = 1 - EE * Math.sin(radLat) * Math.sin(radLat);
|
||||
const sqrtMagic = Math.sqrt(magic);
|
||||
dLat = (dLat * 180.0) / (((A * (1 - EE)) / (magic * sqrtMagic)) * PI);
|
||||
dLng = (dLng * 180.0) / ((A / sqrtMagic) * Math.cos(radLat) * PI);
|
||||
return { lat: lat + dLat, lng: lng + dLng };
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
/** 后端枚举 → 中文。未知码才回落到原文,避免表格直接露出英文。 */
|
||||
export const ZH: Record<string, string> = {
|
||||
TECH_INITIAL: '技术初审',
|
||||
FETCH: '取标',
|
||||
TECH_FINAL: '技术终审',
|
||||
DRAFTING: '制标',
|
||||
REVIEW1: '商务一审',
|
||||
REVIEW2: '商务二审',
|
||||
PRINT: '印封',
|
||||
LIST: '投标执行',
|
||||
PENDING: '待办理',
|
||||
WON: '中标',
|
||||
LOST: '未中标',
|
||||
FAILED: '流标废标',
|
||||
TERMINATED: '已终止',
|
||||
DRAFT: '草稿',
|
||||
APPROVED: '已通过',
|
||||
REJECTED: '已驳回',
|
||||
CANCELLED: '已取消',
|
||||
ACTIVE: '启用',
|
||||
DISABLED: '停用',
|
||||
OPEN: '未还 / 进行中',
|
||||
OFFSET: '已冲账',
|
||||
DONE: '已完成',
|
||||
RETURNED: '已交回',
|
||||
PAID: '已付',
|
||||
UNPAID: '待付',
|
||||
TO_ISSUE: '待开',
|
||||
ISSUED: '已开',
|
||||
RECEIVED: '已回',
|
||||
OVERDUE: '逾期',
|
||||
INTERNAL_ACCEPTED: '内部已验收',
|
||||
ACCEPTED: '已验收',
|
||||
ALL: '全部',
|
||||
DEPT: '本部门',
|
||||
SELF: '本人',
|
||||
BID: '投标',
|
||||
PROJECT: '正式项目',
|
||||
ADMIN: '行政',
|
||||
MARKETING: '营销',
|
||||
LABOR: '人工',
|
||||
TRAVEL: '差旅',
|
||||
PURCHASE: '采购',
|
||||
TENDER: '投标保证金',
|
||||
PERFORMANCE: '履约保证金',
|
||||
WARRANTY: '项目质保金',
|
||||
EXPENSE: '报销打款',
|
||||
BOND: '保证金打出',
|
||||
INTERN: '实习',
|
||||
PROBATION: '试用',
|
||||
REGULAR: '正式',
|
||||
LEFT: '离职',
|
||||
SINGLE: '单休',
|
||||
DOUBLE: '双休',
|
||||
MALE: '男',
|
||||
FEMALE: '女',
|
||||
HIGH_SCHOOL: '高中及以下',
|
||||
COLLEGE: '专科',
|
||||
BACHELOR: '本科',
|
||||
MASTER: '硕士',
|
||||
DOCTOR: '博士',
|
||||
HIRE: '入职',
|
||||
REGULARIZE: '转正',
|
||||
TRANSFER: '调岗',
|
||||
LEAVE: '离职',
|
||||
CREATE: '发起',
|
||||
SUBMIT: '提交',
|
||||
APPROVE: '同意',
|
||||
REJECT: '驳回',
|
||||
DRAFTER: '制标人',
|
||||
FETCHER: '取标人',
|
||||
COORDINATOR: '特殊协调人',
|
||||
BIDDER: '投标人',
|
||||
SPECIAL: '特殊筛选',
|
||||
FLOW: '普通筛选',
|
||||
BID_TECH_INITIAL: '技术初审',
|
||||
BID_TECH_FINAL: '技术终审',
|
||||
BID_FETCH: '取标',
|
||||
BID_ASSIGN_FETCH: '派取标人',
|
||||
BID_ASSIGN_DRAFT: '派制标人',
|
||||
BID_FINAL_PASSED: '终审通过通知',
|
||||
BID_DRAFT_DONE: '制标完成通知',
|
||||
BID_TERMINATED: '投标终止通知',
|
||||
BID_DRAFT: '制标',
|
||||
BID_REVIEW1: '商务一审',
|
||||
BID_REVIEW2: '商务二审',
|
||||
BID_PRINT: '印封',
|
||||
BID_COMPLETE: '补全投标',
|
||||
BID_EXECUTION: '投标执行',
|
||||
BID_ASSIGN_FETCHER: '派取标人',
|
||||
CONFIRMED: '已确认',
|
||||
INTERNAL: '内部验收',
|
||||
CUSTOMER: '客户验收',
|
||||
CONTRACT_APPROVAL: '合同审批',
|
||||
CONTRACT_DRAFT: '完善合同草稿',
|
||||
EXPENSE_APPROVAL: '报销确认',
|
||||
LOAN_APPROVAL: '借款确认',
|
||||
LOAN_OFFSET: '借款冲账',
|
||||
PROJECT_TIMESHEET: '工时填报',
|
||||
PROJECT_ACCEPT: '项目验收',
|
||||
SEAL_APPROVAL: '用章审批',
|
||||
SEAL_RETURN: '印章归还',
|
||||
CREDENTIAL_RETURN: '证照归还',
|
||||
BID_OPEN: '开标反馈',
|
||||
WORK_ASSIGN: '工作安排',
|
||||
OFFICE_APPLY: '办公申请',
|
||||
HR_LEAVE: '人事申请',
|
||||
SEAL_REQUEST: '用章申请',
|
||||
WORK_REPORT: '工作汇报',
|
||||
NOTICE: '通知公告',
|
||||
ASSET_BUY: '资产购置',
|
||||
ASSET_USE: '资产领用',
|
||||
VEHICLE: '用车',
|
||||
DIGITAL: '数字资产',
|
||||
FIXED: '固定资产',
|
||||
IN_USE: '在用',
|
||||
IN_STOCK: '在库',
|
||||
OUT_STOCK: '已出库',
|
||||
TAKEN_OUT: '外带',
|
||||
ID_CARD: '身份证',
|
||||
PASSPORT: '护照',
|
||||
GENERAL: '通用',
|
||||
TODO: '待付款',
|
||||
TASK: '任务截止',
|
||||
BORROW: '证照归还',
|
||||
SEAL: '印章归还',
|
||||
LOGIN: '登录',
|
||||
LOGIN_FAIL: '登录失败',
|
||||
APPROVAL: '审批',
|
||||
FORBIDDEN: '越权',
|
||||
ACL: '权限',
|
||||
SENT: '已提交',
|
||||
UPDATE: '修改',
|
||||
DELETE: '删除',
|
||||
OK: '成功',
|
||||
FAIL: '失败',
|
||||
BUSINESS: '商务',
|
||||
TECH: '技术',
|
||||
HR: '人事',
|
||||
FINANCE: '财务',
|
||||
meal: '餐饮',
|
||||
meals: '餐饮',
|
||||
food: '餐饮',
|
||||
traffic: '交通',
|
||||
transport: '交通',
|
||||
transportation: '交通',
|
||||
hotel: '住宿',
|
||||
office: '办公',
|
||||
entertain: '招待',
|
||||
entertainment: '招待',
|
||||
OVERTIME: '加班',
|
||||
MAKEUP: '补卡',
|
||||
PROMOTE: '晋升',
|
||||
RESIGN: '离职',
|
||||
PENDING_IN: '待入库',
|
||||
OCCUPIED: '占用中',
|
||||
LENT: '外借',
|
||||
BORROWED: '已外借',
|
||||
IDLE: '闲置',
|
||||
SCRAPPED: '已报废',
|
||||
AVAILABLE: '可用',
|
||||
MAINTAIN: '维修中',
|
||||
PUBLISHED: '已发布',
|
||||
ANNOUNCE: '公告',
|
||||
POLICY: '制度',
|
||||
};
|
||||
|
||||
/** 同一枚举码在不同业务里中文不同,按字段上下文取。 */
|
||||
const ZH_CTX: Record<string, Record<string, string>> = {
|
||||
OUT: { seal: '外带', hr: '外出', punch: '下班' },
|
||||
};
|
||||
|
||||
export function zhCtx(code?: string | number | null, context?: string) {
|
||||
if (code === undefined || code === null || code === '') return '—';
|
||||
const key = String(code);
|
||||
if (context && ZH_CTX[key]?.[context]) return ZH_CTX[key][context];
|
||||
return ZH[key] || key;
|
||||
}
|
||||
|
||||
export const ZH_COLOR: Record<string, string> = {
|
||||
TECH_INITIAL: 'processing',
|
||||
FETCH: 'processing',
|
||||
TECH_FINAL: 'processing',
|
||||
DRAFTING: 'gold',
|
||||
REVIEW1: 'gold',
|
||||
REVIEW2: 'gold',
|
||||
PRINT: 'gold',
|
||||
LIST: 'blue',
|
||||
PENDING: 'processing',
|
||||
WON: 'success',
|
||||
LOST: 'default',
|
||||
FAILED: 'default',
|
||||
TERMINATED: 'error',
|
||||
DRAFT: 'default',
|
||||
APPROVED: 'success',
|
||||
REJECTED: 'error',
|
||||
CANCELLED: 'default',
|
||||
ACTIVE: 'success',
|
||||
DISABLED: 'default',
|
||||
OPEN: 'warning',
|
||||
OFFSET: 'success',
|
||||
DONE: 'success',
|
||||
RETURNED: 'success',
|
||||
PAID: 'success',
|
||||
UNPAID: 'default',
|
||||
TO_ISSUE: 'default',
|
||||
ISSUED: 'processing',
|
||||
RECEIVED: 'success',
|
||||
OVERDUE: 'error',
|
||||
INTERNAL_ACCEPTED: 'gold',
|
||||
ACCEPTED: 'success',
|
||||
REGULAR: 'success',
|
||||
PROBATION: 'gold',
|
||||
INTERN: 'blue',
|
||||
LEFT: 'default',
|
||||
OK: 'success',
|
||||
FAIL: 'error',
|
||||
};
|
||||
|
||||
export function zh(code?: string | number | null) {
|
||||
if (code === undefined || code === null || code === '') return '—';
|
||||
const key = String(code);
|
||||
return ZH[key] || key;
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Layout, Menu, Dropdown, Button, Badge } from 'antd';
|
||||
import {
|
||||
HomeOutlined,
|
||||
AppstoreOutlined,
|
||||
ClusterOutlined,
|
||||
SafetyCertificateOutlined,
|
||||
AuditOutlined,
|
||||
FileProtectOutlined,
|
||||
ProjectOutlined,
|
||||
AccountBookOutlined,
|
||||
TeamOutlined,
|
||||
BarChartOutlined,
|
||||
SettingOutlined,
|
||||
DatabaseOutlined,
|
||||
LogoutOutlined,
|
||||
EditOutlined,
|
||||
LockOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { Link, Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import type { MenuProps } from 'antd';
|
||||
import { useAuth, type MenuItem } from '../store/auth';
|
||||
import AmbientBackground from '../components/AmbientBackground';
|
||||
import { BrandLogo } from '../components/BrandLogo';
|
||||
import { MobileDownload } from '../components/MobileDownload';
|
||||
import { DesktopDownload } from '../components/DesktopDownload';
|
||||
import { LinuxDownload } from '../components/LinuxDownload';
|
||||
import { SiteFooter } from '../components/SiteFooter';
|
||||
import { ProfileDrawer, UserAvatar } from '../pages/ProfileDrawer';
|
||||
|
||||
const { Sider, Content } = Layout;
|
||||
|
||||
const ICONS: Record<string, ReactNode> = {
|
||||
HomeOutlined: <HomeOutlined />,
|
||||
AppstoreOutlined: <AppstoreOutlined />,
|
||||
ClusterOutlined: <ClusterOutlined />,
|
||||
SafetyCertificateOutlined: <SafetyCertificateOutlined />,
|
||||
AuditOutlined: <AuditOutlined />,
|
||||
FileProtectOutlined: <FileProtectOutlined />,
|
||||
ProjectOutlined: <ProjectOutlined />,
|
||||
AccountBookOutlined: <AccountBookOutlined />,
|
||||
TeamOutlined: <TeamOutlined />,
|
||||
BarChartOutlined: <BarChartOutlined />,
|
||||
DatabaseOutlined: <DatabaseOutlined />,
|
||||
SettingOutlined: <SettingOutlined />,
|
||||
};
|
||||
|
||||
function bare(path?: string | null) {
|
||||
return (path || '').split('?')[0];
|
||||
}
|
||||
|
||||
function toItems(menus: MenuItem[]): MenuProps['items'] {
|
||||
const walk = (m: MenuItem): NonNullable<MenuProps['items']>[number] => ({
|
||||
key: m.path || m.code,
|
||||
icon: m.icon ? ICONS[m.icon] : undefined,
|
||||
label: m.children?.length ? m.name : <Link to={m.path || '/'}>{m.name}</Link>,
|
||||
children: m.children?.length ? m.children.map(walk) : undefined,
|
||||
});
|
||||
return menus.map(walk);
|
||||
}
|
||||
|
||||
function collectOpenKeys(menus: MenuItem[], pathname: string): string[] {
|
||||
const keys: string[] = [];
|
||||
const walk = (items: MenuItem[], ancestors: string[]) => {
|
||||
for (const m of items) {
|
||||
const key = m.path || m.code;
|
||||
const next = [...ancestors, key];
|
||||
const p = bare(m.path);
|
||||
const hit = Boolean(p && (pathname === p || pathname.startsWith(`${p}/`)));
|
||||
const childHit = Boolean(m.children?.length && pathInTree(m.children, pathname));
|
||||
if (hit || childHit) keys.push(...next);
|
||||
if (m.children?.length) walk(m.children, next);
|
||||
}
|
||||
};
|
||||
walk(menus, []);
|
||||
return [...new Set(keys)];
|
||||
}
|
||||
|
||||
function rootSubmenuKeys(menus: MenuItem[]): string[] {
|
||||
return menus.filter((m) => m.children?.length).map((m) => m.path || m.code);
|
||||
}
|
||||
|
||||
function accordionKeys(menus: MenuItem[], pathname: string, openKeys: string[]): string[] {
|
||||
const roots = new Set(rootSubmenuKeys(menus));
|
||||
const fromPath = collectOpenKeys(menus, pathname);
|
||||
const openRoot = openKeys.find((k) => roots.has(k)) || fromPath.find((k) => roots.has(k));
|
||||
const nested = [...openKeys, ...fromPath].filter((k) => !roots.has(k));
|
||||
return openRoot ? [...new Set([openRoot, ...nested])] : [...new Set(nested)];
|
||||
}
|
||||
|
||||
function pathInTree(items: MenuItem[], pathname: string): boolean {
|
||||
return items.some(
|
||||
(m) =>
|
||||
(m.path && (pathname === bare(m.path) || pathname.startsWith(`${bare(m.path)}/`))) ||
|
||||
(m.children?.length ? pathInTree(m.children, pathname) : false),
|
||||
);
|
||||
}
|
||||
|
||||
function selectedMenuKeys(menus: MenuItem[], pathname: string, search: string): string[] {
|
||||
const keys: string[] = [];
|
||||
const collect = (items: MenuItem[]) => {
|
||||
for (const m of items) {
|
||||
if (m.path) keys.push(m.path);
|
||||
if (m.children?.length) collect(m.children);
|
||||
}
|
||||
};
|
||||
collect(menus);
|
||||
const full = `${pathname}${search}`;
|
||||
if (keys.includes(full)) return [full];
|
||||
if (keys.includes(pathname)) return [pathname];
|
||||
let best = '';
|
||||
for (const k of keys) {
|
||||
if (k.includes('?')) continue;
|
||||
const p = bare(k);
|
||||
if (p && (pathname === p || pathname.startsWith(`${p}/`)) && p.length >= bare(best).length) best = k;
|
||||
}
|
||||
return best ? [best] : [full];
|
||||
}
|
||||
|
||||
export default function AppLayout() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const { displayName, menus, logout, idPhotoFileId } = useAuth();
|
||||
const [profileMode, setProfileMode] = useState<'profile' | 'password' | null>(null);
|
||||
const [openKeys, setOpenKeys] = useState<string[]>([]);
|
||||
const isWorkbench = location.pathname === '/workbench' || location.pathname === '/office/overview';
|
||||
const rootKeys = useMemo(() => rootSubmenuKeys(menus), [menus]);
|
||||
|
||||
useEffect(() => {
|
||||
setOpenKeys(accordionKeys(menus, location.pathname, []));
|
||||
}, [location.pathname, menus]);
|
||||
|
||||
const onOpenChange: MenuProps['onOpenChange'] = (keys) => {
|
||||
const latest = keys.find((k) => !openKeys.includes(k));
|
||||
if (latest && rootKeys.includes(latest)) {
|
||||
setOpenKeys([latest]);
|
||||
return;
|
||||
}
|
||||
const openRoot = keys.find((k) => rootKeys.includes(k));
|
||||
setOpenKeys(openRoot ? keys.filter((k) => k === openRoot || !rootKeys.includes(k)) : []);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="yzl-shell">
|
||||
<AmbientBackground />
|
||||
<header className="yzl-topbar">
|
||||
<Link to="/workbench" className="yzl-brand">
|
||||
<span className="yzl-logo">
|
||||
<BrandLogo size={36} />
|
||||
</span>
|
||||
<span className="yzl-brand-name">风影智慧办公综合平台</span>
|
||||
</Link>
|
||||
<div className="yzl-topbar-right">
|
||||
<MobileDownload compact />
|
||||
<DesktopDownload />
|
||||
<LinuxDownload />
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
{ key: 'profile', icon: <EditOutlined />, label: '修改个人资料', onClick: () => setProfileMode('profile') },
|
||||
{ key: 'password', icon: <LockOutlined />, label: '修改密码', onClick: () => setProfileMode('password') },
|
||||
{ type: 'divider' },
|
||||
{
|
||||
key: 'logout',
|
||||
icon: <LogoutOutlined />,
|
||||
label: '退出登录',
|
||||
onClick: async () => {
|
||||
await logout();
|
||||
navigate('/login');
|
||||
},
|
||||
},
|
||||
],
|
||||
}}
|
||||
>
|
||||
<Button type="text" style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
|
||||
<Badge dot={!idPhotoFileId} color="red">
|
||||
<UserAvatar size={28} />
|
||||
</Badge>
|
||||
{displayName}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</div>
|
||||
<ProfileDrawer open={Boolean(profileMode)} mode={profileMode || 'profile'} onClose={() => setProfileMode(null)} />
|
||||
</header>
|
||||
<div className="yzl-body">
|
||||
<Sider className="ops-sider" breakpoint="lg" collapsedWidth={64} width={220} theme="light">
|
||||
<div className="ops-brand">
|
||||
<strong>智能工作空间</strong>
|
||||
<span>内部经营</span>
|
||||
</div>
|
||||
<Menu
|
||||
theme="light"
|
||||
mode="inline"
|
||||
selectedKeys={selectedMenuKeys(menus, location.pathname, location.search)}
|
||||
openKeys={openKeys}
|
||||
onOpenChange={onOpenChange}
|
||||
items={toItems(menus)}
|
||||
style={{ borderInlineEnd: 0, flex: 1, overflow: 'auto' }}
|
||||
/>
|
||||
</Sider>
|
||||
<Layout style={{ background: 'transparent', flex: 1, minWidth: 0 }}>
|
||||
<Content className="ops-content">
|
||||
<div className={isWorkbench ? undefined : 'ops-sheet'}>
|
||||
<Outlet />
|
||||
</div>
|
||||
</Content>
|
||||
</Layout>
|
||||
</div>
|
||||
<SiteFooter compact />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { ConfigProvider, App as AntApp } from 'antd';
|
||||
import zhCN from 'antd/locale/zh_CN';
|
||||
import dayjs from 'dayjs';
|
||||
import 'dayjs/locale/zh-cn';
|
||||
import 'antd/dist/reset.css';
|
||||
import './index.css';
|
||||
import App from './App';
|
||||
|
||||
dayjs.locale('zh-cn');
|
||||
const client = new QueryClient();
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ConfigProvider
|
||||
locale={zhCN}
|
||||
theme={{
|
||||
token: {
|
||||
colorPrimary: '#6b38d4',
|
||||
colorInfo: '#6063ee',
|
||||
colorLink: '#6b38d4',
|
||||
colorBgLayout: 'transparent',
|
||||
colorBorder: '#ddd6fe',
|
||||
colorText: '#191c1e',
|
||||
borderRadius: 12,
|
||||
borderRadiusLG: 16,
|
||||
controlHeight: 36,
|
||||
fontFamily:
|
||||
"'Noto Sans SC', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif",
|
||||
},
|
||||
components: {
|
||||
Layout: { bodyBg: 'transparent', headerBg: 'transparent', siderBg: 'transparent' },
|
||||
Menu: { itemBg: 'transparent', subMenuItemBg: 'transparent', itemBorderRadius: 10, itemMarginInline: 8 },
|
||||
Table: { headerBg: 'rgba(248,250,252,0.8)', headerSplitColor: 'transparent' },
|
||||
Button: { primaryShadow: '0 4px 16px -4px rgba(132, 85, 239, 0.45)' },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<AntApp>
|
||||
<QueryClientProvider client={client}>
|
||||
<BrowserRouter>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</AntApp>
|
||||
</ConfigProvider>
|
||||
</StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,3 @@
|
||||
declare module 'mammoth/mammoth.browser' {
|
||||
export function convertToHtml(input: { arrayBuffer: ArrayBuffer }): Promise<{ value: string; messages: unknown[] }>;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import { Alert, Button, Form, Input, InputNumber, Switch, message } from 'antd';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '../api/client';
|
||||
import { PageHeader } from './ResourcePage';
|
||||
|
||||
type Release = {
|
||||
version?: string;
|
||||
build?: number;
|
||||
url?: string;
|
||||
windowsUrl?: string;
|
||||
linuxUrl?: string;
|
||||
linuxDebUrl?: string;
|
||||
changelog?: string;
|
||||
force?: boolean;
|
||||
publishedAt?: string;
|
||||
};
|
||||
|
||||
export default function AppReleasePage() {
|
||||
const [form] = Form.useForm<Release>();
|
||||
const qc = useQueryClient();
|
||||
const q = useQuery({
|
||||
queryKey: ['app-release'],
|
||||
queryFn: async () => {
|
||||
const res = await api.get('/system/app-release');
|
||||
const row = (res as { data?: Release }).data ?? res;
|
||||
return row as Release;
|
||||
},
|
||||
});
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (v: Release) => api.patch('/system/app-release', v),
|
||||
onSuccess: () => {
|
||||
message.success('已登记新版本,手机端下次打开会提示升级');
|
||||
qc.invalidateQueries({ queryKey: ['app-release'] });
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="手机端升级" description="登记 APK 版本后,员工打开「风影办公」会提示下载安装。build 必须大于手机里当前数字才会弹窗。" />
|
||||
{q.data ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message={`当前登记 ${q.data.version || '-'}(build ${q.data.build ?? '-'})${q.data.publishedAt ? ` · ${q.data.publishedAt}` : ''}`}
|
||||
/>
|
||||
) : null}
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
style={{ maxWidth: 520 }}
|
||||
initialValues={q.data}
|
||||
key={q.data?.publishedAt || 'empty'}
|
||||
onFinish={(v) => save.mutate(v)}
|
||||
>
|
||||
<Form.Item name="version" label="版本号" rules={[{ required: true }]}>
|
||||
<Input placeholder="1.1.0" />
|
||||
</Form.Item>
|
||||
<Form.Item name="build" label="Build" rules={[{ required: true }]} extra="整数,比上一版大即可">
|
||||
<InputNumber min={1} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="url" label="Android 下载地址" rules={[{ required: true }]}>
|
||||
<Input placeholder="https://oa.fysxkj.com/fengying-oa.apk" />
|
||||
</Form.Item>
|
||||
<Form.Item name="windowsUrl" label="Windows 下载地址">
|
||||
<Input placeholder="https://oa.fysxkj.com/fengying-oa-setup.exe" />
|
||||
</Form.Item>
|
||||
<Form.Item name="linuxUrl" label="Linux tar.gz(备用)">
|
||||
<Input placeholder="https://oa.fysxkj.com/fengying-oa-linux.tar.gz" />
|
||||
</Form.Item>
|
||||
<Form.Item name="linuxDebUrl" label="Linux deb(Ubuntu/Debian/Deepin)">
|
||||
<Input placeholder="https://oa.fysxkj.com/fengying-oa_amd64.deb" />
|
||||
</Form.Item>
|
||||
<Form.Item name="changelog" label="更新说明">
|
||||
<Input.TextArea rows={3} placeholder="这次改了什么" />
|
||||
</Form.Item>
|
||||
<Form.Item name="force" label="强制升级" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={save.isPending}>
|
||||
保存并发布
|
||||
</Button>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Segmented, Space } from 'antd';
|
||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||
|
||||
export type ApplyBucket = { value: string; label: string };
|
||||
|
||||
/** 申请类列表统一分段筛选。空字符串表示全部。会保留当前 URL 上的其它参数。 */
|
||||
export function ApplyBucketBar({
|
||||
basePath,
|
||||
buckets,
|
||||
extra,
|
||||
}: {
|
||||
basePath: string;
|
||||
buckets: ApplyBucket[];
|
||||
extra?: ReactNode;
|
||||
}) {
|
||||
const [params] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const current = params.get('bucket') || '';
|
||||
const path = basePath.split('?')[0];
|
||||
return (
|
||||
<Space wrap>
|
||||
<Segmented
|
||||
value={current}
|
||||
onChange={(v) => {
|
||||
const next = new URLSearchParams(params);
|
||||
const val = String(v);
|
||||
if (val) next.set('bucket', val);
|
||||
else next.delete('bucket');
|
||||
const s = next.toString();
|
||||
navigate(s ? `${path}?${s}` : path);
|
||||
}}
|
||||
options={[{ label: '全部', value: '' }, ...buckets]}
|
||||
/>
|
||||
{extra}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
export const PROCESS_BUCKETS: ApplyBucket[] = [
|
||||
{ value: 'pending', label: '待审批' },
|
||||
{ value: 'approved', label: '已通过' },
|
||||
{ value: 'rejected', label: '已驳回' },
|
||||
];
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Table, Tag } from 'antd';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api } from '../api/client';
|
||||
import { PageHeader } from './ResourcePage';
|
||||
import { money } from './financeStatus';
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
title: string;
|
||||
amount?: string | number | null;
|
||||
reason?: string | null;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
applicant?: { displayName?: string } | null;
|
||||
project?: { projectNo?: string; name?: string } | null;
|
||||
purchase?: { id: string; status: string } | null;
|
||||
inboundAsset?: { code?: string | null; name?: string; status?: string } | null;
|
||||
};
|
||||
|
||||
const STATUS: Record<string, { color: string; text: string }> = {
|
||||
PENDING: { color: 'processing', text: '待审批' },
|
||||
APPROVED: { color: 'success', text: '已通过' },
|
||||
REJECTED: { color: 'error', text: '已驳回' },
|
||||
};
|
||||
|
||||
export default function AssetPurchaseLedgerPage() {
|
||||
const q = useQuery({ queryKey: ['/asset-purchases'], queryFn: () => api.get('/asset-purchases') });
|
||||
const rows = (q.data?.data ?? []) as Row[];
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="资产购置台账"
|
||||
description="记录个人办公「资产购置」申请。审批通过后自动形成采购记录、付款待办和待入库资产。"
|
||||
/>
|
||||
<Table
|
||||
className="ops-table"
|
||||
rowKey="id"
|
||||
loading={q.isLoading}
|
||||
dataSource={rows}
|
||||
scroll={{ x: 'max-content' }}
|
||||
columns={[
|
||||
{ title: '购置内容', dataIndex: 'title', width: 230 },
|
||||
{ title: '金额', dataIndex: 'amount', width: 120, render: (v: string | number) => money(v) },
|
||||
{ title: '申请人', width: 120, render: (_: unknown, r: Row) => r.applicant?.displayName || '—' },
|
||||
{ title: '关联项目', width: 220, render: (_: unknown, r: Row) => r.project ? `${r.project.projectNo || ''} ${r.project.name || ''}`.trim() : '—' },
|
||||
{ title: '采购记录', width: 120, render: (_: unknown, r: Row) => r.purchase ? <Link to="/project/purchases">已入采购</Link> : '待审批' },
|
||||
{ title: '待入库资产', width: 180, render: (_: unknown, r: Row) => r.inboundAsset ? `${r.inboundAsset.code || '待编码'} ${r.inboundAsset.name || ''}`.trim() : '—' },
|
||||
{ title: '状态', dataIndex: 'status', width: 110, render: (v: string) => <Tag color={(STATUS[v] || {}).color}>{(STATUS[v] || {}).text || v}</Tag> },
|
||||
{ title: '申请时间', dataIndex: 'createdAt', width: 180, render: (v: string) => v ? String(v).replace('T', ' ').slice(0, 19) : '—' },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { Button, Checkbox, Form, Input, InputNumber, Popconfirm, Select, Space, Table, Tag, message } from 'antd';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../api/client';
|
||||
import { FormDrawer } from './FormDrawer';
|
||||
import { PageHeader, unwrapList } from './ResourcePage';
|
||||
import { money } from './financeStatus';
|
||||
import { ApplyBucketBar } from './ApplyBucketBar';
|
||||
|
||||
export function AssetRegistryPage({ kind, title }: { kind: 'DIGITAL' | 'FIXED'; title: string }) {
|
||||
const qc = useQueryClient();
|
||||
const [params] = useSearchParams();
|
||||
const bucket = params.get('bucket') || '';
|
||||
const basePath = kind === 'DIGITAL' ? '/asset/digital' : '/asset/fixed';
|
||||
const endpoint = `/registered-assets?kind=${kind}`;
|
||||
const q = useQuery({ queryKey: [endpoint], queryFn: () => api.get(endpoint) });
|
||||
const entities = useQuery({ queryKey: ['/legal-entities'], queryFn: () => api.get('/legal-entities') });
|
||||
const staff = useQuery({ queryKey: ['/staff'], queryFn: () => api.get('/staff') });
|
||||
const { items } = unwrapList(q.data);
|
||||
const [form] = Form.useForm();
|
||||
const [drawer, setDrawer] = useState<null | 'create' | { id: string }>(null);
|
||||
const entityOpts = ((entities.data as { data?: { id: string; name: string; shortName?: string }[] })?.data || []).map(
|
||||
(e) => ({ value: e.id, label: e.shortName || e.name }),
|
||||
);
|
||||
const staffOpts = ((staff.data as { data?: { name: string; department?: string }[] })?.data || []).map((s) => ({
|
||||
value: s.name,
|
||||
label: `${s.name}${s.department ? `(${s.department})` : ''}`,
|
||||
}));
|
||||
const filtered = useMemo(() => {
|
||||
return items.filter((r) => {
|
||||
if (bucket === 'pending_in') return r.status === 'PENDING_IN';
|
||||
if (bucket === 'occupying') return Boolean((r.occupancies as { holder: string }[] | undefined)?.length);
|
||||
if (bucket === 'vehicle') return r.usage === 'VEHICLE';
|
||||
return true;
|
||||
});
|
||||
}, [items, bucket]);
|
||||
const mut = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) => api.post('/registered-assets', { ...body, kind }),
|
||||
onSuccess: () => {
|
||||
message.success('已登记');
|
||||
qc.invalidateQueries({ queryKey: [endpoint] });
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={title}
|
||||
description={
|
||||
kind === 'DIGITAL'
|
||||
? '软件账号、域名、数字证书等数字资产登记。占用和领用必须从本台账选择。保管人是责任人,占用另记。'
|
||||
: '设备、车辆、家具等固定资产登记。占用、领用、用车必须从本台账选择。购置通过后会出现待入库,到货后点确认入库。'
|
||||
}
|
||||
extra={
|
||||
<ApplyBucketBar
|
||||
basePath={basePath}
|
||||
buckets={[
|
||||
{ value: 'pending_in', label: '待入库' },
|
||||
{ value: 'occupying', label: '占用中' },
|
||||
...(kind === 'FIXED' ? [{ value: 'vehicle', label: '车辆' }] : []),
|
||||
]}
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
form.resetFields();
|
||||
setDrawer('create');
|
||||
}}
|
||||
>
|
||||
登记
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormDrawer
|
||||
title={drawer === 'create' ? `登记${title}` : `编辑${title}`}
|
||||
open={Boolean(drawer)}
|
||||
onClose={() => setDrawer(null)}
|
||||
okText="提交保存"
|
||||
okLoading={mut.isPending}
|
||||
onOk={async () => {
|
||||
const v = await form.validateFields();
|
||||
const body = { ...v, usage: kind === 'FIXED' && v.vehicle ? 'VEHICLE' : 'GENERAL' };
|
||||
delete body.vehicle;
|
||||
if (drawer === 'create') await mut.mutateAsync(body);
|
||||
else if (drawer && typeof drawer === 'object') {
|
||||
await api.patch(`/registered-assets/${drawer.id}`, body);
|
||||
message.success('已保存');
|
||||
qc.invalidateQueries({ queryKey: [endpoint] });
|
||||
}
|
||||
setDrawer(null);
|
||||
}}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="code" label="编号">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="spec" label="规格 / 说明">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
{kind === 'FIXED' ? (
|
||||
<Form.Item name="vehicle" valuePropName="checked">
|
||||
<Checkbox>这是车辆(用车申请会优先列出)</Checkbox>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
<Form.Item name="legalEntityId" label="所属公司">
|
||||
<Select allowClear showSearch optionFilterProp="label" options={entityOpts} />
|
||||
</Form.Item>
|
||||
<Form.Item name="keeper" label="保管人">
|
||||
<Select allowClear showSearch optionFilterProp="label" options={staffOpts} />
|
||||
</Form.Item>
|
||||
<Form.Item name="location" label="存放 / 使用位置">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="amount" label="金额">
|
||||
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</FormDrawer>
|
||||
<Table
|
||||
className="ops-table"
|
||||
rowKey="id"
|
||||
loading={q.isLoading}
|
||||
dataSource={filtered}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{ title: '名称', dataIndex: 'name', width: 200 },
|
||||
{ title: '编号', dataIndex: 'code', width: 140, render: (v: string) => v || '—' },
|
||||
{ title: '规格', dataIndex: 'spec', ellipsis: true, render: (v: string) => v || '—' },
|
||||
{
|
||||
title: '状态',
|
||||
width: 90,
|
||||
render: (_: unknown, r: Record<string, unknown>) =>
|
||||
r.status === 'PENDING_IN' ? <Tag color="gold">待入库</Tag> : r.usage === 'VEHICLE' ? <Tag>车辆</Tag> : <Tag color="success">在库</Tag>,
|
||||
},
|
||||
{
|
||||
title: '所属公司',
|
||||
width: 140,
|
||||
render: (_: unknown, r: Record<string, unknown>) => {
|
||||
const e = r.legalEntity as { shortName?: string; name?: string } | null;
|
||||
return e?.shortName || e?.name || '—';
|
||||
},
|
||||
},
|
||||
{ title: '保管', dataIndex: 'keeper', width: 100, render: (v: string) => v || '—' },
|
||||
{
|
||||
title: '占用',
|
||||
width: 110,
|
||||
render: (_: unknown, r: Record<string, unknown>) => {
|
||||
const occ = r.occupancies as { holder: string }[] | undefined;
|
||||
return occ?.length ? `${occ[0].holder}在用` : '空闲';
|
||||
},
|
||||
},
|
||||
{ title: '位置', dataIndex: 'location', width: 140, render: (v: string) => v || '—' },
|
||||
{ title: '金额', dataIndex: 'amount', width: 110, render: (v: string) => money(v) },
|
||||
{
|
||||
title: '操作',
|
||||
width: 200,
|
||||
render: (_: unknown, r: Record<string, unknown>) => (
|
||||
<Space>
|
||||
{r.status === 'PENDING_IN' ? (
|
||||
<Button
|
||||
type="link"
|
||||
onClick={async () => {
|
||||
await api.post(`/registered-assets/${r.id}/inbound`);
|
||||
message.success('已入库');
|
||||
qc.invalidateQueries({ queryKey: [endpoint] });
|
||||
}}
|
||||
>
|
||||
确认入库
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="link"
|
||||
onClick={() => {
|
||||
form.setFieldsValue({ ...r, vehicle: r.usage === 'VEHICLE' });
|
||||
setDrawer({ id: String(r.id) });
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确认删除?"
|
||||
onConfirm={async () => {
|
||||
await api.delete(`/registered-assets/${r.id}`);
|
||||
message.success('已删除');
|
||||
qc.invalidateQueries({ queryKey: [endpoint] });
|
||||
}}
|
||||
>
|
||||
<Button type="link" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { AutoComplete, Button, Checkbox, Col, DatePicker, Form, Input, Row, Select, Upload, message } from 'antd';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api/client';
|
||||
import { FormDrawer } from './FormDrawer';
|
||||
import { OPEN_METHOD_OPTIONS, TENDER_METHOD_OPTIONS, TYPE_REVIEW_ROLES } from './bidStatus';
|
||||
|
||||
function asList<T>(raw: unknown): T[] {
|
||||
const body = (raw as { data?: unknown })?.data ?? raw;
|
||||
if (Array.isArray(body)) return body as T[];
|
||||
if (body && typeof body === 'object' && 'items' in body) return ((body as { items: T[] }).items ?? []) as T[];
|
||||
return [];
|
||||
}
|
||||
|
||||
type Director = { id: string; name: string; department?: string; roles?: string[] };
|
||||
|
||||
function pickReviewers(type: string, directors: Director[]) {
|
||||
const wanted = TYPE_REVIEW_ROLES[type] || ['tech_director'];
|
||||
const ids = directors.filter((d) => (d.roles || []).some((r) => wanted.includes(r))).map((d) => d.id);
|
||||
return type === '集成' ? [...new Set(ids)] : ids.slice(0, 1);
|
||||
}
|
||||
|
||||
export default function BidCreatePage({
|
||||
open = true,
|
||||
onClose,
|
||||
special = false,
|
||||
}: {
|
||||
open?: boolean;
|
||||
onClose?: () => void;
|
||||
special?: boolean;
|
||||
}) {
|
||||
const [form] = Form.useForm();
|
||||
const navigate = useNavigate();
|
||||
const listPath = special ? '/bid/special-screening' : '/bid/screening';
|
||||
const close = () => {
|
||||
if (onClose) onClose();
|
||||
else navigate(listPath);
|
||||
};
|
||||
const [files, setFiles] = useState<File[]>([]);
|
||||
const types = useQuery({ queryKey: ['dict', 'projectType'], queryFn: () => api.get('/system/dicts?type=projectType') });
|
||||
const quals = useQuery({
|
||||
queryKey: ['dict', 'qualificationNeed'],
|
||||
queryFn: () => api.get('/system/dicts?type=qualificationNeed'),
|
||||
});
|
||||
const entities = useQuery({ queryKey: ['legal-entities'], queryFn: () => api.get('/legal-entities') });
|
||||
const directors = useQuery({ queryKey: ['directors'], queryFn: () => api.get('/directors') });
|
||||
const coordinators = useQuery({
|
||||
queryKey: ['special-coordinators'],
|
||||
queryFn: () => api.get('/bid-cases/special-coordinators'),
|
||||
enabled: special && open,
|
||||
});
|
||||
const directorList = asList<Director>(directors.data);
|
||||
const coordinatorList = asList<{ id: string; displayName?: string; name?: string; username?: string; employee?: { department?: { name?: string } } }>(
|
||||
coordinators.data,
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
form.resetFields();
|
||||
setFiles([]);
|
||||
}, [open, form]);
|
||||
|
||||
const mut = useMutation({
|
||||
mutationFn: async (values: Record<string, unknown>) => {
|
||||
const res = await api.post('/bid-cases', values);
|
||||
const id = res.data?.id as string | undefined;
|
||||
if (id) {
|
||||
for (const f of files) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', f);
|
||||
fd.append('bizType', 'BID_ANNOUNCE');
|
||||
fd.append('bizId', id);
|
||||
await api.post('/files', fd);
|
||||
}
|
||||
}
|
||||
return res;
|
||||
},
|
||||
onSuccess: (res: { data?: { id?: string } }) => {
|
||||
message.success(special ? '已提交特殊筛选' : '已提交项目筛选');
|
||||
if (onClose) onClose();
|
||||
navigate(res.data?.id ? `/bid/cases/${res.data.id}` : listPath);
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
|
||||
const dictOpts = (q: { data?: unknown }) =>
|
||||
asList<{ label: string; value: string }>(q.data).map((i) => ({ label: i.label, value: i.value }));
|
||||
|
||||
return (
|
||||
<FormDrawer
|
||||
title={special ? '发起特殊筛选' : '发起项目筛选'}
|
||||
extra={
|
||||
special
|
||||
? '跳过初审。指定全公司任一办理人派取标,取标后再由总监终审,通过后进入特殊制标。'
|
||||
: '项目编号完全手填,填写招标公告上的官方编号;系统不生成编号,重复公告可再次发起筛选。'
|
||||
}
|
||||
open={open}
|
||||
onClose={close}
|
||||
onOk={() => form.submit()}
|
||||
okText={special ? '提交特殊筛选' : '提交筛选'}
|
||||
okLoading={mut.isPending}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{ source: '军采网' }}
|
||||
onFinish={(values) => {
|
||||
mut.mutate({
|
||||
...values,
|
||||
sourceType: special ? 'SPECIAL' : 'FLOW',
|
||||
applyStartAt: values.applyRange?.[0]?.toISOString(),
|
||||
applyEndAt: values.applyRange?.[1]?.toISOString(),
|
||||
openAt: values.openAt?.toISOString(),
|
||||
applyRange: undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="name" label="项目名称" rules={[{ required: true, message: '请填写项目名称' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="externalNo" label="项目编号" rules={[{ required: true, message: '请填写招标公告里的官方编号' }]} extra="手填公告编号,不由系统生成;同一公告再次筛选时可重复填写。">
|
||||
<Input placeholder="手填招标公告中的项目编号" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="projectType" label="项目类型" rules={[{ required: true, message: '请选择类型' }]}>
|
||||
<Select
|
||||
options={dictOpts(types)}
|
||||
placeholder="软件 / 三维 / 摄制 / 物资 / 集成"
|
||||
onChange={(v: string) => form.setFieldsValue({ reviewerIds: pickReviewers(v, directorList) })}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item
|
||||
name="source"
|
||||
label="项目来源"
|
||||
extra="默认军采网,其他来源直接改或手输"
|
||||
rules={[{ required: true, message: '请填写来源' }]}
|
||||
>
|
||||
<AutoComplete
|
||||
options={[{ value: '军采网' }, { value: '公开招标公告' }, { value: '其他' }]}
|
||||
placeholder="默认军采网,也可手输"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="qualificationNeed" label="所需资质" rules={[{ required: true, message: '请选择资质' }]}>
|
||||
<Select options={dictOpts(quals)} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="legalEntityId" label="参与主体" rules={[{ required: true, message: '请选择用哪家公司名义投' }]}>
|
||||
<Select
|
||||
options={asList<{ id: string; name: string }>(entities.data).map((e) => ({
|
||||
label: e.name,
|
||||
value: e.id,
|
||||
}))}
|
||||
placeholder="我方用哪家公司名义投"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="tenderMethod" label="招标方式">
|
||||
<Select allowClear options={TENDER_METHOD_OPTIONS} placeholder="可暂不填写" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="needSurvey" valuePropName="checked" extra="项目筛选阶段标记是否需要现场踏勘">
|
||||
<Checkbox>是否需要踏勘</Checkbox>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={16}>
|
||||
<Form.Item
|
||||
name="applyRange"
|
||||
label="申领时间"
|
||||
extra="招标文件申领的开始时间和截止时间"
|
||||
rules={[{ required: true, type: 'array', min: 2, message: '请选择申领起止时间' }]}
|
||||
>
|
||||
<DatePicker.RangePicker format="YYYY-MM-DD HH:mm" showTime={{ format: 'HH:mm' }} style={{ width: '100%' }} placeholder={['开始时间', '结束时间']} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="applyMethod" label="申领方式" rules={[{ required: true, message: '请选择申领方式' }]}>
|
||||
<Select
|
||||
options={[
|
||||
{ label: '现场申领', value: '现场申领' },
|
||||
{ label: '网上申领', value: '网上申领' },
|
||||
{ label: '其他', value: '其他' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name="openMethod" label="开标方式" rules={[{ required: true, message: '请选择开标方式' }]}>
|
||||
<Select options={OPEN_METHOD_OPTIONS} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="openAt" label="开标时间" rules={[{ required: true, message: '请选择开标时间' }]}>
|
||||
<DatePicker format="YYYY-MM-DD HH:mm" showTime={{ format: 'HH:mm' }} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item
|
||||
name="openPlace"
|
||||
label="开标地点"
|
||||
extra="线上写平台名称,线下写现场地址,没有默认值"
|
||||
rules={[{ required: true, message: '请填写开标地点' }]}
|
||||
>
|
||||
<AutoComplete
|
||||
options={[{ value: '军采网' }, { value: '线上开标大厅' }]}
|
||||
placeholder="请填写开标地点"
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
{special ? (
|
||||
<Col span={24}>
|
||||
<Form.Item
|
||||
name="coordinatorIds"
|
||||
label="发给"
|
||||
extra="可从全公司在职人员中选择;只有这里选中的人收到该步骤短信并可派取标人。"
|
||||
rules={[{ required: true, type: 'array', min: 1, message: '请选择特殊筛选办理人' }]}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
options={coordinatorList.map((c) => ({
|
||||
label: `${c.displayName || c.name || c.username}${c.employee?.department?.name ? `(${c.employee.department.name})` : ''}`,
|
||||
value: c.id,
|
||||
}))}
|
||||
placeholder={coordinatorList.length ? '选择办理人' : '暂无在职人员'}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
) : null}
|
||||
<Col span={24}>
|
||||
<Form.Item
|
||||
name="reviewerIds"
|
||||
label={special ? '终审总监' : '项目初审人'}
|
||||
extra={
|
||||
special
|
||||
? '取标完成后转给这些总监终审。按项目类型自动勾选,集成必须多选。'
|
||||
: '按项目类型自动勾选对应部门总监;集成必须多选。'
|
||||
}
|
||||
rules={[{ required: true, type: 'array', min: 1, message: special ? '请选择终审总监' : '请选择项目初审人' }]}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
options={directorList.map((d) => ({
|
||||
label: `${d.name}${d.department ? `(${d.department})` : ''}`,
|
||||
value: d.id,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Item label="附件">
|
||||
<Upload
|
||||
multiple
|
||||
beforeUpload={(file) => {
|
||||
setFiles((prev) => [...prev, file]);
|
||||
return false;
|
||||
}}
|
||||
onRemove={(file) => {
|
||||
setFiles((prev) => prev.filter((f) => f.name !== file.name));
|
||||
}}
|
||||
>
|
||||
<Button>上传招标公告等小文件</Button>
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</FormDrawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
import { Button, Form, Input, InputNumber, Modal, Select, Space, Tag, Typography, message } from 'antd';
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useAuth } from '../store/auth';
|
||||
import BidCreatePage from './BidCreatePage';
|
||||
import BidDetailPage from './BidDetailPage';
|
||||
import { FormDrawer } from './FormDrawer';
|
||||
import { inferTenderMethod, OPEN_METHOD_OPTIONS, statusMeta, tenderNo, BID_STATUS_META } from './bidStatus';
|
||||
import { ResourcePage } from './ResourcePage';
|
||||
import { api } from '../api/client';
|
||||
|
||||
function text(v: unknown) {
|
||||
return v ? String(v) : '—';
|
||||
}
|
||||
|
||||
function day(v: unknown) {
|
||||
if (!v) return '—';
|
||||
return String(v).slice(0, 10);
|
||||
}
|
||||
|
||||
export default function BidListPage({
|
||||
title,
|
||||
bucket,
|
||||
extra,
|
||||
special,
|
||||
}: {
|
||||
title: string;
|
||||
bucket?: string;
|
||||
extra?: boolean;
|
||||
special?: boolean;
|
||||
}) {
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [openRow, setOpenRow] = useState<Record<string, unknown>>();
|
||||
const [editRow, setEditRow] = useState<Record<string, unknown>>();
|
||||
const [competitorForm] = Form.useForm();
|
||||
const [projectType, setProjectType] = useState<string>();
|
||||
const [status, setStatus] = useState<string>();
|
||||
const [legalEntityId, setLegalEntityId] = useState<string>();
|
||||
const roles = useAuth((s) => s.roles);
|
||||
const permissions = useAuth((s) => s.permissions);
|
||||
const qc = useQueryClient();
|
||||
const entities = useQuery({ queryKey: ['/legal-entities'], queryFn: () => api.get('/legal-entities') });
|
||||
const canCreateSpecial = roles.some((r) => r === 'admin' || r === 'owner');
|
||||
const canCreate =
|
||||
['admin', 'owner', 'biz_director', 'biz_staff'].some((r) => roles.includes(r)) || permissions.includes('bid:screening');
|
||||
const params = new URLSearchParams();
|
||||
if (bucket) params.set('bucket', bucket);
|
||||
if (special) {
|
||||
params.set('sourceType', 'SPECIAL');
|
||||
if (!bucket) params.set('bucket', 'screening');
|
||||
}
|
||||
if (projectType) params.set('projectType', projectType);
|
||||
if (status) params.set('status', status);
|
||||
if (legalEntityId) params.set('legalEntityId', legalEntityId);
|
||||
const qs = params.toString();
|
||||
const listBucket = bucket || (special ? 'screening' : undefined);
|
||||
const statusOpts = (
|
||||
listBucket === 'screening'
|
||||
? ['TECH_INITIAL', 'FETCH', 'TECH_FINAL']
|
||||
: listBucket === 'drafting'
|
||||
? ['DRAFTING', 'REVIEW1', 'REVIEW2', 'PRINT']
|
||||
: listBucket === 'execution'
|
||||
? ['LIST', 'PENDING']
|
||||
: listBucket === 'result'
|
||||
? ['WON', 'LOST', 'FAILED']
|
||||
: listBucket === 'terminated'
|
||||
? ['TERMINATED']
|
||||
: Object.keys(BID_STATUS_META)
|
||||
).map((v) => ({ value: v, label: BID_STATUS_META[v]?.text || v }));
|
||||
return (
|
||||
<>
|
||||
<ResourcePage
|
||||
title={title}
|
||||
description={
|
||||
special
|
||||
? extra
|
||||
? '企业负责人发起,指定全公司办理人派取标人,再转总监终审。通过后进入特殊制标。'
|
||||
: '特殊筛选终审通过后,由已指定办理人派制标人。表单和后续与普通制标相同。'
|
||||
: bucket === 'screening'
|
||||
? '软件、三维、摄制、物资合成一张表,用「类型」区分。只看待初审、取标、终审。'
|
||||
: bucket === 'drafting'
|
||||
? '终审通过后、印封完成前的制标、一审、二审、印封。已开标的不在这里。'
|
||||
: bucket === 'execution'
|
||||
? '印封完成后在这里派投标人。开标结果和费用由投标人在个人办公 · 待办事项里反馈,不在本页登记。'
|
||||
: bucket === 'result'
|
||||
? '投标人已反馈的开标结果:中标、未中标、流标废标。'
|
||||
: bucket === 'terminated'
|
||||
? '生产里已终止,以及各筛选回收站「已删除」的,都在这里。点「编辑」登记对方中标单位和金额,经营报表「落标对照」能查到。'
|
||||
: '中标前费用挂招标编号。点办理进入详情。'
|
||||
}
|
||||
searchPlaceholder="搜索招标编号、项目名称"
|
||||
exportName={title}
|
||||
filters={[
|
||||
{
|
||||
key: 'applyMethod',
|
||||
label: '申领方式',
|
||||
options: [
|
||||
{ label: '现场申领', value: '现场申领' },
|
||||
{ label: '网上申领', value: '网上申领' },
|
||||
{ label: '其他', value: '其他' },
|
||||
],
|
||||
},
|
||||
{ key: 'openMethod', label: '开标方式', options: OPEN_METHOD_OPTIONS },
|
||||
]}
|
||||
onRowClick={(row) => setOpenRow(row)}
|
||||
emptyText={
|
||||
extra
|
||||
? '还没有在途筛选。右上角可以发起。'
|
||||
: bucket === 'drafting'
|
||||
? '没有正在制标的事项。终审通过后才会进来。'
|
||||
: bucket === 'execution'
|
||||
? '没有待派投标人或待开标的事项。'
|
||||
: bucket === 'result'
|
||||
? '还没有开标结果。'
|
||||
: bucket === 'terminated'
|
||||
? '没有终止的投标。'
|
||||
: '还没有记录。'
|
||||
}
|
||||
endpoint={qs ? `/bid-cases?${qs}` : '/bid-cases'}
|
||||
toolbarExtra={
|
||||
<Space wrap>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="状态"
|
||||
style={{ width: 140 }}
|
||||
value={status}
|
||||
onChange={(v) => setStatus(v)}
|
||||
options={statusOpts}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="项目类型"
|
||||
style={{ width: 140 }}
|
||||
value={projectType}
|
||||
onChange={(v) => setProjectType(v)}
|
||||
options={[
|
||||
{ label: '软件', value: '软件' },
|
||||
{ label: '三维', value: '三维' },
|
||||
{ label: '摄制', value: '摄制' },
|
||||
{ label: '物资', value: '物资' },
|
||||
{ label: '集成', value: '集成' },
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="参与主体"
|
||||
style={{ width: 200 }}
|
||||
value={legalEntityId}
|
||||
onChange={(v) => setLegalEntityId(v)}
|
||||
options={(((entities.data as { data?: { id: string; name: string; shortName?: string }[] })?.data) || []).map((e) => ({
|
||||
value: e.id,
|
||||
label: e.shortName && e.shortName !== e.name ? `${e.shortName} · ${e.name}` : e.name,
|
||||
}))}
|
||||
/>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
extra ? (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => setCreating(true)}
|
||||
disabled={special ? !canCreateSpecial : !canCreate}
|
||||
>
|
||||
{special ? '发起特殊筛选' : '发起筛选'}
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
columns={[
|
||||
{
|
||||
title: '招标编号',
|
||||
width: 170,
|
||||
render: (_: unknown, row: Record<string, unknown>) =>
|
||||
tenderNo({ externalNo: row.externalNo as string, bidNo: row.bidNo as string }),
|
||||
},
|
||||
{
|
||||
title: '项目名称',
|
||||
dataIndex: 'name',
|
||||
width: 240,
|
||||
ellipsis: true,
|
||||
render: (v: string, row: Record<string, unknown>) => (
|
||||
<Button type="link" onClick={() => setOpenRow(row)}>
|
||||
{v}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '参与主体',
|
||||
width: 140,
|
||||
ellipsis: true,
|
||||
render: (_: unknown, row: Record<string, unknown>) =>
|
||||
text(
|
||||
(row.legalEntity as { name?: string; shortName?: string } | null)?.name ||
|
||||
(row.legalEntity as { shortName?: string } | null)?.shortName,
|
||||
),
|
||||
},
|
||||
{ title: '类型', dataIndex: 'projectType', width: 80, render: (v: string) => text(v) },
|
||||
{ title: '项目来源', dataIndex: 'source', width: 120, ellipsis: true, render: (v: string) => text(v) },
|
||||
{
|
||||
title: '招标方式',
|
||||
width: 140,
|
||||
render: (_: unknown, row: Record<string, unknown>) =>
|
||||
text(inferTenderMethod(row.name as string, row.tenderMethod as string)),
|
||||
},
|
||||
{ title: '所需资质', dataIndex: 'qualificationNeed', width: 100, render: (v: string) => text(v) },
|
||||
{ title: '申领方式', dataIndex: 'applyMethod', width: 100, render: (v: string) => text(v) },
|
||||
{ title: '开标方式', dataIndex: 'openMethod', width: 100, render: (v: string) => text(v) },
|
||||
{ title: '开标地点', dataIndex: 'openPlace', width: 140, ellipsis: true, render: (v: string) => text(v) },
|
||||
...(extra
|
||||
? [
|
||||
{
|
||||
title: '是否踏勘',
|
||||
width: 120,
|
||||
render: (_: unknown, row: Record<string, unknown>) => (
|
||||
<Button
|
||||
size="small"
|
||||
type={row.needSurvey ? 'primary' : 'default'}
|
||||
onClick={async (e) => {
|
||||
e.stopPropagation();
|
||||
try {
|
||||
await api.patch(`/bid-cases/${row.id}/survey`, { needSurvey: !row.needSurvey });
|
||||
message.success(row.needSurvey ? '已改为不需要踏勘' : '已标记需要踏勘');
|
||||
qc.invalidateQueries();
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : '操作失败');
|
||||
}
|
||||
}}
|
||||
>
|
||||
{row.needSurvey ? '需要踏勘' : '不需要'}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 110,
|
||||
render: (v: string, row: Record<string, unknown>) => {
|
||||
const s = statusMeta(v);
|
||||
return (
|
||||
<>
|
||||
{row.sourceType === 'SPECIAL' ? <Tag color="purple">特殊</Tag> : null}
|
||||
<Tag color={s.color}>{s.text}</Tag>
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '经办',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
render: (_: unknown, row: Record<string, unknown>) =>
|
||||
text(
|
||||
(row.handlerName as string) ||
|
||||
(row.createdBy as { displayName?: string } | null)?.displayName,
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '申领时间',
|
||||
width: 200,
|
||||
render: (_: unknown, row: Record<string, unknown>) => {
|
||||
const start = day(row.applyStartAt);
|
||||
const end = day(row.applyEndAt);
|
||||
if (start === '—' && end === '—') return '—';
|
||||
return `${start} ~ ${end}`;
|
||||
},
|
||||
},
|
||||
{ title: '开标时间', dataIndex: 'openAt', width: 110, render: (v: string) => day(v) },
|
||||
...(bucket === 'terminated' || bucket === 'result'
|
||||
? [
|
||||
{
|
||||
title: '对方中标单位',
|
||||
width: 160,
|
||||
ellipsis: true,
|
||||
render: (_: unknown, row: Record<string, unknown>) => text(row.winnerName),
|
||||
},
|
||||
{
|
||||
title: '对方中标金额',
|
||||
width: 130,
|
||||
render: (_: unknown, row: Record<string, unknown>) => text(row.winnerAmount),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 170,
|
||||
render: (v: string) => (v ? String(v).replace('T', ' ').slice(0, 19) : '—'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: bucket === 'terminated' || bucket === 'result' ? 150 : 80,
|
||||
render: (_: unknown, row: Record<string, unknown>) => (
|
||||
<Space>
|
||||
<Button type="link" onClick={() => setOpenRow(row)}>
|
||||
办理
|
||||
</Button>
|
||||
{bucket === 'terminated' || (bucket === 'result' && row.status !== 'WON') ? (
|
||||
<Button
|
||||
type="link"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
competitorForm.setFieldsValue({
|
||||
winnerName: row.winnerName || undefined,
|
||||
winnerAmount: row.winnerAmount != null && row.winnerAmount !== '' ? Number(row.winnerAmount) : undefined,
|
||||
winnerNote: row.winnerNote || undefined,
|
||||
});
|
||||
setEditRow(row);
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<BidCreatePage open={creating} onClose={() => setCreating(false)} special={special} />
|
||||
<FormDrawer
|
||||
title="投标办理"
|
||||
open={Boolean(openRow)}
|
||||
onClose={() => setOpenRow(undefined)}
|
||||
hideOk
|
||||
width="min(1120px, 86vw)"
|
||||
createdAt={openRow?.createdAt as string | undefined}
|
||||
>
|
||||
{openRow ? <BidDetailPage id={String(openRow.id)} embedded /> : null}
|
||||
</FormDrawer>
|
||||
<Modal
|
||||
title="登记对方中标信息"
|
||||
open={Boolean(editRow)}
|
||||
onCancel={() => setEditRow(undefined)}
|
||||
onOk={() => competitorForm.submit()}
|
||||
okText="保存"
|
||||
destroyOnClose
|
||||
>
|
||||
<Typography.Paragraph type="secondary">
|
||||
没中或不做的项目,登记别人中了多少钱。经营报表「落标对照」按这笔统计。
|
||||
</Typography.Paragraph>
|
||||
<Form
|
||||
form={competitorForm}
|
||||
layout="vertical"
|
||||
onFinish={async (v) => {
|
||||
if (!editRow?.id) return;
|
||||
try {
|
||||
await api.patch(`/bid-cases/${editRow.id}/competitor`, {
|
||||
winnerName: v.winnerName,
|
||||
winnerAmount: v.winnerAmount,
|
||||
winnerNote: v.winnerNote,
|
||||
});
|
||||
message.success('已登记对方中标信息');
|
||||
setEditRow(undefined);
|
||||
qc.invalidateQueries();
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : '保存失败');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form.Item name="winnerName" label="中标单位" rules={[{ required: true, message: '请填写中标单位' }]}>
|
||||
<Input placeholder="实际中标单位全称" />
|
||||
</Form.Item>
|
||||
<Form.Item name="winnerAmount" label="中标金额">
|
||||
<InputNumber min={0} style={{ width: '100%' }} placeholder="对方中标金额" />
|
||||
</Form.Item>
|
||||
<Form.Item name="winnerNote" label="备注">
|
||||
<Input.TextArea rows={2} placeholder="可选,例如我们报价、放弃原因" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
import { Button, DatePicker, Form, Input, InputNumber, Popconfirm, Select, Space, Tag, message } from 'antd';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import dayjs from 'dayjs';
|
||||
import { api } from '../api/client';
|
||||
import { FormDrawer } from './FormDrawer';
|
||||
import { ResourcePage } from './ResourcePage';
|
||||
import { CostTargetLink, targetName } from './FinanceCells';
|
||||
import { BOND_BUCKET_META, BOND_TYPE_LABEL, money } from './financeStatus';
|
||||
import { ApplyBucketBar } from './ApplyBucketBar';
|
||||
|
||||
const META: Record<
|
||||
string,
|
||||
{ title: string; description: string; path: string; needBid?: boolean; needProject?: boolean }
|
||||
> = {
|
||||
TENDER: {
|
||||
title: '投标保证金',
|
||||
description: '挂投标。开标后该退未退会标成逾期。后续中标转项目时仍可回看这笔。',
|
||||
path: '/finance/bonds/tender',
|
||||
needBid: true,
|
||||
},
|
||||
PERFORMANCE: {
|
||||
title: '履约保证金',
|
||||
description: '挂中标后的投标或已立项项目。缴出未退会进资金占用。',
|
||||
path: '/finance/bonds/performance',
|
||||
},
|
||||
WARRANTY: {
|
||||
title: '项目质保金',
|
||||
description: '挂正式项目。验收后按约定日期退回,逾期单独列出。',
|
||||
path: '/finance/bonds/warranty',
|
||||
needProject: true,
|
||||
},
|
||||
};
|
||||
|
||||
export default function BondListPage({ bondType }: { bondType: 'TENDER' | 'PERFORMANCE' | 'WARRANTY' }) {
|
||||
const [params] = useSearchParams();
|
||||
const bucket = params.get('bucket') || '';
|
||||
const meta = META[bondType];
|
||||
const endpoint = `/bonds?bondType=${bondType}${bucket ? `&bucket=${bucket}` : ''}`;
|
||||
const qc = useQueryClient();
|
||||
const [form] = Form.useForm();
|
||||
const [creating, setCreating] = useState(false);
|
||||
const targets = useQuery({
|
||||
queryKey: ['cost-targets'],
|
||||
queryFn: () => api.get('/cost-targets'),
|
||||
});
|
||||
const bids = ((targets.data?.data?.bids ?? []) as { id: string; bidNo: string; name: string }[]).map((b) => ({
|
||||
value: b.id,
|
||||
label: `${b.bidNo} ${b.name}`,
|
||||
}));
|
||||
const projects = ((targets.data?.data?.projects ?? []) as { id: string; projectNo: string; name: string }[]).map((p) => ({
|
||||
value: p.id,
|
||||
label: `${p.projectNo} ${p.name}`,
|
||||
}));
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) => api.post('/bonds', body),
|
||||
onSuccess: () => {
|
||||
message.success('已登记');
|
||||
qc.invalidateQueries({ queryKey: [endpoint] });
|
||||
qc.invalidateQueries({ queryKey: ['/bonds'] });
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
const actMut = useMutation({
|
||||
mutationFn: ({ id, action, dueBackAt }: { id: string; action: 'pay' | 'return'; dueBackAt?: string }) =>
|
||||
api.post(`/bonds/${id}/${action}`, action === 'pay' ? { dueBackAt } : {}),
|
||||
onSuccess: () => {
|
||||
message.success('已更新');
|
||||
qc.invalidateQueries();
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
|
||||
const openCreate = () => {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ bondType });
|
||||
setCreating(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResourcePage
|
||||
title={meta.title}
|
||||
description={meta.description}
|
||||
endpoint={endpoint}
|
||||
extra={
|
||||
<ApplyBucketBar
|
||||
basePath={meta.path}
|
||||
buckets={[
|
||||
{ value: 'unpaid', label: '未缴纳' },
|
||||
{ value: 'due', label: '应退' },
|
||||
{ value: 'overdue', label: '逾期' },
|
||||
]}
|
||||
extra={
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
登记
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
columns={[
|
||||
{ title: '编号', dataIndex: 'bondNo', width: 150 },
|
||||
{ title: '类型', dataIndex: 'bondType', width: 120, render: (v: string) => BOND_TYPE_LABEL[v] || v },
|
||||
{ title: '金额', dataIndex: 'amount', width: 110, render: (v: string) => money(v) },
|
||||
{
|
||||
title: '对象',
|
||||
width: 160,
|
||||
render: (_: unknown, row: Record<string, unknown>) => (
|
||||
<CostTargetLink
|
||||
bid={row.bidCase as { id?: string; bidNo?: string } | null}
|
||||
project={row.project as { id?: string; projectNo?: string } | null}
|
||||
contract={row.contract as { id?: string; contractNo?: string } | null}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: bondType === 'TENDER' ? '投标' : '项目',
|
||||
ellipsis: true,
|
||||
render: (_: unknown, row: Record<string, unknown>) =>
|
||||
targetName(
|
||||
row.bidCase as { name?: string } | null,
|
||||
row.project as { name?: string } | null,
|
||||
row.contract as { name?: string } | null,
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'bucket',
|
||||
width: 100,
|
||||
render: (v: string) => {
|
||||
const s = BOND_BUCKET_META[v] || { color: 'default', text: v || '—' };
|
||||
return <Tag color={s.color}>{s.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '应退日',
|
||||
dataIndex: 'dueBackAt',
|
||||
width: 120,
|
||||
render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD') : '—'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 180,
|
||||
render: (_: unknown, row: Record<string, unknown>) => (
|
||||
<Space>
|
||||
{row.status === 'UNPAID' ? (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
const due = window.prompt('预计退回日期 YYYY-MM-DD(可空)') || undefined;
|
||||
actMut.mutate({ id: String(row.id), action: 'pay', dueBackAt: due });
|
||||
}}
|
||||
>
|
||||
登记缴纳
|
||||
</Button>
|
||||
) : null}
|
||||
{row.status === 'PAID' ? (
|
||||
<Button type="link" size="small" onClick={() => actMut.mutate({ id: String(row.id), action: 'return' })}>
|
||||
退回
|
||||
</Button>
|
||||
) : null}
|
||||
<Popconfirm
|
||||
title="确认删除这条保证金?"
|
||||
onConfirm={async () => {
|
||||
await api.delete(`/bonds/${row.id}`);
|
||||
message.success('已删除');
|
||||
qc.invalidateQueries();
|
||||
}}
|
||||
>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<FormDrawer
|
||||
title={`登记${meta.title}`}
|
||||
open={creating}
|
||||
onClose={() => setCreating(false)}
|
||||
okText="提交保存"
|
||||
okLoading={createMut.isPending}
|
||||
onOk={async () => {
|
||||
const v = await form.validateFields();
|
||||
await createMut.mutateAsync({
|
||||
...v,
|
||||
bondType,
|
||||
dueBackAt: v.dueBackAt ? v.dueBackAt.toISOString() : undefined,
|
||||
});
|
||||
setCreating(false);
|
||||
}}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item label="类型">
|
||||
<Input disabled value={BOND_TYPE_LABEL[bondType]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="amount" label="金额" rules={[{ required: true }]}>
|
||||
<InputNumber min={0.01} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="bidCaseId"
|
||||
label="投标"
|
||||
rules={meta.needBid ? [{ required: true, message: '投标保证金必须挂投标' }] : undefined}
|
||||
extra={bondType === 'TENDER' ? '后续中标转项目后仍可从这笔回看。' : '可挂来源投标,现在可空。'}
|
||||
>
|
||||
<Select allowClear showSearch optionFilterProp="label" options={bids} placeholder="选择招标编号" />
|
||||
</Form.Item>
|
||||
{bondType !== 'TENDER' ? (
|
||||
<Form.Item
|
||||
name="projectId"
|
||||
label="项目"
|
||||
rules={meta.needProject ? [{ required: true, message: '项目质保金必须挂项目' }] : undefined}
|
||||
extra={bondType === 'PERFORMANCE' ? '履约金请挂投标或项目,至少填一项。' : undefined}
|
||||
>
|
||||
<Select allowClear showSearch optionFilterProp="label" options={projects} placeholder="选择项目" />
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Form.Item name="projectId" label="项目" extra="中标立项后可补挂,现在可空。">
|
||||
<Select allowClear showSearch optionFilterProp="label" options={projects} placeholder="可选" />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="dueBackAt" label="预计退回">
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="remark" label="说明">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</FormDrawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Table, Tag } from 'antd';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import dayjs from 'dayjs';
|
||||
import { api } from '../api/client';
|
||||
import { PageHeader } from './ResourcePage';
|
||||
|
||||
const KIND_LABEL: Record<string, string> = {
|
||||
BID_OPEN: '开标',
|
||||
TASK: '任务截止',
|
||||
TODO: '待办',
|
||||
BORROW: '证照归还',
|
||||
SEAL: '印章归还',
|
||||
VEHICLE: '用车',
|
||||
};
|
||||
|
||||
type CalItem = {
|
||||
id: string;
|
||||
kind: string;
|
||||
title: string;
|
||||
start?: string | null;
|
||||
place?: string | null;
|
||||
ref?: string | null;
|
||||
overdue?: boolean;
|
||||
path?: string | null;
|
||||
};
|
||||
|
||||
export default function CalendarPage() {
|
||||
const q = useQuery({
|
||||
queryKey: ['/office/calendar'],
|
||||
queryFn: () => api.get('/office/calendar'),
|
||||
});
|
||||
const items = (q.data?.data ?? []) as CalItem[];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="我的日程"
|
||||
description="只显示派给你的事:自己的待办、任务截止、被派去参加的开标。投标全貌在投标管理里看,不会因为有权限就把所有开标塞进来。"
|
||||
/>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={q.isLoading}
|
||||
dataSource={items}
|
||||
columns={[
|
||||
{
|
||||
title: '类型',
|
||||
dataIndex: 'kind',
|
||||
width: 110,
|
||||
render: (v: string) => KIND_LABEL[v] || v,
|
||||
},
|
||||
{
|
||||
title: '事项',
|
||||
dataIndex: 'title',
|
||||
render: (v: string, r: CalItem) => (r.path ? <Link to={r.path}>{v}</Link> : v),
|
||||
},
|
||||
{ title: '编号', dataIndex: 'ref', width: 150, render: (v: string) => v || '—' },
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'start',
|
||||
width: 170,
|
||||
render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '—'),
|
||||
},
|
||||
{ title: '地点 / 说明', dataIndex: 'place', render: (v: string) => v || '—' },
|
||||
{
|
||||
title: '状态',
|
||||
width: 90,
|
||||
render: (_: unknown, r: CalItem) =>
|
||||
r.overdue ? <Tag color="error">逾期</Tag> : <Tag>待办</Tag>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
import { useEffect, type ReactNode } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
DatePicker,
|
||||
Descriptions,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Timeline,
|
||||
Typography,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { MinusCircleOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import dayjs from 'dayjs';
|
||||
import { api } from '../api/client';
|
||||
import { PageHeader } from './ResourcePage';
|
||||
import { FormDrawer } from './FormDrawer';
|
||||
import { BizFileList } from './FileAttachments';
|
||||
import { REVIEW_RESULT } from './bidStatus';
|
||||
import { CONTRACT_ACTION_LABEL, contractNoText, contractStatusMeta } from './contractStatus';
|
||||
|
||||
type UserRef = { id: string; displayName: string; username?: string };
|
||||
|
||||
type ContractDetail = {
|
||||
id: string;
|
||||
contractNo: string;
|
||||
name: string;
|
||||
amount: string | number;
|
||||
status: string;
|
||||
partyA?: string | null;
|
||||
partyB?: string | null;
|
||||
nasPath?: string | null;
|
||||
remark?: string | null;
|
||||
signedAt?: string | null;
|
||||
submittedAt?: string | null;
|
||||
approvedAt?: string | null;
|
||||
createdAt: string;
|
||||
bidCase?: { id: string; bidNo: string; name: string; status: string; quoteAmount?: string | null } | null;
|
||||
legalEntity?: { name: string } | null;
|
||||
createdBy?: UserRef | null;
|
||||
milestones: {
|
||||
id: string;
|
||||
name: string;
|
||||
ratio: string | number;
|
||||
dueAt?: string | null;
|
||||
trigger?: string | null;
|
||||
invoice?: { id: string; status: string; invoiceNo?: string | null } | null;
|
||||
}[];
|
||||
reviewers: {
|
||||
id: string;
|
||||
userId: string;
|
||||
result: string;
|
||||
comment?: string | null;
|
||||
decidedAt?: string | null;
|
||||
user?: UserRef;
|
||||
}[];
|
||||
actionLogs: {
|
||||
id: string;
|
||||
action: string;
|
||||
fromStatus?: string | null;
|
||||
toStatus?: string | null;
|
||||
comment?: string | null;
|
||||
createdAt: string;
|
||||
actor?: UserRef;
|
||||
}[];
|
||||
sealRequests: {
|
||||
id: string;
|
||||
sealType: string;
|
||||
reason: string;
|
||||
takeOut: boolean;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
}[];
|
||||
projects: { id: string; projectNo: string; status: string; presalesCost?: string | number }[];
|
||||
files?: { id: string; fileName: string; size: number; mimeType: string; bizType?: string }[];
|
||||
myActions: {
|
||||
canEdit: boolean;
|
||||
canSubmit: boolean;
|
||||
canRequestSeal: boolean;
|
||||
canReview: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
function fmt(v?: string | null) {
|
||||
return v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '—';
|
||||
}
|
||||
|
||||
export default function ContractDetailPage({
|
||||
id: idProp,
|
||||
embedded,
|
||||
}: {
|
||||
id?: string;
|
||||
embedded?: boolean;
|
||||
} = {}) {
|
||||
const { id: routeId } = useParams();
|
||||
const id = idProp || routeId;
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const [infoForm] = Form.useForm();
|
||||
const [mileForm] = Form.useForm();
|
||||
const [sealForm] = Form.useForm();
|
||||
const [reviewForm] = Form.useForm();
|
||||
|
||||
const q = useQuery({
|
||||
queryKey: ['contract', id],
|
||||
enabled: Boolean(id),
|
||||
queryFn: () => api.get(`/contracts/${id}`),
|
||||
});
|
||||
const row = q.data?.data as ContractDetail | undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!row) return;
|
||||
infoForm.setFieldsValue({
|
||||
contractNo: row.contractNo || undefined,
|
||||
name: row.name,
|
||||
amount: Number(row.amount) || undefined,
|
||||
partyA: row.partyA || undefined,
|
||||
partyB: row.partyB || undefined,
|
||||
nasPath: row.nasPath || undefined,
|
||||
remark: row.remark || undefined,
|
||||
});
|
||||
mileForm.setFieldsValue({
|
||||
items: row.milestones.length
|
||||
? row.milestones.map((m) => ({
|
||||
name: m.name,
|
||||
ratio: Number(m.ratio),
|
||||
dueAt: m.dueAt ? dayjs(m.dueAt) : undefined,
|
||||
trigger: m.trigger || undefined,
|
||||
}))
|
||||
: [
|
||||
{ name: '预付款', ratio: 30 },
|
||||
{ name: '进度款', ratio: 40 },
|
||||
{ name: '验收款', ratio: 30 },
|
||||
],
|
||||
});
|
||||
}, [row, infoForm, mileForm]);
|
||||
|
||||
const invalidate = async () => {
|
||||
await qc.invalidateQueries({ queryKey: ['contract', id] });
|
||||
await qc.invalidateQueries({ queryKey: ['/contracts'] });
|
||||
await qc.invalidateQueries({ queryKey: ['/projects'] });
|
||||
await qc.invalidateQueries({ queryKey: ['/office/approvals'] });
|
||||
await qc.invalidateQueries({ queryKey: ['/office/todos'] });
|
||||
};
|
||||
|
||||
const updateMut = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) => api.patch(`/contracts/${id}`, body),
|
||||
onSuccess: async () => {
|
||||
message.success('已保存');
|
||||
await invalidate();
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
const mileMut = useMutation({
|
||||
mutationFn: (items: Array<Record<string, unknown>>) => api.put(`/contracts/${id}/milestones`, { items }),
|
||||
onSuccess: async () => {
|
||||
message.success('收款条款已保存');
|
||||
await invalidate();
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
const sealMut = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) =>
|
||||
api.post(`/contracts/${id}/seal`, {
|
||||
...body,
|
||||
returnAt: body.returnAt ? dayjs(body.returnAt as string).format('YYYY-MM-DD') : undefined,
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
message.success('已申请用章');
|
||||
sealForm.resetFields();
|
||||
await invalidate();
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
const submitMut = useMutation({
|
||||
mutationFn: () => api.post(`/contracts/${id}/submit`),
|
||||
onSuccess: async () => {
|
||||
message.success('已提交审批');
|
||||
await invalidate();
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
const reviewMut = useMutation({
|
||||
mutationFn: (body: { result: 'APPROVED' | 'REJECTED'; comment?: string }) =>
|
||||
api.post(`/contracts/${id}/reviews`, body),
|
||||
onSuccess: async () => {
|
||||
message.success('已办理');
|
||||
reviewForm.resetFields();
|
||||
await invalidate();
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
const invoiceMut = useMutation({
|
||||
mutationFn: (milestoneId: string) => api.post('/invoices', { contractId: id, milestoneId }),
|
||||
onSuccess: async () => {
|
||||
message.success('已生成待开票,请到开票收款登记发票号');
|
||||
await invalidate();
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
|
||||
if (q.isError) {
|
||||
return (
|
||||
<Alert
|
||||
type="error"
|
||||
message={(q.error as Error).message || '无法打开合同'}
|
||||
action={
|
||||
<Button onClick={() => navigate('/contract/tender')} size="small">
|
||||
返回
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!row) {
|
||||
return <Typography.Paragraph>加载中…</Typography.Paragraph>;
|
||||
}
|
||||
|
||||
const meta = contractStatusMeta(row.status);
|
||||
const project = row.projects[0];
|
||||
|
||||
const decide = (result: 'APPROVED' | 'REJECTED') => {
|
||||
if (result === 'REJECTED') {
|
||||
Modal.confirm({
|
||||
title: '驳回合同',
|
||||
content: (
|
||||
<Form form={reviewForm} layout="vertical" style={{ marginTop: 12 }}>
|
||||
<Form.Item name="comment" label="意见" rules={[{ required: true, message: '驳回请填写意见' }]}>
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
okButtonProps: { danger: true },
|
||||
okText: '确认驳回',
|
||||
onOk: async () => {
|
||||
const v = await reviewForm.validateFields();
|
||||
await reviewMut.mutateAsync({ result: 'REJECTED', comment: v.comment });
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
reviewMut.mutate({ result: 'APPROVED' });
|
||||
};
|
||||
|
||||
const page = (
|
||||
<>
|
||||
<PageHeader
|
||||
title={row.name}
|
||||
description={contractNoText(row.contractNo)}
|
||||
extra={
|
||||
<Space>
|
||||
{embedded ? null : (
|
||||
<Button>
|
||||
<Link to={row.bidCase ? '/contract/tender' : '/contract/incremental'}>返回</Link>
|
||||
</Button>
|
||||
)}
|
||||
{row.bidCase ? (
|
||||
<Button>
|
||||
<Link to={`/bid/cases/${row.bidCase.id}`}>原投标</Link>
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Tag color={meta.color}>{meta.text}</Tag>
|
||||
{row.legalEntity?.name ? <Tag>{row.legalEntity.name}</Tag> : null}
|
||||
{row.bidCase?.bidNo ? <Tag>{row.bidCase.bidNo}</Tag> : null}
|
||||
{project ? (
|
||||
<Tag color="success">
|
||||
<Link to={`/project/${project.id}`}>已立项 {project.projectNo}</Link>
|
||||
</Tag>
|
||||
) : null}
|
||||
</Space>
|
||||
|
||||
{row.status === 'APPROVED' && project ? (
|
||||
<Alert
|
||||
type="success"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message={`合同已通过,项目 ${project.projectNo} 可继续执行。售前成本 ${project.presalesCost ?? 0} 已继承。`}
|
||||
/>
|
||||
) : null}
|
||||
{(row.status === 'DRAFT' || row.status === 'REJECTED') && project && row.myActions.canEdit ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="中标后已自动写入项目台账并生成本草稿。请完善正式编号、金额、收款条款并申请用章后提交审批。报销可先挂项目。"
|
||||
/>
|
||||
) : null}
|
||||
{row.myActions.canReview ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message={
|
||||
row.projects?.length
|
||||
? '项目台账已在中标时创建,报销可挂项目。通过后合同生效;驳回只退回草稿,不删除项目。'
|
||||
: '请办理合同审批。全部通过才立项;一人驳回则退回草稿,不创建项目。'
|
||||
}
|
||||
action={
|
||||
<Space>
|
||||
<Button type="primary" onClick={() => decide('APPROVED')}>
|
||||
通过
|
||||
</Button>
|
||||
<Button danger onClick={() => decide('REJECTED')}>
|
||||
驳回
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<Descriptions bordered size="small" column={2} style={{ marginBottom: 24 }}>
|
||||
<Descriptions.Item label="合同编号">{contractNoText(row.contractNo)}</Descriptions.Item>
|
||||
<Descriptions.Item label="甲方">{row.partyA || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="乙方">{row.partyB || row.legalEntity?.name || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="合同金额">{row.amount}</Descriptions.Item>
|
||||
<Descriptions.Item label="经办">{row.createdBy?.displayName === '系统管理员' ? '—' : row.createdBy?.displayName || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="提交时间">{fmt(row.submittedAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label="通过时间">{fmt(row.approvedAt)}</Descriptions.Item>
|
||||
<Descriptions.Item label="资料路径" span={2}>
|
||||
{row.nasPath || '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="备注" span={2}>
|
||||
{row.remark || '—'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Typography.Text strong>附件</Typography.Text>
|
||||
<BizFileList files={row.files} empty="该合同没有从生产带过来的扫描件。" />
|
||||
</div>
|
||||
|
||||
{row.myActions.canEdit ? (
|
||||
<Panel title="合同信息" hint="正式编号填纸质合同上的号,系统不自动生成。草稿可改甲乙方、金额和扫描件路径。">
|
||||
<Form
|
||||
form={infoForm}
|
||||
layout="vertical"
|
||||
onFinish={(v) => updateMut.mutate(v)}
|
||||
>
|
||||
<Form.Item name="contractNo" label="正式合同编号" extra="由业务手填,提交审批前必填。">
|
||||
<Input placeholder="纸质合同上的编号" />
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="合同名称" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="partyA" label="甲方" rules={[{ required: true, message: '请填写甲方' }]}>
|
||||
<Input placeholder="对方单位" />
|
||||
</Form.Item>
|
||||
<Form.Item name="partyB" label="乙方" rules={[{ required: true, message: '请填写乙方' }]}>
|
||||
<Input placeholder="我方签约主体" />
|
||||
</Form.Item>
|
||||
<Form.Item name="amount" label="合同金额" rules={[{ required: true }]}>
|
||||
<InputNumber min={0.01} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="nasPath" label="合同扫描件 NAS 路径">
|
||||
<Input placeholder="\\\\nas\\contracts\\..." />
|
||||
</Form.Item>
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={updateMut.isPending}>
|
||||
保存
|
||||
</Button>
|
||||
</Form>
|
||||
</Panel>
|
||||
) : null}
|
||||
|
||||
<Panel
|
||||
title="收款条款"
|
||||
hint="比例合计必须为 100%。提交审批前请先保存。"
|
||||
>
|
||||
{row.myActions.canEdit ? (
|
||||
<Form
|
||||
form={mileForm}
|
||||
layout="vertical"
|
||||
onFinish={(v) =>
|
||||
mileMut.mutate(
|
||||
(v.items || []).map((i: { name: string; ratio: number; dueAt?: dayjs.Dayjs; trigger?: string }) => ({
|
||||
name: i.name,
|
||||
ratio: i.ratio,
|
||||
dueAt: i.dueAt ? i.dueAt.toISOString() : undefined,
|
||||
trigger: i.trigger,
|
||||
})),
|
||||
)
|
||||
}
|
||||
>
|
||||
<Form.List name="items">
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
{fields.map((field) => (
|
||||
<Space key={field.key} align="baseline" wrap style={{ display: 'flex', marginBottom: 8 }}>
|
||||
<Form.Item
|
||||
{...field}
|
||||
name={[field.name, 'name']}
|
||||
rules={[{ required: true, message: '名称' }]}
|
||||
>
|
||||
<Input placeholder="条款名称" style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
{...field}
|
||||
name={[field.name, 'ratio']}
|
||||
rules={[{ required: true, message: '比例' }]}
|
||||
>
|
||||
<InputNumber min={0.01} max={100} addonAfter="%" style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
<Form.Item {...field} name={[field.name, 'dueAt']}>
|
||||
<DatePicker placeholder="预计日期" />
|
||||
</Form.Item>
|
||||
<Form.Item {...field} name={[field.name, 'trigger']}>
|
||||
<Input placeholder="触发条件" style={{ width: 160 }} />
|
||||
</Form.Item>
|
||||
<MinusCircleOutlined onClick={() => remove(field.name)} />
|
||||
</Space>
|
||||
))}
|
||||
<Form.Item>
|
||||
<Button type="dashed" onClick={() => add()} icon={<PlusOutlined />}>
|
||||
加一条
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
<Button type="primary" htmlType="submit" loading={mileMut.isPending}>
|
||||
保存条款
|
||||
</Button>
|
||||
</Form>
|
||||
) : (
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={row.milestones}
|
||||
columns={[
|
||||
{ title: '条款', dataIndex: 'name' },
|
||||
{ title: '比例 %', dataIndex: 'ratio', width: 100 },
|
||||
{ title: '预计日期', dataIndex: 'dueAt', render: (v: string) => fmt(v) },
|
||||
{ title: '触发条件', dataIndex: 'trigger', render: (v: string) => v || '—' },
|
||||
{
|
||||
title: '开票',
|
||||
width: 140,
|
||||
render: (_: unknown, m: ContractDetail['milestones'][0]) => {
|
||||
if (row.status !== 'APPROVED') return '—';
|
||||
if (m.invoice) {
|
||||
return <Link to="/finance/invoices">{m.invoice.invoiceNo || m.invoice.status}</Link>;
|
||||
}
|
||||
return (
|
||||
<Button type="link" size="small" onClick={() => invoiceMut.mutate(m.id)}>
|
||||
生成待开票
|
||||
</Button>
|
||||
);
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</Panel>
|
||||
|
||||
<Panel title="用章" hint="提交审批前必须有一条关联本合同的用章申请。">
|
||||
{row.sealRequests.length ? (
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
style={{ marginBottom: 12 }}
|
||||
dataSource={row.sealRequests}
|
||||
columns={[
|
||||
{ title: '印章', dataIndex: 'sealType', width: 120 },
|
||||
{ title: '事由', dataIndex: 'reason' },
|
||||
{ title: '外带', dataIndex: 'takeOut', width: 80, render: (v: boolean) => (v ? '是' : '否') },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
render: (v: string) => {
|
||||
const s = REVIEW_RESULT[v] || { color: 'default', text: v };
|
||||
return <Tag color={s.color}>{s.text}</Tag>;
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<Typography.Paragraph type="secondary">尚未申请用章。</Typography.Paragraph>
|
||||
)}
|
||||
{row.myActions.canRequestSeal ? (
|
||||
<Form form={sealForm} layout="vertical" onFinish={(v) => sealMut.mutate(v)}>
|
||||
<Form.Item name="sealType" label="印章类型" rules={[{ required: true }]}>
|
||||
<Input placeholder="公章 / 合同章 / 法人章" />
|
||||
</Form.Item>
|
||||
<Form.Item name="reason" label="事由" rules={[{ required: true }]}>
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Form.Item name="takeOut" valuePropName="checked">
|
||||
<Checkbox>外带</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{() =>
|
||||
sealForm.getFieldValue('takeOut') ? (
|
||||
<Form.Item name="returnAt" label="预计归还" rules={[{ required: true, message: '外带请填写预计归还' }]}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
) : null
|
||||
}
|
||||
</Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={sealMut.isPending}>
|
||||
申请用章
|
||||
</Button>
|
||||
</Form>
|
||||
) : null}
|
||||
</Panel>
|
||||
|
||||
{row.myActions.canSubmit ? (
|
||||
<Panel title="提交审批" hint="正式编号、金额、收款条款合计 100%、至少一条用章齐备后提交。审批人在个人办公 · 待我审批办理。">
|
||||
<Button type="primary" onClick={() => submitMut.mutate()} loading={submitMut.isPending}>
|
||||
提交合同审批
|
||||
</Button>
|
||||
</Panel>
|
||||
) : null}
|
||||
|
||||
{row.reviewers.length ? (
|
||||
<>
|
||||
<Typography.Title level={5}>会签</Typography.Title>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
style={{ marginBottom: 24 }}
|
||||
dataSource={row.reviewers}
|
||||
columns={[
|
||||
{ title: '审批人', render: (_: unknown, r: ContractDetail['reviewers'][0]) => r.user?.displayName || r.userId },
|
||||
{
|
||||
title: '结果',
|
||||
dataIndex: 'result',
|
||||
width: 110,
|
||||
render: (v: string) => {
|
||||
const s = REVIEW_RESULT[v] || { color: 'default', text: v };
|
||||
return <Tag color={s.color}>{s.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{ title: '意见', dataIndex: 'comment', render: (v: string) => v || '—' },
|
||||
{ title: '时间', dataIndex: 'decidedAt', width: 170, render: (v: string) => fmt(v) },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{row.actionLogs.length ? (
|
||||
<>
|
||||
<Typography.Title level={5}>办理记录</Typography.Title>
|
||||
<Timeline
|
||||
items={row.actionLogs.map((l) => ({
|
||||
children: (
|
||||
<span>
|
||||
{fmt(l.createdAt)} · {l.actor?.displayName || ''} · {CONTRACT_ACTION_LABEL[l.action] || l.action}
|
||||
{l.comment ? `:${l.comment}` : ''}
|
||||
</span>
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
if (embedded) return page;
|
||||
return (
|
||||
<FormDrawer title={row.name} extra={row.contractNo} open hideOk width="min(1120px, 86vw)" createdAt={row.createdAt} onClose={() => navigate(row.bidCase ? '/contract/tender' : '/contract/incremental')}>
|
||||
{page}
|
||||
</FormDrawer>
|
||||
);
|
||||
}
|
||||
|
||||
function Panel({ title, hint, children }: { title: string; hint: string; children: ReactNode }) {
|
||||
return (
|
||||
<div style={{ marginBottom: 24, maxWidth: 720 }}>
|
||||
<Typography.Title level={5}>{title}</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">{hint}</Typography.Paragraph>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
import { Button, Form, Input, InputNumber, Select, Tag, message } from 'antd';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import ContractDetailPage from './ContractDetailPage';
|
||||
import { FormDrawer } from './FormDrawer';
|
||||
import { ResourcePage } from './ResourcePage';
|
||||
import { contractHandler, contractNoText, contractStatusMeta } from './contractStatus';
|
||||
import { money } from './financeStatus';
|
||||
import { tenderNo } from './bidStatus';
|
||||
import { api } from '../api/client';
|
||||
|
||||
export default function ContractListPage({
|
||||
title,
|
||||
description,
|
||||
bucket,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
bucket?: string;
|
||||
}) {
|
||||
const kind = bucket === 'tender' ? 'TENDER' : bucket === 'direct' ? 'DIRECT' : 'OTHER';
|
||||
const createLabel = bucket === 'tender' ? '新建投标合同' : bucket === 'direct' ? '新建直签合同' : '新建其他合同';
|
||||
const qc = useQueryClient();
|
||||
const [openRow, setOpenRow] = useState<Record<string, unknown>>();
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const endpoint = bucket ? `/contracts?bucket=${bucket}` : '/contracts';
|
||||
const entities = useQuery({
|
||||
queryKey: ['/legal-entities'],
|
||||
queryFn: () => api.get('/legal-entities'),
|
||||
});
|
||||
const bids = useQuery({
|
||||
queryKey: ['/bid-cases', 'won'],
|
||||
queryFn: () => api.get('/bid-cases?bucket=result'),
|
||||
enabled: bucket === 'tender',
|
||||
});
|
||||
const entityOpts = ((entities.data as { data?: { id: string; name: string; shortName?: string }[] })?.data || []).map(
|
||||
(e) => ({ value: e.id, label: e.shortName || e.name }),
|
||||
);
|
||||
const bidItems = ((bids.data as { data?: { items?: { id: string; bidNo: string; name: string; status: string }[] } })?.data?.items ||
|
||||
[]) as { id: string; bidNo: string; name: string; status: string }[];
|
||||
const bidOpts = bidItems
|
||||
.filter((b) => b.status === 'WON')
|
||||
.map((b) => ({ value: b.id, label: `${b.bidNo} ${b.name}` }));
|
||||
const createMut = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) => api.post('/contracts', { ...body, kind }),
|
||||
onSuccess: () => {
|
||||
message.success(`已${createLabel}`);
|
||||
qc.invalidateQueries({ queryKey: [endpoint] });
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '合同号',
|
||||
dataIndex: 'contractNo',
|
||||
width: 180,
|
||||
render: (v: string) => contractNoText(v),
|
||||
},
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
width: 220,
|
||||
ellipsis: true,
|
||||
render: (v: string, row: Record<string, unknown>) => (
|
||||
<Button type="link" onClick={() => setOpenRow(row)}>
|
||||
{v}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
...(bucket === 'tender'
|
||||
? [
|
||||
{
|
||||
title: '招标编号',
|
||||
width: 150,
|
||||
render: (_: unknown, row: Record<string, unknown>) => {
|
||||
const bid = row.bidCase as { bidNo?: string; externalNo?: string } | null;
|
||||
return tenderNo(bid);
|
||||
},
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
title: '甲方',
|
||||
dataIndex: 'partyA',
|
||||
width: 180,
|
||||
ellipsis: true,
|
||||
render: (v: string, row: Record<string, unknown>) => {
|
||||
const bid = row.bidCase as { party?: { name?: string } } | null;
|
||||
return v || bid?.party?.name || '—';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '乙方',
|
||||
dataIndex: 'partyB',
|
||||
width: 180,
|
||||
ellipsis: true,
|
||||
render: (v: string, row: Record<string, unknown>) => {
|
||||
const e = row.legalEntity as { shortName?: string; name?: string } | null;
|
||||
return v || e?.name || '—';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '我方主体',
|
||||
width: 120,
|
||||
ellipsis: true,
|
||||
render: (_: unknown, row: Record<string, unknown>) => {
|
||||
const e = row.legalEntity as { shortName?: string; name?: string } | null;
|
||||
return e?.shortName || e?.name || '—';
|
||||
},
|
||||
},
|
||||
{ title: '金额', dataIndex: 'amount', width: 120, render: (v: string) => money(v) },
|
||||
{
|
||||
title: '经办',
|
||||
width: 90,
|
||||
render: (_: unknown, row: Record<string, unknown>) =>
|
||||
contractHandler({
|
||||
createdBy: row.createdBy as { displayName?: string; username?: string } | null,
|
||||
bidCase: row.bidCase as { createdBy?: { displayName?: string } | null } | null,
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: '签订',
|
||||
dataIndex: 'signedAt',
|
||||
width: 110,
|
||||
render: (v: string) => (v ? String(v).slice(0, 10) : '—'),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 110,
|
||||
render: (v: string) => {
|
||||
const s = contractStatusMeta(v);
|
||||
return <Tag color={s.color}>{s.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 90,
|
||||
render: (_: unknown, row: Record<string, unknown>) => (
|
||||
<Button type="link" onClick={() => setOpenRow(row)}>
|
||||
办理
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<ResourcePage
|
||||
title={title}
|
||||
description={
|
||||
description ||
|
||||
(bucket === 'tender'
|
||||
? '投标管理转来或本页新建的投标合同。'
|
||||
: bucket === 'direct'
|
||||
? '不经过投标、直接签订的合同。'
|
||||
: '除投标合同、直签合同以外的合同。')
|
||||
}
|
||||
searchPlaceholder="搜索合同号、名称、甲乙方"
|
||||
exportName={title}
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
form.resetFields();
|
||||
setCreating(true);
|
||||
}}
|
||||
>
|
||||
{createLabel}
|
||||
</Button>
|
||||
}
|
||||
filters={[
|
||||
{
|
||||
key: 'status',
|
||||
label: '状态',
|
||||
options: [
|
||||
{ label: '草稿', value: 'DRAFT' },
|
||||
{ label: '审批中', value: 'PENDING' },
|
||||
{ label: '已通过', value: 'APPROVED' },
|
||||
{ label: '已驳回', value: 'REJECTED' },
|
||||
],
|
||||
},
|
||||
]}
|
||||
onRowClick={(row) => setOpenRow(row)}
|
||||
endpoint={endpoint}
|
||||
columns={columns}
|
||||
/>
|
||||
<FormDrawer
|
||||
title={createLabel}
|
||||
open={creating}
|
||||
onClose={() => setCreating(false)}
|
||||
okText="提交保存"
|
||||
okLoading={createMut.isPending}
|
||||
onOk={async () => {
|
||||
const v = await form.validateFields();
|
||||
await createMut.mutateAsync(v);
|
||||
setCreating(false);
|
||||
}}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="合同名称" rules={[{ required: true }]}>
|
||||
<Input placeholder="例如:某某项目合同" />
|
||||
</Form.Item>
|
||||
{bucket === 'tender' ? (
|
||||
<Form.Item name="bidCaseId" label="关联中标事项" extra="中标后一般已自动生成草稿,无需再新建。仅补登历史合同时再选。">
|
||||
<Select allowClear showSearch optionFilterProp="label" options={bidOpts} />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
<Form.Item name="contractNo" label="正式合同编号" extra="合同编号由业务手填;没有正式合同可先空着,提交审批前必须填写。">
|
||||
<Input placeholder="纸质合同上的编号" />
|
||||
</Form.Item>
|
||||
<Form.Item name="amount" label="金额" rules={[{ required: true }]}>
|
||||
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="partyA" label="甲方">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="partyB" label="乙方">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="legalEntityId" label="我方主体">
|
||||
<Select allowClear showSearch optionFilterProp="label" options={entityOpts} />
|
||||
</Form.Item>
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</FormDrawer>
|
||||
<FormDrawer
|
||||
title="合同办理"
|
||||
open={Boolean(openRow)}
|
||||
onClose={() => setOpenRow(undefined)}
|
||||
hideOk
|
||||
width="min(1120px, 86vw)"
|
||||
createdAt={openRow?.createdAt as string | undefined}
|
||||
>
|
||||
{openRow ? <ContractDetailPage id={String(openRow.id)} embedded /> : null}
|
||||
</FormDrawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { Form, Input, InputNumber, Select } from 'antd';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../api/client';
|
||||
import { COST_TYPE_LABEL } from './financeStatus';
|
||||
import { tenderNo } from './bidStatus';
|
||||
|
||||
type Target = { id: string; bidNo?: string; externalNo?: string; projectNo?: string; name: string };
|
||||
|
||||
const FALLBACK_CATEGORY = ['交通', '餐饮', '住宿', '办公', '招待', '其他'].map((v) => ({ value: v, label: v }));
|
||||
|
||||
const COST_TYPE_HINT: Record<string, string> = {
|
||||
BID: '中标前的差旅、踏勘,选出下面那条投标。',
|
||||
PROJECT: '中标后已立项的项目。技术部门的车票、机票、住宿进技术预算;商务差旅进商务预算。',
|
||||
DEPT: '本部门日常开支,不用挂投标或项目。',
|
||||
ADMIN: '公司公共办公,不算某个部门或项目。',
|
||||
MARKETING: '市场、接待这类,不算投标售前。',
|
||||
};
|
||||
|
||||
export function CostObjectFields({
|
||||
disabled,
|
||||
showCategory,
|
||||
showPurpose,
|
||||
hideAmount,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
showCategory?: boolean;
|
||||
showPurpose?: boolean;
|
||||
hideAmount?: boolean;
|
||||
}) {
|
||||
const costType = Form.useWatch('costType');
|
||||
const budgetKind = Form.useWatch('budgetCategory');
|
||||
const q = useQuery({
|
||||
queryKey: ['cost-targets'],
|
||||
queryFn: () => api.get('/cost-targets'),
|
||||
});
|
||||
const cats = useQuery({
|
||||
queryKey: ['dict', 'expenseCategory'],
|
||||
queryFn: () => api.get('/system/dicts?type=expenseCategory'),
|
||||
enabled: Boolean(showCategory),
|
||||
});
|
||||
const bids = ((q.data?.data?.bids ?? []) as Target[]).map((b) => ({
|
||||
value: b.id,
|
||||
label: `${tenderNo(b)} ${b.name}`,
|
||||
}));
|
||||
const projects = ((q.data?.data?.projects ?? []) as Target[]).map((p) => ({
|
||||
value: p.id,
|
||||
label: `${p.projectNo} ${p.name}`,
|
||||
}));
|
||||
const categoryOpts =
|
||||
((cats.data?.data ?? []) as { label: string; value: string }[]).map((i) => ({
|
||||
value: i.value,
|
||||
label: i.label,
|
||||
}));
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
name="costType"
|
||||
label="费用归属"
|
||||
extra={COST_TYPE_HINT[costType] || '先选这笔费用算哪边。'}
|
||||
rules={[{ required: true, message: '请选择这笔费用算哪边' }]}
|
||||
>
|
||||
<Select
|
||||
disabled={disabled}
|
||||
placeholder="这笔费用算哪边"
|
||||
options={[
|
||||
{ value: 'BID', label: COST_TYPE_LABEL.BID },
|
||||
{ value: 'PROJECT', label: COST_TYPE_LABEL.PROJECT },
|
||||
{ value: 'DEPT', label: COST_TYPE_LABEL.DEPT },
|
||||
{ value: 'ADMIN', label: COST_TYPE_LABEL.ADMIN },
|
||||
{ value: 'MARKETING', label: COST_TYPE_LABEL.MARKETING },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
{costType === 'BID' ? (
|
||||
<Form.Item name="bidCaseId" label="投标事项" rules={[{ required: true, message: '请选择投标' }]}>
|
||||
<Select showSearch optionFilterProp="label" options={bids} disabled={disabled} placeholder="中标前费用挂招标编号" />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{costType === 'PROJECT' ? (
|
||||
<>
|
||||
<Form.Item name="projectId" label="正式项目" rules={[{ required: true, message: '请选择项目' }]}>
|
||||
<Select showSearch optionFilterProp="label" options={projects} disabled={disabled} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="budgetCategory"
|
||||
label="花费类型"
|
||||
extra="车票、机票、住宿、打车选差旅;买设备材料选采购。"
|
||||
rules={[{ required: true, message: '请选择差旅或采购' }]}
|
||||
>
|
||||
<Select
|
||||
disabled={disabled}
|
||||
placeholder="差旅或采购"
|
||||
options={[
|
||||
{ value: 'TRAVEL', label: '差旅(车票 / 机票 / 住宿)' },
|
||||
{ value: 'PURCHASE', label: '采购(设备 / 材料)' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
{budgetKind !== 'PURCHASE' ? (
|
||||
<Form.Item
|
||||
name="budgetEnvelope"
|
||||
label="预算归属"
|
||||
extra="技术部门报销、代买技术人员的差旅一律进技术预算。商务自己出差进商务预算。"
|
||||
rules={[{ required: true, message: '请选择商务或技术预算' }]}
|
||||
>
|
||||
<Select
|
||||
disabled={disabled}
|
||||
placeholder="这笔差旅算商务还是技术"
|
||||
options={[
|
||||
{ value: 'TECH', label: '技术预算' },
|
||||
{ value: 'BUSINESS', label: '商务预算' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
{showCategory ? (
|
||||
<Form.Item name="expenseCategory" label="费用类别" rules={[{ required: true, message: '请选择交通、餐饮等类别' }]}>
|
||||
<Select options={categoryOpts.length ? categoryOpts : FALLBACK_CATEGORY} disabled={disabled} placeholder="交通 / 餐饮 / 住宿 / 办公 / 招待" />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{showPurpose ? (
|
||||
<Form.Item name="purpose" label="用途" rules={[{ required: true, message: '请填写用途' }]}>
|
||||
<Input placeholder="如:出差投标打车、现场踏勘" disabled={disabled} />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{hideAmount ? null : (
|
||||
<Form.Item name="amount" label="金额" rules={[{ required: true, message: '请填写金额' }]}>
|
||||
<InputNumber min={0.01} style={{ width: '100%' }} disabled={disabled} />
|
||||
</Form.Item>
|
||||
)}
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={2} disabled={disabled} placeholder="选填" />
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { Alert, Button, Input, QRCode, Typography } from 'antd';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api/client';
|
||||
import { useAuth } from '../store/auth';
|
||||
import AmbientBackground from '../components/AmbientBackground';
|
||||
import { BrandLogo } from '../components/BrandLogo';
|
||||
import { SiteFooter } from '../components/SiteFooter';
|
||||
|
||||
type QrStart = { ticket: string; expiresAt: string; payload: string };
|
||||
|
||||
export default function DesktopLoginPage() {
|
||||
const navigate = useNavigate();
|
||||
const acceptSession = useAuth((s) => s.acceptSession);
|
||||
const [ticket, setTicket] = useState('');
|
||||
const [payload, setPayload] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [hint, setHint] = useState('打开手机端「消息」右上角 + → 扫一扫,扫描此二维码');
|
||||
const [mobileCode, setMobileCode] = useState('');
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const start = useCallback(async () => {
|
||||
setError('');
|
||||
const res = await api.post('/auth/qr/start', {});
|
||||
const data = (res.data || res) as QrStart;
|
||||
setTicket(data.ticket);
|
||||
setPayload(data.payload || `fengyingoa://qr?ticket=${data.ticket}`);
|
||||
setMobileCode('');
|
||||
setHint('打开手机端「消息」右上角 + → 扫一扫,扫描此二维码');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void start().catch((e) => setError(e instanceof Error ? e.message : '无法生成登录码'));
|
||||
}, [start]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ticket) return;
|
||||
const timer = window.setInterval(async () => {
|
||||
try {
|
||||
const res = await api.get(`/auth/qr/status?ticket=${encodeURIComponent(ticket)}`);
|
||||
const data = (res.data || res) as {
|
||||
status?: string;
|
||||
accessToken?: string;
|
||||
refreshToken?: string;
|
||||
clientKind?: string;
|
||||
};
|
||||
if (data.status === 'code_required') {
|
||||
setHint('手机已确认,请输入手机屏幕显示的六位登录码');
|
||||
return;
|
||||
}
|
||||
if (data.status === 'expired' || data.status === 'consumed') {
|
||||
window.clearInterval(timer);
|
||||
setHint('登录码已过期,正在刷新…');
|
||||
await start();
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '查询登录状态失败');
|
||||
}
|
||||
}, 1600);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [ticket, acceptSession, navigate, start]);
|
||||
|
||||
const complete = async () => {
|
||||
if (!/^\d{6}$/.test(mobileCode)) {
|
||||
setError('请输入手机端显示的六位登录码');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
setError('');
|
||||
try {
|
||||
const res = await api.post('/auth/qr/complete', { ticket, code: mobileCode });
|
||||
const data = (res.data || res) as { accessToken?: string; refreshToken?: string; clientKind?: string };
|
||||
if (!data.accessToken) throw new Error('电脑登录未完成');
|
||||
await acceptSession(data.accessToken, data.refreshToken, data.clientKind || 'desktop');
|
||||
navigate('/workbench');
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '登录码校验失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ops-login">
|
||||
<AmbientBackground />
|
||||
<div className="ops-login-wrap">
|
||||
<div className="ops-login-card" style={{ textAlign: 'center' }}>
|
||||
<div className="login-mark">
|
||||
<BrandLogo size={72} />
|
||||
</div>
|
||||
<h1>电脑端登录</h1>
|
||||
<p className="login-sub">请用已登录的手机 App 扫码并输入手机生成的六位码,不可用账号密码登录电脑端</p>
|
||||
{error ? <Alert type="error" message={error} style={{ marginBottom: 16 }} /> : null}
|
||||
{payload ? (
|
||||
<div style={{ display: 'flex', justifyContent: 'center', margin: '16px 0' }}>
|
||||
<QRCode value={payload} size={220} />
|
||||
</div>
|
||||
) : null}
|
||||
<Typography.Paragraph type="secondary" copyable>{ticket || '……'}</Typography.Paragraph>
|
||||
<Typography.Paragraph type="secondary">{hint}</Typography.Paragraph>
|
||||
<Input.OTP
|
||||
length={6}
|
||||
value={mobileCode}
|
||||
onChange={setMobileCode}
|
||||
formatter={(value) => value.replace(/\D/g, '')}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<br />
|
||||
<Button type="primary" onClick={() => void complete()} loading={submitting} disabled={!ticket} style={{ marginRight: 8 }}>
|
||||
确认电脑登录
|
||||
</Button>
|
||||
<Button onClick={() => void start()}>刷新登录码</Button>
|
||||
</div>
|
||||
</div>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Button, Table } from 'antd';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api/client';
|
||||
import { PageHeader } from './ResourcePage';
|
||||
import { useAuth } from '../store/auth';
|
||||
|
||||
type StaffRow = {
|
||||
id: string;
|
||||
name: string;
|
||||
username?: string;
|
||||
department?: string | null;
|
||||
title?: string | null;
|
||||
mobile?: string | null;
|
||||
email?: string | null;
|
||||
};
|
||||
|
||||
export default function DirectoryPage() {
|
||||
const q = useQuery({ queryKey: ['/staff'], queryFn: () => api.get('/staff') });
|
||||
const items = ((q.data as { data?: StaffRow[] })?.data || []) as StaffRow[];
|
||||
const navigate = useNavigate();
|
||||
const me = useAuth((s) => s.id);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="员工通讯" description="在职同事的部门和手机。点「发消息」进入私聊;群聊、通话在「消息」里。" />
|
||||
<Table
|
||||
className="ops-table"
|
||||
rowKey="id"
|
||||
loading={q.isLoading}
|
||||
dataSource={items}
|
||||
pagination={{ defaultPageSize: 20, showSizeChanger: true, showTotal: (n) => `共 ${n} 人` }}
|
||||
columns={[
|
||||
{ title: '姓名', dataIndex: 'name', width: 120 },
|
||||
{ title: '部门', dataIndex: 'department', width: 160, render: (v: string) => v || '—' },
|
||||
{ title: '职务', dataIndex: 'title', width: 140, render: (v: string) => v || '—' },
|
||||
{ title: '手机', dataIndex: 'mobile', width: 140, render: (v: string) => v || '—' },
|
||||
{ title: '邮箱', dataIndex: 'email', width: 220, render: (v: string) => v || '—' },
|
||||
{
|
||||
title: '沟通',
|
||||
width: 100,
|
||||
render: (_: unknown, r: StaffRow) =>
|
||||
r.id === me ? (
|
||||
'—'
|
||||
) : (
|
||||
<Button type="link" onClick={() => navigate(`/office/im?peerId=${r.id}&name=${encodeURIComponent(r.name)}`)}>
|
||||
发消息
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,482 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Alert, Button, Descriptions, Form, Select, Space, Spin, Table, Tag, message } from 'antd';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import dayjs from 'dayjs';
|
||||
import { api } from '../api/client';
|
||||
import { FormDrawer } from './FormDrawer';
|
||||
import { CostObjectFields } from './CostObjectFields';
|
||||
import { ExpenseInvoiceFields, type LineFile } from './ExpenseInvoiceFields';
|
||||
import { BizFileActions, FileAttachments } from './FileAttachments';
|
||||
import { belongLabel, CostTargetLink } from './FinanceCells';
|
||||
import {
|
||||
COST_TYPE_LABEL,
|
||||
EXPENSE_LINE_KIND_LABEL,
|
||||
EXPENSE_STATUS_META,
|
||||
BUDGET_CATEGORY_LABEL,
|
||||
money,
|
||||
} from './financeStatus';
|
||||
|
||||
type ExpenseLine = {
|
||||
id?: string;
|
||||
kind: string;
|
||||
amount: string | number;
|
||||
invoiceNo?: string | null;
|
||||
occurAt?: string | null;
|
||||
remark?: string | null;
|
||||
files?: LineFile[];
|
||||
};
|
||||
|
||||
type Expense = {
|
||||
id: string;
|
||||
claimNo: string;
|
||||
costType: string;
|
||||
amount: string | number;
|
||||
status: string;
|
||||
remark?: string | null;
|
||||
rejectComment?: string | null;
|
||||
budgetCategory?: string | null;
|
||||
bidCaseId?: string | null;
|
||||
projectId?: string | null;
|
||||
createdAt?: string;
|
||||
loanId?: string | null;
|
||||
expenseCategory?: string | null;
|
||||
purpose?: string | null;
|
||||
prepaid?: boolean;
|
||||
payerId?: string | null;
|
||||
payer?: { displayName: string } | null;
|
||||
lines?: ExpenseLine[];
|
||||
bidCase?: { id: string; bidNo: string; name: string } | null;
|
||||
project?: { id: string; projectNo: string; name: string } | null;
|
||||
loan?: { id: string; loanNo: string; status: string } | null;
|
||||
applicant?: { displayName: string; employee?: { department?: { name?: string } } };
|
||||
files?: { id: string; fileName: string }[];
|
||||
payout?: { kind: string; name?: string | null; bankName?: string | null; bankAccount?: string | null };
|
||||
payment?: { id: string; payNo: string; status: string } | null;
|
||||
myActions?: {
|
||||
canEdit: boolean;
|
||||
canSubmit: boolean;
|
||||
canDelete?: boolean;
|
||||
canReview: boolean;
|
||||
canAttachFiles?: boolean;
|
||||
canLinkLoan?: boolean;
|
||||
};
|
||||
approvalChain?: { role: string; name: string; status: string }[];
|
||||
};
|
||||
|
||||
function packExpense(v: Record<string, unknown>) {
|
||||
const lines = ((v.lines as ExpenseLine[]) || []).map((l) => ({
|
||||
id: l.id,
|
||||
kind: l.kind,
|
||||
amount: Number(l.amount) > 0 ? Number(l.amount) : 0,
|
||||
invoiceNo: l.invoiceNo || undefined,
|
||||
occurAt: l.occurAt ? dayjs(l.occurAt).format('YYYY-MM-DD') : undefined,
|
||||
remark: l.remark || undefined,
|
||||
}));
|
||||
const kind = v.budgetCategory === 'PURCHASE' ? 'PURCHASE' : 'TRAVEL';
|
||||
const envelope = v.budgetEnvelope === 'TECH' || v.budgetCategory === 'TECH' ? 'TECH' : 'BUSINESS';
|
||||
return {
|
||||
...v,
|
||||
prepaid: Boolean(v.prepaid),
|
||||
payerId: v.prepaid ? v.payerId : null,
|
||||
lines,
|
||||
amount: lines.reduce((s, l) => s + Number(l.amount || 0), 0),
|
||||
budgetCategory: v.costType === 'PROJECT' ? (kind === 'PURCHASE' ? 'PURCHASE' : envelope === 'TECH' ? 'TECH' : 'TRAVEL') : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
export default function ExpenseDetailPage() {
|
||||
const { id } = useParams();
|
||||
const [params] = useSearchParams();
|
||||
const location = useLocation();
|
||||
const onDesk = location.pathname.startsWith('/finance');
|
||||
const listPath = onDesk ? '/finance/expenses' : '/office/expenses';
|
||||
const isNew = id === 'new' || !id;
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const [form] = Form.useForm();
|
||||
const bootstrapped = useRef(false);
|
||||
const [createTick, setCreateTick] = useState(0);
|
||||
const [creating, setCreating] = useState(isNew);
|
||||
const [createError, setCreateError] = useState<string | null>(null);
|
||||
const [linkLoanId, setLinkLoanId] = useState<string>();
|
||||
const prepaid = Form.useWatch('prepaid', form);
|
||||
|
||||
const q = useQuery({
|
||||
queryKey: ['expense', id],
|
||||
enabled: !isNew && Boolean(id),
|
||||
queryFn: () => api.get(`/expenses/${id}`),
|
||||
});
|
||||
const loans = useQuery({
|
||||
queryKey: ['loans', 'mine-for-offset'],
|
||||
queryFn: () => api.get('/loans?bucket=mine&pageSize=100'),
|
||||
});
|
||||
const row = q.data?.data as Expense | undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNew || bootstrapped.current) return;
|
||||
bootstrapped.current = true;
|
||||
setCreating(true);
|
||||
setCreateError(null);
|
||||
(async () => {
|
||||
try {
|
||||
const res = (await api.post('/expenses', {
|
||||
costType: params.get('costType') || 'BID',
|
||||
bidCaseId: params.get('bidCaseId') || undefined,
|
||||
projectId: params.get('projectId') || undefined,
|
||||
loanId: params.get('loanId') || undefined,
|
||||
prepaid: false,
|
||||
lines: [{ kind: 'AIR', amount: 0 }],
|
||||
})) as { data?: { id?: string } };
|
||||
if (res.data?.id) navigate(`${listPath}/${res.data.id}`, { replace: true });
|
||||
} catch (e) {
|
||||
bootstrapped.current = false;
|
||||
setCreateError((e as Error).message || '新建报销单失败');
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
})();
|
||||
}, [isNew, listPath, navigate, params, createTick]);
|
||||
|
||||
const lineIds = (row?.lines || []).map((l) => l.id).join(',');
|
||||
useEffect(() => {
|
||||
if (isNew || !row) return;
|
||||
const currentIds = ((form.getFieldValue('lines') as { id?: string }[]) || []).map((l) => l.id).join(',');
|
||||
if (currentIds && currentIds === lineIds) return;
|
||||
form.setFieldsValue({
|
||||
costType: row.costType,
|
||||
bidCaseId: row.bidCaseId || undefined,
|
||||
projectId: row.projectId || undefined,
|
||||
loanId: row.loanId || undefined,
|
||||
amount: Number(row.amount) || undefined,
|
||||
remark: row.remark || undefined,
|
||||
budgetCategory: row.budgetCategory === 'TECH' ? 'TRAVEL' : row.budgetCategory || undefined,
|
||||
budgetEnvelope: row.budgetCategory === 'TECH' || row.budgetCategory === 'PURCHASE' ? 'TECH' : 'BUSINESS',
|
||||
prepaid: Boolean(row.prepaid),
|
||||
payerId: row.payerId || undefined,
|
||||
lines: row.lines?.length
|
||||
? row.lines.map((l) => ({
|
||||
id: l.id,
|
||||
kind: l.kind,
|
||||
amount: Number(l.amount) || undefined,
|
||||
invoiceNo: l.invoiceNo || undefined,
|
||||
occurAt: l.occurAt ? dayjs(l.occurAt) : undefined,
|
||||
remark: l.remark || undefined,
|
||||
}))
|
||||
: [{ kind: 'AIR' }],
|
||||
});
|
||||
}, [isNew, row, form, lineIds]);
|
||||
|
||||
const invalidate = () => qc.invalidateQueries();
|
||||
const persist = async (lines?: Record<string, unknown>[]) => {
|
||||
const body = packExpense({ ...form.getFieldsValue(), ...(lines ? { lines } : {}) });
|
||||
const res = (isNew
|
||||
? await api.post('/expenses', body)
|
||||
: await api.patch(`/expenses/${id}`, body)) as { data?: Expense };
|
||||
if (res.data) qc.setQueryData(['expense', res.data.id], { data: res.data });
|
||||
if (isNew && res.data?.id) navigate(`${listPath}/${res.data.id}`, { replace: true });
|
||||
return res.data;
|
||||
};
|
||||
const saveMut = useMutation({
|
||||
mutationFn: async () => persist(),
|
||||
onSuccess: () => message.success('已保存草稿'),
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
const submitMut = useMutation({
|
||||
mutationFn: async (savedId?: string) => {
|
||||
const target = savedId || id;
|
||||
if (!target || target === 'new') throw new Error('请先保存');
|
||||
return api.post(`/expenses/${target}/submit`);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
message.success('已提交审批');
|
||||
await invalidate();
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
const reviewMut = useMutation({
|
||||
mutationFn: (body: { result: 'APPROVED' | 'REJECTED'; comment?: string }) =>
|
||||
api.post(`/expenses/${id}/reviews`, body),
|
||||
onSuccess: async () => {
|
||||
message.success('已办理');
|
||||
await invalidate();
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
const linkLoanMut = useMutation({
|
||||
mutationFn: (loanId: string) => api.post(`/expenses/${id}/link-loan`, { loanId }),
|
||||
onSuccess: async () => {
|
||||
message.success('已关联冲账借款,并同步报销归属');
|
||||
setLinkLoanId(undefined);
|
||||
await invalidate();
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
|
||||
const allLoans = (loans.data?.data?.items ?? []) as {
|
||||
id: string;
|
||||
loanNo: string;
|
||||
remaining?: number;
|
||||
costType: string;
|
||||
status: string;
|
||||
bidCaseId?: string | null;
|
||||
projectId?: string | null;
|
||||
bidCase?: { bidNo?: string | null; name?: string | null } | null;
|
||||
project?: { projectNo?: string | null; name?: string | null } | null;
|
||||
}[];
|
||||
const offsetLoans = allLoans.filter((l) => Number(l.remaining || 0) > 0 && ['OPEN', 'PENDING'].includes(l.status));
|
||||
const openLoanCount = offsetLoans.filter((l) => l.status === 'OPEN').length;
|
||||
const loanTarget = (l: (typeof offsetLoans)[number]) => {
|
||||
if (l.costType === 'BID') return l.bidCase?.name || l.bidCase?.bidNo || '投标费用';
|
||||
if (l.costType === 'PROJECT') return l.project?.name || l.project?.projectNo || '项目费用';
|
||||
return l.costType === 'ADMIN' ? '行政费用' : '部门费用';
|
||||
};
|
||||
|
||||
const meta = EXPENSE_STATUS_META[row?.status || 'DRAFT'] || EXPENSE_STATUS_META.DRAFT;
|
||||
const editable = isNew || row?.myActions?.canEdit;
|
||||
|
||||
return (
|
||||
<FormDrawer
|
||||
title={isNew ? '填报销' : row?.claimNo || '报销单'}
|
||||
extra="每张票都可以挂图片或文件。机票高铁住宿由别人垫付时打开代付。"
|
||||
open
|
||||
onClose={() => navigate(listPath)}
|
||||
hideOk
|
||||
createdAt={isNew ? undefined : row?.createdAt}
|
||||
>
|
||||
{row ? (
|
||||
<Space style={{ marginBottom: 16 }}>
|
||||
<Tag color={meta.color}>{meta.text}</Tag>
|
||||
<Tag>{COST_TYPE_LABEL[row.costType] || row.costType}</Tag>
|
||||
{row.bidCase ? <Link to={`/bid/cases/${row.bidCase.id}`}>{row.bidCase.bidNo}</Link> : null}
|
||||
{row.project ? <Link to={`/project/${row.project.id}`}>{row.project.projectNo}</Link> : null}
|
||||
{row.budgetCategory ? <Tag>{BUDGET_CATEGORY_LABEL[row.budgetCategory] || row.budgetCategory}</Tag> : null}
|
||||
</Space>
|
||||
) : null}
|
||||
{row?.status === 'REJECTED' ? (
|
||||
<Alert type="error" showIcon style={{ marginBottom: 16 }} message={`被打回:${row.rejectComment || ''}`} />
|
||||
) : null}
|
||||
{row?.approvalChain?.length ? (
|
||||
<p style={{ color: 'var(--mute)', marginBottom: 16 }}>
|
||||
审批:
|
||||
{row.approvalChain
|
||||
.map((s) => {
|
||||
const st = s.status === 'APPROVED' ? '已过' : s.status === 'PENDING' ? '待审' : s.status === 'REJECTED' ? '驳回' : '等待';
|
||||
return `${s.role} ${s.name}(${st})`;
|
||||
})
|
||||
.join(' → ')}
|
||||
</p>
|
||||
) : null}
|
||||
{row?.myActions?.canReview ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="请确认这笔费用。通过后进入下一岗;最后一岗通过后,若挂了借款将冲减未还金额。"
|
||||
action={
|
||||
<Space>
|
||||
<Button type="primary" onClick={() => reviewMut.mutate({ result: 'APPROVED' })}>
|
||||
通过
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
onClick={() => {
|
||||
const comment = window.prompt('打回意见');
|
||||
if (comment) reviewMut.mutate({ result: 'REJECTED', comment });
|
||||
}}
|
||||
>
|
||||
打回
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!editable && row ? (
|
||||
<Descriptions bordered size="small" column={2} style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="金额">{money(row.amount)}</Descriptions.Item>
|
||||
<Descriptions.Item label="申请人">{row.applicant?.displayName || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="代付">{row.prepaid ? row.payer?.displayName || '已代付' : '自付'}</Descriptions.Item>
|
||||
<Descriptions.Item label="打款到">
|
||||
{row.payout?.kind === 'OFFSET'
|
||||
? '冲借款,不打款'
|
||||
: row.payout?.name
|
||||
? `${row.payout.kind === 'PAYER' ? '代付人' : '申请人'} ${row.payout.name}`
|
||||
: '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="收款账户">
|
||||
{row.payout?.kind === 'OFFSET'
|
||||
? '—'
|
||||
: [row.payout?.bankName, row.payout?.bankAccount].filter(Boolean).join(' ') || '档案缺银行卡'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="付款单">{row.payment?.payNo || (row.status === 'APPROVED' ? '待生成' : '通过后进入付款执行')}</Descriptions.Item>
|
||||
<Descriptions.Item label="归属">{belongLabel(row)}</Descriptions.Item>
|
||||
<Descriptions.Item label="对象">
|
||||
<CostTargetLink costType={row.costType} bid={row.bidCase} project={row.project} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="借款冲账">{row.prepaid ? '代付不冲账' : row.loan?.loanNo || '无'}</Descriptions.Item>
|
||||
<Descriptions.Item label="备注" span={2}>
|
||||
{row.remark || '—'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
) : null}
|
||||
{!editable && row?.myActions?.canLinkLoan ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="关联冲账"
|
||||
description="可在审批开始前关联一笔本人已放款借款;选择后会同步报销的项目或投标归属,审批通过后自动冲减该借款余额。"
|
||||
action={
|
||||
<Space direction="vertical" size={8} style={{ minWidth: 300 }}>
|
||||
<Select
|
||||
allowClear
|
||||
value={linkLoanId}
|
||||
onChange={setLinkLoanId}
|
||||
placeholder="选择本人未结清借款"
|
||||
options={offsetLoans
|
||||
.filter((loan) => loan.status === 'OPEN')
|
||||
.map((loan) => ({
|
||||
value: loan.id,
|
||||
label: `${loan.loanNo} · ${loanTarget(loan)} · 未还 ${loan.remaining ?? 0}`,
|
||||
}))}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={!linkLoanId}
|
||||
loading={linkLoanMut.isPending}
|
||||
onClick={() => linkLoanId && linkLoanMut.mutate(linkLoanId)}
|
||||
>
|
||||
关联冲账
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{!editable && row?.lines?.length ? (
|
||||
<Table
|
||||
size="small"
|
||||
pagination={false}
|
||||
style={{ marginBottom: 24 }}
|
||||
rowKey={(_, i) => String(i)}
|
||||
dataSource={row.lines}
|
||||
columns={[
|
||||
{ title: '票种', dataIndex: 'kind', width: 90, render: (v: string) => EXPENSE_LINE_KIND_LABEL[v] || v },
|
||||
{ title: '金额', dataIndex: 'amount', width: 110, render: (v: string) => money(v) },
|
||||
{ title: '发票号', dataIndex: 'invoiceNo', render: (v: string) => v || '—' },
|
||||
{ title: '日期', dataIndex: 'occurAt', width: 120, render: (v: string) => (v ? String(v).slice(0, 10) : '—') },
|
||||
{ title: '说明', dataIndex: 'remark', render: (v: string) => v || '—' },
|
||||
{
|
||||
title: '附件',
|
||||
render: (_: unknown, r: ExpenseLine) =>
|
||||
r.files?.length
|
||||
? r.files.map((f) => (
|
||||
<BizFileActions
|
||||
key={f.id}
|
||||
file={{ id: f.id, fileName: f.fileName, size: 0, mimeType: '' }}
|
||||
/>
|
||||
))
|
||||
: '—',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
{!editable && row ? (
|
||||
<FileAttachments
|
||||
bizType="EXPENSE"
|
||||
bizId={row.id}
|
||||
disabled={!row.myActions?.canAttachFiles}
|
||||
label="报销附件"
|
||||
/>
|
||||
) : null}
|
||||
{isNew && creating ? (
|
||||
<div style={{ padding: '48px 0', textAlign: 'center' }}>
|
||||
<Spin />
|
||||
<p style={{ color: 'var(--mute)', marginTop: 12 }}>正在打开报销单,请稍候…</p>
|
||||
</div>
|
||||
) : null}
|
||||
{isNew && createError ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="报销单没有建出来"
|
||||
description={createError}
|
||||
action={
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => {
|
||||
bootstrapped.current = false;
|
||||
setCreateTick((n) => n + 1);
|
||||
}}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{!editable && row ? null : isNew ? null : (
|
||||
<Form form={form} layout="vertical" style={{ maxWidth: 720 }} onFinish={() => saveMut.mutate()}>
|
||||
<CostObjectFields disabled={!editable} hideAmount />
|
||||
<ExpenseInvoiceFields
|
||||
disabled={!editable}
|
||||
lineFiles={Object.fromEntries((row?.lines || []).filter((l) => l.id).map((l) => [l.id as string, l.files || []]))}
|
||||
onPersist={persist}
|
||||
/>
|
||||
{prepaid ? null : (
|
||||
<Form.Item
|
||||
name="loanId"
|
||||
label="冲账借款"
|
||||
extra={
|
||||
openLoanCount
|
||||
? '选择已放款借款后,会自动带入对应的投标/项目;报销审批通过后自动冲减该借款余额。'
|
||||
: '暂无已放款、可冲销的借款。待财务确认的借款会在确认放款后自动变为可选。'
|
||||
}
|
||||
>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="选择本人未结清借款"
|
||||
onChange={(loanId) => {
|
||||
const loan = offsetLoans.find((l) => l.id === loanId);
|
||||
if (!loan || loan.status !== 'OPEN') return;
|
||||
form.setFieldsValue({
|
||||
costType: loan.costType,
|
||||
bidCaseId: loan.bidCaseId || undefined,
|
||||
projectId: loan.projectId || undefined,
|
||||
});
|
||||
}}
|
||||
options={offsetLoans.map((l) => ({
|
||||
value: l.id,
|
||||
disabled: l.status !== 'OPEN',
|
||||
label: `${l.loanNo} · ${loanTarget(l)} · 未还 ${l.remaining ?? 0}${l.status === 'PENDING' ? '(待财务确认)' : ''}`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
)}
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" loading={saveMut.isPending}>
|
||||
保存草稿
|
||||
</Button>
|
||||
<Button
|
||||
loading={submitMut.isPending}
|
||||
onClick={async () => {
|
||||
await form.validateFields();
|
||||
const saved = await persist();
|
||||
const target = saved?.id || id;
|
||||
if (target && target !== 'new') await submitMut.mutateAsync(target);
|
||||
}}
|
||||
>
|
||||
保存并提交
|
||||
</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
)}
|
||||
{!editable && row?.myActions?.canSubmit ? (
|
||||
<Button type="primary" onClick={() => submitMut.mutate()} loading={submitMut.isPending}>
|
||||
提交确认
|
||||
</Button>
|
||||
) : null}
|
||||
{null}
|
||||
</FormDrawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
import { Alert, Button, Card, Col, DatePicker, Form, Input, InputNumber, Row, Select, Space, Switch, Typography, Upload, message } from 'antd';
|
||||
import type { UploadFile } from 'antd/es/upload/interface';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '../api/client';
|
||||
import { useState } from 'react';
|
||||
import { AttachmentPreviewModal, displayFileName, loadBizPreview, type FilePreview } from './FileAttachments';
|
||||
import { EXPENSE_LINE_KIND_OPTIONS, money } from './financeStatus';
|
||||
|
||||
type Staff = {
|
||||
id: string;
|
||||
name: string;
|
||||
department?: string;
|
||||
bankReady?: boolean;
|
||||
bankName?: string | null;
|
||||
bankAccountMasked?: string | null;
|
||||
};
|
||||
|
||||
export type LineFile = { id: string; fileName: string };
|
||||
|
||||
function LineAttach({
|
||||
lineId,
|
||||
files,
|
||||
disabled,
|
||||
onNeedLine,
|
||||
}: {
|
||||
lineId?: string;
|
||||
files?: LineFile[];
|
||||
disabled?: boolean;
|
||||
onNeedLine: () => Promise<string | undefined>;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const [preview, setPreview] = useState<FilePreview | null>(null);
|
||||
const fileList: UploadFile[] = (files || []).map((f) => ({
|
||||
uid: f.id,
|
||||
name: displayFileName(f.fileName),
|
||||
status: 'done',
|
||||
}));
|
||||
|
||||
return (
|
||||
<Form.Item label="附件" extra="图片或文件都可以,一次能选多张,立刻挂到这一行。">
|
||||
<Upload
|
||||
multiple
|
||||
listType="picture-card"
|
||||
accept="image/*,.pdf,.doc,.docx,.xls,.xlsx"
|
||||
fileList={fileList}
|
||||
disabled={disabled}
|
||||
beforeUpload={() => true}
|
||||
onPreview={async (file) => {
|
||||
if (!file.uid) return;
|
||||
try {
|
||||
const next = await loadBizPreview(file.uid, undefined, file.name);
|
||||
if (next) setPreview(next);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '无法打开附件');
|
||||
}
|
||||
}}
|
||||
onRemove={async (file) => {
|
||||
if (disabled || !file.uid) return false;
|
||||
try {
|
||||
await api.delete(`/files/${file.uid}`);
|
||||
message.success('已删除');
|
||||
qc.invalidateQueries({ queryKey: ['expense'] });
|
||||
return true;
|
||||
} catch (e) {
|
||||
message.error((e as Error).message);
|
||||
return false;
|
||||
}
|
||||
}}
|
||||
customRequest={async (opt) => {
|
||||
const file = opt.file as File;
|
||||
try {
|
||||
const target = lineId || (await onNeedLine());
|
||||
if (!target) throw new Error('发票还没建起来,请稍后再传');
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fd.append('bizType', 'EXPENSE_LINE');
|
||||
fd.append('bizId', target);
|
||||
await api.post('/files', fd);
|
||||
qc.invalidateQueries({ queryKey: ['expense'] });
|
||||
opt.onSuccess?.({}, new XMLHttpRequest());
|
||||
} catch (e) {
|
||||
const err = e as Error;
|
||||
message.error(err.message);
|
||||
opt.onError?.(err);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{disabled ? null : <div>上传</div>}
|
||||
</Upload>
|
||||
<AttachmentPreviewModal
|
||||
preview={preview}
|
||||
onClose={() => {
|
||||
if (preview?.kind === 'image') URL.revokeObjectURL(preview.url);
|
||||
setPreview(null);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExpenseInvoiceFields({
|
||||
disabled,
|
||||
lineFiles,
|
||||
onPersist,
|
||||
}: {
|
||||
disabled?: boolean;
|
||||
lineFiles?: Record<string, LineFile[]>;
|
||||
onPersist?: (lines: Record<string, unknown>[]) => Promise<{ id?: string; lines?: { id?: string }[] } | undefined>;
|
||||
}) {
|
||||
const form = Form.useFormInstance();
|
||||
const prepaid = Form.useWatch('prepaid');
|
||||
const payerId = Form.useWatch('payerId');
|
||||
const lines = Form.useWatch('lines') as { id?: string; amount?: number }[] | undefined;
|
||||
const staff = useQuery({
|
||||
queryKey: ['/staff'],
|
||||
queryFn: () => api.get('/staff'),
|
||||
});
|
||||
const sum = (lines || []).reduce((s, l) => s + Number(l?.amount || 0), 0);
|
||||
const people = ((staff.data?.data ?? []) as Staff[]).map((s) => ({
|
||||
value: s.id,
|
||||
label: s.department ? `${s.name} · ${s.department}` : s.name,
|
||||
}));
|
||||
const payer = ((staff.data?.data ?? []) as Staff[]).find((s) => s.id === payerId);
|
||||
|
||||
const persistLines = async (next: Record<string, unknown>[]) => {
|
||||
if (!onPersist) return undefined;
|
||||
const saved = await onPersist(next);
|
||||
if (saved?.lines?.length) {
|
||||
form.setFieldsValue({
|
||||
lines: next.map((l, i) => ({ ...l, id: saved.lines?.[i]?.id || l.id })),
|
||||
});
|
||||
}
|
||||
return saved;
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
name="prepaid"
|
||||
label="是否代付"
|
||||
valuePropName="checked"
|
||||
extra="机票、高铁、火车票、住宿等由公司里别人垫付或对公支付时打开。通过后打款到代付人银行卡,不冲申请人的借款。"
|
||||
>
|
||||
<Switch disabled={disabled} checkedChildren="代付" unCheckedChildren="自付" />
|
||||
</Form.Item>
|
||||
{prepaid ? (
|
||||
<>
|
||||
<Form.Item name="payerId" label="代付人" rules={[{ required: true, message: '请选择代付人' }]}>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
options={people}
|
||||
disabled={disabled}
|
||||
placeholder="谁垫付或对公支付的"
|
||||
/>
|
||||
</Form.Item>
|
||||
{payer ? (
|
||||
payer.bankReady ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message={`通过后打款到 ${payer.name} · ${payer.bankName || '开户行未填'} ${payer.bankAccountMasked}`}
|
||||
/>
|
||||
) : (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message={`${payer.name} 的人事档案没有银行账号,先补卡号才能代付。`}
|
||||
/>
|
||||
)
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Form.List
|
||||
name="lines"
|
||||
rules={[
|
||||
{
|
||||
validator: async (_, value) => {
|
||||
if (!value?.length) throw new Error('至少填一张发票');
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
{(fields, { add, remove }, { errors }) => (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 600 }}>发票明细</div>
|
||||
{fields.map((field, index) => {
|
||||
const lineId = lines?.[index]?.id;
|
||||
return (
|
||||
<Card
|
||||
key={field.key}
|
||||
size="small"
|
||||
title={`发票 ${index + 1}`}
|
||||
extra={
|
||||
disabled || fields.length <= 1 ? null : (
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
onClick={async () => {
|
||||
remove(field.name);
|
||||
const next = ((form.getFieldValue('lines') as Record<string, unknown>[]) || []).filter((_, i) => i !== index);
|
||||
await persistLines(next);
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
style={{ marginBottom: 12 }}
|
||||
>
|
||||
<Form.Item name={[field.name, 'id']} hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Row gutter={12}>
|
||||
<Col span={8}>
|
||||
<Form.Item
|
||||
name={[field.name, 'kind']}
|
||||
label="票种"
|
||||
rules={[{ required: true, message: '请选票种' }]}
|
||||
>
|
||||
<Select options={EXPENSE_LINE_KIND_OPTIONS} disabled={disabled} placeholder="机票 / 高铁 / 住宿" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item
|
||||
name={[field.name, 'amount']}
|
||||
label="金额"
|
||||
rules={[{ required: true, message: '请填写金额' }]}
|
||||
>
|
||||
<InputNumber min={0.01} style={{ width: '100%' }} disabled={disabled} placeholder="发票金额" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name={[field.name, 'invoiceNo']} label="发票号">
|
||||
<Input disabled={disabled} placeholder="选填" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Form.Item name={[field.name, 'occurAt']} label="发生日期">
|
||||
<DatePicker style={{ width: '100%' }} disabled={disabled} />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={16}>
|
||||
<Form.Item name={[field.name, 'remark']} label="说明">
|
||||
<Input disabled={disabled} placeholder="如:上海—杭州高铁、希尔顿一晚" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<LineAttach
|
||||
lineId={lineId}
|
||||
files={lineId ? lineFiles?.[lineId] : undefined}
|
||||
disabled={disabled}
|
||||
onNeedLine={async () => {
|
||||
const current = (form.getFieldValue('lines') as Record<string, unknown>[]) || [];
|
||||
const saved = await persistLines(current);
|
||||
return saved?.lines?.[index]?.id;
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
{disabled ? null : (
|
||||
<Space>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
add({ kind: 'AIR' });
|
||||
const next = [...((form.getFieldValue('lines') as Record<string, unknown>[]) || [])];
|
||||
await persistLines(next);
|
||||
}}
|
||||
>
|
||||
添加发票
|
||||
</Button>
|
||||
<Typography.Text type="secondary">合计 {money(sum)}</Typography.Text>
|
||||
</Space>
|
||||
)}
|
||||
<Form.ErrorList errors={errors} />
|
||||
</div>
|
||||
)}
|
||||
</Form.List>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { Tag } from 'antd';
|
||||
import { Link, Outlet, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { ResourcePage } from './ResourcePage';
|
||||
import { belongLabel, CostTargetLink } from './FinanceCells';
|
||||
import { applicantDept, applicantName, EXPENSE_STATUS_META, expenseCategoryLabel, money } from './financeStatus';
|
||||
import { ApplyBucketBar } from './ApplyBucketBar';
|
||||
|
||||
export default function ExpenseListPage() {
|
||||
const [params] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const bucket = params.get('bucket') || '';
|
||||
const endpoint = bucket ? `/expenses?bucket=${bucket}` : '/expenses';
|
||||
return (
|
||||
<>
|
||||
<ResourcePage
|
||||
onRowClick={(row) => navigate(`/finance/expenses/${row.id}`)}
|
||||
title="费用报销"
|
||||
description="只看已提交的报销。草稿只在填写人「我的费用」里。没确认通过的不会进付款和报表。"
|
||||
endpoint={endpoint}
|
||||
searchPlaceholder="搜索单号、申请人、用途"
|
||||
exportName="费用报销"
|
||||
filters={[
|
||||
{
|
||||
key: 'status',
|
||||
label: '状态',
|
||||
options: Object.entries(EXPENSE_STATUS_META).map(([value, m]) => ({ label: m.text, value })),
|
||||
},
|
||||
]}
|
||||
extra={
|
||||
<ApplyBucketBar
|
||||
basePath="/finance/expenses"
|
||||
buckets={[
|
||||
{ value: 'pending', label: '待确认' },
|
||||
{ value: 'approved', label: '已通过' },
|
||||
{ value: 'rejected', label: '已驳回' },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
columns={[
|
||||
{
|
||||
title: '单号',
|
||||
dataIndex: 'claimNo',
|
||||
width: 160,
|
||||
render: (v: string, row: Record<string, unknown>) => <Link to={`/finance/expenses/${row.id}`}>{v}</Link>,
|
||||
},
|
||||
{
|
||||
title: '申请人',
|
||||
width: 90,
|
||||
render: (_: unknown, row: Record<string, unknown>) => applicantName(row),
|
||||
},
|
||||
{
|
||||
title: '部门',
|
||||
width: 110,
|
||||
render: (_: unknown, row: Record<string, unknown>) => applicantDept(row),
|
||||
},
|
||||
{ title: '金额', dataIndex: 'amount', width: 110, render: (v: string) => money(v) },
|
||||
{
|
||||
title: '代付',
|
||||
width: 90,
|
||||
render: (_: unknown, row: Record<string, unknown>) =>
|
||||
row.prepaid ? (row.payer as { displayName?: string } | null)?.displayName || '代付' : '自付',
|
||||
},
|
||||
{ title: '类别', width: 80, render: (_: unknown, row: Record<string, unknown>) => expenseCategoryLabel(row.expenseCategory as string) },
|
||||
{ title: '用途', ellipsis: true, render: (_: unknown, row: Record<string, unknown>) => String(row.purpose || '—') },
|
||||
{
|
||||
title: '归属',
|
||||
ellipsis: true,
|
||||
render: (_: unknown, row: Record<string, unknown>) =>
|
||||
belongLabel({
|
||||
costType: row.costType as string,
|
||||
project: row.project as { name?: string } | null,
|
||||
bidCase: row.bidCase as { name?: string } | null,
|
||||
applicant: row.applicant as { employee?: { department?: { name?: string } } } | null,
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: '对象',
|
||||
width: 150,
|
||||
render: (_: unknown, row: Record<string, unknown>) => (
|
||||
<CostTargetLink
|
||||
costType={row.costType as string}
|
||||
bid={row.bidCase as { id?: string; bidNo?: string } | null}
|
||||
project={row.project as { id?: string; projectNo?: string } | null}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '日期',
|
||||
width: 110,
|
||||
render: (_: unknown, row: Record<string, unknown>) =>
|
||||
String(row.submittedAt || row.createdAt || '').slice(0, 10) || '—',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
render: (v: string) => {
|
||||
const s = EXPENSE_STATUS_META[v] || { color: 'default', text: v };
|
||||
return <Tag color={s.color}>{s.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_: unknown, row: Record<string, unknown>) => <Link to={`/finance/expenses/${row.id}`}>办理</Link>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoanListPage() {
|
||||
const [params] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const bucket = params.get('bucket') || '';
|
||||
const endpoint = bucket ? `/loans?bucket=${bucket}` : '/loans';
|
||||
return (
|
||||
<>
|
||||
<ResourcePage
|
||||
onRowClick={(row) => navigate(`/finance/loans/${row.id}`)}
|
||||
title="借支记录"
|
||||
description="全公司借支台账。员工在「我的费用」申请,财务在「待我审批」确认。未还借款会拦住同一成本对象上的新报销。"
|
||||
endpoint={endpoint}
|
||||
searchPlaceholder="搜索借款单号、用途、申请人"
|
||||
exportName="借支记录"
|
||||
extra={
|
||||
<ApplyBucketBar
|
||||
basePath="/finance/loans"
|
||||
buckets={[
|
||||
{ value: 'pending', label: '待确认' },
|
||||
{ value: 'open', label: '未还' },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
columns={[
|
||||
{
|
||||
title: '单号',
|
||||
dataIndex: 'loanNo',
|
||||
width: 160,
|
||||
render: (v: string, row: Record<string, unknown>) => <Link to={`/finance/loans/${row.id}`}>{v}</Link>,
|
||||
},
|
||||
{
|
||||
title: '申请人',
|
||||
width: 90,
|
||||
render: (_: unknown, row: Record<string, unknown>) => applicantName(row),
|
||||
},
|
||||
{
|
||||
title: '部门',
|
||||
width: 110,
|
||||
render: (_: unknown, row: Record<string, unknown>) => applicantDept(row),
|
||||
},
|
||||
{ title: '用途', dataIndex: 'purpose', ellipsis: true },
|
||||
{ title: '金额', dataIndex: 'amount', width: 110, render: (v: string) => money(v) },
|
||||
{ title: '未还', dataIndex: 'remaining', width: 110, render: (v: string) => money(v) },
|
||||
{
|
||||
title: '归属',
|
||||
ellipsis: true,
|
||||
render: (_: unknown, row: Record<string, unknown>) =>
|
||||
belongLabel({
|
||||
costType: row.costType as string,
|
||||
project: row.project as { name?: string } | null,
|
||||
bidCase: row.bidCase as { name?: string } | null,
|
||||
applicant: row.applicant as { employee?: { department?: { name?: string } } } | null,
|
||||
}),
|
||||
},
|
||||
{
|
||||
title: '对象',
|
||||
width: 150,
|
||||
render: (_: unknown, row: Record<string, unknown>) => (
|
||||
<CostTargetLink
|
||||
costType={row.costType as string}
|
||||
bid={row.bidCase as { id?: string; bidNo?: string } | null}
|
||||
project={row.project as { id?: string; projectNo?: string } | null}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '日期',
|
||||
width: 110,
|
||||
render: (_: unknown, row: Record<string, unknown>) =>
|
||||
String(row.submittedAt || row.createdAt || '').slice(0, 10) || '—',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
render: (v: string) => {
|
||||
const map: Record<string, { color: string; text: string }> = {
|
||||
DRAFT: { color: 'default', text: '草稿' },
|
||||
PENDING: { color: 'processing', text: '待确认' },
|
||||
OPEN: { color: 'warning', text: '未还' },
|
||||
OFFSET: { color: 'success', text: '已冲账' },
|
||||
REJECTED: { color: 'error', text: '被打回' },
|
||||
};
|
||||
const s = map[v] || { color: 'default', text: v };
|
||||
return <Tag color={s.color}>{s.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 90,
|
||||
render: (_: unknown, row: Record<string, unknown>) => <Link to={`/finance/loans/${row.id}`}>办理</Link>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,463 @@
|
||||
import { Suspense, lazy, useState } from 'react';
|
||||
import { Button, Modal, Space, Spin, Upload, message } from 'antd';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '../api/client';
|
||||
|
||||
const PdfPreview = lazy(() => import('./PdfPreview').then((m) => ({ default: m.PdfPreview })));
|
||||
|
||||
type FileRow = { id: string; fileName: string; size: number; mimeType: string; bizType?: string };
|
||||
|
||||
export const FILE_KIND_LABEL: Record<string, string> = {
|
||||
BID_ANNOUNCE: '招标 / 询价文件',
|
||||
BID_FILE: '投标附件',
|
||||
BID_FORMAL: '正式标书',
|
||||
BID_PROOF: '递交 / 保证金证明',
|
||||
CONTRACT: '合同扫描件',
|
||||
CONTRACT_ACCEPT: '验收单',
|
||||
CONTRACT_AWARD: '中标通知书',
|
||||
PROJECT: '项目附件',
|
||||
PROJECT_ACCEPT: '验收材料',
|
||||
PURCHASE: '采购合同',
|
||||
PURCHASE_ACCEPT: '采购验收',
|
||||
EXPENSE: '发票 / 凭证',
|
||||
LOAN: '借据 / 凭证',
|
||||
USER_AVATAR: '头像',
|
||||
USER_ID_PHOTO: '证件照',
|
||||
EMPLOYEE_ID_PHOTO: '证件照',
|
||||
EMPLOYEE_ID_FRONT: '身份证正面',
|
||||
EMPLOYEE_ID_BACK: '身份证反面',
|
||||
EMPLOYEE_CERT: '个人证书',
|
||||
EMPLOYEE_LABOR: '劳动合同',
|
||||
NOTICE: '通知附件',
|
||||
};
|
||||
|
||||
/** 纠正 multer latin1 解出来的中文文件名。 */
|
||||
export function displayFileName(name: string) {
|
||||
if (!name) return '附件';
|
||||
if (/[\u4e00-\u9fff]/.test(name)) return name;
|
||||
try {
|
||||
const bytes = Uint8Array.from(name, (c) => c.charCodeAt(0) & 0xff);
|
||||
const decoded = new TextDecoder('utf-8').decode(bytes);
|
||||
if (/[\u4e00-\u9fff]/.test(decoded) && !decoded.includes('\uFFFD')) return decoded;
|
||||
} catch {
|
||||
/* keep original */
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
function sniffMime(bytes: Uint8Array, name?: string, declared?: string) {
|
||||
const declaredType = (declared || '').toLowerCase();
|
||||
if (bytes.length >= 4 && bytes[0] === 0x25 && bytes[1] === 0x50 && bytes[2] === 0x44 && bytes[3] === 0x46) {
|
||||
return 'application/pdf';
|
||||
}
|
||||
if (bytes.length >= 3 && bytes[0] === 0xff && bytes[1] === 0xd8 && bytes[2] === 0xff) return 'image/jpeg';
|
||||
if (bytes.length >= 8 && bytes[0] === 0x89 && bytes[1] === 0x50 && bytes[2] === 0x4e && bytes[3] === 0x47) {
|
||||
return 'image/png';
|
||||
}
|
||||
if (bytes.length >= 6 && bytes[0] === 0x47 && bytes[1] === 0x49 && bytes[2] === 0x46) return 'image/gif';
|
||||
if (declaredType.startsWith('image/') || declaredType === 'application/pdf') return declaredType;
|
||||
if (/\.pdf$/i.test(name || '')) return 'application/pdf';
|
||||
if (/\.(png|jpe?g|gif|webp|bmp)$/i.test(name || '')) {
|
||||
const ext = (name || '').split('.').pop()?.toLowerCase();
|
||||
if (ext === 'jpg' || ext === 'jpeg') return 'image/jpeg';
|
||||
if (ext === 'png') return 'image/png';
|
||||
if (ext === 'gif') return 'image/gif';
|
||||
if (ext === 'webp') return 'image/webp';
|
||||
if (ext === 'bmp') return 'image/bmp';
|
||||
}
|
||||
return declaredType;
|
||||
}
|
||||
|
||||
function isPreviewable(name: string, mime?: string) {
|
||||
const type = (mime || '').toLowerCase();
|
||||
if (type.startsWith('image/') || type === 'application/pdf') return true;
|
||||
return /\.(png|jpe?g|gif|webp|bmp|pdf)$/i.test(name);
|
||||
}
|
||||
|
||||
async function fetchBizFileBlob(id: string, mime?: string, name?: string) {
|
||||
const raw = (await api.get(`/files/${id}`, { responseType: 'blob' })) as unknown;
|
||||
let blob: Blob | null = null;
|
||||
if (raw instanceof Blob) blob = raw;
|
||||
else if (raw && typeof raw === 'object' && (raw as { data?: unknown }).data instanceof Blob) {
|
||||
blob = (raw as { data: Blob }).data;
|
||||
}
|
||||
if (!blob) throw new Error('无法读取附件');
|
||||
const head = new Uint8Array(await blob.slice(0, 24).arrayBuffer());
|
||||
const headText = new TextDecoder().decode(head).trimStart();
|
||||
if (headText.startsWith('{') || headText.startsWith('<')) {
|
||||
const full = await blob.text();
|
||||
try {
|
||||
const parsed = JSON.parse(full) as { message?: string };
|
||||
throw new Error(parsed.message || '无法打开附件');
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message !== '无法打开附件' && !e.message.includes('JSON')) throw e;
|
||||
throw new Error('无法打开附件');
|
||||
}
|
||||
}
|
||||
const type = sniffMime(head, name, mime || blob.type);
|
||||
if (type && blob.type !== type) return new Blob([await blob.arrayBuffer()], { type });
|
||||
return blob;
|
||||
}
|
||||
|
||||
export async function downloadBizFile(id: string, name: string) {
|
||||
const blob = await fetchBizFileBlob(id, undefined, name);
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = displayFileName(name);
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
export function nonImageFiles(files?: FileRow[]) {
|
||||
return (files || []).filter((f) => !(f.mimeType || '').startsWith('image/'));
|
||||
}
|
||||
|
||||
export async function uploadBizFile(bizType: string, bizId: string, file: File) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fd.append('bizType', bizType);
|
||||
fd.append('bizId', bizId);
|
||||
const res = (await api.post('/files', fd)) as { data: FileRow };
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export function IdScanUpload({
|
||||
label,
|
||||
bizType,
|
||||
bizId,
|
||||
fileId,
|
||||
onDone,
|
||||
}: {
|
||||
label: string;
|
||||
bizType: string;
|
||||
bizId: string;
|
||||
fileId?: string | null;
|
||||
onDone?: () => void | Promise<void>;
|
||||
}) {
|
||||
return (
|
||||
<Space wrap>
|
||||
<Upload
|
||||
showUploadList={false}
|
||||
accept="image/*,.pdf"
|
||||
customRequest={async (opt) => {
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', opt.file as File);
|
||||
fd.append('bizType', bizType);
|
||||
fd.append('bizId', bizId);
|
||||
await api.post('/files', fd);
|
||||
message.success(`${label}已上传`);
|
||||
await onDone?.();
|
||||
opt.onSuccess?.({}, new XMLHttpRequest());
|
||||
} catch (e) {
|
||||
message.error((e as Error).message);
|
||||
opt.onError?.(e as Error);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button>{fileId ? '重新上传' : `上传${label}`}</Button>
|
||||
</Upload>
|
||||
{fileId ? (
|
||||
<BizFileActions
|
||||
file={{
|
||||
id: fileId,
|
||||
fileName: `${label}.jpg`,
|
||||
size: 0,
|
||||
mimeType: 'image/jpeg',
|
||||
bizType,
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span style={{ color: '#cf1322' }}>未上传</span>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
export type FilePreview =
|
||||
| { kind: 'image'; url: string; fileName: string }
|
||||
| { kind: 'pdf'; data: ArrayBuffer; fileName: string }
|
||||
| { kind: 'html'; html: string; fileName: string };
|
||||
|
||||
export async function loadBizPreview(id: string, mime?: string, name?: string): Promise<FilePreview | null> {
|
||||
const display = displayFileName(name || '');
|
||||
const blob = await fetchBizFileBlob(id, mime, display);
|
||||
if (isPreviewable(display, blob.type)) {
|
||||
const isImage = (blob.type || '').startsWith('image/') || /\.(png|jpe?g|gif|webp|bmp)$/i.test(display);
|
||||
if (isImage) {
|
||||
return { kind: 'image', url: URL.createObjectURL(blob), fileName: display || '图片' };
|
||||
}
|
||||
const buf = await blob.arrayBuffer();
|
||||
return { kind: 'pdf', data: buf.slice(0), fileName: display || '附件.pdf' };
|
||||
}
|
||||
const buffer = await blob.arrayBuffer();
|
||||
if (/\.docx$/i.test(display) || blob.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
|
||||
const mammoth = await import('mammoth/mammoth.browser');
|
||||
const result = await mammoth.convertToHtml({ arrayBuffer: buffer });
|
||||
return {
|
||||
kind: 'html',
|
||||
fileName: display || 'Word 文档',
|
||||
html: `<style>body{font:15px/1.7 system-ui;padding:24px;color:#222}img{max-width:100%}table{border-collapse:collapse}td,th{border:1px solid #ddd;padding:6px}</style>${result.value}`,
|
||||
};
|
||||
}
|
||||
if (/\.(xlsx?|csv)$/i.test(display) || /spreadsheet|ms-excel|csv/i.test(blob.type)) {
|
||||
const XLSX = await import('xlsx');
|
||||
const workbook = XLSX.read(buffer, { type: 'array' });
|
||||
const sheets = workbook.SheetNames.map((sheetName) => {
|
||||
const heading = `<h2>${sheetName.replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[c]!)}</h2>`;
|
||||
return heading + XLSX.utils.sheet_to_html(workbook.Sheets[sheetName]);
|
||||
}).join('');
|
||||
return {
|
||||
kind: 'html',
|
||||
fileName: display || 'Excel 表格',
|
||||
html: `<style>body{font:14px/1.5 system-ui;padding:20px;color:#222}table{border-collapse:collapse;margin-bottom:28px}td,th{border:1px solid #bbb;padding:5px 8px;white-space:nowrap}tr:first-child{background:#f5f5f5}</style>${sheets}`,
|
||||
};
|
||||
}
|
||||
await downloadBizFile(id, display);
|
||||
message.info('旧版 Word .doc 等格式已下载,请用本地软件打开');
|
||||
return null;
|
||||
}
|
||||
|
||||
export function AttachmentPreviewModal({
|
||||
preview,
|
||||
onClose,
|
||||
}: {
|
||||
preview: FilePreview | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Modal
|
||||
title={preview?.fileName || '预览'}
|
||||
open={Boolean(preview)}
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
width="80vw"
|
||||
destroyOnClose
|
||||
>
|
||||
{preview?.kind === 'image' ? (
|
||||
<img src={preview.url} alt={preview.fileName} style={{ width: '100%' }} />
|
||||
) : preview?.kind === 'pdf' ? (
|
||||
<Suspense fallback={<Spin />}>
|
||||
<PdfPreview data={preview.data} fileName={preview.fileName} />
|
||||
</Suspense>
|
||||
) : preview?.kind === 'html' ? (
|
||||
<iframe
|
||||
title={preview.fileName}
|
||||
sandbox=""
|
||||
srcDoc={preview.html}
|
||||
style={{ width: '100%', height: '72vh', border: 0 }}
|
||||
/>
|
||||
) : null}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function BizFileActions({
|
||||
file,
|
||||
onDelete,
|
||||
}: {
|
||||
file: FileRow;
|
||||
onDelete?: () => void;
|
||||
}) {
|
||||
const [preview, setPreview] = useState<FilePreview | null>(null);
|
||||
const name = displayFileName(file.fileName);
|
||||
const kindLabel = file.bizType && FILE_KIND_LABEL[file.bizType] ? `【${FILE_KIND_LABEL[file.bizType]}】` : '';
|
||||
|
||||
const closePreview = () => {
|
||||
if (preview?.kind === 'image') URL.revokeObjectURL(preview.url);
|
||||
setPreview(null);
|
||||
};
|
||||
|
||||
const openPreview = async () => {
|
||||
try {
|
||||
const next = await loadBizPreview(file.id, file.mimeType, name);
|
||||
if (next) setPreview(next);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '无法打开附件');
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button type="link" onClick={() => void openPreview()}>
|
||||
{kindLabel}
|
||||
{name}
|
||||
</Button>
|
||||
<Button type="link" onClick={() => void downloadBizFile(file.id, name)}>
|
||||
下载
|
||||
</Button>
|
||||
{onDelete ? (
|
||||
<Button type="link" danger onClick={onDelete}>
|
||||
删除
|
||||
</Button>
|
||||
) : null}
|
||||
<AttachmentPreviewModal preview={preview} onClose={closePreview} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function BizFileList({ files, empty = '没有附件' }: { files?: FileRow[]; empty?: string }) {
|
||||
const rows = files || [];
|
||||
if (!rows.length) return <p style={{ color: 'var(--mute)' }}>{empty}</p>;
|
||||
return (
|
||||
<ul style={{ paddingLeft: 18, marginBottom: 16 }}>
|
||||
{rows.map((f) => (
|
||||
<li key={f.id}>
|
||||
<BizFileActions file={f} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
export function BizFileUpload({
|
||||
bizType,
|
||||
bizId,
|
||||
files,
|
||||
disabled,
|
||||
label,
|
||||
extra,
|
||||
queryKey,
|
||||
onUploaded,
|
||||
}: {
|
||||
bizType: string;
|
||||
bizId?: string;
|
||||
files?: FileRow[];
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
extra?: string;
|
||||
queryKey: unknown[];
|
||||
onUploaded?: () => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const rows = files || [];
|
||||
const del = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/files/${id}`),
|
||||
onSuccess: () => {
|
||||
message.success('已删除附件');
|
||||
qc.invalidateQueries({ queryKey });
|
||||
onUploaded?.();
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
{label ? <div style={{ marginBottom: 8 }}>{label}</div> : null}
|
||||
{extra ? <p style={{ color: 'var(--mute)', marginBottom: 8 }}>{extra}</p> : null}
|
||||
{!bizId ? (
|
||||
<p style={{ color: 'var(--mute)' }}>保存后再上传附件。</p>
|
||||
) : (
|
||||
<>
|
||||
<ul style={{ paddingLeft: 18 }}>
|
||||
{rows.map((f) => (
|
||||
<li key={f.id}>
|
||||
<BizFileActions file={f} onDelete={disabled ? undefined : () => del.mutate(f.id)} />
|
||||
</li>
|
||||
))}
|
||||
{!rows.length ? <li style={{ color: 'var(--mute)' }}>还没有附件</li> : null}
|
||||
</ul>
|
||||
{!disabled ? (
|
||||
<Upload
|
||||
multiple
|
||||
showUploadList={false}
|
||||
beforeUpload={() => true}
|
||||
customRequest={async (opt) => {
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', opt.file as File);
|
||||
fd.append('bizType', bizType);
|
||||
fd.append('bizId', bizId);
|
||||
await api.post('/files', fd);
|
||||
message.success('已上传');
|
||||
qc.invalidateQueries({ queryKey });
|
||||
onUploaded?.();
|
||||
opt.onSuccess?.({}, new XMLHttpRequest());
|
||||
} catch (e) {
|
||||
const err = e as Error;
|
||||
message.error(err.message);
|
||||
opt.onError?.(err);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button>上传附件</Button>
|
||||
</Upload>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FileAttachments({
|
||||
bizType,
|
||||
bizId,
|
||||
disabled,
|
||||
label,
|
||||
}: {
|
||||
bizType: 'EXPENSE' | 'LOAN';
|
||||
bizId?: string;
|
||||
disabled?: boolean;
|
||||
label: string;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const q = useQuery({
|
||||
queryKey: [bizType === 'EXPENSE' ? 'expense' : 'loan', bizId],
|
||||
enabled: Boolean(bizId),
|
||||
queryFn: () => api.get(bizType === 'EXPENSE' ? `/expenses/${bizId}` : `/loans/${bizId}`),
|
||||
});
|
||||
const files = ((q.data?.data?.files ?? []) as FileRow[]);
|
||||
const del = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/files/${id}`),
|
||||
onSuccess: () => {
|
||||
message.success('已删除附件');
|
||||
qc.invalidateQueries({ queryKey: [bizType === 'EXPENSE' ? 'expense' : 'loan', bizId] });
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ marginBottom: 8 }}>{label}</div>
|
||||
{!bizId ? (
|
||||
<p style={{ color: 'var(--mute)' }}>先保存草稿,再上传发票或凭证。</p>
|
||||
) : (
|
||||
<>
|
||||
<ul style={{ paddingLeft: 18 }}>
|
||||
{files.map((f) => (
|
||||
<li key={f.id}>
|
||||
<BizFileActions file={f} onDelete={disabled ? undefined : () => del.mutate(f.id)} />
|
||||
</li>
|
||||
))}
|
||||
{!files.length ? <li style={{ color: 'var(--mute)' }}>还没有附件</li> : null}
|
||||
</ul>
|
||||
{!disabled ? (
|
||||
<Upload
|
||||
showUploadList={false}
|
||||
beforeUpload={() => true}
|
||||
customRequest={async (opt) => {
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', opt.file as File);
|
||||
fd.append('bizType', bizType);
|
||||
fd.append('bizId', bizId);
|
||||
await api.post('/files', fd);
|
||||
message.success('已上传');
|
||||
qc.invalidateQueries({ queryKey: [bizType === 'EXPENSE' ? 'expense' : 'loan', bizId] });
|
||||
opt.onSuccess?.({}, new XMLHttpRequest());
|
||||
} catch (e) {
|
||||
const err = e as Error;
|
||||
message.error(err.message);
|
||||
opt.onError?.(err);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button>上传发票 / 凭证</Button>
|
||||
</Upload>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Link } from 'react-router-dom';
|
||||
import { COST_TYPE_LABEL, applicantDept } from './financeStatus';
|
||||
|
||||
export type CostTarget = {
|
||||
id?: string;
|
||||
bidNo?: string;
|
||||
projectNo?: string;
|
||||
contractNo?: string;
|
||||
name?: string;
|
||||
} | null;
|
||||
|
||||
export function systemTargetNo(bid?: CostTarget, project?: CostTarget, contract?: CostTarget) {
|
||||
return project?.projectNo || bid?.bidNo || contract?.contractNo || '—';
|
||||
}
|
||||
|
||||
export function CostTargetLink({
|
||||
costType,
|
||||
bid,
|
||||
project,
|
||||
contract,
|
||||
}: {
|
||||
costType?: string;
|
||||
bid?: CostTarget;
|
||||
project?: CostTarget;
|
||||
contract?: CostTarget;
|
||||
}) {
|
||||
if (costType === 'BID' && bid?.id && bid.bidNo) {
|
||||
return <Link to={`/bid/cases/${bid.id}`}>{bid.bidNo}</Link>;
|
||||
}
|
||||
if (costType === 'PROJECT' && project?.id && project.projectNo) {
|
||||
return <Link to={`/project/${project.id}`}>{project.projectNo}</Link>;
|
||||
}
|
||||
if (project?.id && project.projectNo) {
|
||||
return <Link to={`/project/${project.id}`}>{project.projectNo}</Link>;
|
||||
}
|
||||
if (bid?.id && bid.bidNo) {
|
||||
return <Link to={`/bid/cases/${bid.id}`}>{bid.bidNo}</Link>;
|
||||
}
|
||||
if (contract?.id && contract.contractNo) {
|
||||
return <Link to={`/contract/${contract.id}`}>{contract.contractNo}</Link>;
|
||||
}
|
||||
return <span>—</span>;
|
||||
}
|
||||
|
||||
export function belongLabel(row: {
|
||||
costType?: string;
|
||||
project?: { name?: string } | null;
|
||||
bidCase?: { name?: string } | null;
|
||||
contract?: { name?: string } | null;
|
||||
applicant?: { employee?: { department?: { name?: string } } } | null;
|
||||
}) {
|
||||
if (row.costType === 'BID') {
|
||||
return row.bidCase?.name ? `投标 · ${row.bidCase.name}` : '投标';
|
||||
}
|
||||
if (row.costType === 'PROJECT') {
|
||||
return row.project?.name ? `项目 · ${row.project.name}` : '正式项目';
|
||||
}
|
||||
if (row.costType === 'DEPT') {
|
||||
const dept = applicantDept(row as Record<string, unknown>);
|
||||
return dept !== '—' ? `部门 · ${dept}` : '部门';
|
||||
}
|
||||
if (row.project?.name) return `项目 · ${row.project.name}`;
|
||||
if (row.bidCase?.name) return `投标 · ${row.bidCase.name}`;
|
||||
if (row.contract?.name) return `合同 · ${row.contract.name}`;
|
||||
return COST_TYPE_LABEL[row.costType || ''] || '—';
|
||||
}
|
||||
|
||||
export function targetName(bid?: CostTarget, project?: CostTarget, contract?: CostTarget) {
|
||||
return project?.name || bid?.name || contract?.name || '—';
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Button, Drawer, Space } from 'antd';
|
||||
import { fmtCreated } from '../time';
|
||||
|
||||
export type FormStep = { key: string; title: string };
|
||||
|
||||
export function FormDrawer({
|
||||
title,
|
||||
open,
|
||||
onClose,
|
||||
width = 'min(1120px, 78vw)',
|
||||
steps,
|
||||
step = 0,
|
||||
maxStep,
|
||||
onStep,
|
||||
onPrev,
|
||||
onNext,
|
||||
onOk,
|
||||
okText = '提交保存',
|
||||
okLoading,
|
||||
hideOk,
|
||||
showOkAlways,
|
||||
children,
|
||||
extra,
|
||||
createdAt,
|
||||
}: {
|
||||
title: ReactNode;
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
width?: number | string;
|
||||
steps?: FormStep[];
|
||||
step?: number;
|
||||
maxStep?: number;
|
||||
onStep?: (index: number) => void;
|
||||
onPrev?: () => void;
|
||||
onNext?: () => void;
|
||||
onOk?: () => void;
|
||||
okText?: string;
|
||||
okLoading?: boolean;
|
||||
hideOk?: boolean;
|
||||
showOkAlways?: boolean;
|
||||
children: ReactNode;
|
||||
extra?: ReactNode;
|
||||
createdAt?: string | null;
|
||||
}) {
|
||||
const total = steps?.length || 0;
|
||||
const last = total ? step >= total - 1 : true;
|
||||
const first = !total || step <= 0;
|
||||
const note = total
|
||||
? last
|
||||
? '填写完成后点保存。'
|
||||
: showOkAlways
|
||||
? `第 ${step + 1} / ${total} 步。改完可直接保存,不必走到最后一步。`
|
||||
: `第 ${step + 1} / ${total} 步,填完本页后点下一步。`
|
||||
: typeof extra === 'string'
|
||||
? extra
|
||||
: null;
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
title={title}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
width={width}
|
||||
destroyOnClose
|
||||
className="form-drawer"
|
||||
styles={{ body: { padding: steps ? '0' : '16px 24px', display: 'flex', flexDirection: 'column' } }}
|
||||
footer={
|
||||
<div className="emp-wizard-foot">
|
||||
<span className="emp-wizard-note">{note}</span>
|
||||
<Space>
|
||||
<Button className="form-drawer-btn" onClick={onClose}>
|
||||
取消
|
||||
</Button>
|
||||
{!first && onPrev ? (
|
||||
<Button className="form-drawer-btn" onClick={onPrev}>
|
||||
上一步
|
||||
</Button>
|
||||
) : null}
|
||||
{!last && onNext ? (
|
||||
<Button className="form-drawer-btn" type={showOkAlways ? 'default' : 'primary'} onClick={onNext}>
|
||||
下一步
|
||||
</Button>
|
||||
) : null}
|
||||
{!hideOk && onOk && (last || showOkAlways) ? (
|
||||
<Button className="form-drawer-btn" type="primary" loading={okLoading} onClick={onOk}>
|
||||
{okText}
|
||||
</Button>
|
||||
) : extra && !onOk && typeof extra !== 'string' ? (
|
||||
extra
|
||||
) : null}
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{steps?.length ? (
|
||||
<div className="emp-wizard">
|
||||
<aside className="emp-wizard-nav">
|
||||
<div className="emp-wizard-nav-head">填写进度</div>
|
||||
<div className="emp-wizard-track">
|
||||
{steps.map((s, i) => {
|
||||
const reached = maxStep ?? step;
|
||||
const locked = i > reached;
|
||||
const state = i === step ? 'on' : i < step ? 'done' : locked ? 'locked' : '';
|
||||
return (
|
||||
<button
|
||||
key={s.key}
|
||||
type="button"
|
||||
className={state}
|
||||
disabled={locked && i !== step + 1}
|
||||
onClick={() => {
|
||||
if (locked && i !== step + 1) return;
|
||||
onStep?.(i);
|
||||
}}
|
||||
>
|
||||
<span className="emp-wizard-n">{state === 'done' ? '✓' : i + 1}</span>
|
||||
<span className="emp-wizard-t">{s.title}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</aside>
|
||||
<div className="emp-wizard-body">
|
||||
<div className="emp-wizard-doc">
|
||||
<span>新建单据</span>
|
||||
<strong>{title}</strong>
|
||||
<em>{steps[step]?.title}</em>
|
||||
</div>
|
||||
{createdAt ? <div className="form-created">创建时间 {fmtCreated(createdAt)}</div> : null}
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{createdAt ? <div className="form-created">创建时间 {fmtCreated(createdAt)}</div> : null}
|
||||
{children}
|
||||
</>
|
||||
)}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
import { Alert, Button, DatePicker, Form, Input, InputNumber, Popconfirm, Select, Table, Tag, Typography, message } from 'antd';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import dayjs from 'dayjs';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../api/client';
|
||||
import { FormDrawer } from './FormDrawer';
|
||||
import { PageHeader } from './ResourcePage';
|
||||
import { HR_APPLY_LABEL, HR_APPLY_OPTIONS, LEAVE_KIND_HINT, LEAVE_STATUS_META, isLeaveApplyKind } from './hrStatus';
|
||||
import { fmtCreated, fmtDay } from '../time';
|
||||
import { ApplyBucketBar, PROCESS_BUCKETS } from './ApplyBucketBar';
|
||||
|
||||
type ApplyRow = {
|
||||
id: string;
|
||||
kind: string;
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
days: string | number;
|
||||
reason: string;
|
||||
status: string;
|
||||
comment?: string | null;
|
||||
targetTitle?: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export default function HrApplyPage() {
|
||||
const qc = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [params] = useSearchParams();
|
||||
const bucket = params.get('bucket') || '';
|
||||
const [form] = Form.useForm();
|
||||
const kind = Form.useWatch('kind', form);
|
||||
const depts = useQuery({ queryKey: ['/departments'], queryFn: () => api.get('/departments') });
|
||||
const leaves = useQuery({
|
||||
queryKey: ['leave-mine'],
|
||||
queryFn: () => api.get('/leave-requests?bucket=mine'),
|
||||
});
|
||||
const balanceQ = useQuery({
|
||||
queryKey: ['comp-balance'],
|
||||
queryFn: () => api.get('/attendance/comp-balance'),
|
||||
});
|
||||
const items = useMemo(() => {
|
||||
const all = ((leaves.data?.data as { items?: ApplyRow[] })?.items || []) as ApplyRow[];
|
||||
if (!bucket) return all;
|
||||
const map: Record<string, string> = { pending: 'PENDING', approved: 'APPROVED', rejected: 'REJECTED' };
|
||||
return all.filter((r) => r.status === map[bucket]);
|
||||
}, [leaves.data, bucket]);
|
||||
const bal = (balanceQ.data?.data || leaves.data?.data?.balance) as
|
||||
| {
|
||||
hours?: number;
|
||||
days?: number;
|
||||
usableHours?: number;
|
||||
usableDays?: number;
|
||||
pendingCompDays?: number;
|
||||
pendingOvertimeHours?: number;
|
||||
canRegularize?: boolean;
|
||||
probationEndAt?: string | null;
|
||||
}
|
||||
| undefined;
|
||||
const canRegularize = Boolean(bal?.canRegularize);
|
||||
const deptRaw = depts.data?.data as { id: string; name: string }[] | { items?: { id: string; name: string }[] } | undefined;
|
||||
const deptOpts = Array.isArray(deptRaw) ? deptRaw : deptRaw?.items || [];
|
||||
|
||||
const mut = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) => api.post('/leave-requests', body),
|
||||
onSuccess: async () => {
|
||||
message.success('已提交,由汇报对象和部门总监审批');
|
||||
setOpen(false);
|
||||
form.resetFields();
|
||||
await qc.invalidateQueries({ queryKey: ['leave-mine'] });
|
||||
await qc.invalidateQueries({ queryKey: ['comp-balance'] });
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
|
||||
const isLeave = isLeaveApplyKind(kind);
|
||||
const isOt = kind === 'OVERTIME';
|
||||
const leaveHint = kind ? LEAVE_KIND_HINT[kind] : undefined;
|
||||
const applyOptions = [
|
||||
...HR_APPLY_OPTIONS,
|
||||
...(canRegularize ? [{ value: 'REGULARIZE', label: '转正' }] : []),
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="人事申请"
|
||||
description="提交请假、出差、加班、补卡、晋升、转岗、离职。试用期默认可申请转正,通过后档案转正日期自动同步,入口随之隐藏。"
|
||||
extra={
|
||||
<ApplyBucketBar
|
||||
basePath="/office/hr-apply"
|
||||
buckets={PROCESS_BUCKETS}
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
form.setFieldsValue({ kind: canRegularize ? 'REGULARIZE' : 'PERSONAL', effectiveAt: dayjs() });
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
新建申请
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message={`剩余调休 ${bal?.usableHours ?? 0} 小时(约 ${bal?.usableDays ?? 0} 天)`}
|
||||
description={
|
||||
(bal?.pendingCompDays || bal?.pendingOvertimeHours)
|
||||
? `待批调休已预扣 ${bal?.pendingCompDays || 0} 天;待批加班 ${bal?.pendingOvertimeHours || 0} 小时,通过后到账。档案结余 ${bal?.hours ?? 0} 小时。`
|
||||
: `档案结余 ${bal?.hours ?? 0} 小时。加班申请通过后按小时转入调休。`
|
||||
}
|
||||
/>
|
||||
<Table
|
||||
className="ops-table"
|
||||
rowKey="id"
|
||||
size="small"
|
||||
dataSource={items}
|
||||
pagination={false}
|
||||
scroll={{ x: 'max-content' }}
|
||||
tableLayout="fixed"
|
||||
columns={[
|
||||
{ title: '类型', width: 140, render: (_: unknown, r: ApplyRow) => HR_APPLY_LABEL[r.kind] || r.kind },
|
||||
{
|
||||
title: '日期',
|
||||
width: 200,
|
||||
render: (_: unknown, r: ApplyRow) =>
|
||||
r.kind === 'MAKEUP' ||
|
||||
r.kind === 'PROMOTE' ||
|
||||
r.kind === 'TRANSFER' ||
|
||||
r.kind === 'RESIGN' ||
|
||||
r.kind === 'OVERTIME' ||
|
||||
r.kind === 'REGULARIZE'
|
||||
? fmtDay(r.startAt)
|
||||
: `${fmtDay(r.startAt)} ~ ${fmtDay(r.endAt)}`,
|
||||
},
|
||||
{
|
||||
title: '时长',
|
||||
dataIndex: 'days',
|
||||
width: 90,
|
||||
render: (v: number, r: ApplyRow) => (r.kind === 'OVERTIME' ? `${v} 小时` : Number(v) ? `${v} 天` : '—'),
|
||||
},
|
||||
{ title: '事由', dataIndex: 'reason', ellipsis: true },
|
||||
{
|
||||
title: '状态',
|
||||
width: 90,
|
||||
render: (_: unknown, r: ApplyRow) => {
|
||||
const s = LEAVE_STATUS_META[r.status] || { color: 'default', text: r.status };
|
||||
return <Tag color={s.color}>{s.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{ title: '创建时间', width: 170, render: (_: unknown, r: ApplyRow) => fmtCreated(r.createdAt) },
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_: unknown, r: ApplyRow) =>
|
||||
r.status === 'PENDING' ? (
|
||||
<Popconfirm
|
||||
title="确认删除这条申请?"
|
||||
onConfirm={async () => {
|
||||
await api.delete(`/leave-requests/${r.id}`);
|
||||
message.success('已删除');
|
||||
await qc.invalidateQueries({ queryKey: ['leave-mine'] });
|
||||
await qc.invalidateQueries({ queryKey: ['comp-balance'] });
|
||||
}}
|
||||
>
|
||||
<Button type="link" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null,
|
||||
},
|
||||
]}
|
||||
locale={{ emptyText: '还没有人事申请' }}
|
||||
/>
|
||||
<FormDrawer
|
||||
title="人事申请"
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
okText="提交"
|
||||
okLoading={mut.isPending}
|
||||
onOk={() => form.submit()}
|
||||
>
|
||||
<Typography.Paragraph type="secondary">提交后生成创建时间,精确到秒。</Typography.Paragraph>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{ kind: 'PERSONAL', range: [dayjs().startOf('day'), dayjs().endOf('day')], punchAt: dayjs(), overtimeAt: dayjs() }}
|
||||
onFinish={(v) => {
|
||||
const start = v.range?.[0] || v.effectiveAt || v.punchAt || v.overtimeAt;
|
||||
const end = v.range?.[1] || v.effectiveAt || v.punchAt || v.overtimeAt;
|
||||
mut.mutate({
|
||||
kind: v.kind,
|
||||
startAt: start ? dayjs(start).toISOString() : undefined,
|
||||
endAt: end ? dayjs(end).toISOString() : undefined,
|
||||
days: v.hours || v.days,
|
||||
reason: v.reason,
|
||||
toDeptId: v.toDeptId,
|
||||
targetTitle: v.targetTitle,
|
||||
punchKind: v.punchKind,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Form.Item name="kind" label="申请类型" rules={[{ required: true }]}>
|
||||
<Select optionFilterProp="label" showSearch options={applyOptions} />
|
||||
</Form.Item>
|
||||
{leaveHint ? (
|
||||
<Alert type="info" showIcon style={{ marginBottom: 16 }} message={leaveHint} />
|
||||
) : null}
|
||||
{kind === 'BUSINESS' ? (
|
||||
<Alert type="info" showIcon style={{ marginBottom: 16 }} message="出差不计入事假、病假扣薪,视同公务外出。审批通过后会自动生成差旅报销草稿,到「我的费用」补金额和票据。" />
|
||||
) : null}
|
||||
{kind === 'OUT' ? (
|
||||
<Alert type="info" showIcon style={{ marginBottom: 16 }} message="外出用于当天离岗办事。审批通过后方可外勤打卡;与印章「外带」不是同一概念。" />
|
||||
) : null}
|
||||
{kind === 'COMP' || isOt ? (
|
||||
<Alert
|
||||
type={kind === 'COMP' ? 'warning' : 'info'}
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message={`剩余调休 ${bal?.usableHours ?? 0} 小时(约 ${bal?.usableDays ?? 0} 天)`}
|
||||
description={kind === 'COMP' ? '请调休不能超过剩余时长。待批调休已预扣。' : '加班通过后按小时转入调休,不发加班费。'}
|
||||
/>
|
||||
) : null}
|
||||
{isLeave ? (
|
||||
<>
|
||||
<Form.Item name="range" label="起止日期" rules={[{ required: true }]}>
|
||||
<DatePicker.RangePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="days" label="天数(可不填,按起止自动算)">
|
||||
<InputNumber min={0.5} step={0.5} max={kind === 'COMP' ? bal?.usableDays || undefined : undefined} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null}
|
||||
{isOt ? (
|
||||
<>
|
||||
<Form.Item name="overtimeAt" label="加班日期" rules={[{ required: true }]}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="hours" label="加班小时" rules={[{ required: true, message: '请填写加班小时' }]}>
|
||||
<InputNumber min={0.5} step={0.5} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null}
|
||||
{kind === 'MAKEUP' ? (
|
||||
<>
|
||||
<Form.Item name="punchKind" label="补卡类型" rules={[{ required: true }]}>
|
||||
<Select options={[{ value: 'IN', label: '上班卡' }, { value: 'OUT', label: '下班卡' }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="punchAt" label="补卡日期" rules={[{ required: true }]}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null}
|
||||
{kind === 'REGULARIZE' ? (
|
||||
<>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="转正通过后,档案里的转正日期按本申请生效日回写,本入口不再显示。"
|
||||
/>
|
||||
<Form.Item name="effectiveAt" label="拟转正日" rules={[{ required: true }]}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null}
|
||||
{kind === 'PROMOTE' ? (
|
||||
<>
|
||||
<Form.Item name="targetTitle" label="拟任职务" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="effectiveAt" label="生效日" rules={[{ required: true }]}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null}
|
||||
{kind === 'TRANSFER' ? (
|
||||
<>
|
||||
<Form.Item name="toDeptId" label="转入部门" rules={[{ required: true }]}>
|
||||
<Select options={deptOpts.map((d) => ({ value: d.id, label: d.name }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="effectiveAt" label="生效日" rules={[{ required: true }]}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null}
|
||||
{kind === 'RESIGN' ? (
|
||||
<Form.Item name="effectiveAt" label="拟离职日" rules={[{ required: true }]}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
<Form.Item name="reason" label="事由" rules={[{ required: true, message: '请填写事由' }]}>
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</FormDrawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { Button, Descriptions, Space, Spin, Tag, Typography, message } from 'antd';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import dayjs from 'dayjs';
|
||||
import { api } from '../api/client';
|
||||
import { PageHeader } from './ResourcePage';
|
||||
import { HR_APPLY_LABEL, LEAVE_STATUS_META } from './hrStatus';
|
||||
|
||||
type LeaveRow = {
|
||||
id: string;
|
||||
kind: string;
|
||||
status: string;
|
||||
days?: number | string;
|
||||
reason?: string | null;
|
||||
comment?: string | null;
|
||||
startAt?: string | null;
|
||||
endAt?: string | null;
|
||||
createdAt?: string;
|
||||
employee?: { name?: string; employeeNo?: string; department?: { name?: string } | null };
|
||||
decidedBy?: { displayName?: string } | null;
|
||||
};
|
||||
|
||||
export default function HrLeaveDetailPage() {
|
||||
const { id } = useParams();
|
||||
const nav = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const q = useQuery({
|
||||
queryKey: ['/leave-requests', id],
|
||||
queryFn: () => api.get(`/leave-requests/${id}`),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
const row = ((q.data as { data?: LeaveRow })?.data || q.data) as LeaveRow | undefined;
|
||||
const mut = useMutation({
|
||||
mutationFn: (result: 'APPROVED' | 'REJECTED') => api.post(`/leave-requests/${id}/review`, { result }),
|
||||
onSuccess: async () => {
|
||||
message.success('已办理');
|
||||
await qc.invalidateQueries();
|
||||
nav('/office/approvals');
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
const st = row ? LEAVE_STATUS_META[row.status] || { color: 'default', text: row.status } : null;
|
||||
const hours = row?.kind === 'OVERTIME';
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="人事申请"
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={() => nav('/office/approvals')}>返回待我审批</Button>
|
||||
{row?.status === 'PENDING' ? (
|
||||
<>
|
||||
<Button type="primary" loading={mut.isPending} onClick={() => mut.mutate('APPROVED')}>
|
||||
通过
|
||||
</Button>
|
||||
<Button danger loading={mut.isPending} onClick={() => mut.mutate('REJECTED')}>
|
||||
驳回
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
{q.isError ? (
|
||||
<Typography.Paragraph type="danger">{(q.error as Error).message || '申请不存在'}</Typography.Paragraph>
|
||||
) : (
|
||||
<Spin spinning={q.isLoading}>
|
||||
<Descriptions bordered column={2}>
|
||||
<Descriptions.Item label="申请人">{row?.employee?.name || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="部门">{row?.employee?.department?.name || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="类型">{row ? HR_APPLY_LABEL[row.kind] || row.kind : '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">{st ? <Tag color={st.color}>{st.text}</Tag> : '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="开始">{row?.startAt ? dayjs(row.startAt).format('YYYY-MM-DD HH:mm') : '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="结束">{row?.endAt ? dayjs(row.endAt).format('YYYY-MM-DD HH:mm') : '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="时长">
|
||||
{row?.days != null && row.days !== '' ? `${row.days}${hours ? ' 小时' : ' 天'}` : '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="审批人">{row?.decidedBy?.displayName || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="事由" span={2}>
|
||||
{row?.reason || '—'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="审批意见" span={2}>
|
||||
{row?.comment || '—'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Spin>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { Button, Form, Input, Popconfirm, Radio, Select, Space, Table, Tag, message } from 'antd';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useLocation, useNavigate, useParams } from 'react-router-dom';
|
||||
import { api } from '../api/client';
|
||||
import { BizFileUpload, nonImageFiles } from './FileAttachments';
|
||||
import { PageHeader, unwrapList } from './ResourcePage';
|
||||
import RichEditor from './RichEditor';
|
||||
import { fmtCreated } from '../time';
|
||||
import { topLevelSelectOptions } from '../deptTree';
|
||||
|
||||
type NoticeRow = {
|
||||
id: string;
|
||||
title: string;
|
||||
excerpt?: string;
|
||||
content?: string;
|
||||
kind?: string;
|
||||
status?: string;
|
||||
audience?: string;
|
||||
departmentIds?: string[];
|
||||
userIds?: string[];
|
||||
createdAt: string;
|
||||
createdBy?: { displayName?: string };
|
||||
files?: { id: string; fileName: string; size: number; mimeType: string; bizType?: string }[];
|
||||
};
|
||||
|
||||
function kindOf(pathname: string) {
|
||||
return pathname.includes('policies') ? 'POLICY' : 'ANNOUNCEMENT';
|
||||
}
|
||||
|
||||
function labels(kind: string) {
|
||||
return kind === 'POLICY'
|
||||
? { title: '规章制度', one: '制度', create: '新建制度', publish: '发送制度' }
|
||||
: { title: '发布公告', one: '通知', create: '新建通知', publish: '发表通知' };
|
||||
}
|
||||
|
||||
export default function HrNoticePage() {
|
||||
const loc = useLocation();
|
||||
const { id } = useParams();
|
||||
const kind = kindOf(loc.pathname);
|
||||
if (id) return <HrNoticeEditor kind={kind} id={id} />;
|
||||
return <HrNoticeList kind={kind} />;
|
||||
}
|
||||
|
||||
function HrNoticeList({ kind }: { kind: string }) {
|
||||
const nav = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const t = labels(kind);
|
||||
const base = kind === 'POLICY' ? '/hr/policies' : '/hr/notices';
|
||||
const qKey = ['/office/notices', 'manage', kind];
|
||||
const q = useQuery({
|
||||
queryKey: qKey,
|
||||
queryFn: () => api.get('/office/notices', { params: { kind, scope: 'manage' } }),
|
||||
});
|
||||
const items = unwrapList(q.data).items as unknown as NoticeRow[];
|
||||
|
||||
const create = useMutation({
|
||||
mutationFn: () => api.post('/office/notices', { kind, status: 'DRAFT', title: '未命名' }),
|
||||
onSuccess: async (res) => {
|
||||
const row = (res as { data: NoticeRow }).data;
|
||||
await qc.invalidateQueries({ queryKey: qKey });
|
||||
nav(`${base}/${row.id}`);
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={t.title}
|
||||
description={`${t.create}后用富文本编写正文,可指定全公司、某些部门或某些人。发布后员工在个人办公「公司公告」里查看。`}
|
||||
extra={
|
||||
<Button type="primary" loading={create.isPending} onClick={() => create.mutate()}>
|
||||
{t.create}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Table
|
||||
className="ops-table"
|
||||
rowKey="id"
|
||||
loading={q.isLoading}
|
||||
dataSource={items}
|
||||
pagination={false}
|
||||
locale={{ emptyText: `还没有${t.one}。` }}
|
||||
onRow={(r) => ({
|
||||
style: { cursor: 'pointer' },
|
||||
onClick: () => nav(`${base}/${r.id}`),
|
||||
})}
|
||||
columns={[
|
||||
{ title: '标题', dataIndex: 'title', ellipsis: true },
|
||||
{
|
||||
title: '状态',
|
||||
width: 100,
|
||||
render: (_: unknown, r: NoticeRow) =>
|
||||
r.status === 'PUBLISHED' ? <Tag color="green">已发布</Tag> : <Tag>草稿</Tag>,
|
||||
},
|
||||
{
|
||||
title: '范围',
|
||||
width: 140,
|
||||
render: (_: unknown, r: NoticeRow) =>
|
||||
r.audience === 'DEPT' ? (
|
||||
<Tag>指定部门</Tag>
|
||||
) : r.audience === 'USER' ? (
|
||||
<Tag>指定人员</Tag>
|
||||
) : (
|
||||
<Tag>全公司</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '发布人', width: 120, render: (_: unknown, r: NoticeRow) => r.createdBy?.displayName || '—' },
|
||||
{ title: '时间', width: 170, render: (_: unknown, r: NoticeRow) => fmtCreated(r.createdAt) },
|
||||
{
|
||||
title: '操作',
|
||||
width: 140,
|
||||
render: (_: unknown, r: NoticeRow) => (
|
||||
<Space onClick={(e) => e.stopPropagation()}>
|
||||
<Button type="link" onClick={() => nav(`${base}/${r.id}`)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title={`确认删除这条${t.one}?`}
|
||||
onConfirm={async () => {
|
||||
await api.delete(`/office/notices/${r.id}`);
|
||||
message.success('已删除');
|
||||
qc.invalidateQueries({ queryKey: qKey });
|
||||
qc.invalidateQueries({ queryKey: ['/office/notices'] });
|
||||
}}
|
||||
>
|
||||
<Button type="link" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
type Staff = { id: string; name: string; department?: string };
|
||||
type Dept = { id: string; name: string; code?: string; parentId?: string | null };
|
||||
|
||||
function HrNoticeEditor({ kind, id }: { kind: string; id: string }) {
|
||||
const nav = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const t = labels(kind);
|
||||
const base = kind === 'POLICY' ? '/hr/policies' : '/hr/notices';
|
||||
const [form] = Form.useForm();
|
||||
const [html, setHtml] = useState('<p></p>');
|
||||
const audience = Form.useWatch('audience', form);
|
||||
const q = useQuery({
|
||||
queryKey: ['/office/notices', id],
|
||||
queryFn: () => api.get(`/office/notices/${id}`),
|
||||
});
|
||||
const staff = useQuery({ queryKey: ['/staff'], queryFn: () => api.get('/staff') });
|
||||
const depts = useQuery({ queryKey: ['/departments'], queryFn: () => api.get('/departments') });
|
||||
const row = q.data?.data as NoticeRow | undefined;
|
||||
const people = ((staff.data as { data?: Staff[] })?.data || []) as Staff[];
|
||||
const departments = ((depts.data as { data?: Dept[] })?.data || []).filter((d) => d.code !== 'ROOT');
|
||||
|
||||
useEffect(() => {
|
||||
if (!row) return;
|
||||
form.setFieldsValue({
|
||||
title: row.title === '未命名' ? '' : row.title,
|
||||
audience: row.audience || 'ALL',
|
||||
departmentIds: row.departmentIds || [],
|
||||
userIds: row.userIds || [],
|
||||
});
|
||||
setHtml(row.content || '<p></p>');
|
||||
}, [row, form]);
|
||||
|
||||
const payload = (status: string) => ({
|
||||
title: form.getFieldValue('title') || (status === 'DRAFT' ? '未命名' : form.getFieldValue('title')),
|
||||
content: html,
|
||||
status,
|
||||
audience: form.getFieldValue('audience') || 'ALL',
|
||||
departmentIds: form.getFieldValue('departmentIds') || [],
|
||||
userIds: form.getFieldValue('userIds') || [],
|
||||
});
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) => api.patch(`/office/notices/${id}`, body),
|
||||
onSuccess: async (_res, vars) => {
|
||||
message.success(vars.status === 'PUBLISHED' ? `${t.one}已发布` : '草稿已保存');
|
||||
await qc.invalidateQueries({ queryKey: ['/office/notices'] });
|
||||
if (vars.status === 'PUBLISHED') nav(base);
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={row?.status === 'PUBLISHED' ? `编辑${t.one}` : t.create}
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={() => nav(base)}>返回列表</Button>
|
||||
<Button loading={save.isPending} onClick={() => save.mutate(payload('DRAFT'))}>
|
||||
保存草稿
|
||||
</Button>
|
||||
<Button type="primary" loading={save.isPending} onClick={() => save.mutate(payload('PUBLISHED'))}>
|
||||
{t.publish}
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
<Form form={form} layout="vertical" initialValues={{ audience: 'ALL' }}>
|
||||
<Form.Item name="title" label="标题" rules={[{ required: true, message: '请填写标题' }]}>
|
||||
<Input placeholder={`${t.one}标题`} maxLength={120} />
|
||||
</Form.Item>
|
||||
<Form.Item name="audience" label="发送范围" extra="默认全公司。也可以只发给某些部门,或点名发给某些人。">
|
||||
<Radio.Group
|
||||
options={[
|
||||
{ value: 'ALL', label: '全公司' },
|
||||
{ value: 'DEPT', label: '按部门' },
|
||||
{ value: 'USER', label: '按人员' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
{audience === 'DEPT' ? (
|
||||
<Form.Item
|
||||
name="departmentIds"
|
||||
label="接收部门"
|
||||
rules={[{ required: true, type: 'array', min: 1, message: '请选择部门' }]}
|
||||
extra="选上级部门时,下级部门的人也能看到。"
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="可选多个部门"
|
||||
options={topLevelSelectOptions(departments)}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{audience === 'USER' ? (
|
||||
<Form.Item
|
||||
name="userIds"
|
||||
label="接收人员"
|
||||
rules={[{ required: true, type: 'array', min: 1, message: '请选择人员' }]}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="搜索姓名"
|
||||
options={people.map((p) => ({
|
||||
value: p.id,
|
||||
label: `${p.name}${p.department ? `(${p.department})` : ''}`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
<Form.Item label="正文" required>
|
||||
<RichEditor value={html} onChange={setHtml} bizId={id} placeholder={`请编写${t.one}正文`} />
|
||||
</Form.Item>
|
||||
<BizFileUpload
|
||||
bizType="NOTICE"
|
||||
bizId={id}
|
||||
files={nonImageFiles(row?.files)}
|
||||
label="附件"
|
||||
extra="可与正文一起发布,员工打开详情后下载。"
|
||||
queryKey={['/office/notices', id]}
|
||||
/>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,450 @@
|
||||
import { Button, DatePicker, Input, InputNumber, Select, Space, Table, Tag, Typography, message } from 'antd';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api } from '../api/client';
|
||||
import { useAuth } from '../store/auth';
|
||||
import { PageHeader } from './ResourcePage';
|
||||
import { canConfirmPay, canHr } from '../access';
|
||||
import { descendantIds, topLevelNameByLeafName, topLevelSelectOptions } from '../deptTree';
|
||||
import {
|
||||
EMP_STATUS_META,
|
||||
HR_APPLY_LABEL,
|
||||
LEAVE_KIND_LABEL,
|
||||
LEAVE_STATUS_META,
|
||||
LIFE_KIND_LABEL,
|
||||
PAYROLL_STATUS_META,
|
||||
REST_LABEL,
|
||||
money,
|
||||
} from './hrStatus';
|
||||
|
||||
type Dept = {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
deptType: string;
|
||||
parentId?: string | null;
|
||||
parent?: { name: string } | null;
|
||||
categoryCode?: string | null;
|
||||
sortNo: number;
|
||||
_count?: { employees: number; children: number };
|
||||
};
|
||||
|
||||
function unwrapItems<T>(data: unknown): T[] {
|
||||
const body = (data as { data?: unknown })?.data ?? data;
|
||||
if (Array.isArray(body)) return body as T[];
|
||||
if (body && typeof body === 'object' && 'items' in body) return ((body as { items: T[] }).items ?? []) as T[];
|
||||
return [];
|
||||
}
|
||||
|
||||
function canPerf(roles: string[]) {
|
||||
return (
|
||||
['admin', 'owner', 'hr', 'biz_director', 'tech_director', 'rd_director', '3d_director', 'video_director', 'material_director'].some(
|
||||
(r) => roles.includes(r),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function useDepartments() {
|
||||
return useQuery({
|
||||
queryKey: ['/departments'],
|
||||
queryFn: () => api.get('/departments'),
|
||||
});
|
||||
}
|
||||
|
||||
export function LifecyclePage() {
|
||||
const events = useQuery({
|
||||
queryKey: ['/employment-events'],
|
||||
queryFn: () => api.get('/employment-events'),
|
||||
});
|
||||
const rows = unwrapItems<{
|
||||
id: string;
|
||||
kind: string;
|
||||
fromStatus?: string;
|
||||
toStatus?: string;
|
||||
effectiveAt: string;
|
||||
comment?: string;
|
||||
createdAt?: string;
|
||||
employee: { name: string; employeeNo: string };
|
||||
fromDept?: { name: string } | null;
|
||||
toDept?: { name: string } | null;
|
||||
}>(events.data);
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="入转调离"
|
||||
description="这里只看审批通过后的流水:转正、调岗、晋升、离职等。员工在个人办公 · 人事申请里提交,人事在假勤结果里批,通过后自动落记录并回写档案。"
|
||||
/>
|
||||
<Table
|
||||
className="ops-table"
|
||||
rowKey="id"
|
||||
loading={events.isLoading}
|
||||
dataSource={rows}
|
||||
scroll={{ x: 'max-content' }}
|
||||
tableLayout="fixed"
|
||||
columns={[
|
||||
{ title: '员工', render: (_: unknown, r: (typeof rows)[0]) => `${r.employee?.name} ${r.employee?.employeeNo}` },
|
||||
{ title: '类型', dataIndex: 'kind', width: 90, render: (v: string) => LIFE_KIND_LABEL[v] || v },
|
||||
{ title: '原状态', dataIndex: 'fromStatus', width: 80, render: (v: string) => EMP_STATUS_META[v]?.text || v || '—' },
|
||||
{ title: '新状态', dataIndex: 'toStatus', width: 80, render: (v: string) => EMP_STATUS_META[v]?.text || v || '—' },
|
||||
{ title: '部门', render: (_: unknown, r: (typeof rows)[0]) => (r.fromDept && r.toDept && r.fromDept.name !== r.toDept.name ? `${r.fromDept.name} → ${r.toDept.name}` : r.toDept?.name || '—') },
|
||||
{ title: '生效日', dataIndex: 'effectiveAt', width: 120, render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD') : '—') },
|
||||
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '—') },
|
||||
{ title: '说明', dataIndex: 'comment' },
|
||||
]}
|
||||
pagination={false}
|
||||
locale={{ emptyText: '暂无数据' }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AttendancePage() {
|
||||
const qc = useQueryClient();
|
||||
const roles = useAuth((s) => s.roles);
|
||||
const [month, setMonth] = useStateMonth();
|
||||
const q = useQuery({
|
||||
queryKey: ['/attendance', month],
|
||||
queryFn: () => api.get(`/attendance?yearMonth=${month}`),
|
||||
});
|
||||
const rows = unwrapItems<{
|
||||
id: string;
|
||||
employeeId: string;
|
||||
workDays: number;
|
||||
leaveDays: number;
|
||||
sickDays: number;
|
||||
overtimeHours: number;
|
||||
compLeaveDays: number;
|
||||
compLeaveHours: number;
|
||||
remark?: string;
|
||||
employee: { name: string; employeeNo: string; restSchedule: string };
|
||||
}>(q.data);
|
||||
const leaves = useQuery({
|
||||
queryKey: ['leave-pending'],
|
||||
queryFn: () => api.get('/leave-requests?bucket=pending'),
|
||||
});
|
||||
const punches = useQuery({
|
||||
queryKey: ['/attendance/punches', month],
|
||||
queryFn: () => api.get(`/attendance/punches?yearMonth=${month}`),
|
||||
});
|
||||
const pendingLeaves = unwrapItems<{
|
||||
id: string;
|
||||
kind: string;
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
days: string | number;
|
||||
reason: string;
|
||||
status: string;
|
||||
employee?: { name: string; employeeNo: string };
|
||||
}>(leaves.data);
|
||||
const punchRows = unwrapItems<{
|
||||
id: string;
|
||||
kind: string;
|
||||
punchedAt: string;
|
||||
employee?: { name: string; employeeNo: string };
|
||||
}>(punches.data);
|
||||
const reviewMut = useMutation({
|
||||
mutationFn: ({ id, result }: { id: string; result: 'APPROVED' | 'REJECTED' }) =>
|
||||
api.post(`/leave-requests/${id}/review`, { result }),
|
||||
onSuccess: () => {
|
||||
message.success('已处理申请');
|
||||
qc.invalidateQueries();
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="假勤结果"
|
||||
description={
|
||||
<>
|
||||
这里审批人事申请,并统计本月打卡、假勤、加班和调休。数据来自员工提交的申请,不做导入。班次在
|
||||
<Link to="/system/attendance-rules"> 考勤设置</Link>。
|
||||
</>
|
||||
}
|
||||
extra={
|
||||
<DatePicker picker="month" value={dayjs(`${month}-01`)} onChange={(d) => d && setMonth(d.format('YYYY-MM'))} />
|
||||
}
|
||||
/>
|
||||
<Typography.Title level={5}>待批人事申请</Typography.Title>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={leaves.isLoading}
|
||||
dataSource={pendingLeaves}
|
||||
pagination={false}
|
||||
style={{ marginBottom: 24 }}
|
||||
columns={[
|
||||
{ title: '员工', render: (_: unknown, r: (typeof pendingLeaves)[0]) => `${r.employee?.name || ''} ${r.employee?.employeeNo || ''}` },
|
||||
{ title: '类型', width: 130, render: (_: unknown, r: (typeof pendingLeaves)[0]) => HR_APPLY_LABEL[r.kind] || LEAVE_KIND_LABEL[r.kind] || r.kind },
|
||||
{ title: '开始', width: 140, render: (_: unknown, r: (typeof pendingLeaves)[0]) => dayjs(r.startAt).format('MM-DD HH:mm') },
|
||||
{ title: '结束', width: 140, render: (_: unknown, r: (typeof pendingLeaves)[0]) => dayjs(r.endAt).format('MM-DD HH:mm') },
|
||||
{
|
||||
title: '时长',
|
||||
width: 90,
|
||||
render: (_: unknown, r: (typeof pendingLeaves)[0]) =>
|
||||
r.kind === 'OVERTIME' ? `${r.days} 小时` : r.days,
|
||||
},
|
||||
{ title: '创建时间', width: 170, render: (_: unknown, r: (typeof pendingLeaves)[0] & { createdAt?: string }) => (r.createdAt ? dayjs(r.createdAt).format('YYYY-MM-DD HH:mm:ss') : '—') },
|
||||
{ title: '事由', dataIndex: 'reason', ellipsis: true },
|
||||
{
|
||||
title: '操作',
|
||||
width: 140,
|
||||
render: (_: unknown, r: (typeof pendingLeaves)[0]) =>
|
||||
canHr(roles) ? (
|
||||
<Space>
|
||||
<Button type="link" onClick={() => reviewMut.mutate({ id: r.id, result: 'APPROVED' })}>
|
||||
通过
|
||||
</Button>
|
||||
<Button type="link" danger onClick={() => reviewMut.mutate({ id: r.id, result: 'REJECTED' })}>
|
||||
驳回
|
||||
</Button>
|
||||
</Space>
|
||||
) : (
|
||||
<Tag color={LEAVE_STATUS_META[r.status]?.color}>{LEAVE_STATUS_META[r.status]?.text || r.status}</Tag>
|
||||
),
|
||||
},
|
||||
]}
|
||||
locale={{ emptyText: '没有待批人事申请' }}
|
||||
/>
|
||||
<Typography.Title level={5}>本月打卡</Typography.Title>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={punches.isLoading}
|
||||
dataSource={punchRows}
|
||||
pagination={{ pageSize: 8 }}
|
||||
style={{ marginBottom: 24 }}
|
||||
columns={[
|
||||
{ title: '员工', render: (_: unknown, r: (typeof punchRows)[0]) => `${r.employee?.name || ''} ${r.employee?.employeeNo || ''}` },
|
||||
{ title: '类型', width: 80, render: (_: unknown, r: (typeof punchRows)[0]) => (r.kind === 'OUT' ? '下班' : '上班') },
|
||||
{ title: '时间', width: 180, render: (_: unknown, r: (typeof punchRows)[0]) => dayjs(r.punchedAt).format('YYYY-MM-DD HH:mm:ss') },
|
||||
]}
|
||||
locale={{ emptyText: '本月还没有打卡' }}
|
||||
/>
|
||||
<Typography.Title level={5}>月度假勤(薪酬统计)</Typography.Title>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={q.isLoading}
|
||||
dataSource={rows}
|
||||
columns={[
|
||||
{ title: '员工', render: (_: unknown, r: (typeof rows)[0]) => `${r.employee?.name} ${r.employee?.employeeNo}` },
|
||||
{ title: '作息', width: 80, render: (_: unknown, r: (typeof rows)[0]) => REST_LABEL[r.employee?.restSchedule] || r.employee?.restSchedule },
|
||||
{ title: '出勤', dataIndex: 'workDays', width: 80 },
|
||||
{ title: '事假', dataIndex: 'leaveDays', width: 80 },
|
||||
{ title: '病假', dataIndex: 'sickDays', width: 80 },
|
||||
{ title: '加班小时', dataIndex: 'overtimeHours', width: 100 },
|
||||
{ title: '用调休', dataIndex: 'compLeaveDays', width: 80 },
|
||||
{ title: '调休结余小时', dataIndex: 'compLeaveHours', width: 120 },
|
||||
{ title: '说明', dataIndex: 'remark' },
|
||||
]}
|
||||
pagination={false}
|
||||
locale={{ emptyText: '本月还没有假勤统计' }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PayrollPage() {
|
||||
return <PayrollTable mode="payroll" />;
|
||||
}
|
||||
|
||||
export function PerformancePage() {
|
||||
return <PayrollTable mode="performance" />;
|
||||
}
|
||||
|
||||
function PayrollTable({ mode }: { mode: 'payroll' | 'performance' }) {
|
||||
const qc = useQueryClient();
|
||||
const roles = useAuth((s) => s.roles);
|
||||
const [month, setMonth] = useStateMonth();
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [deptId, setDeptId] = useState<string>();
|
||||
const depts = useDepartments();
|
||||
const deptRows = unwrapItems<Dept>(depts.data);
|
||||
const q = useQuery({
|
||||
queryKey: ['/payroll', month],
|
||||
queryFn: () => api.get(`/payroll?yearMonth=${month}`),
|
||||
});
|
||||
const payload = (q.data?.data ?? {}) as {
|
||||
run?: { id: string; status: string } | null;
|
||||
items?: Record<string, unknown>[];
|
||||
};
|
||||
const items = (payload.items ?? []) as {
|
||||
id: string;
|
||||
name: string;
|
||||
employeeNo: string;
|
||||
departmentName?: string;
|
||||
restSchedule: string;
|
||||
employmentStatus: string;
|
||||
basePay: number;
|
||||
performancePay: number;
|
||||
performanceRatio: number;
|
||||
leaveDeduct: number;
|
||||
socialPersonal: number;
|
||||
housingFund: number;
|
||||
tax: number;
|
||||
grossPay: number;
|
||||
netPay: number;
|
||||
overtimeHours: number;
|
||||
compLeaveHours: number;
|
||||
}[];
|
||||
const runMut = useMutation({
|
||||
mutationFn: () => api.post('/payroll/run', { yearMonth: month }),
|
||||
onSuccess: () => {
|
||||
message.success('已按单休/双休核算,请核对后点「确认本月」');
|
||||
qc.invalidateQueries({ queryKey: ['/payroll'] });
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
const confirmMut = useMutation({
|
||||
mutationFn: (id: string) => api.post(`/payroll/${id}/confirm`, {}),
|
||||
onSuccess: () => {
|
||||
message.success('已确认本月薪酬,不能再改');
|
||||
qc.invalidateQueries({ queryKey: ['/payroll'] });
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
const unconfirmMut = useMutation({
|
||||
mutationFn: (id: string) => api.post(`/payroll/${id}/unconfirm`, {}),
|
||||
onSuccess: () => {
|
||||
message.success('已取消确认,可以重新核算');
|
||||
qc.invalidateQueries({ queryKey: ['/payroll'] });
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
const ratioMut = useMutation({
|
||||
mutationFn: ({ id, performanceRatio }: { id: string; performanceRatio: number }) =>
|
||||
api.patch(`/payroll-items/${id}`, { performanceRatio }),
|
||||
onSuccess: () => {
|
||||
message.success('已调整发放比例');
|
||||
qc.invalidateQueries({ queryKey: ['/payroll'] });
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
const draft = payload.run?.status === 'DRAFT';
|
||||
const confirmed = payload.run?.status === 'CONFIRMED';
|
||||
const shown = items.filter((r) => {
|
||||
if (deptId) {
|
||||
const set = descendantIds(deptRows, deptId);
|
||||
const leaf = deptRows.find((d) => d.name === r.departmentName);
|
||||
if (set && leaf && !set.has(leaf.id)) return false;
|
||||
if (set && !leaf) {
|
||||
const top = topLevelNameByLeafName(deptRows, r.departmentName);
|
||||
if (top !== deptRows.find((d) => d.id === deptId)?.name) return false;
|
||||
}
|
||||
}
|
||||
if (!keyword.trim()) return true;
|
||||
const k = keyword.trim();
|
||||
return r.name.includes(k) || r.employeeNo.includes(k) || (r.departmentName || '').includes(k);
|
||||
});
|
||||
const deptOpts = topLevelSelectOptions(deptRows);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={mode === 'performance' ? '绩效发放' : '薪酬核算'}
|
||||
description={
|
||||
mode === 'performance'
|
||||
? '总监改本部门或下属的发放比例,改完商务部立刻能看到;已确认的月份再改会退回待确认。加班不发加班费,只转调休。'
|
||||
: '软件研发双休按 21.75 天计薪,三维/摄制/物资单休按 25.75 天。外协部、保障部不核算。核算后必须点「确认本月」才锁定,默认都是未确认。'
|
||||
}
|
||||
extra={
|
||||
<Space>
|
||||
<DatePicker picker="month" value={dayjs(`${month}-01`)} onChange={(d) => d && setMonth(d.format('YYYY-MM'))} />
|
||||
{payload.run ? (
|
||||
<Tag color={PAYROLL_STATUS_META[payload.run.status]?.color}>{PAYROLL_STATUS_META[payload.run.status]?.text}</Tag>
|
||||
) : (
|
||||
<Tag>未核算</Tag>
|
||||
)}
|
||||
{mode === 'payroll' && canHr(roles) && !confirmed ? (
|
||||
<Button loading={runMut.isPending} onClick={() => runMut.mutate()}>
|
||||
{payload.run ? '重新核算' : '生成本月核算'}
|
||||
</Button>
|
||||
) : null}
|
||||
{mode === 'payroll' && canConfirmPay(roles) && payload.run && draft ? (
|
||||
<Button type="primary" loading={confirmMut.isPending} onClick={() => confirmMut.mutate(payload.run!.id)}>
|
||||
确认本月
|
||||
</Button>
|
||||
) : null}
|
||||
{mode === 'payroll' && canConfirmPay(roles) && payload.run && confirmed ? (
|
||||
<Button loading={unconfirmMut.isPending} onClick={() => unconfirmMut.mutate(payload.run!.id)}>
|
||||
取消确认
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
<div className="ops-toolbar" style={{ flexWrap: 'wrap', gap: 8 }}>
|
||||
<Input.Search
|
||||
allowClear
|
||||
enterButton
|
||||
className="yzl-search"
|
||||
placeholder="搜索姓名、工号、部门"
|
||||
style={{ maxWidth: 320 }}
|
||||
onSearch={setKeyword}
|
||||
onChange={(e) => {
|
||||
if (!e.target.value) setKeyword('');
|
||||
}}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="一级部门"
|
||||
style={{ minWidth: 140 }}
|
||||
value={deptId}
|
||||
options={deptOpts}
|
||||
onChange={setDeptId}
|
||||
/>
|
||||
</div>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={q.isLoading}
|
||||
dataSource={shown}
|
||||
columns={[
|
||||
{ title: '姓名', dataIndex: 'name', width: 90 },
|
||||
{ title: '工号', dataIndex: 'employeeNo', width: 100 },
|
||||
{ title: '部门', dataIndex: 'departmentName', width: 110 },
|
||||
{ title: '作息', dataIndex: 'restSchedule', width: 70, render: (v: string) => REST_LABEL[v] || v },
|
||||
{ title: '基本应发', dataIndex: 'basePay', width: 110, render: (v: number) => money(v) },
|
||||
{ title: '扣假', dataIndex: 'leaveDeduct', width: 90, render: (v: number) => money(v) },
|
||||
{
|
||||
title: '绩效比例',
|
||||
dataIndex: 'performanceRatio',
|
||||
width: 110,
|
||||
render: (v: number, row) =>
|
||||
mode === 'performance' && canPerf(roles) ? (
|
||||
<InputNumber
|
||||
min={0}
|
||||
max={200}
|
||||
value={v}
|
||||
size="small"
|
||||
onBlur={(e) => {
|
||||
const next = Number((e.target as HTMLInputElement).value);
|
||||
if (Number.isFinite(next) && next !== v) void ratioMut.mutateAsync({ id: row.id, performanceRatio: next });
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
`${v}%`
|
||||
),
|
||||
},
|
||||
{ title: '绩效应发', dataIndex: 'performancePay', width: 110, render: (v: number) => money(v) },
|
||||
{ title: '应发合计', dataIndex: 'grossPay', width: 110, render: (v: number) => money(v) },
|
||||
{ title: '社保个人', dataIndex: 'socialPersonal', width: 100, render: (v: number) => money(v) },
|
||||
{ title: '公积金', dataIndex: 'housingFund', width: 80, render: (v: number) => money(v) },
|
||||
{ title: '个税', dataIndex: 'tax', width: 90, render: (v: number) => money(v) },
|
||||
{ title: '实发', dataIndex: 'netPay', width: 110, render: (v: number) => money(v) },
|
||||
]}
|
||||
scroll={{ x: 1400 }}
|
||||
pagination={false}
|
||||
locale={{ emptyText: mode === 'performance' ? '请人事先生成本月核算' : '点「生成本月核算」按假勤结果出工资条' }}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function useStateMonth() {
|
||||
const d = new Date();
|
||||
const initial = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`;
|
||||
return useState(initial);
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
import { Button, Form, Input, InputNumber, Modal, Select, Space, Switch, Table, TimePicker, message } from 'antd';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { api } from '../api/client';
|
||||
import { useAuth } from '../store/auth';
|
||||
import { FormDrawer } from './FormDrawer';
|
||||
import { PageHeader } from './ResourcePage';
|
||||
import { PunchLocationMap } from './PunchLocationMap';
|
||||
import { REST_LABEL } from './hrStatus';
|
||||
import { canHr } from '../access';
|
||||
|
||||
function unwrapPayload<T>(data: unknown): T {
|
||||
return ((data as { data?: T })?.data ?? data) as T;
|
||||
}
|
||||
|
||||
function unwrapItems<T>(data: unknown): T[] {
|
||||
const body = (data as { data?: unknown })?.data ?? data;
|
||||
if (Array.isArray(body)) return body as T[];
|
||||
if (body && typeof body === 'object' && 'items' in body) return ((body as { items: T[] }).items ?? []) as T[];
|
||||
return [];
|
||||
}
|
||||
|
||||
type Settings = {
|
||||
idMask: boolean;
|
||||
autoAccount: boolean;
|
||||
probationMonths: number;
|
||||
defaultRest: string;
|
||||
workStart: string;
|
||||
workEnd: string;
|
||||
};
|
||||
|
||||
type Rule = {
|
||||
id: string;
|
||||
name: string;
|
||||
restSchedule: string;
|
||||
workStart: string;
|
||||
workEnd: string;
|
||||
lateGraceMinutes: number;
|
||||
punchPlace?: string | null;
|
||||
punchAddress?: string | null;
|
||||
punchLat?: string | number | null;
|
||||
punchLng?: string | number | null;
|
||||
punchRadiusMeters?: number;
|
||||
enabled: boolean;
|
||||
sortNo: number;
|
||||
remark?: string | null;
|
||||
};
|
||||
|
||||
export function HrParamsPage() {
|
||||
const qc = useQueryClient();
|
||||
const roles = useAuth((s) => s.roles);
|
||||
const [form] = Form.useForm();
|
||||
const q = useQuery({
|
||||
queryKey: ['/hr-settings'],
|
||||
queryFn: () => api.get('/hr-settings'),
|
||||
});
|
||||
useEffect(() => {
|
||||
if (!q.data) return;
|
||||
const s = unwrapPayload<Settings>(q.data);
|
||||
form.setFieldsValue({
|
||||
...s,
|
||||
workStart: s.workStart ? dayjs(s.workStart, 'HH:mm') : undefined,
|
||||
workEnd: s.workEnd ? dayjs(s.workEnd, 'HH:mm') : undefined,
|
||||
});
|
||||
}, [form, q.data]);
|
||||
const mut = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) => api.patch('/hr-settings', body),
|
||||
onSuccess: () => {
|
||||
message.success('人事参数已保存');
|
||||
qc.invalidateQueries({ queryKey: ['/hr-settings'] });
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="人事参数"
|
||||
description="身份证脱敏、默认试用期和默认作息。入职开户请到人事设置 · 员工账号手工办理。"
|
||||
extra={
|
||||
canHr(roles) ? (
|
||||
<Button
|
||||
type="primary"
|
||||
loading={mut.isPending}
|
||||
onClick={async () => {
|
||||
const v = await form.validateFields();
|
||||
await mut.mutateAsync({
|
||||
...v,
|
||||
autoAccount: false,
|
||||
workStart: v.workStart ? dayjs(v.workStart).format('HH:mm') : '09:00',
|
||||
workEnd: v.workEnd ? dayjs(v.workEnd).format('HH:mm') : '18:00',
|
||||
});
|
||||
}}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
<Form form={form} layout="vertical" className="hr-settings-grid" disabled={!canHr(roles) || q.isLoading}>
|
||||
<Form.Item name="idMask" label="身份证号脱敏" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="autoAccount"
|
||||
label="入职自动开通登录账号"
|
||||
valuePropName="checked"
|
||||
extra="已停用:请到人事设置 · 员工账号中开户、设初始密码和重置密码。"
|
||||
>
|
||||
<Switch disabled />
|
||||
</Form.Item>
|
||||
<Form.Item name="probationMonths" label="默认试用期(月)" rules={[{ required: true }]}>
|
||||
<InputNumber min={0} max={12} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="defaultRest" label="默认作息" rules={[{ required: true }]}>
|
||||
<Select options={[{ value: 'SINGLE', label: '单休' }, { value: 'DOUBLE', label: '双休' }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="workStart" label="默认上班时间">
|
||||
<TimePicker format="HH:mm" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="workEnd" label="默认下班时间">
|
||||
<TimePicker format="HH:mm" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AttendanceRulesPage() {
|
||||
const qc = useQueryClient();
|
||||
const roles = useAuth((s) => s.roles);
|
||||
const [form] = Form.useForm();
|
||||
const [editing, setEditing] = useState<Rule | 'new' | null>(null);
|
||||
const q = useQuery({ queryKey: ['/attendance-rules'], queryFn: () => api.get('/attendance-rules') });
|
||||
const rows = unwrapItems<Rule>(q.data);
|
||||
const saveMut = useMutation({
|
||||
mutationFn: ({ id, body }: { id?: string; body: Record<string, unknown> }) =>
|
||||
id ? api.patch(`/attendance-rules/${id}`, body) : api.post('/attendance-rules', body),
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
const delMut = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/attendance-rules/${id}`),
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
|
||||
const openForm = (row?: Rule) => {
|
||||
form.setFieldsValue(
|
||||
row
|
||||
? {
|
||||
...row,
|
||||
workStart: dayjs(row.workStart, 'HH:mm'),
|
||||
workEnd: dayjs(row.workEnd, 'HH:mm'),
|
||||
}
|
||||
: { restSchedule: 'SINGLE', workStart: dayjs('09:00', 'HH:mm'), workEnd: dayjs('18:00', 'HH:mm'), lateGraceMinutes: 0, punchRadiusMeters: 300, enabled: true, sortNo: 99 },
|
||||
);
|
||||
setEditing(row || 'new');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="考勤设置"
|
||||
description="维护班次时间和打卡地点,给后续手机端定位打卡用。假勤结果仍可在组织人事里按月导入。"
|
||||
extra={
|
||||
<Space>
|
||||
<Link to="/hr/attendance">查看假勤结果</Link>
|
||||
{canHr(roles) ? (
|
||||
<Button type="primary" onClick={() => openForm()}>
|
||||
新建班次
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
<FormDrawer
|
||||
title={editing && editing !== 'new' ? '编辑班次' : '新建班次'}
|
||||
open={Boolean(editing)}
|
||||
onClose={() => setEditing(null)}
|
||||
okText="提交保存"
|
||||
okLoading={saveMut.isPending}
|
||||
onOk={async () => {
|
||||
const v = await form.validateFields();
|
||||
if (v.punchLat == null || v.punchLng == null) {
|
||||
message.warning('请先在地图上点选打卡位置');
|
||||
return;
|
||||
}
|
||||
const row = editing && editing !== 'new' ? editing : undefined;
|
||||
await saveMut.mutateAsync({
|
||||
id: row?.id,
|
||||
body: {
|
||||
...v,
|
||||
enabled: true,
|
||||
workStart: dayjs(v.workStart).format('HH:mm'),
|
||||
workEnd: dayjs(v.workEnd).format('HH:mm'),
|
||||
},
|
||||
});
|
||||
message.success(row ? '班次已保存' : '班次已创建');
|
||||
setEditing(null);
|
||||
await qc.invalidateQueries({ queryKey: ['/attendance-rules'] });
|
||||
}}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="班次名称" rules={[{ required: true }]}>
|
||||
<Input placeholder="如 单休班次" />
|
||||
</Form.Item>
|
||||
<Form.Item name="restSchedule" label="作息" rules={[{ required: true }]}>
|
||||
<Select options={[{ value: 'SINGLE', label: '单休' }, { value: 'DOUBLE', label: '双休' }]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="workStart" label="上班时间" rules={[{ required: true }]}>
|
||||
<TimePicker format="HH:mm" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="workEnd" label="下班时间" rules={[{ required: true }]}>
|
||||
<TimePicker format="HH:mm" style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="lateGraceMinutes" label="迟到宽限(分钟)">
|
||||
<InputNumber min={0} max={60} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="punchPlace" label="打卡地点名称">
|
||||
<Input placeholder="如 总部办公楼" />
|
||||
</Form.Item>
|
||||
<Form.Item label="打卡地址" extra="地址只用来跳到附近。准确位置请在地图上点选,不要依赖自动解析。">
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Form.Item name="punchAddress" noStyle>
|
||||
<Input placeholder="如 南京市秦淮区瑞金路" />
|
||||
</Form.Item>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
const address = String(form.getFieldValue('punchAddress') || form.getFieldValue('punchPlace') || '').trim();
|
||||
if (!address) {
|
||||
message.warning('请先填写打卡地址,或直接点「地图选点」');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = (await api.post('/office/geo/geocode', { address })) as {
|
||||
data?: { lat: number; lng: number; address?: string; title?: string };
|
||||
};
|
||||
const hit = res.data;
|
||||
if (!hit) throw new Error('没有解析到坐标');
|
||||
form.setFieldsValue({
|
||||
punchLat: hit.lat,
|
||||
punchLng: hit.lng,
|
||||
});
|
||||
message.info('已跳到附近,请在地图上点准确位置,并看紫色范围圈');
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '解析失败');
|
||||
}
|
||||
}}
|
||||
>
|
||||
跳到附近
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item name="punchLat" hidden>
|
||||
<InputNumber style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="punchLng" hidden>
|
||||
<InputNumber style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="punchRadiusMeters" label="有效半径(米)" extra="圈内才能打卡,建议 100~300 米。">
|
||||
<InputNumber min={50} max={2000} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item shouldUpdate>
|
||||
{() => (
|
||||
<PunchLocationMap
|
||||
lat={form.getFieldValue('punchLat')}
|
||||
lng={form.getFieldValue('punchLng')}
|
||||
radiusMeters={form.getFieldValue('punchRadiusMeters')}
|
||||
onPick={(hit) => {
|
||||
form.setFieldsValue({
|
||||
punchLat: hit.lat,
|
||||
punchLng: hit.lng,
|
||||
punchAddress: hit.address || form.getFieldValue('punchAddress'),
|
||||
punchPlace: form.getFieldValue('punchPlace') || hit.title,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Form.Item>
|
||||
<Form.Item shouldUpdate>
|
||||
{() => {
|
||||
const lat = form.getFieldValue('punchLat');
|
||||
const lng = form.getFieldValue('punchLng');
|
||||
return lat && lng ? (
|
||||
<p style={{ color: 'var(--mute)', marginTop: -8 }}>
|
||||
已选坐标 {Number(lat).toFixed(6)}, {Number(lng).toFixed(6)},保存班次后生效。
|
||||
</p>
|
||||
) : (
|
||||
<p style={{ color: 'var(--mute)', marginTop: -8 }}>请地图选点后再保存。</p>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
<Form.Item name="sortNo" label="排序">
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="remark" label="说明">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</FormDrawer>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={q.isLoading}
|
||||
dataSource={rows}
|
||||
pagination={false}
|
||||
locale={{ emptyText: '还没有班次。点右上角新建。' }}
|
||||
columns={[
|
||||
{ title: '班次', dataIndex: 'name' },
|
||||
{ title: '作息', dataIndex: 'restSchedule', width: 90, render: (v: string) => REST_LABEL[v] || v },
|
||||
{ title: '上班', dataIndex: 'workStart', width: 90 },
|
||||
{ title: '下班', dataIndex: 'workEnd', width: 90 },
|
||||
{ title: '打卡地点', dataIndex: 'punchPlace', render: (_: string, r: Rule) => r.punchPlace || r.punchAddress || '未设' },
|
||||
{ title: '半径', dataIndex: 'punchRadiusMeters', width: 90, render: (v: number) => (v ? `${v} 米` : '—') },
|
||||
{ title: '迟到宽限', dataIndex: 'lateGraceMinutes', width: 100, render: (v: number) => `${v} 分钟` },
|
||||
{ title: '说明', dataIndex: 'remark', render: (v: string) => v || '—' },
|
||||
canHr(roles)
|
||||
? {
|
||||
title: '操作',
|
||||
width: 140,
|
||||
render: (_: unknown, row: Rule) => (
|
||||
<Space>
|
||||
<Button type="link" onClick={() => openForm(row)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
onClick={() =>
|
||||
Modal.confirm({
|
||||
title: '删除班次',
|
||||
content: `确认删除「${row.name}」?`,
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
await delMut.mutateAsync(row.id);
|
||||
message.success('已删除');
|
||||
await qc.invalidateQueries({ queryKey: ['/attendance-rules'] });
|
||||
},
|
||||
})
|
||||
}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
}
|
||||
: { title: '', width: 1 },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
import { Button, DatePicker, Form, InputNumber, Select, Space, Tag, message } from 'antd';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import dayjs from 'dayjs';
|
||||
import { api } from '../api/client';
|
||||
import { FormDrawer } from './FormDrawer';
|
||||
import { ResourcePage } from './ResourcePage';
|
||||
import { INVOICE_STATUS_META, money } from './financeStatus';
|
||||
import { ApplyBucketBar } from './ApplyBucketBar';
|
||||
|
||||
type Contract = {
|
||||
id: string;
|
||||
contractNo: string;
|
||||
name: string;
|
||||
status: string;
|
||||
amount: string | number;
|
||||
milestones?: { id: string; name: string; ratio: string | number; invoice?: { id: string } | null }[];
|
||||
};
|
||||
|
||||
export default function InvoiceListPage() {
|
||||
const [params] = useSearchParams();
|
||||
const bucket = params.get('bucket') || '';
|
||||
const endpoint = bucket ? `/invoices?bucket=${bucket}` : '/invoices';
|
||||
const qc = useQueryClient();
|
||||
const [form] = Form.useForm();
|
||||
const [creating, setCreating] = useState(false);
|
||||
const contracts = useQuery({
|
||||
queryKey: ['/contracts'],
|
||||
queryFn: () => api.get('/contracts'),
|
||||
});
|
||||
const items = ((contracts.data?.data?.items ?? []) as Contract[]).filter((c) => c.status === 'APPROVED');
|
||||
|
||||
const createMut = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) => api.post('/invoices', body),
|
||||
onSuccess: () => {
|
||||
message.success('已生成待开票');
|
||||
qc.invalidateQueries();
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
const actMut = useMutation({
|
||||
mutationFn: ({ id, action, invoiceNo }: { id: string; action: 'issue' | 'receive'; invoiceNo?: string }) =>
|
||||
api.post(`/invoices/${id}/${action}`, action === 'issue' ? { invoiceNo } : {}),
|
||||
onSuccess: () => {
|
||||
message.success('已更新');
|
||||
qc.invalidateQueries();
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
|
||||
const openCreate = () => {
|
||||
form.resetFields();
|
||||
setCreating(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FormDrawer
|
||||
title="按收款条款开票"
|
||||
open={creating}
|
||||
onClose={() => setCreating(false)}
|
||||
okText="提交保存"
|
||||
okLoading={createMut.isPending}
|
||||
onOk={async () => {
|
||||
const v = await form.validateFields();
|
||||
await createMut.mutateAsync({
|
||||
...v,
|
||||
dueAt: v.dueAt ? v.dueAt.toISOString() : undefined,
|
||||
});
|
||||
setCreating(false);
|
||||
}}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="contractId" label="合同" rules={[{ required: true }]}>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
options={items.map((c) => ({ value: c.id, label: `${c.contractNo} ${c.name}` }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{() => {
|
||||
const cid = form.getFieldValue('contractId');
|
||||
const c = items.find((x) => x.id === cid);
|
||||
const miles = (c?.milestones || []).filter((m) => !m.invoice);
|
||||
return (
|
||||
<Form.Item name="milestoneId" label="收款条款" rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={miles.map((m) => ({
|
||||
value: m.id,
|
||||
label: `${m.name} ${m.ratio}%`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
);
|
||||
}}
|
||||
</Form.Item>
|
||||
<Form.Item name="amount" label="金额(空则按条款比例)">
|
||||
<InputNumber min={0.01} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="dueAt" label="应收日期">
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</FormDrawer>
|
||||
<ResourcePage
|
||||
title="开票收款"
|
||||
description="合同里程碑开票:待开、已开、已回、逾期。同一条款不能重复开。"
|
||||
endpoint={endpoint}
|
||||
extra={
|
||||
<ApplyBucketBar
|
||||
basePath="/finance/invoices"
|
||||
buckets={[
|
||||
{ value: 'to_issue', label: '待开' },
|
||||
{ value: 'overdue', label: '逾期' },
|
||||
]}
|
||||
extra={
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
按条款开票
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
columns={[
|
||||
{ title: '发票号', dataIndex: 'invoiceNo', render: (v: string) => v || '—' },
|
||||
{
|
||||
title: '合同',
|
||||
render: (_: unknown, row: Record<string, unknown>) => {
|
||||
const c = row.contract as { id?: string; contractNo?: string; name?: string } | null;
|
||||
return c?.id ? <Link to={`/contract/${c.id}`}>{c.contractNo}{c.name ? ` ${c.name}` : ''}</Link> : '—';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '条款',
|
||||
render: (_: unknown, row: Record<string, unknown>) => (row.milestone as { name?: string } | null)?.name || '—',
|
||||
},
|
||||
{ title: '金额', dataIndex: 'amount', width: 120, render: (v: string) => money(v) },
|
||||
{
|
||||
title: '合同额',
|
||||
width: 110,
|
||||
render: (_: unknown, row: Record<string, unknown>) => {
|
||||
const c = row.contract as { amount?: string | number } | null;
|
||||
return money(c?.amount);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
render: (v: string) => {
|
||||
const s = INVOICE_STATUS_META[v] || { color: 'default', text: v };
|
||||
return <Tag color={s.color}>{s.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '应收日',
|
||||
dataIndex: 'dueAt',
|
||||
width: 120,
|
||||
render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD') : '—'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 140,
|
||||
render: (_: unknown, row: Record<string, unknown>) => (
|
||||
<Space>
|
||||
{row.status === 'TO_ISSUE' ? (
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
const invoiceNo = window.prompt('发票号');
|
||||
if (invoiceNo) actMut.mutate({ id: String(row.id), action: 'issue', invoiceNo });
|
||||
}}
|
||||
>
|
||||
登记开票
|
||||
</Button>
|
||||
) : null}
|
||||
{row.status === 'ISSUED' || row.status === 'OVERDUE' ? (
|
||||
<Button type="link" size="small" onClick={() => actMut.mutate({ id: String(row.id), action: 'receive' })}>
|
||||
登记回款
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { Button, DatePicker, Form, Input, Popconfirm, Select, Space, Table, message } from 'antd';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMemo, useState } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import { api } from '../api/client';
|
||||
import { FormDrawer } from './FormDrawer';
|
||||
import { PageHeader, unwrapList } from './ResourcePage';
|
||||
import { BizFileUpload } from './FileAttachments';
|
||||
|
||||
type EmpOpt = {
|
||||
id: string;
|
||||
name: string;
|
||||
employeeNo: string;
|
||||
department?: string;
|
||||
contractStartAt?: string | null;
|
||||
contractEndAt?: string | null;
|
||||
};
|
||||
|
||||
type FileRow = { id: string; fileName: string; size: number; mimeType: string };
|
||||
|
||||
type LaborRow = {
|
||||
id: string;
|
||||
contractNo?: string | null;
|
||||
startAt?: string | null;
|
||||
endAt?: string | null;
|
||||
remark?: string | null;
|
||||
createdAt?: string;
|
||||
employee?: { id: string; name: string; employeeNo: string; department?: { name?: string } | null };
|
||||
files?: FileRow[];
|
||||
};
|
||||
|
||||
export default function LaborContractPage() {
|
||||
const qc = useQueryClient();
|
||||
const [form] = Form.useForm();
|
||||
const [drawer, setDrawer] = useState<null | 'create' | { id: string }>(null);
|
||||
const q = useQuery({
|
||||
queryKey: ['/labor-contracts'],
|
||||
queryFn: () => api.get('/labor-contracts'),
|
||||
});
|
||||
const body = (q.data as { data?: { items?: LaborRow[]; employees?: EmpOpt[] } })?.data;
|
||||
const items = unwrapList(q.data).items as unknown as LaborRow[];
|
||||
const employees = body?.employees || [];
|
||||
const empOpts = useMemo(
|
||||
() =>
|
||||
employees.map((e) => ({
|
||||
value: e.id,
|
||||
label: `${e.name}(${e.employeeNo}${e.department ? ` · ${e.department}` : ''})`,
|
||||
emp: e,
|
||||
})),
|
||||
[employees],
|
||||
);
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: async (body: Record<string, unknown>) => {
|
||||
if (drawer && typeof drawer === 'object') {
|
||||
return api.patch(`/labor-contracts/${drawer.id}`, body);
|
||||
}
|
||||
return api.post('/labor-contracts', body);
|
||||
},
|
||||
onSuccess: (res: { data?: { id?: string } }) => {
|
||||
message.success('已保存人事合同');
|
||||
qc.invalidateQueries({ queryKey: ['/labor-contracts'] });
|
||||
const id = res.data?.id;
|
||||
if (id && drawer === 'create') setDrawer({ id });
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
const delMut = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/labor-contracts/${id}`),
|
||||
onSuccess: () => {
|
||||
message.success('已删除');
|
||||
qc.invalidateQueries({ queryKey: ['/labor-contracts'] });
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
|
||||
const currentId = drawer && typeof drawer === 'object' ? drawer.id : undefined;
|
||||
const current = items.find((r) => r.id === currentId);
|
||||
|
||||
const openCreate = () => {
|
||||
form.resetFields();
|
||||
setDrawer('create');
|
||||
};
|
||||
const openEdit = (row: LaborRow) => {
|
||||
form.setFieldsValue({
|
||||
employeeId: row.employee?.id,
|
||||
contractNo: row.contractNo,
|
||||
startAt: row.startAt ? dayjs(row.startAt) : undefined,
|
||||
endAt: row.endAt ? dayjs(row.endAt) : undefined,
|
||||
remark: row.remark,
|
||||
});
|
||||
setDrawer({ id: row.id });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="人事合同"
|
||||
description="上传员工劳动合同电子件。与投标合同、直签合同、其他合同分开,不走项目立项。"
|
||||
extra={
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
新建人事合同
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<FormDrawer
|
||||
title={drawer === 'create' ? '新建人事合同' : '人事合同'}
|
||||
open={Boolean(drawer)}
|
||||
onClose={() => setDrawer(null)}
|
||||
okText="提交保存"
|
||||
okLoading={saveMut.isPending}
|
||||
onOk={async () => {
|
||||
const v = await form.validateFields();
|
||||
await saveMut.mutateAsync({
|
||||
...v,
|
||||
startAt: v.startAt ? dayjs(v.startAt).format('YYYY-MM-DD') : undefined,
|
||||
endAt: v.endAt ? dayjs(v.endAt).format('YYYY-MM-DD') : undefined,
|
||||
});
|
||||
if (currentId) setDrawer(null);
|
||||
}}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="employeeId" label="员工" rules={[{ required: true }]}>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
disabled={Boolean(currentId)}
|
||||
options={empOpts}
|
||||
onChange={(id: string) => {
|
||||
const emp = employees.find((e) => e.id === id);
|
||||
if (!emp) return;
|
||||
form.setFieldsValue({
|
||||
startAt: emp.contractStartAt ? dayjs(emp.contractStartAt) : undefined,
|
||||
endAt: emp.contractEndAt ? dayjs(emp.contractEndAt) : undefined,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="contractNo" label="合同编号">
|
||||
<Input placeholder="劳动合同编号,可空" />
|
||||
</Form.Item>
|
||||
<Form.Item name="startAt" label="合同开始">
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="endAt" label="合同结束">
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<BizFileUpload
|
||||
bizType="EMPLOYEE_LABOR"
|
||||
bizId={currentId}
|
||||
files={current?.files}
|
||||
label="电子劳动合同"
|
||||
extra="保存后再上传 PDF 或扫描件。"
|
||||
queryKey={['/labor-contracts']}
|
||||
/>
|
||||
</FormDrawer>
|
||||
<Table
|
||||
className="ops-table"
|
||||
rowKey="id"
|
||||
loading={q.isLoading}
|
||||
dataSource={items}
|
||||
pagination={{ defaultPageSize: 10, showSizeChanger: true, showTotal: (n) => `共 ${n} 条` }}
|
||||
scroll={{ x: 'max-content' }}
|
||||
tableLayout="fixed"
|
||||
columns={[
|
||||
{ title: '员工', width: 110, render: (_: unknown, r: LaborRow) => r.employee?.name || '—' },
|
||||
{ title: '工号', width: 110, render: (_: unknown, r: LaborRow) => r.employee?.employeeNo || '—' },
|
||||
{
|
||||
title: '部门',
|
||||
width: 120,
|
||||
render: (_: unknown, r: LaborRow) => r.employee?.department?.name || '—',
|
||||
},
|
||||
{ title: '合同编号', dataIndex: 'contractNo', width: 160, render: (v: string) => v || '—' },
|
||||
{
|
||||
title: '合同期限',
|
||||
width: 200,
|
||||
render: (_: unknown, r: LaborRow) => {
|
||||
const a = r.startAt ? String(r.startAt).slice(0, 10) : '—';
|
||||
const b = r.endAt ? String(r.endAt).slice(0, 10) : '—';
|
||||
return `${a} ~ ${b}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '附件',
|
||||
width: 80,
|
||||
render: (_: unknown, r: LaborRow) => (r.files?.length ? `${r.files.length} 个` : '未传'),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 170,
|
||||
render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '—'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 140,
|
||||
render: (_: unknown, r: LaborRow) => (
|
||||
<Space>
|
||||
<Button type="link" size="small" onClick={() => openEdit(r)}>
|
||||
办理
|
||||
</Button>
|
||||
<Popconfirm title="删除这份人事合同?" onConfirm={() => delMut.mutate(r.id)}>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Typography } from 'antd';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { api } from '../api/client';
|
||||
import AmbientBackground from '../components/AmbientBackground';
|
||||
import { BrandLogo } from '../components/BrandLogo';
|
||||
import { SiteFooter } from '../components/SiteFooter';
|
||||
|
||||
type LegalDoc = {
|
||||
kind: string;
|
||||
title: string;
|
||||
updatedAt: string;
|
||||
operator: string;
|
||||
product: string;
|
||||
paragraphs: { heading?: string; body: string }[];
|
||||
};
|
||||
|
||||
export default function LegalPage() {
|
||||
const { kind } = useParams<{ kind: string }>();
|
||||
const q = useQuery({
|
||||
queryKey: ['/legal', kind],
|
||||
queryFn: () => api.get(`/legal/${kind}`),
|
||||
enabled: Boolean(kind),
|
||||
});
|
||||
const doc = (q.data?.data ?? q.data) as LegalDoc | undefined;
|
||||
const other = kind === 'privacy' ? 'user-agreement' : 'privacy';
|
||||
const otherTitle = kind === 'privacy' ? '用户协议' : '隐私政策';
|
||||
|
||||
return (
|
||||
<div className="ops-login ops-legal">
|
||||
<AmbientBackground />
|
||||
<div className="ops-login-wrap ops-legal-wrap">
|
||||
<div className="ops-login-card ops-legal-card">
|
||||
<div className="login-mark">
|
||||
<BrandLogo size={56} />
|
||||
</div>
|
||||
<h1>{doc?.title || (kind === 'privacy' ? '隐私政策' : '用户协议')}</h1>
|
||||
<p className="login-sub">
|
||||
{doc ? `${doc.operator} · ${doc.product} · 更新日期 ${doc.updatedAt}` : '加载中…'}
|
||||
</p>
|
||||
{q.isError ? (
|
||||
<Typography.Paragraph type="danger">文档暂时无法加载,请稍后重试。</Typography.Paragraph>
|
||||
) : null}
|
||||
{(doc?.paragraphs || []).map((p, i) => (
|
||||
<section key={i} className="ops-legal-section">
|
||||
{p.heading ? <h2>{p.heading}</h2> : null}
|
||||
<p>{p.body}</p>
|
||||
</section>
|
||||
))}
|
||||
<p className="ops-legal-nav">
|
||||
<Link to="/login">返回登录</Link>
|
||||
<span> · </span>
|
||||
<Link to={`/legal/${other}`}>查看{otherTitle}</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Alert, Button, Descriptions, Form, Input, Space, Tag, Typography, message } from 'antd';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../api/client';
|
||||
import { FormDrawer } from './FormDrawer';
|
||||
import { CostObjectFields } from './CostObjectFields';
|
||||
import { FileAttachments } from './FileAttachments';
|
||||
import { belongLabel, CostTargetLink } from './FinanceCells';
|
||||
import { COST_TYPE_LABEL, LOAN_STATUS_META, money } from './financeStatus';
|
||||
|
||||
type Loan = {
|
||||
id: string;
|
||||
loanNo: string;
|
||||
costType: string;
|
||||
amount: string | number;
|
||||
remaining?: number;
|
||||
purpose: string;
|
||||
status: string;
|
||||
remark?: string | null;
|
||||
createdAt?: string;
|
||||
bidCase?: { id: string; bidNo: string; name?: string; externalNo?: string } | null;
|
||||
project?: { id?: string; projectNo: string; name?: string } | null;
|
||||
applicant?: { displayName: string; employee?: { department?: { name?: string } } };
|
||||
myActions?: { canEdit: boolean; canSubmit: boolean; canDelete?: boolean; canReview: boolean; canOffset: boolean };
|
||||
};
|
||||
|
||||
export default function LoanDetailPage() {
|
||||
const { id } = useParams();
|
||||
const [params] = useSearchParams();
|
||||
const location = useLocation();
|
||||
const onDesk = location.pathname.startsWith('/finance');
|
||||
const listPath = onDesk ? '/finance/loans' : '/office/expenses';
|
||||
const selfPath = onDesk ? '/finance/loans' : '/office/loans';
|
||||
const isNew = id === 'new' || !id;
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const [form] = Form.useForm();
|
||||
const q = useQuery({
|
||||
queryKey: ['loan', id],
|
||||
enabled: !isNew && Boolean(id),
|
||||
queryFn: () => api.get(`/loans/${id}`),
|
||||
});
|
||||
const row = q.data?.data as Loan | undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (isNew) {
|
||||
form.setFieldsValue({
|
||||
costType: params.get('costType') || 'BID',
|
||||
bidCaseId: params.get('bidCaseId') || undefined,
|
||||
projectId: params.get('projectId') || undefined,
|
||||
purpose: params.get('purpose') || undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (!row) return;
|
||||
form.setFieldsValue({
|
||||
costType: row.costType,
|
||||
bidCaseId: row.bidCase?.id,
|
||||
projectId: row.project ? (row as Loan & { project?: { id?: string } }).project?.id : undefined,
|
||||
amount: Number(row.amount),
|
||||
purpose: row.purpose,
|
||||
remark: row.remark || undefined,
|
||||
});
|
||||
}, [isNew, row, form, params]);
|
||||
|
||||
const saveMut = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) => (isNew ? api.post('/loans', body) : api.patch(`/loans/${id}`, body)),
|
||||
onSuccess: async (res: { data?: { id?: string } }) => {
|
||||
message.success('已保存');
|
||||
await qc.invalidateQueries();
|
||||
if (isNew && res.data?.id) navigate(`${selfPath}/${res.data.id}`, { replace: true });
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
const submitMut = useMutation({
|
||||
mutationFn: async (savedId?: string) => {
|
||||
const target = savedId || id;
|
||||
if (!target || target === 'new') throw new Error('请先保存');
|
||||
return api.post(`/loans/${target}/submit`);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
message.success('已提交财务确认');
|
||||
await qc.invalidateQueries();
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
const reviewMut = useMutation({
|
||||
mutationFn: (body: { result: 'APPROVED' | 'REJECTED'; comment?: string }) => api.post(`/loans/${id}/reviews`, body),
|
||||
onSuccess: async () => {
|
||||
message.success('已办理');
|
||||
await qc.invalidateQueries();
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
const delMut = useMutation({
|
||||
mutationFn: () => api.delete(`/loans/${id}`),
|
||||
onSuccess: () => {
|
||||
message.success('已删除');
|
||||
navigate(listPath);
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
|
||||
const meta = LOAN_STATUS_META[row?.status || 'DRAFT'] || LOAN_STATUS_META.DRAFT;
|
||||
const editable = isNew || row?.myActions?.canEdit;
|
||||
|
||||
return (
|
||||
<FormDrawer
|
||||
title={isNew ? '申请借款' : row?.loanNo || '借款单'}
|
||||
extra="取标差旅等先借款。报销时必须冲这笔借款,不能再领一笔。"
|
||||
open
|
||||
onClose={() => navigate(listPath)}
|
||||
hideOk
|
||||
createdAt={isNew ? undefined : row?.createdAt}
|
||||
>
|
||||
{row ? (
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Tag color={meta.color}>{meta.text}</Tag>
|
||||
<Tag>{COST_TYPE_LABEL[row.costType] || row.costType}</Tag>
|
||||
<span>未还 {money(row.remaining ?? row.amount)}</span>
|
||||
</Space>
|
||||
) : null}
|
||||
{row?.myActions?.canReview ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="请确认借款。通过后申请人待办会出现「报销冲账」。"
|
||||
action={
|
||||
<Space>
|
||||
<Button type="primary" onClick={() => reviewMut.mutate({ result: 'APPROVED' })}>
|
||||
通过
|
||||
</Button>
|
||||
<Button
|
||||
danger
|
||||
onClick={() => {
|
||||
const comment = window.prompt('打回意见');
|
||||
if (comment) reviewMut.mutate({ result: 'REJECTED', comment });
|
||||
}}
|
||||
>
|
||||
打回
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{row?.myActions?.canOffset ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="借款未还,请报销冲账,不要另报一笔。"
|
||||
action={
|
||||
<Button type="primary">
|
||||
<Link to={`/office/expenses/new?loanId=${row.id}&costType=${row.costType}&bidCaseId=${row.bidCase?.id || ''}`} style={{ color: 'inherit' }}>
|
||||
去冲账
|
||||
</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!editable && row ? (
|
||||
<Descriptions bordered size="small" column={2}>
|
||||
<Descriptions.Item label="金额">{money(row.amount)}</Descriptions.Item>
|
||||
<Descriptions.Item label="未还">{money(row.remaining)}</Descriptions.Item>
|
||||
<Descriptions.Item label="用途" span={2}>
|
||||
{row.purpose}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="申请人">{row.applicant?.displayName || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="归属">{belongLabel(row)}</Descriptions.Item>
|
||||
<Descriptions.Item label="对象">
|
||||
<CostTargetLink costType={row.costType} bid={row.bidCase} project={row.project} />
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="备注" span={2}>
|
||||
{row.remark || '—'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
) : (
|
||||
<Form form={form} layout="vertical" style={{ maxWidth: 640 }} onFinish={(v) => saveMut.mutate(v)}>
|
||||
<CostObjectFields />
|
||||
<Form.Item name="purpose" label="用途" rules={[{ required: true, message: '请填写用途' }]}>
|
||||
<Input placeholder="如:取标差旅、预办证照" />
|
||||
</Form.Item>
|
||||
<FileAttachments bizType="LOAN" bizId={isNew ? undefined : id} disabled={!editable} label="借据 / 凭证" />
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" loading={saveMut.isPending}>
|
||||
保存草稿
|
||||
</Button>
|
||||
<Button
|
||||
loading={submitMut.isPending}
|
||||
onClick={async () => {
|
||||
const v = await form.validateFields();
|
||||
if (isNew) {
|
||||
const res = await saveMut.mutateAsync(v);
|
||||
const newId = (res as { data?: { id?: string } }).data?.id;
|
||||
if (newId) await submitMut.mutateAsync(newId);
|
||||
} else {
|
||||
await saveMut.mutateAsync(v);
|
||||
await submitMut.mutateAsync(id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
保存并提交
|
||||
</Button>
|
||||
{row?.myActions?.canDelete ? (
|
||||
<Button danger onClick={() => delMut.mutate()}>
|
||||
删除
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
</Form>
|
||||
)}
|
||||
{!editable && row ? <FileAttachments bizType="LOAN" bizId={id} disabled label="借据 / 凭证" /> : null}
|
||||
{!editable && row?.myActions?.canSubmit ? (
|
||||
<Button type="primary" style={{ marginTop: 16 }} onClick={() => submitMut.mutate()}>
|
||||
提交确认
|
||||
</Button>
|
||||
) : null}
|
||||
<Typography.Paragraph type="secondary" style={{ marginTop: 24 }}>
|
||||
财务确认借款后才会放款记账;报销通过才冲减未还金额。
|
||||
</Typography.Paragraph>
|
||||
</FormDrawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Alert, Button, Checkbox, Form, Input, Space } from 'antd';
|
||||
import { LockOutlined, SafetyOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api/client';
|
||||
import { useAuth } from '../store/auth';
|
||||
import AmbientBackground from '../components/AmbientBackground';
|
||||
import { BrandLogo } from '../components/BrandLogo';
|
||||
import { MobileDownload } from '../components/MobileDownload';
|
||||
import { SiteFooter } from '../components/SiteFooter';
|
||||
|
||||
export default function LoginPage() {
|
||||
const login = useAuth((s) => s.login);
|
||||
const navigate = useNavigate();
|
||||
const [form] = Form.useForm();
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sending, setSending] = useState(false);
|
||||
const [wait, setWait] = useState(0);
|
||||
const agreed = Form.useWatch('agreeTerms', form);
|
||||
|
||||
const optQ = useQuery({
|
||||
queryKey: ['/auth/login-options'],
|
||||
queryFn: () => api.get('/auth/login-options'),
|
||||
retry: false,
|
||||
});
|
||||
const smsLogin = Boolean((optQ.data?.data as { smsLogin?: boolean } | undefined)?.smsLogin);
|
||||
|
||||
useEffect(() => {
|
||||
if (wait <= 0) return;
|
||||
const t = window.setTimeout(() => setWait((n) => n - 1), 1000);
|
||||
return () => window.clearTimeout(t);
|
||||
}, [wait]);
|
||||
|
||||
const sendCode = async () => {
|
||||
const username = String(form.getFieldValue('username') || '').trim();
|
||||
if (!username) {
|
||||
setError('请先填写手机号');
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
setSending(true);
|
||||
try {
|
||||
await api.post('/auth/sms-code', { username });
|
||||
setWait(60);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '验证码发送失败');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ops-login">
|
||||
<AmbientBackground />
|
||||
<div className="ops-login-wrap">
|
||||
<div className="ops-login-card">
|
||||
<div className="login-mark">
|
||||
<BrandLogo size={72} />
|
||||
</div>
|
||||
<h1>风影智慧办公综合平台</h1>
|
||||
<p className="login-sub">连接无限可能的智能工作空间</p>
|
||||
{error ? <Alert type="error" message={error} style={{ marginBottom: 16 }} /> : null}
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={async (v) => {
|
||||
if (!v.agreeTerms) {
|
||||
setError('请先阅读并同意用户协议和隐私政策');
|
||||
return;
|
||||
}
|
||||
setError('');
|
||||
setLoading(true);
|
||||
try {
|
||||
await login(v.username, v.password, v.smsCode, true);
|
||||
const next = useAuth.getState().needsOnboard ? '/office/onboard' : '/workbench';
|
||||
navigate(next);
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : '登录失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form.Item name="username" label="手机号" rules={[{ required: true, message: '请填写手机号' }]}>
|
||||
<Input prefix={<UserOutlined />} autoComplete="username" size="large" placeholder="请输入登录手机号" />
|
||||
</Form.Item>
|
||||
<Form.Item name="password" label="密码" rules={[{ required: true, message: '请填写密码' }]}>
|
||||
<Input.Password
|
||||
prefix={<LockOutlined />}
|
||||
autoComplete="current-password"
|
||||
size="large"
|
||||
placeholder="请输入密码"
|
||||
/>
|
||||
</Form.Item>
|
||||
{smsLogin ? (
|
||||
<Form.Item label="短信验证码" required>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Form.Item name="smsCode" noStyle rules={[{ required: true, message: '请填写短信验证码' }]}>
|
||||
<Input prefix={<SafetyOutlined />} size="large" placeholder="6 位验证码" maxLength={6} />
|
||||
</Form.Item>
|
||||
<Button size="large" loading={sending} disabled={wait > 0} onClick={() => void sendCode()}>
|
||||
{wait > 0 ? `${wait}s` : '获取验证码'}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
<Form.Item
|
||||
name="agreeTerms"
|
||||
valuePropName="checked"
|
||||
initialValue={false}
|
||||
style={{ marginBottom: 16 }}
|
||||
rules={[
|
||||
{
|
||||
validator: async (_, v) => {
|
||||
if (!v) throw new Error('请先阅读并同意用户协议和隐私政策');
|
||||
},
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Checkbox className="login-agree">
|
||||
我已阅读并同意
|
||||
<Link to="/legal/user-agreement" target="_blank" rel="noreferrer" onClick={(e) => e.stopPropagation()}>
|
||||
《用户协议》
|
||||
</Link>
|
||||
和
|
||||
<Link to="/legal/privacy" target="_blank" rel="noreferrer" onClick={(e) => e.stopPropagation()}>
|
||||
《隐私政策》
|
||||
</Link>
|
||||
</Checkbox>
|
||||
</Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
block
|
||||
size="large"
|
||||
loading={loading}
|
||||
disabled={!agreed}
|
||||
className="gradient-btn"
|
||||
>
|
||||
进入工作台
|
||||
</Button>
|
||||
<div className="login-mobile-dl">
|
||||
<MobileDownload placement="top" />
|
||||
</div>
|
||||
<div style={{ marginTop: 12, textAlign: 'center' }}>
|
||||
<Link to="/login/desktop">电脑端请扫码登录</Link>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
</div>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { Alert, Button, Descriptions, Space } from 'antd';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../api/client';
|
||||
import { PageHeader } from './ResourcePage';
|
||||
|
||||
type ChannelRow = {
|
||||
id?: string;
|
||||
inboxEnabled?: string;
|
||||
mobileImSdk?: string;
|
||||
online?: number;
|
||||
note?: string;
|
||||
};
|
||||
|
||||
export default function MessagesChannelPage() {
|
||||
const q = useQuery({
|
||||
queryKey: ['/system/message-channel'],
|
||||
queryFn: () => api.get('/system/message-channel'),
|
||||
});
|
||||
const rows = ((q.data as { data?: ChannelRow[] })?.data || q.data || []) as ChannelRow[];
|
||||
const row = rows[0] || {};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="消息通道"
|
||||
description="站内待办、审批提醒和即时通讯的运行状态。同事私聊请用「消息」;待办和审批走统一待办/待我审批。"
|
||||
extra={
|
||||
<Space>
|
||||
<Link to="/office/im">
|
||||
<Button type="default">打开消息</Button>
|
||||
</Link>
|
||||
<Link to="/office/todos">
|
||||
<Button type="default">待办事项</Button>
|
||||
</Link>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="Web 与手机 App 均已接入自研 IM(MobileIMSDK + TCP 长连接)。通话依赖双方打开页面并走 STUN,失败时先确认网络而不是改弹层。"
|
||||
/>
|
||||
<Descriptions bordered size="small" column={1} style={{ maxWidth: 720 }}>
|
||||
<Descriptions.Item label="站内待办 / 审批">{row.inboxEnabled || '已开启'}</Descriptions.Item>
|
||||
<Descriptions.Item label="即时通讯">{row.mobileImSdk || '已启用'}</Descriptions.Item>
|
||||
<Descriptions.Item label="当前在线">{typeof row.online === 'number' ? `${row.online} 人` : '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="说明">{row.note || '—'}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Button, DatePicker, Form, Input, InputNumber, Select, Space, Table, Tag, Typography, message } from 'antd';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import dayjs from 'dayjs';
|
||||
import { api } from '../api/client';
|
||||
import { FormDrawer } from './FormDrawer';
|
||||
import { PageHeader } from './ResourcePage';
|
||||
import { HR_APPLY_LABEL, LEAVE_KIND_OPTIONS, LEAVE_STATUS_META } from './hrStatus';
|
||||
import { useState } from 'react';
|
||||
|
||||
type PunchMine = { clockIn?: string | null; clockOut?: string | null; employeeId?: string | null };
|
||||
type LeaveRow = {
|
||||
id: string;
|
||||
kind: string;
|
||||
startAt: string;
|
||||
endAt: string;
|
||||
days: string | number;
|
||||
reason: string;
|
||||
status: string;
|
||||
comment?: string | null;
|
||||
};
|
||||
|
||||
export default function MyAttendancePage() {
|
||||
const qc = useQueryClient();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const punch = useQuery({
|
||||
queryKey: ['attendance-mine'],
|
||||
queryFn: () => api.get('/attendance/punches/mine'),
|
||||
});
|
||||
const leaves = useQuery({
|
||||
queryKey: ['leave-mine'],
|
||||
queryFn: () => api.get('/leave-requests?bucket=mine'),
|
||||
});
|
||||
const mine = punch.data?.data as PunchMine | undefined;
|
||||
const items = ((leaves.data?.data as { items?: LeaveRow[] })?.items || []) as LeaveRow[];
|
||||
|
||||
const punchMut = useMutation({
|
||||
mutationFn: (kind: string) => api.post('/attendance/punch', { kind }),
|
||||
onSuccess: async () => {
|
||||
message.success('打卡成功');
|
||||
await qc.invalidateQueries({ queryKey: ['attendance-mine'] });
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
const leaveMut = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) => api.post('/leave-requests', body),
|
||||
onSuccess: async () => {
|
||||
message.success('请假已提交,人事或上级审批');
|
||||
setOpen(false);
|
||||
form.resetFields();
|
||||
await qc.invalidateQueries({ queryKey: ['leave-mine'] });
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="打卡请假"
|
||||
description="上班、下班各打一次。请假、出差提交后,人事或上级在假勤结果里批。通过后计入当月假勤,薪酬按假勤核算。"
|
||||
extra={
|
||||
<Button type="primary" onClick={() => setOpen(true)}>
|
||||
请假 / 出差
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div className="ops-toolbar" style={{ marginBottom: 16 }}>
|
||||
<Space wrap>
|
||||
<Typography.Text>
|
||||
今日上班:{mine?.clockIn ? dayjs(mine.clockIn).format('HH:mm:ss') : '未打'}
|
||||
</Typography.Text>
|
||||
<Typography.Text>
|
||||
今日下班:{mine?.clockOut ? dayjs(mine.clockOut).format('HH:mm:ss') : '未打'}
|
||||
</Typography.Text>
|
||||
<Button
|
||||
type="primary"
|
||||
disabled={Boolean(mine?.clockIn) || !mine?.employeeId}
|
||||
loading={punchMut.isPending}
|
||||
onClick={() => punchMut.mutate('IN')}
|
||||
>
|
||||
上班打卡
|
||||
</Button>
|
||||
<Button
|
||||
disabled={!mine?.clockIn || Boolean(mine?.clockOut) || !mine?.employeeId}
|
||||
loading={punchMut.isPending}
|
||||
onClick={() => punchMut.mutate('OUT')}
|
||||
>
|
||||
下班打卡
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
{!mine?.employeeId ? (
|
||||
<Typography.Paragraph type="secondary">当前账号没有关联员工档案,不能打卡请假。请人事在员工账号里绑上档案。</Typography.Paragraph>
|
||||
) : null}
|
||||
<Typography.Title level={5}>我的请假 / 出差</Typography.Title>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
dataSource={items}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{ title: '类型', width: 140, render: (_: unknown, r: LeaveRow) => HR_APPLY_LABEL[r.kind] || r.kind },
|
||||
{ title: '开始', width: 150, render: (_: unknown, r: LeaveRow) => dayjs(r.startAt).format('YYYY-MM-DD HH:mm') },
|
||||
{ title: '结束', width: 150, render: (_: unknown, r: LeaveRow) => dayjs(r.endAt).format('YYYY-MM-DD HH:mm') },
|
||||
{ title: '天数', dataIndex: 'days', width: 80 },
|
||||
{ title: '事由', dataIndex: 'reason', ellipsis: true },
|
||||
{
|
||||
title: '状态',
|
||||
width: 90,
|
||||
render: (_: unknown, r: LeaveRow) => {
|
||||
const s = LEAVE_STATUS_META[r.status] || { color: 'default', text: r.status };
|
||||
return <Tag color={s.color}>{s.text}</Tag>;
|
||||
},
|
||||
},
|
||||
]}
|
||||
locale={{ emptyText: '还没有请假记录' }}
|
||||
/>
|
||||
<FormDrawer
|
||||
title="请假 / 出差"
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
okText="提交"
|
||||
okLoading={leaveMut.isPending}
|
||||
onOk={() => form.submit()}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
initialValues={{ kind: 'PERSONAL', range: [dayjs().startOf('day'), dayjs().endOf('day')] }}
|
||||
onFinish={(v) =>
|
||||
leaveMut.mutate({
|
||||
kind: v.kind,
|
||||
startAt: v.range[0].toISOString(),
|
||||
endAt: v.range[1].toISOString(),
|
||||
days: v.days,
|
||||
reason: v.reason,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Form.Item name="kind" label="类型" rules={[{ required: true }]}>
|
||||
<Select options={LEAVE_KIND_OPTIONS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="range" label="起止时间" rules={[{ required: true }]}>
|
||||
<DatePicker.RangePicker showTime style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="days" label="天数(可不填,按起止自动算)">
|
||||
<InputNumber min={0.5} step={0.5} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="reason" label="事由" rules={[{ required: true, message: '请填写事由' }]}>
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</FormDrawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
import { Button, Modal, Space, Table, Tabs, Tag, message } from 'antd';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, Outlet, useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api/client';
|
||||
import { PageHeader } from './ResourcePage';
|
||||
import { belongLabel, CostTargetLink } from './FinanceCells';
|
||||
import { EXPENSE_STATUS_META, LOAN_STATUS_META, expenseCategoryLabel, money } from './financeStatus';
|
||||
|
||||
type Expense = {
|
||||
id: string;
|
||||
claimNo: string;
|
||||
amount: string | number;
|
||||
costType: string;
|
||||
status: string;
|
||||
remark?: string | null;
|
||||
purpose?: string | null;
|
||||
expenseCategory?: string | null;
|
||||
prepaid?: boolean;
|
||||
payer?: { displayName?: string } | null;
|
||||
submittedAt?: string;
|
||||
createdAt?: string;
|
||||
applicant?: { employee?: { department?: { name?: string } } } | null;
|
||||
bidCase?: { id?: string; bidNo?: string; name?: string; party?: { name?: string } } | null;
|
||||
project?: { id?: string; projectNo?: string; name?: string } | null;
|
||||
};
|
||||
type Loan = {
|
||||
id: string;
|
||||
loanNo: string;
|
||||
amount: string | number;
|
||||
remaining?: number;
|
||||
purpose: string;
|
||||
costType: string;
|
||||
status: string;
|
||||
remark?: string | null;
|
||||
submittedAt?: string;
|
||||
createdAt?: string;
|
||||
applicant?: { employee?: { department?: { name?: string } } } | null;
|
||||
bidCase?: { id?: string; bidNo?: string; name?: string } | null;
|
||||
project?: { id?: string; projectNo?: string; name?: string } | null;
|
||||
};
|
||||
|
||||
export default function MyExpensesPage() {
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const q = useQuery({
|
||||
queryKey: ['/office/my-expenses'],
|
||||
queryFn: () => api.get('/office/my-expenses'),
|
||||
});
|
||||
const delExp = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/expenses/${id}`),
|
||||
onSuccess: () => {
|
||||
message.success('已删除');
|
||||
qc.invalidateQueries({ queryKey: ['/office/my-expenses'] });
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
const expenses = (q.data?.data?.expenses ?? []) as Expense[];
|
||||
const loans = (q.data?.data?.loans ?? []) as Loan[];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="我的费用"
|
||||
description="只显示你本人提交的报销和借款。待确认、被打回的报销,以及未还借款都在这里。全公司单据请到财务收支 · 费用报销 / 借款管理。"
|
||||
extra={
|
||||
<Space>
|
||||
<Button>
|
||||
<Link to="/office/loans/new">申请借款</Link>
|
||||
</Button>
|
||||
<Button type="primary">
|
||||
<Link to="/office/expenses/new" style={{ color: 'inherit' }}>
|
||||
填报销
|
||||
</Link>
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'exp',
|
||||
label: `报销(${expenses.length})`,
|
||||
children: (
|
||||
<Table
|
||||
className="ops-table ops-table-click"
|
||||
rowKey="id"
|
||||
loading={q.isLoading}
|
||||
dataSource={expenses}
|
||||
scroll={{ x: 'max-content' }}
|
||||
tableLayout="fixed"
|
||||
onRow={(r) => ({
|
||||
onClick: (e) => {
|
||||
if ((e.target as HTMLElement).closest('a,button,.ant-btn')) return;
|
||||
navigate(`/office/expenses/${r.id}`);
|
||||
},
|
||||
})}
|
||||
locale={{ emptyText: '你还没有报销单。右上角「填报销」提交后会出现在这里。' }}
|
||||
pagination={{
|
||||
defaultPageSize: 10,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [10, 20, 50],
|
||||
showTotal: (n) => `共 ${n} 条`,
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
title: '单号',
|
||||
dataIndex: 'claimNo',
|
||||
width: 160,
|
||||
render: (v: string, r: Expense) => <Link to={`/office/expenses/${r.id}`}>{v}</Link>,
|
||||
},
|
||||
{ title: '金额', dataIndex: 'amount', width: 110, render: (v: string) => money(v) },
|
||||
{ title: '代付', width: 90, render: (_: unknown, r: Expense) => (r.prepaid ? r.payer?.displayName || '代付' : '自付') },
|
||||
{ title: '类别', width: 80, render: (_: unknown, r: Expense) => expenseCategoryLabel(r.expenseCategory) },
|
||||
{ title: '用途', ellipsis: true, render: (_: unknown, r: Expense) => r.purpose || '—' },
|
||||
{ title: '归属', ellipsis: true, render: (_: unknown, r: Expense) => belongLabel(r) },
|
||||
{ title: '对象', width: 150, render: (_: unknown, r: Expense) => <CostTargetLink costType={r.costType} bid={r.bidCase} project={r.project} /> },
|
||||
{
|
||||
title: '创建时间',
|
||||
width: 170,
|
||||
render: (_: unknown, r: Expense) =>
|
||||
r.createdAt ? String(r.createdAt).replace('T', ' ').slice(0, 19) : String(r.submittedAt || '').slice(0, 10) || '—',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 110,
|
||||
render: (v: string) => {
|
||||
const s = EXPENSE_STATUS_META[v] || { color: 'default', text: v };
|
||||
return <Tag color={s.color}>{s.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 140,
|
||||
render: (_: unknown, r: Expense) => (
|
||||
<Space>
|
||||
<Link to={`/office/expenses/${r.id}`}>查看</Link>
|
||||
{r.status === 'DRAFT' || r.status === 'REJECTED' ? (
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: '删除这张报销?',
|
||||
content: r.claimNo,
|
||||
okText: '删除',
|
||||
okButtonProps: { danger: true },
|
||||
onOk: () => delExp.mutateAsync(r.id),
|
||||
});
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'loan',
|
||||
label: `借款(${loans.length})`,
|
||||
children: (
|
||||
<Table
|
||||
className="ops-table ops-table-click"
|
||||
rowKey="id"
|
||||
loading={q.isLoading}
|
||||
dataSource={loans}
|
||||
scroll={{ x: 'max-content' }}
|
||||
tableLayout="fixed"
|
||||
onRow={(r) => ({
|
||||
onClick: (e) => {
|
||||
if ((e.target as HTMLElement).closest('a,button,.ant-btn')) return;
|
||||
navigate(`/office/loans/${r.id}`);
|
||||
},
|
||||
})}
|
||||
locale={{ emptyText: '你还没有借款。申请后由财务在「待我审批」确认,台账在财务收支 · 借支记录。' }}
|
||||
pagination={{
|
||||
defaultPageSize: 10,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [10, 20, 50],
|
||||
showTotal: (n) => `共 ${n} 条`,
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
title: '单号',
|
||||
dataIndex: 'loanNo',
|
||||
render: (v: string, r: Loan) => <Link to={`/office/loans/${r.id}`}>{v}</Link>,
|
||||
},
|
||||
{ title: '用途', dataIndex: 'purpose' },
|
||||
{ title: '金额', dataIndex: 'amount', width: 110, render: (v: string) => money(v) },
|
||||
{ title: '未还', width: 110, render: (_: unknown, r: Loan) => money(r.remaining ?? r.amount) },
|
||||
{ title: '归属', ellipsis: true, render: (_: unknown, r: Loan) => belongLabel(r) },
|
||||
{ title: '对象', width: 150, render: (_: unknown, r: Loan) => <CostTargetLink costType={r.costType} bid={r.bidCase} project={r.project} /> },
|
||||
{
|
||||
title: '创建时间',
|
||||
width: 170,
|
||||
render: (_: unknown, r: Loan) =>
|
||||
r.createdAt ? String(r.createdAt).replace('T', ' ').slice(0, 19) : '—',
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 110,
|
||||
render: (v: string) => {
|
||||
const s = LOAN_STATUS_META[v] || { color: 'default', text: v };
|
||||
return <Tag color={s.color}>{s.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 90,
|
||||
render: (_: unknown, r: Loan) => <Link to={`/office/loans/${r.id}`}>查看</Link>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Outlet />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { Button, Segmented, Space, Table, Tag } from 'antd';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { api } from '../api/client';
|
||||
import { PageHeader, unwrapList } from './ResourcePage';
|
||||
import { ApplyBucketBar, PROCESS_BUCKETS } from './ApplyBucketBar';
|
||||
import { fmtCreated } from '../time';
|
||||
import { zh } from '../labels';
|
||||
import { HR_APPLY_LABEL } from './hrStatus';
|
||||
|
||||
const SOURCE_LABEL: Record<string, string> = {
|
||||
HR: '人事',
|
||||
SEAL: '用章',
|
||||
OFFICE: '办公',
|
||||
EXPENSE: '费用',
|
||||
};
|
||||
|
||||
const KIND_LABEL: Record<string, string> = {
|
||||
...HR_APPLY_LABEL,
|
||||
SEAL: '用章',
|
||||
ASSET_BUY: '资产购置',
|
||||
ASSET_USE: '资产领用',
|
||||
VEHICLE: '用车',
|
||||
EXPENSE: '报销',
|
||||
LOAN: '借支',
|
||||
};
|
||||
|
||||
const BUCKET_META: Record<string, { color: string; text: string }> = {
|
||||
pending: { color: 'processing', text: '待审批' },
|
||||
approved: { color: 'success', text: '已通过' },
|
||||
rejected: { color: 'error', text: '已驳回' },
|
||||
occupying: { color: 'blue', text: '占用中' },
|
||||
out: { color: 'blue', text: '外带中' },
|
||||
overdue: { color: 'error', text: '逾期未还' },
|
||||
returned: { color: 'success', text: '已归还' },
|
||||
};
|
||||
|
||||
type FlowRow = {
|
||||
id: string;
|
||||
source: string;
|
||||
kind: string;
|
||||
title: string;
|
||||
status: string;
|
||||
bucket: string;
|
||||
createdAt: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
export default function MyFlowPage() {
|
||||
const [params] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const bucket = params.get('bucket') || '';
|
||||
const kind = params.get('kind') || '';
|
||||
const qs = new URLSearchParams();
|
||||
if (bucket) qs.set('bucket', bucket);
|
||||
if (kind) qs.set('kind', kind);
|
||||
const endpoint = `/office/flow${qs.toString() ? `?${qs}` : ''}`;
|
||||
const q = useQuery({ queryKey: [endpoint], queryFn: () => api.get(endpoint) });
|
||||
const items = unwrapList(q.data).items as unknown as FlowRow[];
|
||||
|
||||
const setKind = (v: string) => {
|
||||
const next = new URLSearchParams();
|
||||
if (bucket) next.set('bucket', bucket);
|
||||
if (v) next.set('kind', v);
|
||||
const s = next.toString();
|
||||
navigate(s ? `/office/flow?${s}` : '/office/flow');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="我的申请"
|
||||
description="跟踪自己提交的人事、用章、办公和费用。要新开一单,从右侧进对应发起页。"
|
||||
extra={
|
||||
<ApplyBucketBar
|
||||
basePath="/office/flow"
|
||||
buckets={[
|
||||
...PROCESS_BUCKETS,
|
||||
{ value: 'occupying', label: '占用中' },
|
||||
{ value: 'out', label: '外带中' },
|
||||
{ value: 'overdue', label: '逾期未还' },
|
||||
]}
|
||||
extra={
|
||||
<Space>
|
||||
<Button onClick={() => navigate('/office/hr-apply')}>人事</Button>
|
||||
<Button onClick={() => navigate('/office/seal-apply')}>用章</Button>
|
||||
<Button type="primary" onClick={() => navigate('/office/apply')}>
|
||||
办公
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Segmented
|
||||
value={kind}
|
||||
onChange={(v) => setKind(String(v))}
|
||||
options={[
|
||||
{ label: '全部类型', value: '' },
|
||||
{ label: '人事', value: 'HR' },
|
||||
{ label: '用章', value: 'SEAL' },
|
||||
{ label: '办公', value: 'OFFICE' },
|
||||
{ label: '费用', value: 'EXPENSE' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<Table
|
||||
className="ops-table"
|
||||
rowKey={(r) => `${r.source}-${r.id}`}
|
||||
loading={q.isLoading}
|
||||
dataSource={items}
|
||||
pagination={{ defaultPageSize: 10, showSizeChanger: true, showTotal: (n) => `共 ${n} 条` }}
|
||||
columns={[
|
||||
{ title: '来源', width: 80, render: (_: unknown, r: FlowRow) => SOURCE_LABEL[r.source] || r.source },
|
||||
{ title: '类型', width: 110, render: (_: unknown, r: FlowRow) => KIND_LABEL[r.kind] || r.kind },
|
||||
{
|
||||
title: '事项',
|
||||
ellipsis: true,
|
||||
render: (_: unknown, r: FlowRow) => <Link to={r.path}>{r.title}</Link>,
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
width: 110,
|
||||
render: (_: unknown, r: FlowRow) => {
|
||||
const s = BUCKET_META[r.bucket] || { color: 'default', text: zh(r.status) };
|
||||
return <Tag color={s.color}>{s.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{ title: '创建时间', width: 170, render: (_: unknown, r: FlowRow) => fmtCreated(r.createdAt) },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Button, Spin, Tag } from 'antd';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { api } from '../api/client';
|
||||
import { PageHeader } from './ResourcePage';
|
||||
import { fmtCreated } from '../time';
|
||||
import { NoticeHtml } from './NoticeHtml';
|
||||
|
||||
type NoticeDetail = {
|
||||
id: string;
|
||||
title: string;
|
||||
content?: string;
|
||||
kind?: string;
|
||||
status?: string;
|
||||
createdAt: string;
|
||||
createdBy?: { displayName?: string };
|
||||
files?: { id: string; fileName: string; size: number; mimeType: string; bizType?: string }[];
|
||||
};
|
||||
|
||||
export default function NoticeDetailPage() {
|
||||
const { id } = useParams();
|
||||
const q = useQuery({
|
||||
queryKey: ['/office/notices', id],
|
||||
queryFn: () => api.get(`/office/notices/${id}`),
|
||||
enabled: Boolean(id),
|
||||
});
|
||||
const row = q.data?.data as NoticeDetail | undefined;
|
||||
|
||||
if (q.isLoading) {
|
||||
return (
|
||||
<div style={{ display: 'grid', placeItems: 'center', minHeight: 240 }}>
|
||||
<Spin />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (!row) {
|
||||
return (
|
||||
<PageHeader
|
||||
title="通知不存在"
|
||||
extra={
|
||||
<Link to="/office/notices">
|
||||
<Button>返回列表</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={row.title}
|
||||
description={`${row.createdBy?.displayName || '—'} · ${fmtCreated(row.createdAt)}`}
|
||||
extra={
|
||||
<Link to="/office/notices">
|
||||
<Button>返回列表</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Tag color={row.kind === 'POLICY' ? 'blue' : 'purple'}>{row.kind === 'POLICY' ? '规章制度' : '通知公告'}</Tag>
|
||||
</div>
|
||||
<NoticeHtml html={row.content} files={row.files} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import { BizFileList, nonImageFiles } from './FileAttachments';
|
||||
|
||||
type FileRow = { id: string; fileName: string; size: number; mimeType: string; bizType?: string };
|
||||
|
||||
function toNoticeHtml(html?: string) {
|
||||
const raw = html || '';
|
||||
if (/<[a-z][\s\S]*>/i.test(raw)) return raw || '<p></p>';
|
||||
const escaped = raw
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>');
|
||||
return `<p>${escaped.replace(/\n/g, '<br/>') || ''}</p>`;
|
||||
}
|
||||
|
||||
export function NoticeHtml({ html, files }: { html?: string; files?: FileRow[] }) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const attach = nonImageFiles(files);
|
||||
const markup = toNoticeHtml(html);
|
||||
|
||||
useEffect(() => {
|
||||
const root = ref.current;
|
||||
if (!root) return;
|
||||
let cancelled = false;
|
||||
const urls: string[] = [];
|
||||
const run = async () => {
|
||||
const imgs = Array.from(root.querySelectorAll('img'));
|
||||
await Promise.all(
|
||||
imgs.map(async (img) => {
|
||||
const src = img.getAttribute('src') || '';
|
||||
const m = src.match(/\/files\/([0-9a-f-]{36})/i);
|
||||
if (!m) return;
|
||||
try {
|
||||
const blob = (await api.get(`/files/${m[1]}`, { responseType: 'blob' })) as unknown as Blob;
|
||||
if (cancelled) return;
|
||||
const url = URL.createObjectURL(blob);
|
||||
urls.push(url);
|
||||
img.src = url;
|
||||
} catch {
|
||||
/* keep original src */
|
||||
}
|
||||
}),
|
||||
);
|
||||
};
|
||||
void run();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
urls.forEach((u) => URL.revokeObjectURL(u));
|
||||
};
|
||||
}, [html]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="notice-html" ref={ref} dangerouslySetInnerHTML={{ __html: markup }} />
|
||||
{attach.length ? (
|
||||
<div style={{ marginTop: 24 }}>
|
||||
<b>附件</b>
|
||||
<BizFileList files={attach} empty="没有附件" />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import { Button, Table, Tag } from 'antd';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import { api } from '../api/client';
|
||||
import { PageHeader, unwrapList } from './ResourcePage';
|
||||
import { fmtCreated } from '../time';
|
||||
|
||||
type NoticeRow = {
|
||||
id: string;
|
||||
title: string;
|
||||
kind?: string;
|
||||
createdAt: string;
|
||||
createdBy?: { displayName?: string };
|
||||
};
|
||||
|
||||
export default function NoticePage() {
|
||||
const nav = useNavigate();
|
||||
const q = useQuery({
|
||||
queryKey: ['/office/notices', 'inbox'],
|
||||
queryFn: () => api.get('/office/notices'),
|
||||
});
|
||||
const items = unwrapList(q.data).items as unknown as NoticeRow[];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="公司公告"
|
||||
description="查看发给你或全公司的通知和规章制度。点标题打开正文和附件。"
|
||||
/>
|
||||
<Table
|
||||
className="ops-table"
|
||||
rowKey="id"
|
||||
loading={q.isLoading}
|
||||
dataSource={items}
|
||||
pagination={false}
|
||||
locale={{ emptyText: '还没有公告。' }}
|
||||
onRow={(r) => ({
|
||||
style: { cursor: 'pointer' },
|
||||
onClick: () => nav(`/office/notices/${r.id}`),
|
||||
})}
|
||||
columns={[
|
||||
{
|
||||
title: '类型',
|
||||
width: 110,
|
||||
render: (_: unknown, r: NoticeRow) =>
|
||||
r.kind === 'POLICY' ? <Tag color="blue">规章制度</Tag> : <Tag color="purple">通知公告</Tag>,
|
||||
},
|
||||
{ title: '标题', dataIndex: 'title', ellipsis: true },
|
||||
{ title: '发布人', width: 120, render: (_: unknown, r: NoticeRow) => r.createdBy?.displayName || '—' },
|
||||
{ title: '时间', width: 170, render: (_: unknown, r: NoticeRow) => fmtCreated(r.createdAt) },
|
||||
{
|
||||
title: '详情',
|
||||
width: 80,
|
||||
render: (_: unknown, r: NoticeRow) => (
|
||||
<Link to={`/office/notices/${r.id}`} onClick={(e) => e.stopPropagation()}>
|
||||
<Button type="link">详情</Button>
|
||||
</Link>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,347 @@
|
||||
import { Alert, Button, DatePicker, Form, Input, InputNumber, Popconfirm, Radio, Select, Space, Table, Tag, message } from 'antd';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { useMemo, useState } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import { api } from '../api/client';
|
||||
import { useAuth } from '../store/auth';
|
||||
import { FormDrawer } from './FormDrawer';
|
||||
import { PageHeader, unwrapList } from './ResourcePage';
|
||||
import { money } from './financeStatus';
|
||||
import { fmtCreated, fmtDay } from '../time';
|
||||
import { zh } from '../labels';
|
||||
import { ApplyBucketBar, PROCESS_BUCKETS } from './ApplyBucketBar';
|
||||
|
||||
const KIND_LABEL: Record<string, string> = {
|
||||
ASSET_BUY: '资产购置',
|
||||
ASSET_USE: '资产领用',
|
||||
VEHICLE: '用车',
|
||||
};
|
||||
|
||||
const KIND_OPTIONS = [
|
||||
{ value: 'ASSET_BUY', label: '资产购置' },
|
||||
{ value: 'ASSET_USE', label: '资产领用' },
|
||||
{ value: 'VEHICLE', label: '用车' },
|
||||
];
|
||||
|
||||
type CatalogAsset = {
|
||||
id: string;
|
||||
name: string;
|
||||
code?: string | null;
|
||||
spec?: string | null;
|
||||
kind: string;
|
||||
status?: string;
|
||||
usage?: string;
|
||||
occupancies?: { id: string; holder: string }[];
|
||||
};
|
||||
|
||||
type ApplyRow = {
|
||||
id: string;
|
||||
kind: string;
|
||||
title: string;
|
||||
reason?: string | null;
|
||||
status: string;
|
||||
assetKind?: string | null;
|
||||
amount?: string | number | null;
|
||||
destination?: string | null;
|
||||
startAt?: string | null;
|
||||
endAt?: string | null;
|
||||
createdAt: string;
|
||||
registeredAsset?: { name: string; code?: string | null; kind: string } | null;
|
||||
project?: { projectNo: string; name: string } | null;
|
||||
purchase?: { id: string; title: string; status: string } | null;
|
||||
occupancy?: { id: string; status: string; assetName: string } | null;
|
||||
inboundAsset?: { id: string; name: string; status: string; kind: string } | null;
|
||||
applicant?: { displayName?: string };
|
||||
};
|
||||
|
||||
const VEHICLE_NAME = /车|车辆|轿车|商务车|越野|皮卡|面包车/;
|
||||
|
||||
function assetLabel(a: CatalogAsset) {
|
||||
const busy = a.occupancies?.length ? ` · ${a.occupancies[0].holder}在用` : '';
|
||||
const pending = a.status === 'PENDING_IN' ? ' · 待入库' : '';
|
||||
return `${a.name}${a.code ? `(${a.code})` : ''}${busy}${pending}`;
|
||||
}
|
||||
|
||||
export default function OfficeApplyPage() {
|
||||
const qc = useQueryClient();
|
||||
const roles = useAuth((s) => s.roles);
|
||||
const canApprove = ['admin', 'owner', 'hr', 'biz_director'].some((r) => roles.includes(r));
|
||||
const [open, setOpen] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [params] = useSearchParams();
|
||||
const bucket = params.get('bucket') || '';
|
||||
const kind = Form.useWatch('kind', form) as string | undefined;
|
||||
const assetKind = Form.useWatch('assetKind', form) as string | undefined;
|
||||
const catalogKind = kind === 'VEHICLE' ? 'FIXED' : assetKind;
|
||||
|
||||
const q = useQuery({
|
||||
queryKey: ['/office/applies', 'mine', bucket],
|
||||
queryFn: () => api.get(`/office/applies?scope=mine${bucket ? `&bucket=${bucket}` : ''}`),
|
||||
});
|
||||
const projects = useQuery({ queryKey: ['/projects'], queryFn: () => api.get('/projects') });
|
||||
const catalog = useQuery({
|
||||
queryKey: ['/registered-assets', catalogKind],
|
||||
queryFn: () => api.get(`/registered-assets?kind=${catalogKind}`),
|
||||
enabled: Boolean(catalogKind),
|
||||
});
|
||||
|
||||
const items = unwrapList(q.data).items as ApplyRow[];
|
||||
const projectOpts = (unwrapList(projects.data).items as { id: string; projectNo: string; name: string }[]).map((p) => ({
|
||||
value: p.id,
|
||||
label: `${p.projectNo} ${p.name}`,
|
||||
}));
|
||||
const assets = unwrapList(catalog.data).items as CatalogAsset[];
|
||||
const assetOpts = useMemo(() => {
|
||||
const usable = assets.filter((a) => a.status !== 'PENDING_IN');
|
||||
const list =
|
||||
kind === 'VEHICLE'
|
||||
? usable
|
||||
.filter((a) => a.usage === 'VEHICLE' || VEHICLE_NAME.test(`${a.name}${a.spec || ''}`))
|
||||
.concat(usable.filter((a) => a.usage !== 'VEHICLE' && !VEHICLE_NAME.test(`${a.name}${a.spec || ''}`)))
|
||||
: usable;
|
||||
return list.map((a) => ({
|
||||
value: a.id,
|
||||
label: assetLabel(a),
|
||||
disabled: kind !== 'ASSET_BUY' && Boolean(a.occupancies?.length),
|
||||
}));
|
||||
}, [assets, kind]);
|
||||
|
||||
const mut = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) => api.post('/office/applies', body),
|
||||
onSuccess: async () => {
|
||||
message.success('已提交,负责人审批');
|
||||
setOpen(false);
|
||||
form.resetFields();
|
||||
await qc.invalidateQueries({ queryKey: ['/office/applies'] });
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="办公申请"
|
||||
description="购置、领用、用车在这里提交。购置通过后进入采购台账,领用/用车通过后占用对应台账资产。"
|
||||
extra={
|
||||
<ApplyBucketBar
|
||||
basePath="/office/apply"
|
||||
buckets={[...PROCESS_BUCKETS, { value: 'occupying', label: '占用中' }]}
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ kind: 'ASSET_USE', assetKind: 'FIXED' });
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
新建申请
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="领用和用车必须先选数字资产或固定资产,再选台账里的具体资产。购置通过后会生成待入库台账和付款待办,到货后到资产管理确认入库。"
|
||||
/>
|
||||
<FormDrawer
|
||||
title="办公申请"
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
okText="提交"
|
||||
okLoading={mut.isPending}
|
||||
onOk={async () => {
|
||||
const v = await form.validateFields();
|
||||
const range = v.range as [dayjs.Dayjs, dayjs.Dayjs] | undefined;
|
||||
await mut.mutateAsync({
|
||||
kind: v.kind,
|
||||
title: v.title,
|
||||
reason: v.reason,
|
||||
assetKind: v.kind === 'VEHICLE' ? 'FIXED' : v.assetKind,
|
||||
registeredAssetId: v.registeredAssetId,
|
||||
amount: v.amount,
|
||||
destination: v.destination,
|
||||
projectId: v.projectId,
|
||||
startAt: range?.[0] ? range[0].format('YYYY-MM-DD') : v.dueAt ? dayjs(v.dueAt).format('YYYY-MM-DD') : undefined,
|
||||
endAt: range?.[1] ? range[1].format('YYYY-MM-DD') : v.dueAt ? dayjs(v.dueAt).format('YYYY-MM-DD') : undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="kind" label="申请类型" rules={[{ required: true }]}>
|
||||
<Radio.Group
|
||||
options={KIND_OPTIONS}
|
||||
optionType="button"
|
||||
onChange={() => form.setFieldsValue({ registeredAssetId: undefined, assetKind: form.getFieldValue('assetKind') || 'FIXED' })}
|
||||
/>
|
||||
</Form.Item>
|
||||
{kind !== 'VEHICLE' ? (
|
||||
<Form.Item name="assetKind" label="资产类型" rules={[{ required: true, message: '请先选择数字资产或固定资产' }]}>
|
||||
<Radio.Group
|
||||
optionType="button"
|
||||
options={[
|
||||
{ value: 'DIGITAL', label: '数字资产' },
|
||||
{ value: 'FIXED', label: '固定资产' },
|
||||
]}
|
||||
onChange={() => form.setFieldsValue({ registeredAssetId: undefined })}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{kind === 'ASSET_USE' || kind === 'VEHICLE' ? (
|
||||
<Form.Item
|
||||
name="registeredAssetId"
|
||||
label={kind === 'VEHICLE' ? '车辆(固定资产)' : '台账资产'}
|
||||
rules={kind === 'ASSET_USE' ? [{ required: true, message: '请从台账选择资产' }] : undefined}
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder={kind === 'VEHICLE' ? '优先列出车辆用途的固定资产' : '先选类型,再选具体资产'}
|
||||
options={assetOpts}
|
||||
allowClear={kind === 'VEHICLE'}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
{kind === 'ASSET_BUY' ? (
|
||||
<>
|
||||
<Form.Item name="title" label="购置内容" rules={[{ required: true }]}>
|
||||
<Input placeholder="例如:摄影灯、渲染软件账号" />
|
||||
</Form.Item>
|
||||
<Form.Item name="amount" label="预估金额" rules={[{ required: true }]}>
|
||||
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null}
|
||||
{kind === 'VEHICLE' ? (
|
||||
<>
|
||||
<Form.Item name="destination" label="目的地" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="range" label="用车时间" rules={[{ required: true }]}>
|
||||
<DatePicker.RangePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</>
|
||||
) : null}
|
||||
{kind === 'ASSET_USE' ? (
|
||||
<Form.Item name="dueAt" label="预计交回">
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
<Form.Item name="projectId" label="关联项目">
|
||||
<Select allowClear showSearch optionFilterProp="label" options={projectOpts} />
|
||||
</Form.Item>
|
||||
<Form.Item name="reason" label="事由" rules={[{ required: true }]}>
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</FormDrawer>
|
||||
<Table
|
||||
className="ops-table"
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={q.isLoading}
|
||||
dataSource={items}
|
||||
pagination={false}
|
||||
scroll={{ x: 'max-content' }}
|
||||
columns={[
|
||||
{ title: '类型', width: 110, render: (_: unknown, r: ApplyRow) => KIND_LABEL[r.kind] || r.kind },
|
||||
{ title: '事项', dataIndex: 'title', width: 200, ellipsis: true },
|
||||
{
|
||||
title: '资产',
|
||||
width: 160,
|
||||
render: (_: unknown, r: ApplyRow) =>
|
||||
r.registeredAsset ? `${r.registeredAsset.name}${r.registeredAsset.code ? `(${r.registeredAsset.code})` : ''}` : r.assetKind ? zh(r.assetKind) : '—',
|
||||
},
|
||||
{
|
||||
title: '金额 / 行程',
|
||||
width: 180,
|
||||
render: (_: unknown, r: ApplyRow) => {
|
||||
if (r.kind === 'ASSET_BUY') return money(r.amount);
|
||||
if (r.kind === 'VEHICLE') {
|
||||
return `${r.destination || '—'} ${r.startAt ? fmtDay(r.startAt) : ''}`.trim();
|
||||
}
|
||||
return r.endAt ? `交回 ${fmtDay(r.endAt)}` : '—';
|
||||
},
|
||||
},
|
||||
{ title: '项目', width: 140, render: (_: unknown, r: ApplyRow) => r.project?.name || '—' },
|
||||
{
|
||||
title: '落地',
|
||||
width: 140,
|
||||
render: (_: unknown, r: ApplyRow) => {
|
||||
if (r.inboundAsset) {
|
||||
return (
|
||||
<Link to={r.inboundAsset.kind === 'DIGITAL' ? '/asset/digital' : '/asset/fixed'}>
|
||||
{r.inboundAsset.status === 'PENDING_IN' ? '待入库' : '已入库'}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
if (r.purchase) return <Link to="/project/purchases">采购已入账</Link>;
|
||||
if (r.occupancy) return <Link to="/project/assets">{r.occupancy.status === 'IN_USE' ? '占用中' : '已交回'}</Link>;
|
||||
return '—';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (v: string) => <Tag color={v === 'APPROVED' ? 'success' : v === 'REJECTED' ? 'error' : 'processing'}>{zh(v)}</Tag>,
|
||||
},
|
||||
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: (v: string) => fmtCreated(v) },
|
||||
{
|
||||
title: '操作',
|
||||
width: 160,
|
||||
render: (_: unknown, r: ApplyRow) => (
|
||||
<Space>
|
||||
{canApprove && r.status === 'PENDING' ? (
|
||||
<>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
onClick={async () => {
|
||||
await api.post(`/office/applies/${r.id}/decide`, { result: 'APPROVED' });
|
||||
message.success('已通过');
|
||||
qc.invalidateQueries({ queryKey: ['/office/applies'] });
|
||||
}}
|
||||
>
|
||||
通过
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
onClick={async () => {
|
||||
await api.post(`/office/applies/${r.id}/decide`, { result: 'REJECTED', comment: '驳回' });
|
||||
message.success('已驳回');
|
||||
qc.invalidateQueries({ queryKey: ['/office/applies'] });
|
||||
}}
|
||||
>
|
||||
驳回
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{r.status === 'PENDING' ? (
|
||||
<Popconfirm
|
||||
title="确认删除这条申请?"
|
||||
onConfirm={async () => {
|
||||
await api.delete(`/office/applies/${r.id}`);
|
||||
message.success('已删除');
|
||||
qc.invalidateQueries({ queryKey: ['/office/applies'] });
|
||||
}}
|
||||
>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,355 @@
|
||||
import { Button, Form, Input, Modal, Segmented, Space, Table, Tabs, Tag, message } from 'antd';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import { useState } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import { api } from '../api/client';
|
||||
import { PageHeader } from './ResourcePage';
|
||||
import { REVIEW_RESULT, statusMeta, tenderNo } from './bidStatus';
|
||||
import { contractStatusMeta, officeItemPath } from './contractStatus';
|
||||
import { EXPENSE_STATUS_META, LOAN_STATUS_META, expenseCategoryLabel, money } from './financeStatus';
|
||||
import { HR_APPLY_LABEL, LEAVE_STATUS_META } from './hrStatus';
|
||||
import { zh } from '../labels';
|
||||
|
||||
type Person = {
|
||||
displayName?: string;
|
||||
employee?: { name?: string; department?: { name?: string } };
|
||||
} | null;
|
||||
|
||||
type Approval = {
|
||||
id: string;
|
||||
title: string;
|
||||
bizType: string;
|
||||
bizId?: string | null;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
bidCase?: {
|
||||
id: string;
|
||||
bidNo: string;
|
||||
name: string;
|
||||
status: string;
|
||||
remark?: string | null;
|
||||
summary?: string | null;
|
||||
quoteAmount?: string | null;
|
||||
createdBy?: Person;
|
||||
party?: { name?: string } | null;
|
||||
} | null;
|
||||
contract?: {
|
||||
id: string;
|
||||
contractNo: string;
|
||||
name: string;
|
||||
status: string;
|
||||
amount?: string | number;
|
||||
remark?: string | null;
|
||||
createdBy?: Person;
|
||||
} | null;
|
||||
expense?: {
|
||||
id: string;
|
||||
claimNo: string;
|
||||
status: string;
|
||||
amount?: string | number;
|
||||
purpose?: string | null;
|
||||
remark?: string | null;
|
||||
expenseCategory?: string | null;
|
||||
costType?: string;
|
||||
applicant?: Person;
|
||||
bidCase?: { name?: string; bidNo?: string; externalNo?: string } | null;
|
||||
project?: { name?: string; projectNo?: string } | null;
|
||||
} | null;
|
||||
loan?: {
|
||||
id: string;
|
||||
loanNo: string;
|
||||
status: string;
|
||||
amount?: string | number;
|
||||
purpose?: string | null;
|
||||
remark?: string | null;
|
||||
costType?: string;
|
||||
applicant?: Person;
|
||||
bidCase?: { name?: string; bidNo?: string; externalNo?: string } | null;
|
||||
project?: { name?: string; projectNo?: string } | null;
|
||||
} | null;
|
||||
sealRequest?: { id: string; sealType: string; status: string; reason?: string; applicant?: Person } | null;
|
||||
officeApply?: {
|
||||
id: string;
|
||||
kind: string;
|
||||
title: string;
|
||||
status: string;
|
||||
amount?: string | number | null;
|
||||
reason?: string | null;
|
||||
destination?: string | null;
|
||||
assetKind?: string | null;
|
||||
applicant?: Person;
|
||||
registeredAsset?: { name: string; code?: string | null } | null;
|
||||
} | null;
|
||||
leaveRequest?: {
|
||||
id: string;
|
||||
kind: string;
|
||||
status: string;
|
||||
days?: number | string | null;
|
||||
reason?: string | null;
|
||||
startAt?: string | null;
|
||||
endAt?: string | null;
|
||||
comment?: string | null;
|
||||
employee?: { name?: string; department?: { name?: string } | null } | null;
|
||||
} | null;
|
||||
};
|
||||
|
||||
function personName(p?: Person) {
|
||||
return p?.displayName || p?.employee?.name || '—';
|
||||
}
|
||||
|
||||
function personDept(p?: Person) {
|
||||
return p?.employee?.department?.name || '—';
|
||||
}
|
||||
|
||||
function approvalApplicant(r: Approval) {
|
||||
return (
|
||||
r.leaveRequest?.employee?.name ||
|
||||
personName(r.expense?.applicant || r.loan?.applicant || r.contract?.createdBy || r.bidCase?.createdBy || r.sealRequest?.applicant || r.officeApply?.applicant)
|
||||
);
|
||||
}
|
||||
|
||||
function approvalDept(r: Approval) {
|
||||
return (
|
||||
r.leaveRequest?.employee?.department?.name ||
|
||||
personDept(r.expense?.applicant || r.loan?.applicant || r.contract?.createdBy || r.bidCase?.createdBy || r.sealRequest?.applicant || r.officeApply?.applicant)
|
||||
);
|
||||
}
|
||||
|
||||
function approvalAmount(r: Approval) {
|
||||
const n = r.expense?.amount ?? r.loan?.amount ?? r.contract?.amount ?? r.bidCase?.quoteAmount ?? r.officeApply?.amount;
|
||||
return n === undefined || n === null || n === '' ? '—' : money(n);
|
||||
}
|
||||
|
||||
function linkedName(r: Approval) {
|
||||
return (
|
||||
r.expense?.bidCase?.name ||
|
||||
r.expense?.project?.name ||
|
||||
r.loan?.bidCase?.name ||
|
||||
r.loan?.project?.name ||
|
||||
r.bidCase?.name ||
|
||||
r.contract?.name ||
|
||||
''
|
||||
);
|
||||
}
|
||||
|
||||
function approvalTitle(r: Approval) {
|
||||
if (r.expense) return r.expense.purpose || linkedName(r) || r.expense.claimNo;
|
||||
if (r.loan) return r.loan.purpose || linkedName(r) || r.loan.loanNo;
|
||||
if (r.contract) return r.contract.name || r.contract.contractNo;
|
||||
if (r.bidCase) return r.bidCase.name;
|
||||
if (r.sealRequest) return r.sealRequest.reason || '用印';
|
||||
if (r.officeApply) return r.officeApply.title;
|
||||
return r.title;
|
||||
}
|
||||
|
||||
function approvalCategory(r: Approval) {
|
||||
if (r.leaveRequest) return HR_APPLY_LABEL[r.leaveRequest.kind] || zh(r.leaveRequest.kind);
|
||||
if (r.expense) return expenseCategoryLabel(r.expense.expenseCategory);
|
||||
if (r.loan) return '借支';
|
||||
if (r.officeApply) return zh(r.officeApply.kind);
|
||||
return '—';
|
||||
}
|
||||
|
||||
function approvalPurpose(r: Approval) {
|
||||
if (r.leaveRequest) {
|
||||
const hours = r.leaveRequest.kind === 'OVERTIME';
|
||||
const span = r.leaveRequest.days != null && r.leaveRequest.days !== '' ? `${r.leaveRequest.days}${hours ? ' 小时' : ' 天'}` : '';
|
||||
return [r.leaveRequest.reason, span].filter(Boolean).join(' · ') || '—';
|
||||
}
|
||||
if (r.expense) return r.expense.purpose || linkedName(r) || '—';
|
||||
if (r.loan) return r.loan.purpose || '—';
|
||||
if (r.officeApply) return r.officeApply.reason || r.officeApply.destination || r.officeApply.registeredAsset?.name || '—';
|
||||
return linkedName(r) || '—';
|
||||
}
|
||||
|
||||
function approvalRemark(r: Approval) {
|
||||
const raw = r.leaveRequest?.comment || r.expense?.remark || r.loan?.remark || r.contract?.remark || r.bidCase?.remark || '';
|
||||
return raw.trim() || '—';
|
||||
}
|
||||
|
||||
function approvalNo(r: Approval) {
|
||||
if (r.expense) return r.expense.claimNo;
|
||||
if (r.loan) return r.loan.loanNo;
|
||||
if (r.contract) return r.contract.contractNo;
|
||||
if (r.bidCase) return tenderNo(r.bidCase);
|
||||
return '—';
|
||||
}
|
||||
|
||||
export default function OfficeApprovalsPage() {
|
||||
const qc = useQueryClient();
|
||||
const [form] = Form.useForm();
|
||||
const [params] = useSearchParams();
|
||||
const [kind, setKind] = useState(params.get('biz') === 'loan' ? 'LOAN_APPROVAL' : 'all');
|
||||
const q = useQuery({
|
||||
queryKey: ['/office/approvals'],
|
||||
queryFn: () => api.get('/office/approvals'),
|
||||
});
|
||||
const items = ((q.data?.data ?? []) as Approval[]).filter((r) => (kind === 'all' ? true : r.bizType === kind));
|
||||
const pending = items.filter((r) => r.status === 'PENDING');
|
||||
const done = items.filter((r) => r.status !== 'PENDING');
|
||||
|
||||
const mut = useMutation({
|
||||
mutationFn: ({ id, result, comment }: { id: string; result: 'APPROVED' | 'REJECTED'; comment?: string }) =>
|
||||
api.post(`/office/approvals/${id}/decide`, { result, comment }),
|
||||
onSuccess: async () => {
|
||||
message.success('已办理');
|
||||
await qc.invalidateQueries();
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
|
||||
const decide = (row: Approval, result: 'APPROVED' | 'REJECTED') => {
|
||||
if (result === 'REJECTED') {
|
||||
form.resetFields();
|
||||
Modal.confirm({
|
||||
title: '驳回意见',
|
||||
content: (
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 12 }}>
|
||||
<Form.Item name="comment" label="原因" rules={[{ required: true, message: '驳回请填写意见' }]}>
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
okButtonProps: { danger: true },
|
||||
okText: '确认驳回',
|
||||
onOk: async () => {
|
||||
const v = await form.validateFields();
|
||||
await mut.mutateAsync({ id: row.id, result: 'REJECTED', comment: v.comment });
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
mut.mutate({ id: row.id, result: 'APPROVED' });
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{ title: '标题', width: 200, ellipsis: true, render: (_: unknown, r: Approval) => approvalTitle(r) },
|
||||
{ title: '类型', dataIndex: 'bizType', width: 90, render: (v: string) => zh(v) },
|
||||
{ title: '编号', width: 150, render: (_: unknown, r: Approval) => approvalNo(r) },
|
||||
{ title: '申请人', width: 90, render: (_: unknown, r: Approval) => approvalApplicant(r) },
|
||||
{ title: '部门', width: 110, render: (_: unknown, r: Approval) => approvalDept(r) },
|
||||
{ title: '金额', width: 110, render: (_: unknown, r: Approval) => approvalAmount(r) },
|
||||
{ title: '类别', width: 80, render: (_: unknown, r: Approval) => approvalCategory(r) },
|
||||
{ title: '用途', width: 200, ellipsis: true, render: (_: unknown, r: Approval) => approvalPurpose(r) },
|
||||
{ title: '备注', width: 160, ellipsis: true, render: (_: unknown, r: Approval) => approvalRemark(r) },
|
||||
{
|
||||
title: '事项状态',
|
||||
width: 100,
|
||||
render: (_: unknown, r: Approval) => {
|
||||
if (r.leaveRequest) {
|
||||
const s = LEAVE_STATUS_META[r.leaveRequest.status] || { color: 'default', text: r.leaveRequest.status };
|
||||
return <Tag color={s.color}>{s.text}</Tag>;
|
||||
}
|
||||
if (r.expense) {
|
||||
const s = EXPENSE_STATUS_META[r.expense.status] || { color: 'default', text: r.expense.status };
|
||||
return <Tag color={s.color}>{s.text}</Tag>;
|
||||
}
|
||||
if (r.loan) {
|
||||
const s = LOAN_STATUS_META[r.loan.status] || { color: 'default', text: r.loan.status };
|
||||
return <Tag color={s.color}>{s.text}</Tag>;
|
||||
}
|
||||
if (r.contract) {
|
||||
const s = contractStatusMeta(r.contract.status);
|
||||
return <Tag color={s.color}>{s.text}</Tag>;
|
||||
}
|
||||
if (!r.bidCase) return '—';
|
||||
const s = statusMeta(r.bidCase.status);
|
||||
return <Tag color={s.color}>{s.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '审批',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (v: string) => {
|
||||
const s = REVIEW_RESULT[v] || { color: 'default', text: v };
|
||||
return <Tag color={s.color}>{s.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 170,
|
||||
render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm:ss'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 180,
|
||||
render: (_: unknown, r: Approval) => (
|
||||
<Space>
|
||||
{officeItemPath(r) ? <Link to={officeItemPath(r)!}>查看</Link> : null}
|
||||
{r.status === 'PENDING' ? (
|
||||
<>
|
||||
<Button type="link" size="small" onClick={() => decide(r, 'APPROVED')}>
|
||||
通过
|
||||
</Button>
|
||||
<Button type="link" size="small" danger onClick={() => decide(r, 'REJECTED')}>
|
||||
驳回
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="待我审批"
|
||||
description="投标会签、合同、报销、借支、人事申请、办公申请都在这里办理。人事加班/请假发给汇报对象和部门总监,不会群发给所有管理员。"
|
||||
extra={
|
||||
<Segmented
|
||||
value={kind}
|
||||
onChange={(v) => setKind(String(v))}
|
||||
options={[
|
||||
{ label: '全部', value: 'all' },
|
||||
{ label: '借支', value: 'LOAN_APPROVAL' },
|
||||
{ label: '报销', value: 'EXPENSE_APPROVAL' },
|
||||
{ label: '合同', value: 'CONTRACT_APPROVAL' },
|
||||
{ label: '办公申请', value: 'OFFICE_APPLY' },
|
||||
{ label: '人事申请', value: 'HR_LEAVE' },
|
||||
]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'pending',
|
||||
label: `待办(${pending.length})`,
|
||||
children: (
|
||||
<Table
|
||||
className="ops-table"
|
||||
rowKey="id"
|
||||
loading={q.isLoading}
|
||||
dataSource={pending}
|
||||
scroll={{ x: 1680 }}
|
||||
locale={{ emptyText: '没有待办审批。' }}
|
||||
columns={columns}
|
||||
pagination={{ defaultPageSize: 10, showSizeChanger: true, showTotal: (n) => `共 ${n} 条` }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'done',
|
||||
label: `已办(${done.length})`,
|
||||
children: (
|
||||
<Table
|
||||
className="ops-table"
|
||||
rowKey="id"
|
||||
loading={q.isLoading}
|
||||
dataSource={done}
|
||||
scroll={{ x: 1680 }}
|
||||
locale={{ emptyText: '还没有已办记录。' }}
|
||||
columns={columns}
|
||||
pagination={{ defaultPageSize: 10, showSizeChanger: true, showTotal: (n) => `共 ${n} 条` }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Alert, Button, Form, Input, Segmented, Select, Space, Typography, message } from 'antd';
|
||||
import { useMutation, useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../api/client';
|
||||
import { PageHeader } from './ResourcePage';
|
||||
import { topLevelSelectOptions } from '../deptTree';
|
||||
|
||||
type Staff = { id: string; name: string; department?: string; mobile?: string | null };
|
||||
type Dept = { id: string; name: string; parentId?: string | null; code?: string };
|
||||
type Target = 'dept' | 'people' | 'extra';
|
||||
|
||||
function previewSms(sign: string, template: string, content: string) {
|
||||
const sample = content.trim() || '……';
|
||||
const body = template
|
||||
.replace(/\$\{name\}/g, '同事')
|
||||
.replace(/\$\{content\}/g, sample)
|
||||
.replace(/\$\{others\}/g, sample)
|
||||
.replace(/\$\{project_name\}/g, sample)
|
||||
.replace(/\$\{task_name\}/g, sample)
|
||||
.replace(/\$\{[^}]+\}/g, '……');
|
||||
return `【${sign}】${body}`;
|
||||
}
|
||||
|
||||
export default function OfficeSmsPage() {
|
||||
const [form] = Form.useForm();
|
||||
const [target, setTarget] = useState<Target>('dept');
|
||||
const opt = useQuery({ queryKey: ['/sms/office-options'], queryFn: () => api.get('/sms/office-options') });
|
||||
const staff = useQuery({ queryKey: ['/staff'], queryFn: () => api.get('/staff') });
|
||||
const depts = useQuery({ queryKey: ['/departments'], queryFn: () => api.get('/departments') });
|
||||
const info = (opt.data?.data ?? {}) as {
|
||||
configured?: boolean;
|
||||
defaultSign?: string;
|
||||
officeTemplate?: string;
|
||||
templateContent?: string;
|
||||
contentLimit?: number;
|
||||
presets?: { code: string; name: string }[];
|
||||
note?: string;
|
||||
};
|
||||
const people = ((staff.data as { data?: Staff[] })?.data || []) as Staff[];
|
||||
const departments = ((depts.data as { data?: Dept[] })?.data || []).filter((d) => d.code !== 'ROOT');
|
||||
const sign = info.defaultSign || '江苏风影随行科技';
|
||||
const template = info.templateContent || '办公通知:${content}';
|
||||
const limit = info.contentLimit || 35;
|
||||
const content = Form.useWatch('content', form) as string | undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!info.officeTemplate) return;
|
||||
form.setFieldsValue({ templateCode: info.officeTemplate || info.presets?.[0]?.code });
|
||||
}, [info.officeTemplate, form]);
|
||||
|
||||
const send = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) => api.post('/sms/send', body),
|
||||
onSuccess: (res) => {
|
||||
const n = (res as { data?: { sent?: number } })?.data?.sent;
|
||||
message.success(n ? `已提交发送(${n} 个号码)` : '已提交发送');
|
||||
form.setFieldsValue({ content: '', extraPhones: '', departmentIds: undefined, userIds: undefined });
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="发送工作通知短信"
|
||||
description="先选发给谁:一个部门、点名同事,或系统外手机号。一次只发一种。"
|
||||
/>
|
||||
{info.configured === false ? (
|
||||
<Alert type="warning" showIcon style={{ marginBottom: 16 }} message={info.note || '短信通道未配置'} />
|
||||
) : (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="你只填中间那句通知。发出去会套进「办公通知」模板,前面自动带公司签名。"
|
||||
/>
|
||||
)}
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
style={{ maxWidth: 640 }}
|
||||
initialValues={{ templateCode: info.officeTemplate || info.presets?.[0]?.code }}
|
||||
onFinish={(v) => {
|
||||
const body: Record<string, unknown> = {
|
||||
signName: sign,
|
||||
templateCode: info.officeTemplate,
|
||||
content: v.content,
|
||||
};
|
||||
if (target === 'dept') body.departmentIds = v.departmentIds;
|
||||
else if (target === 'people') body.userIds = v.userIds;
|
||||
else body.extraPhones = v.extraPhones;
|
||||
send.mutate(body);
|
||||
}}
|
||||
>
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<Segmented
|
||||
value={target}
|
||||
onChange={(v) => setTarget(v as Target)}
|
||||
options={[
|
||||
{ label: '按部门', value: 'dept' },
|
||||
{ label: '按同事', value: 'people' },
|
||||
{ label: '系统外号码', value: 'extra' },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{target === 'dept' ? (
|
||||
<Form.Item
|
||||
name="departmentIds"
|
||||
label="发给哪个部门"
|
||||
extra="选中的部门里,在职且有手机号的人都会收到。选上级时下级也会收到。"
|
||||
rules={[{ required: true, type: 'array', min: 1, message: '请选择部门' }]}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="选择部门"
|
||||
options={topLevelSelectOptions(departments)}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
|
||||
{target === 'people' ? (
|
||||
<Form.Item
|
||||
name="userIds"
|
||||
label="发给哪些同事"
|
||||
extra="只发给你点名的人,可多选。"
|
||||
rules={[{ required: true, type: 'array', min: 1, message: '请选择同事' }]}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="搜索姓名"
|
||||
options={people.map((p) => ({
|
||||
value: p.id,
|
||||
label: `${p.name}${p.department ? `(${p.department})` : ''}${p.mobile ? ` ${p.mobile}` : ''}`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
|
||||
{target === 'extra' ? (
|
||||
<Form.Item
|
||||
name="extraPhones"
|
||||
label="系统外手机号"
|
||||
extra="不在通讯录里的号码,用逗号、空格或换行分开。"
|
||||
rules={[{ required: true, message: '请填写手机号' }]}
|
||||
>
|
||||
<Input.TextArea rows={3} placeholder="例如 13800000000,13900000000" />
|
||||
</Form.Item>
|
||||
) : null}
|
||||
|
||||
<Form.Item
|
||||
name="content"
|
||||
label="通知内容"
|
||||
extra={`只填要告诉对方的那句话,大约 ${limit} 字。不要写成完整短信。`}
|
||||
rules={[
|
||||
{ required: true, message: '请填写通知内容' },
|
||||
{ max: limit, message: `运营商模板最多 ${limit} 字` },
|
||||
]}
|
||||
>
|
||||
<Input.TextArea rows={3} placeholder="例如:请明日上午9点到会议室开会" maxLength={limit} showCount />
|
||||
</Form.Item>
|
||||
|
||||
<div
|
||||
style={{
|
||||
padding: '12px 14px',
|
||||
background: 'var(--ant-color-fill-alter, #fafafa)',
|
||||
border: '1px solid var(--ant-color-border-secondary, #f0f0f0)',
|
||||
borderRadius: 8,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
|
||||
对方将收到
|
||||
</Typography.Text>
|
||||
<div style={{ marginTop: 6, lineHeight: 1.6 }}>{previewSms(sign, template, content || '')}</div>
|
||||
</div>
|
||||
|
||||
<Form.Item label="短信签名">
|
||||
<Typography.Text>{sign}</Typography.Text>
|
||||
<div style={{ color: 'var(--mute)', fontSize: 12, marginTop: 4 }}>
|
||||
运营商审核过的公司签名,发办公通知不能改。
|
||||
</div>
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" loading={send.isPending} disabled={info.configured === false}>
|
||||
发送
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { Space, Table, Tag } from 'antd';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import dayjs from 'dayjs';
|
||||
import { api } from '../api/client';
|
||||
import { PageHeader } from './ResourcePage';
|
||||
import { statusMeta } from './bidStatus';
|
||||
import { contractStatusMeta } from './contractStatus';
|
||||
import { officeItemPath, projectStatusMeta } from './projectStatus';
|
||||
import { zh } from '../labels';
|
||||
|
||||
const TODO_STATUS: Record<string, { color: string; text: string }> = {
|
||||
OPEN: { color: 'processing', text: '待办' },
|
||||
DONE: { color: 'success', text: '已办' },
|
||||
CANCELLED: { color: 'default', text: '已取消' },
|
||||
};
|
||||
|
||||
type Todo = {
|
||||
id: string;
|
||||
title: string;
|
||||
bizType: string;
|
||||
bizId?: string | null;
|
||||
status: string;
|
||||
dueAt?: string | null;
|
||||
createdAt: string;
|
||||
bidCase?: { id: string; bidNo: string; name: string; status: string; remark?: string | null } | null;
|
||||
contract?: { id: string; contractNo: string; name: string; status: string; remark?: string | null } | null;
|
||||
expense?: { id: string; claimNo: string; status: string; amount?: string | number; remark?: string | null; applicant?: { displayName?: string } | null } | null;
|
||||
loan?: { id: string; loanNo: string; status: string; amount?: string | number; purpose?: string | null; applicant?: { displayName?: string } | null } | null;
|
||||
project?: { id: string; projectNo: string; name: string; status: string } | null;
|
||||
sealRequest?: { id: string; sealType: string; status: string; reason?: string; applicant?: { displayName?: string } | null } | null;
|
||||
borrow?: { id: string; itemName: string; status: string; borrower?: string } | null;
|
||||
officeApply?: { id: string; kind: string; title: string; status: string } | null;
|
||||
};
|
||||
|
||||
export default function OfficeTodosPage() {
|
||||
const q = useQuery({
|
||||
queryKey: ['/office/todos'],
|
||||
queryFn: () => api.get('/office/todos'),
|
||||
});
|
||||
const items = (q.data?.data ?? []) as Todo[];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="待办事项" description="取标、制标、印封、开标反馈、完善合同草稿、报销冲账等事务。开标结果和投标费用在「开标反馈」里登记。审批类请到「待我审批」。" />
|
||||
<Table
|
||||
className="ops-table"
|
||||
rowKey="id"
|
||||
loading={q.isLoading}
|
||||
dataSource={items}
|
||||
scroll={{ x: 'max-content' }}
|
||||
tableLayout="fixed"
|
||||
columns={[
|
||||
{ title: '标题', dataIndex: 'title', width: 240, ellipsis: true },
|
||||
{ title: '类型', dataIndex: 'bizType', width: 120, render: (v: string) => zh(v) },
|
||||
{
|
||||
title: '编号',
|
||||
width: 150,
|
||||
render: (_: unknown, r: Todo) =>
|
||||
r.expense?.claimNo ||
|
||||
r.loan?.loanNo ||
|
||||
r.contract?.contractNo ||
|
||||
r.project?.projectNo ||
|
||||
r.sealRequest?.sealType ||
|
||||
r.borrow?.itemName ||
|
||||
r.bidCase?.bidNo ||
|
||||
'—',
|
||||
},
|
||||
{
|
||||
title: '相关人',
|
||||
width: 90,
|
||||
render: (_: unknown, r: Todo) =>
|
||||
r.expense?.applicant?.displayName || r.loan?.applicant?.displayName || r.sealRequest?.applicant?.displayName || r.borrow?.borrower || '—',
|
||||
},
|
||||
{
|
||||
title: '说明',
|
||||
ellipsis: true,
|
||||
render: (_: unknown, r: Todo) =>
|
||||
r.expense?.remark || r.loan?.purpose || r.contract?.remark || r.bidCase?.remark || r.sealRequest?.reason || '—',
|
||||
},
|
||||
{
|
||||
title: '事项状态',
|
||||
width: 110,
|
||||
render: (_: unknown, r: Todo) => {
|
||||
if (r.contract) {
|
||||
const s = contractStatusMeta(r.contract.status);
|
||||
return <Tag color={s.color}>{s.text}</Tag>;
|
||||
}
|
||||
if (r.project) {
|
||||
const s = projectStatusMeta(r.project.status);
|
||||
return <Tag color={s.color}>{s.text}</Tag>;
|
||||
}
|
||||
if (!r.bidCase) return '—';
|
||||
const s = statusMeta(r.bidCase.status);
|
||||
return <Tag color={s.color}>{s.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '任务状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (v: string) => {
|
||||
const s = TODO_STATUS[v] || { color: 'default', text: v };
|
||||
return <Tag color={s.color}>{s.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '截止',
|
||||
dataIndex: 'dueAt',
|
||||
width: 170,
|
||||
render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm') : '—'),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 170,
|
||||
render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '—'),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 90,
|
||||
render: (_: unknown, r: Todo) => {
|
||||
const path = officeItemPath(r);
|
||||
return path ? (
|
||||
<Space>
|
||||
<Link to={path}>{r.status === 'OPEN' ? '办理' : '查看'}</Link>
|
||||
</Space>
|
||||
) : null;
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
import { Alert, Button, Card, Col, DatePicker, Form, Input, Row, Select, Steps, Typography, message } from 'antd';
|
||||
import { useEffect, useRef, useState, type CSSProperties } from 'react';
|
||||
import { Navigate, useNavigate } from 'react-router-dom';
|
||||
import dayjs from 'dayjs';
|
||||
import { api } from '../api/client';
|
||||
import { useAuth } from '../store/auth';
|
||||
import AmbientBackground from '../components/AmbientBackground';
|
||||
import { EDUCATION_LABEL, GENDER_LABEL, ID_TYPE_LABEL, MARITAL_LABEL } from './hrStatus';
|
||||
import { IdScanUpload } from './FileAttachments';
|
||||
import { SiteFooter } from '../components/SiteFooter';
|
||||
|
||||
const opts = (map: Record<string, string>) => Object.entries(map).map(([value, label]) => ({ value, label }));
|
||||
|
||||
function firstError(e: unknown) {
|
||||
if (e && typeof e === 'object' && 'errorFields' in e) {
|
||||
const msg = (e as { errorFields?: { errors?: string[] }[] }).errorFields?.[0]?.errors?.[0];
|
||||
if (msg) return msg;
|
||||
}
|
||||
if (e instanceof Error && e.message) return e.message;
|
||||
return '请检查填写内容';
|
||||
}
|
||||
|
||||
function pane(active: boolean): CSSProperties {
|
||||
return { display: active ? 'block' : 'none' };
|
||||
}
|
||||
|
||||
export default function OnboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const auth = useAuth();
|
||||
const [pwdForm] = Form.useForm();
|
||||
const [basicForm] = Form.useForm();
|
||||
const [eduForm] = Form.useForm();
|
||||
const [payForm] = Form.useForm();
|
||||
const hasEmployee = Boolean(auth.employeeId);
|
||||
const last = hasEmployee ? 4 : 0;
|
||||
const [cur, setCur] = useState(() => {
|
||||
if (auth.mustChangePassword) return 0;
|
||||
if (hasEmployee && (!auth.idPhotoFileId || !auth.idFrontFileId || !auth.idBackFileId)) return 2;
|
||||
return hasEmployee ? 1 : 0;
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const filled = useRef(false);
|
||||
const steps = hasEmployee
|
||||
? [{ title: '修改密码' }, { title: '基本信息' }, { title: '证件影像' }, { title: '教育信息' }, { title: '财务信息' }]
|
||||
: [{ title: '修改密码' }];
|
||||
|
||||
const fillEmployee = async () => {
|
||||
if (filled.current || !hasEmployee) return;
|
||||
filled.current = true;
|
||||
const me = await api.get('/auth/me');
|
||||
const emp = (me.data?.employee || {}) as Record<string, string | null>;
|
||||
basicForm.setFieldsValue({
|
||||
name: emp.name,
|
||||
gender: emp.gender,
|
||||
mobile: emp.mobile,
|
||||
email: emp.email,
|
||||
birthday: emp.birthday ? dayjs(emp.birthday) : undefined,
|
||||
ethnicity: emp.ethnicity,
|
||||
maritalStatus: emp.maritalStatus,
|
||||
emergencyName: emp.emergencyName,
|
||||
emergencyPhone: emp.emergencyPhone,
|
||||
hukouAddress: emp.hukouAddress,
|
||||
liveAddress: emp.liveAddress,
|
||||
idType: emp.idType || 'ID_CARD',
|
||||
idNo: emp.idNo,
|
||||
idIssuer: emp.idIssuer,
|
||||
idValidFrom: emp.idValidFrom ? dayjs(emp.idValidFrom) : undefined,
|
||||
idValidTo: emp.idValidTo ? dayjs(emp.idValidTo) : undefined,
|
||||
});
|
||||
eduForm.setFieldsValue({
|
||||
education: emp.education,
|
||||
school: emp.school,
|
||||
major: emp.major,
|
||||
graduatedAt: emp.graduatedAt ? dayjs(emp.graduatedAt) : undefined,
|
||||
});
|
||||
payForm.setFieldsValue({ bankName: emp.bankName, bankAccount: emp.bankAccount });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (hasEmployee) void fillEmployee();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [hasEmployee]);
|
||||
|
||||
if (auth.roles.includes('admin') || !auth.needsOnboard) {
|
||||
return <Navigate to="/workbench" replace />;
|
||||
}
|
||||
|
||||
const refreshFiles = async () => {
|
||||
await auth.loadSession();
|
||||
};
|
||||
|
||||
const finishProfile = async () => {
|
||||
if (!hasEmployee) {
|
||||
await api.patch('/auth/onboard-profile', {});
|
||||
await auth.loadSession();
|
||||
message.success('已完成初次设置');
|
||||
navigate('/workbench', { replace: true });
|
||||
return;
|
||||
}
|
||||
if (!auth.idPhotoFileId) throw new Error('请上传证件照');
|
||||
if (!auth.idFrontFileId) throw new Error('请上传身份证正面');
|
||||
if (!auth.idBackFileId) throw new Error('请上传身份证反面');
|
||||
const basic = await basicForm.validateFields();
|
||||
const edu = await eduForm.validateFields();
|
||||
const pay = await payForm.validateFields();
|
||||
await api.patch('/auth/onboard-profile', {
|
||||
...basic,
|
||||
...edu,
|
||||
...pay,
|
||||
birthday: basic.birthday ? dayjs(basic.birthday).format('YYYY-MM-DD') : undefined,
|
||||
idValidFrom: basic.idValidFrom ? dayjs(basic.idValidFrom).format('YYYY-MM-DD') : undefined,
|
||||
idValidTo: basic.idValidTo ? dayjs(basic.idValidTo).format('YYYY-MM-DD') : undefined,
|
||||
graduatedAt: edu.graduatedAt ? dayjs(edu.graduatedAt).format('YYYY-MM-DD') : undefined,
|
||||
});
|
||||
await auth.loadSession();
|
||||
message.success('档案已提交,岗位等信息由专人补全');
|
||||
navigate('/workbench', { replace: true });
|
||||
};
|
||||
|
||||
const next = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
if (cur === 0) {
|
||||
if (auth.mustChangePassword) {
|
||||
const v = await pwdForm.validateFields();
|
||||
if (v.newPassword !== v.confirm) {
|
||||
throw new Error('两次输入的新密码不一致');
|
||||
}
|
||||
await api.patch('/auth/password', { oldPassword: v.oldPassword, newPassword: v.newPassword });
|
||||
message.success('密码已更新');
|
||||
await auth.loadSession();
|
||||
}
|
||||
if (!hasEmployee) {
|
||||
await finishProfile();
|
||||
return;
|
||||
}
|
||||
await fillEmployee();
|
||||
setCur(1);
|
||||
return;
|
||||
}
|
||||
if (cur === 1) {
|
||||
await basicForm.validateFields();
|
||||
setCur(2);
|
||||
return;
|
||||
}
|
||||
if (cur === 2) {
|
||||
await refreshFiles();
|
||||
const me = useAuth.getState();
|
||||
if (!me.idPhotoFileId) throw new Error('请上传证件照');
|
||||
if (!me.idFrontFileId) throw new Error('请上传身份证正面');
|
||||
if (!me.idBackFileId) throw new Error('请上传身份证反面');
|
||||
setCur(3);
|
||||
return;
|
||||
}
|
||||
if (cur === 3) {
|
||||
await eduForm.validateFields();
|
||||
setCur(4);
|
||||
return;
|
||||
}
|
||||
await finishProfile();
|
||||
} catch (e) {
|
||||
message.error(firstError(e));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="ops-login" style={{ alignItems: 'flex-start', paddingTop: 48 }}>
|
||||
<AmbientBackground />
|
||||
<div style={{ width: 'min(720px, 94vw)', margin: '0 auto', position: 'relative', flex: 1 }}>
|
||||
<Card>
|
||||
<Typography.Title level={4} style={{ marginTop: 0 }}>
|
||||
完善个人账号
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
没有证件照或身份证正反面的,下次登录会停在这一页,必须传完才能进系统。岗位仍由专人补全。
|
||||
</Typography.Paragraph>
|
||||
<Steps current={cur} items={steps} size="small" style={{ marginBottom: 24 }} />
|
||||
<div style={pane(cur === 0)}>
|
||||
<Form form={pwdForm} layout="vertical" preserve>
|
||||
{auth.mustChangePassword ? (
|
||||
<>
|
||||
<Alert type="warning" showIcon style={{ marginBottom: 16 }} message="系统管理员以外的账号首次登录必须修改密码。" />
|
||||
<Form.Item name="oldPassword" label="当前密码" rules={[{ required: true, message: '请输入当前密码' }]}>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
<Form.Item name="newPassword" label="新密码" rules={[{ required: true, min: 6, message: '新密码至少 6 位' }]}>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
<Form.Item name="confirm" label="确认新密码" rules={[{ required: true, message: '请再次输入新密码' }]}>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
</>
|
||||
) : (
|
||||
<Alert type="success" showIcon message="密码已修改,点击下一步继续。" />
|
||||
)}
|
||||
</Form>
|
||||
</div>
|
||||
{hasEmployee ? (
|
||||
<>
|
||||
<div style={pane(cur === 1)}>
|
||||
<Form form={basicForm} layout="vertical" preserve>
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true, message: '请填写姓名' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="gender" label="性别" rules={[{ required: true, message: '请选择性别' }]}>
|
||||
<Select options={opts(GENDER_LABEL)} />
|
||||
</Form.Item>
|
||||
<Form.Item name="mobile" label="手机号码" rules={[{ required: true, message: '请填写手机号码' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="email" label="邮箱">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="birthday" label="生日">
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="ethnicity" label="民族">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="maritalStatus" label="婚姻状态">
|
||||
<Select allowClear options={opts(MARITAL_LABEL)} />
|
||||
</Form.Item>
|
||||
<Form.Item name="emergencyName" label="紧急联系人">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="emergencyPhone" label="紧急联系电话">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="hukouAddress" label="户籍地址">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="liveAddress" label="居住地址">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Typography.Title level={5}>证件信息</Typography.Title>
|
||||
<Row gutter={16}>
|
||||
<Col span={12}>
|
||||
<Form.Item name="idType" label="证件类型" rules={[{ required: true, message: '请选择证件类型' }]}>
|
||||
<Select options={opts(ID_TYPE_LABEL)} placeholder="如身份证、护照、港澳台通行证" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="idNo" label="证件号码" rules={[{ required: true, message: '请填写证件号码' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Item name="idIssuer" label="发证机关" rules={[{ required: true, message: '请填写发证机关' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="idValidFrom" label="有效期起" rules={[{ required: true, message: '请选择有效期起' }]}>
|
||||
<DatePicker style={{ width: '100%' }} placeholder="请选择日期" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col span={12}>
|
||||
<Form.Item name="idValidTo" label="有效期止" rules={[{ required: true, message: '请选择有效期止' }]}>
|
||||
<DatePicker style={{ width: '100%' }} placeholder="请选择日期" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</div>
|
||||
<div style={pane(cur === 2)}>
|
||||
<Alert type="warning" showIcon style={{ marginBottom: 16 }} message="必须上传证件照、身份证正面和反面,才能进入下一步。" />
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="证件照" required>
|
||||
<IdScanUpload
|
||||
label="证件照"
|
||||
bizType="USER_ID_PHOTO"
|
||||
bizId={auth.id}
|
||||
fileId={auth.idPhotoFileId}
|
||||
onDone={refreshFiles}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="身份证正面" required>
|
||||
<IdScanUpload
|
||||
label="身份证正面"
|
||||
bizType="EMPLOYEE_ID_FRONT"
|
||||
bizId={auth.employeeId || ''}
|
||||
fileId={auth.idFrontFileId}
|
||||
onDone={refreshFiles}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="身份证反面" required>
|
||||
<IdScanUpload
|
||||
label="身份证反面"
|
||||
bizType="EMPLOYEE_ID_BACK"
|
||||
bizId={auth.employeeId || ''}
|
||||
fileId={auth.idBackFileId}
|
||||
onDone={refreshFiles}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
<div style={pane(cur === 3)}>
|
||||
<Form form={eduForm} layout="vertical" preserve>
|
||||
<Form.Item name="education" label="学历" rules={[{ required: true, message: '请选择学历' }]}>
|
||||
<Select options={opts(EDUCATION_LABEL)} />
|
||||
</Form.Item>
|
||||
<Form.Item name="school" label="毕业院校">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="major" label="专业">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="graduatedAt" label="毕业日期">
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
<div style={pane(cur === 4)}>
|
||||
<Form form={payForm} layout="vertical" preserve>
|
||||
<Alert type="info" showIcon style={{ marginBottom: 16 }} message="请填写南京银行卡信息" />
|
||||
<Form.Item
|
||||
name="bankName"
|
||||
label="开户银行"
|
||||
extra="请填写南京银行卡开户行"
|
||||
rules={[{ required: true, message: '请填写南京银行卡开户行' }]}
|
||||
>
|
||||
<Input placeholder="例如:南京银行某某支行" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="bankAccount"
|
||||
label="银行账号"
|
||||
extra="请填写南京银行卡号"
|
||||
rules={[{ required: true, message: '请填写南京银行卡号' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||
{cur > 0 && hasEmployee ? (
|
||||
<Button onClick={() => setCur((s) => Math.max(0, s - 1))}>上一步</Button>
|
||||
) : null}
|
||||
<Button type="primary" loading={loading} onClick={() => void next()}>
|
||||
{cur === last ? '完成' : '下一步'}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
<SiteFooter />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
import { Button, Card, Form, Input, InputNumber, Modal, Select, Space, Tree, Typography, message } from 'antd';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useMemo, useState, type Key } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import { useAuth } from '../store/auth';
|
||||
import { PageHeader } from './ResourcePage';
|
||||
import { canHr } from '../access';
|
||||
import { DEPT_TYPE_LABEL } from './hrStatus';
|
||||
|
||||
type Dept = {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
deptType: string;
|
||||
parentId?: string | null;
|
||||
parent?: { name: string } | null;
|
||||
categoryCode?: string | null;
|
||||
sortNo: number;
|
||||
_count?: { employees: number; children: number };
|
||||
};
|
||||
|
||||
function unwrapItems<T>(data: unknown): T[] {
|
||||
const body = (data as { data?: unknown })?.data ?? data;
|
||||
if (Array.isArray(body)) return body as T[];
|
||||
if (body && typeof body === 'object' && 'items' in body) return ((body as { items: T[] }).items ?? []) as T[];
|
||||
return [];
|
||||
}
|
||||
|
||||
export default function OrganizationPage() {
|
||||
const qc = useQueryClient();
|
||||
const roles = useAuth((s) => s.roles);
|
||||
const depts = useQuery({ queryKey: ['/departments'], queryFn: () => api.get('/departments') });
|
||||
const items = unwrapItems<Dept>(depts.data);
|
||||
const [selected, setSelected] = useState<string>();
|
||||
const [form] = Form.useForm();
|
||||
const createMut = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) => api.post('/departments', body),
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
const patchMut = useMutation({
|
||||
mutationFn: ({ id, body }: { id: string; body: Record<string, unknown> }) => api.patch(`/departments/${id}`, body),
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
const delMut = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/departments/${id}`),
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
|
||||
const apply = (id?: string, asNewUnder?: string) => {
|
||||
setSelected(id);
|
||||
if (!id) {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ deptType: 'TECH', sortNo: 99, parentId: asNewUnder });
|
||||
return;
|
||||
}
|
||||
const row = items.find((d) => d.id === id);
|
||||
form.setFieldsValue({
|
||||
name: row?.name,
|
||||
code: row?.code,
|
||||
deptType: row?.deptType,
|
||||
parentId: row?.parentId || undefined,
|
||||
categoryCode: row?.categoryCode,
|
||||
sortNo: row?.sortNo ?? 0,
|
||||
});
|
||||
};
|
||||
|
||||
const treeData = useMemo(() => {
|
||||
const byParent = new Map<string, Dept[]>();
|
||||
const roots: Dept[] = [];
|
||||
const ids = new Set(items.map((d) => d.id));
|
||||
for (const d of items) {
|
||||
const pid = d.parentId ? String(d.parentId) : '';
|
||||
if (pid && ids.has(pid)) {
|
||||
const list = byParent.get(pid) || [];
|
||||
list.push(d);
|
||||
byParent.set(pid, list);
|
||||
} else roots.push(d);
|
||||
}
|
||||
const walk = (d: Dept): { key: string; title: string; children: unknown[] } => ({
|
||||
key: d.id,
|
||||
title: `${d.name}(${DEPT_TYPE_LABEL[d.deptType] || d.deptType} · ${d._count?.employees ?? 0}人)`,
|
||||
children: (byParent.get(d.id) || []).sort((a, b) => a.sortNo - b.sortNo).map(walk),
|
||||
});
|
||||
return roots.sort((a, b) => a.sortNo - b.sortNo).map(walk);
|
||||
}, [items]);
|
||||
|
||||
const save = async () => {
|
||||
const v = await form.validateFields();
|
||||
if (selected) {
|
||||
await patchMut.mutateAsync({ id: selected, body: v });
|
||||
message.success('部门已保存');
|
||||
} else {
|
||||
const saved = (await createMut.mutateAsync(v)) as { data?: { id?: string } };
|
||||
message.success('部门已创建');
|
||||
await qc.invalidateQueries({ queryKey: ['/departments'] });
|
||||
apply(saved.data?.id);
|
||||
return;
|
||||
}
|
||||
await qc.invalidateQueries({ queryKey: ['/departments'] });
|
||||
};
|
||||
|
||||
const remove = () => {
|
||||
if (!selected) return;
|
||||
const name = items.find((d) => d.id === selected)?.name;
|
||||
Modal.confirm({
|
||||
title: '删除部门',
|
||||
content: `确认删除「${name || selected}」?下级和在职员工需先处理完。`,
|
||||
okButtonProps: { danger: true },
|
||||
onOk: async () => {
|
||||
await delMut.mutateAsync(selected);
|
||||
message.success('已删除');
|
||||
form.resetFields();
|
||||
setSelected(undefined);
|
||||
await qc.invalidateQueries({ queryKey: ['/departments'] });
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="组织管理"
|
||||
description="维护公司组织与部门。人员档案、权限和审批按部门使用;人事里只管员工档案。"
|
||||
/>
|
||||
<div className="org-page">
|
||||
<Card
|
||||
size="small"
|
||||
title="组织树"
|
||||
loading={depts.isLoading}
|
||||
extra={
|
||||
canHr(roles) ? (
|
||||
<Space>
|
||||
<Button size="small" onClick={() => apply()}>
|
||||
新建部门
|
||||
</Button>
|
||||
<Button size="small" disabled={!selected} onClick={() => apply(undefined, selected)}>
|
||||
新建下级
|
||||
</Button>
|
||||
</Space>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{treeData.length ? (
|
||||
<Tree
|
||||
defaultExpandAll
|
||||
selectedKeys={selected ? [selected] : []}
|
||||
treeData={treeData as never}
|
||||
onSelect={(keys: Key[]) => apply(keys[0] ? String(keys[0]) : undefined)}
|
||||
/>
|
||||
) : (
|
||||
<Typography.Paragraph type="secondary">还没有部门,请先新建。</Typography.Paragraph>
|
||||
)}
|
||||
</Card>
|
||||
<Card size="small" title={selected ? '编辑部门' : '新建部门'}>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="部门名称" rules={[{ required: true, message: '请填写名称' }]}>
|
||||
<Input placeholder="如 软件研发部门" />
|
||||
</Form.Item>
|
||||
<Form.Item name="code" label="编码" rules={[{ required: true, message: '请填写编码' }]}>
|
||||
<Input placeholder="如 TECH_SOFTWARE" />
|
||||
</Form.Item>
|
||||
<Form.Item name="deptType" label="类型" rules={[{ required: true }]}>
|
||||
<Select options={Object.entries(DEPT_TYPE_LABEL).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item name="parentId" label="上级部门">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="无(作为顶级)"
|
||||
options={items.filter((d) => d.id !== selected).map((d) => ({ value: d.id, label: d.name }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="categoryCode" label="技术分类编码">
|
||||
<Input placeholder="如 CAT_SOFTWARE,仅技术部门需要" />
|
||||
</Form.Item>
|
||||
<Form.Item name="sortNo" label="排序">
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
{canHr(roles) ? (
|
||||
<Space>
|
||||
<Button type="primary" loading={createMut.isPending || patchMut.isPending} onClick={() => void save()}>
|
||||
保存
|
||||
</Button>
|
||||
{selected ? (
|
||||
<Button danger onClick={remove}>
|
||||
删除
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
) : null}
|
||||
</Form>
|
||||
</Card>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
import { Table, Tag } from 'antd';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import dayjs from 'dayjs';
|
||||
import { api } from '../api/client';
|
||||
import { QUAL_BUCKET_META } from './QualificationListPage';
|
||||
import { tenderNo } from './bidStatus';
|
||||
import { WeatherGreeting } from './WeatherGreeting';
|
||||
|
||||
type WarningQual = { id: string; name: string; expiresAt?: string | null; bucket: string };
|
||||
type WarningBorrow = { id: string; itemName: string; borrower: string; dueAt?: string | null };
|
||||
type WarningSeal = { id: string; sealType: string; reason: string; returnAt?: string | null };
|
||||
type WarningOpen = {
|
||||
id: string;
|
||||
bidNo: string;
|
||||
externalNo?: string | null;
|
||||
name: string;
|
||||
openAt?: string | null;
|
||||
openPlace?: string | null;
|
||||
bidders?: string | null;
|
||||
};
|
||||
|
||||
type TileTone = 'normal' | 'warn' | 'alert';
|
||||
|
||||
function Tile({
|
||||
to,
|
||||
label,
|
||||
value,
|
||||
hint,
|
||||
tone = 'normal',
|
||||
}: {
|
||||
to: string;
|
||||
label: string;
|
||||
value: number;
|
||||
hint: string;
|
||||
tone?: TileTone;
|
||||
}) {
|
||||
return (
|
||||
<Link to={to} className={`wb-tile${tone === 'warn' ? ' is-warn' : tone === 'alert' ? ' is-alert' : ''}`}>
|
||||
<div className="label">{label}</div>
|
||||
<div className="value">{value}</div>
|
||||
<div className="hint">{hint}</div>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
export default function OverviewPage() {
|
||||
const q = useQuery({
|
||||
queryKey: ['office-overview'],
|
||||
queryFn: () => api.get('/office/overview'),
|
||||
});
|
||||
const noticesQ = useQuery({
|
||||
queryKey: ['/office/notices'],
|
||||
queryFn: () => api.get('/office/notices', { params: { kind: 'ANNOUNCEMENT' } }),
|
||||
});
|
||||
const d = q.data?.data as
|
||||
| {
|
||||
pendingTodos?: number;
|
||||
pendingApprovals?: number;
|
||||
myPending?: number;
|
||||
canBid?: boolean;
|
||||
canSeal?: boolean;
|
||||
canApprove?: boolean;
|
||||
activeBids?: number;
|
||||
overdueTodos?: number;
|
||||
expiringQualifications?: number;
|
||||
overdueBorrows?: number;
|
||||
overdueSeals?: number;
|
||||
upcomingOpens?: number;
|
||||
warnings?: {
|
||||
qualifications: WarningQual[];
|
||||
borrows: WarningBorrow[];
|
||||
seals: WarningSeal[];
|
||||
opens: WarningOpen[];
|
||||
};
|
||||
}
|
||||
| undefined;
|
||||
const warn = d?.warnings;
|
||||
const canBid = Boolean(d?.canBid);
|
||||
const canSeal = Boolean(d?.canSeal);
|
||||
const notices = ((noticesQ.data?.data as { items?: { id: string; title: string; createdAt: string }[] })?.items || []).slice(0, 5);
|
||||
|
||||
return (
|
||||
<div className="ops-workbench">
|
||||
<div className="wb-head">
|
||||
<div>
|
||||
<p className="wb-kicker">今日经营</p>
|
||||
<h1 className="wb-title">工作台</h1>
|
||||
</div>
|
||||
<WeatherGreeting />
|
||||
</div>
|
||||
|
||||
<div className="wb-metrics">
|
||||
<Tile to="/office/todos" label="待办事项" value={d?.pendingTodos ?? 0} hint="点进去办理" />
|
||||
<Tile to="/office/approvals" label="待我审批" value={d?.pendingApprovals ?? 0} hint="会签 / 合同 / 报销 / 办公申请" />
|
||||
<Tile to="/office/flow" label="我的申请" value={d?.myPending ?? 0} hint="人事、用章、办公、费用" />
|
||||
{canBid ? <Tile to="/bid/screening" label="在途投标" value={d?.activeBids ?? 0} hint="筛选到开标" /> : null}
|
||||
{canBid ? (
|
||||
<Tile
|
||||
to="/office/calendar"
|
||||
label="近两周开标"
|
||||
value={d?.upcomingOpens ?? 0}
|
||||
hint="日历里看完整日程"
|
||||
tone={(d?.upcomingOpens ?? 0) > 0 ? 'warn' : 'normal'}
|
||||
/>
|
||||
) : null}
|
||||
{canSeal ? (
|
||||
<Tile
|
||||
to="/seal/qualifications"
|
||||
label="资质到期"
|
||||
value={d?.expiringQualifications ?? 0}
|
||||
hint="即将到期或已过期"
|
||||
tone={(d?.expiringQualifications ?? 0) > 0 ? 'warn' : 'normal'}
|
||||
/>
|
||||
) : null}
|
||||
{canSeal ? (
|
||||
<Tile
|
||||
to="/seal/borrows?bucket=overdue"
|
||||
label="证照未还"
|
||||
value={d?.overdueBorrows ?? 0}
|
||||
hint="外带超期"
|
||||
tone={(d?.overdueBorrows ?? 0) > 0 ? 'alert' : 'normal'}
|
||||
/>
|
||||
) : null}
|
||||
{canSeal ? (
|
||||
<Tile
|
||||
to="/seal/requests?bucket=overdue"
|
||||
label="印章未还"
|
||||
value={d?.overdueSeals ?? 0}
|
||||
hint="外带超期"
|
||||
tone={(d?.overdueSeals ?? 0) > 0 ? 'alert' : 'normal'}
|
||||
/>
|
||||
) : null}
|
||||
<Tile
|
||||
to="/office/calendar"
|
||||
label="超期待办"
|
||||
value={d?.overdueTodos ?? 0}
|
||||
hint="已过约定日期"
|
||||
tone={(d?.overdueTodos ?? 0) > 0 ? 'alert' : 'normal'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="wb-boards">
|
||||
<section className="wb-board">
|
||||
<header>
|
||||
<b>公司公告</b>
|
||||
<Link to="/office/notices">全部</Link>
|
||||
</header>
|
||||
{notices.length ? (
|
||||
notices.map((n) => (
|
||||
<div className="wb-row" key={n.id}>
|
||||
<Link className="name" to={`/office/notices/${n.id}`}>
|
||||
{n.title}
|
||||
</Link>
|
||||
<span className="meta">{n.createdAt ? dayjs(n.createdAt).format('MM-DD') : ''}</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="wb-empty">暂无公告。</div>
|
||||
)}
|
||||
</section>
|
||||
{canBid ? (
|
||||
<section className="wb-board">
|
||||
<header>
|
||||
<b>即将开标</b>
|
||||
<Link to="/office/calendar">看日程</Link>
|
||||
</header>
|
||||
{warn?.opens?.length ? (
|
||||
<Table
|
||||
size="small"
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
dataSource={warn.opens}
|
||||
columns={[
|
||||
{
|
||||
title: '编号',
|
||||
width: 160,
|
||||
render: (_: unknown, o: WarningOpen) => <Link to={`/bid/cases/${o.id}`}>{tenderNo(o)}</Link>,
|
||||
},
|
||||
{ title: '项目名称', dataIndex: 'name', ellipsis: true },
|
||||
{
|
||||
title: '开标日程',
|
||||
width: 130,
|
||||
render: (_: unknown, o: WarningOpen) => (o.openAt ? dayjs(o.openAt).format('MM-DD HH:mm') : '—'),
|
||||
},
|
||||
{ title: '开标地点', dataIndex: 'openPlace', width: 180, ellipsis: true, render: (v: string) => v || '—' },
|
||||
{ title: '投标人', dataIndex: 'bidders', width: 140, ellipsis: true, render: (v: string) => v || '未派' },
|
||||
]}
|
||||
/>
|
||||
) : (
|
||||
<div className="wb-empty">近两周没有开标。新筛选在投标管理里发起。</div>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{canSeal ? (
|
||||
<section className="wb-board">
|
||||
<header>
|
||||
<b>资质到期</b>
|
||||
<Link to="/seal/qualifications">看台账</Link>
|
||||
</header>
|
||||
{warn?.qualifications?.length ? (
|
||||
warn.qualifications.map((o) => {
|
||||
const s = QUAL_BUCKET_META[o.bucket] || { color: 'default', text: o.bucket };
|
||||
return (
|
||||
<div className="wb-row" key={o.id}>
|
||||
<Link className="name" to="/seal/qualifications">
|
||||
{o.name}
|
||||
</Link>
|
||||
<Tag color={s.color}>{s.text}</Tag>
|
||||
<span className="meta">{o.expiresAt ? dayjs(o.expiresAt).format('YYYY-MM-DD') : ''}</span>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<div className="wb-empty">没有到期或过期的资质。</div>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{canSeal ? (
|
||||
<section className="wb-board">
|
||||
<header>
|
||||
<b>证照外带未还</b>
|
||||
<Link to="/seal/borrows">去交还</Link>
|
||||
</header>
|
||||
{warn?.borrows?.length ? (
|
||||
warn.borrows.map((o) => (
|
||||
<div className="wb-row" key={o.id}>
|
||||
<Link className="name" to="/seal/borrows">
|
||||
{o.itemName}
|
||||
</Link>
|
||||
<span className="meta">
|
||||
{o.borrower}
|
||||
{o.dueAt ? ` · 应还 ${dayjs(o.dueAt).format('MM-DD')}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="wb-empty">没有超期未还的证照。</div>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{canSeal ? (
|
||||
<section className="wb-board">
|
||||
<header>
|
||||
<b>印章外带未还</b>
|
||||
<Link to="/seal/requests">去交还</Link>
|
||||
</header>
|
||||
{warn?.seals?.length ? (
|
||||
warn.seals.map((o) => (
|
||||
<div className="wb-row" key={o.id}>
|
||||
<Link className="name" to="/seal/requests">
|
||||
{o.sealType}
|
||||
</Link>
|
||||
<span className="meta">
|
||||
{o.reason}
|
||||
{o.returnAt ? ` · 应还 ${dayjs(o.returnAt).format('MM-DD')}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="wb-empty">没有超期未还的印章。</div>
|
||||
)}
|
||||
</section>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,573 @@
|
||||
import { Button, Form, Input, Select, Space, Switch, Table, Tag, message } from 'antd';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import { fmtCreated } from '../time';
|
||||
import { FormDrawer } from './FormDrawer';
|
||||
import { PageHeader } from './ResourcePage';
|
||||
|
||||
type Contact = { id?: string; name: string; phone?: string | null; title?: string | null };
|
||||
type Party = {
|
||||
id: string;
|
||||
name: string;
|
||||
creditNo?: string | null;
|
||||
level: string;
|
||||
riskFlag?: boolean;
|
||||
remark?: string | null;
|
||||
contacts?: Contact[];
|
||||
createdAt?: string;
|
||||
};
|
||||
type Entity = {
|
||||
id: string;
|
||||
name: string;
|
||||
shortName: string;
|
||||
creditNo?: string | null;
|
||||
isDefault?: boolean;
|
||||
createdAt?: string;
|
||||
};
|
||||
type ContactRow = Contact & { id: string; partyId: string; party?: { id: string; name: string; creditNo?: string | null } };
|
||||
|
||||
function unwrapList<T>(data: unknown): T[] {
|
||||
const body = (data as { data?: unknown })?.data ?? data;
|
||||
if (Array.isArray(body)) return body as T[];
|
||||
if (body && typeof body === 'object' && 'items' in body) return ((body as { items: T[] }).items ?? []) as T[];
|
||||
return [];
|
||||
}
|
||||
|
||||
const LEVELS = [
|
||||
{ value: 'A', label: 'A' },
|
||||
{ value: 'B', label: 'B' },
|
||||
{ value: 'C', label: 'C' },
|
||||
];
|
||||
|
||||
export function TenderPartyPage() {
|
||||
const qc = useQueryClient();
|
||||
const [form] = Form.useForm();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [view, setView] = useState<Party | null>(null);
|
||||
const [editing, setEditing] = useState<Party | null>(null);
|
||||
const q = useQuery({ queryKey: ['/parties'], queryFn: () => api.get('/parties?pageSize=100') });
|
||||
const items = unwrapList<Party>(q.data);
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) =>
|
||||
editing ? api.patch(`/parties/${editing.id}`, body) : api.post('/parties', body),
|
||||
onSuccess: () => {
|
||||
message.success(editing ? '已保存单位' : '已新增单位');
|
||||
qc.invalidateQueries({ queryKey: ['/parties'] });
|
||||
qc.invalidateQueries({ queryKey: ['/contacts'] });
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
const del = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/parties/${id}`),
|
||||
onSuccess: () => {
|
||||
message.success('已删除');
|
||||
qc.invalidateQueries({ queryKey: ['/parties'] });
|
||||
qc.invalidateQueries({ queryKey: ['/contacts'] });
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
|
||||
const openForm = (row?: Party) => {
|
||||
setEditing(row || null);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
form.setFieldsValue(
|
||||
editing
|
||||
? {
|
||||
name: editing.name,
|
||||
creditNo: editing.creditNo || undefined,
|
||||
level: editing.level || 'B',
|
||||
riskFlag: editing.riskFlag,
|
||||
remark: editing.remark || undefined,
|
||||
contacts: editing.contacts?.length ? editing.contacts : [{ name: '' }],
|
||||
}
|
||||
: { level: 'B', contacts: [{ name: '' }] },
|
||||
);
|
||||
}, [open, editing, form]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="招标单位"
|
||||
description="招标人、业主。社会信用代码和联系人一起维护,投标时可直接选用。"
|
||||
extra={
|
||||
<Button type="primary" onClick={() => openForm()}>
|
||||
新增单位
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Table
|
||||
className="ops-table ops-table-click"
|
||||
rowKey="id"
|
||||
loading={q.isLoading}
|
||||
dataSource={items}
|
||||
onRow={(r) => ({
|
||||
onClick: (e) => {
|
||||
if ((e.target as HTMLElement).closest('a,button,.ant-btn')) return;
|
||||
setView(r);
|
||||
},
|
||||
})}
|
||||
scroll={{ x: 'max-content' }}
|
||||
tableLayout="fixed"
|
||||
columns={[
|
||||
{ title: '名称', dataIndex: 'name', width: 240, ellipsis: true },
|
||||
{ title: '社会信用代码', dataIndex: 'creditNo', width: 220, render: (v: string) => v || '—' },
|
||||
{ title: '等级', dataIndex: 'level', width: 70 },
|
||||
{
|
||||
title: '联系人',
|
||||
width: 220,
|
||||
ellipsis: true,
|
||||
render: (_: unknown, r: Party) =>
|
||||
r.contacts?.length
|
||||
? r.contacts.map((c) => `${c.name}${c.phone ? ` ${c.phone}` : ''}`).join(';')
|
||||
: '—',
|
||||
},
|
||||
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: (v: string) => fmtCreated(v) },
|
||||
{
|
||||
title: '操作',
|
||||
width: 180,
|
||||
render: (_: unknown, r: Party) => (
|
||||
<Space>
|
||||
<Button type="link" onClick={() => setView(r)}>
|
||||
查看
|
||||
</Button>
|
||||
<Button type="link" onClick={() => openForm(r)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button type="link" danger onClick={() => del.mutate(r.id)}>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<FormDrawer
|
||||
title={editing ? '编辑招标单位' : '新增招标单位'}
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
width={720}
|
||||
createdAt={editing?.createdAt}
|
||||
okText="提交保存"
|
||||
okLoading={save.isPending}
|
||||
onOk={async () => {
|
||||
try {
|
||||
const v = await form.validateFields();
|
||||
await save.mutateAsync({
|
||||
...v,
|
||||
contacts: (v.contacts || []).filter((c: Contact) => c?.name),
|
||||
});
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message) message.error(e.message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 12 }}>
|
||||
<Form.Item name="name" label="单位名称" rules={[{ required: true, message: '请填写单位名称' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="creditNo" label="统一社会信用代码">
|
||||
<Input placeholder="18 位社会信用代码" maxLength={18} />
|
||||
</Form.Item>
|
||||
<Form.Item name="level" label="等级">
|
||||
<Select options={LEVELS} />
|
||||
</Form.Item>
|
||||
<Form.Item name="remark" label="备注">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Form.List name="contacts">
|
||||
{(fields, { add, remove }) => (
|
||||
<>
|
||||
{fields.map((field) => (
|
||||
<Space key={field.key} align="baseline" style={{ display: 'flex', marginBottom: 8 }} wrap>
|
||||
<Form.Item name={[field.name, 'id']} hidden>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name={[field.name, 'name']}>
|
||||
<Input placeholder="联系人" />
|
||||
</Form.Item>
|
||||
<Form.Item name={[field.name, 'phone']}>
|
||||
<Input placeholder="电话" />
|
||||
</Form.Item>
|
||||
<Form.Item name={[field.name, 'title']}>
|
||||
<Input placeholder="职务" />
|
||||
</Form.Item>
|
||||
<Button type="link" onClick={() => remove(field.name)}>
|
||||
删
|
||||
</Button>
|
||||
</Space>
|
||||
))}
|
||||
<Button type="dashed" onClick={() => add()} block>
|
||||
加联系人
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</Form.List>
|
||||
</Form>
|
||||
</FormDrawer>
|
||||
<FormDrawer title="单位详情" width={640} open={Boolean(view)} onClose={() => setView(null)} hideOk createdAt={view?.createdAt}>
|
||||
{view ? (
|
||||
<div>
|
||||
<p>名称:{view.name}</p>
|
||||
<p>社会信用代码:{view.creditNo || '—'}</p>
|
||||
<p>等级:{view.level}</p>
|
||||
<p>备注:{view.remark || '—'}</p>
|
||||
<p>联系人:</p>
|
||||
<ul>
|
||||
{(view.contacts || []).map((c) => (
|
||||
<li key={c.id || c.name}>
|
||||
{c.name} {c.phone || ''} {c.title || ''}
|
||||
</li>
|
||||
))}
|
||||
{!view.contacts?.length ? <li>无</li> : null}
|
||||
</ul>
|
||||
</div>
|
||||
) : null}
|
||||
</FormDrawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function LegalEntityPage() {
|
||||
const qc = useQueryClient();
|
||||
const [form] = Form.useForm();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Entity | null>(null);
|
||||
const q = useQuery({ queryKey: ['/legal-entities'], queryFn: () => api.get('/legal-entities') });
|
||||
const items = unwrapList<Entity>(q.data);
|
||||
const save = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) =>
|
||||
editing ? api.patch(`/legal-entities/${editing.id}`, body) : api.post('/legal-entities', body),
|
||||
onSuccess: () => {
|
||||
message.success(editing ? '已保存主体' : '已新增主体');
|
||||
qc.invalidateQueries({ queryKey: ['/legal-entities'] });
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
const del = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/legal-entities/${id}`),
|
||||
onSuccess: () => {
|
||||
message.success('已删除');
|
||||
qc.invalidateQueries({ queryKey: ['/legal-entities'] });
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
const openForm = (row?: Entity) => {
|
||||
setEditing(row || null);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
form.setFieldsValue(editing || { isDefault: false });
|
||||
}, [open, editing, form]);
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="参与主体"
|
||||
description="投标和签合同时用哪家公司的名义。社会信用代码要填全。"
|
||||
extra={
|
||||
<Button type="primary" onClick={() => openForm()}>
|
||||
新增主体
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Table
|
||||
className="ops-table"
|
||||
rowKey="id"
|
||||
loading={q.isLoading}
|
||||
dataSource={items}
|
||||
scroll={{ x: 'max-content' }}
|
||||
tableLayout="fixed"
|
||||
columns={[
|
||||
{ title: '全称', dataIndex: 'name', width: 240, ellipsis: true },
|
||||
{ title: '简称', dataIndex: 'shortName', width: 140 },
|
||||
{ title: '社会信用代码', dataIndex: 'creditNo', width: 220, render: (v: string) => v || '—' },
|
||||
{
|
||||
title: '默认',
|
||||
width: 80,
|
||||
render: (_: unknown, r: Entity) => (r.isDefault ? <Tag color="success">是</Tag> : '—'),
|
||||
},
|
||||
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: (v: string) => fmtCreated(v) },
|
||||
{
|
||||
title: '操作',
|
||||
width: 140,
|
||||
render: (_: unknown, r: Entity) => (
|
||||
<Space>
|
||||
<Button type="link" onClick={() => openForm(r)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button type="link" danger onClick={() => del.mutate(r.id)}>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<FormDrawer
|
||||
title={editing ? '编辑主体' : '新增主体'}
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
createdAt={editing?.createdAt}
|
||||
okText="提交保存"
|
||||
okLoading={save.isPending}
|
||||
onOk={async () => {
|
||||
try {
|
||||
await save.mutateAsync(await form.validateFields());
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message) message.error(e.message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 12 }}>
|
||||
<Form.Item name="name" label="主体全称" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="shortName" label="简称" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="creditNo" label="统一社会信用代码">
|
||||
<Input maxLength={18} />
|
||||
</Form.Item>
|
||||
<Form.Item name="isDefault" label="设为默认" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</FormDrawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function ContactDirectoryPage() {
|
||||
const qc = useQueryClient();
|
||||
const [form] = Form.useForm();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<ContactRow | null>(null);
|
||||
const q = useQuery({ queryKey: ['/contacts'], queryFn: () => api.get('/contacts') });
|
||||
const parties = useQuery({ queryKey: ['/parties'], queryFn: () => api.get('/parties?pageSize=100') });
|
||||
const items = unwrapList<ContactRow>(q.data);
|
||||
const partyOpts = unwrapList<Party>(parties.data).map((p) => ({ value: p.id, label: p.name }));
|
||||
const save = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) =>
|
||||
editing ? api.patch(`/contacts/${editing.id}`, body) : api.post('/contacts', body),
|
||||
onSuccess: () => {
|
||||
message.success(editing ? '已保存联系人' : '已新增联系人');
|
||||
qc.invalidateQueries({ queryKey: ['/contacts'] });
|
||||
qc.invalidateQueries({ queryKey: ['/parties'] });
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
const del = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/contacts/${id}`),
|
||||
onSuccess: () => {
|
||||
message.success('已删除');
|
||||
qc.invalidateQueries({ queryKey: ['/contacts'] });
|
||||
qc.invalidateQueries({ queryKey: ['/parties'] });
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
const openForm = (row?: ContactRow) => {
|
||||
setEditing(row || null);
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
if (editing) {
|
||||
form.setFieldsValue({
|
||||
partyId: editing.partyId,
|
||||
name: editing.name,
|
||||
phone: editing.phone || undefined,
|
||||
title: editing.title || undefined,
|
||||
});
|
||||
} else {
|
||||
form.resetFields();
|
||||
}
|
||||
}, [open, editing, form]);
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="联系名录"
|
||||
description="招标单位的联系人。也可以在招标单位里一起维护。"
|
||||
extra={
|
||||
<Button type="primary" onClick={() => openForm()}>
|
||||
新增联系人
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Table
|
||||
className="ops-table"
|
||||
rowKey="id"
|
||||
loading={q.isLoading}
|
||||
dataSource={items}
|
||||
scroll={{ x: 'max-content' }}
|
||||
tableLayout="fixed"
|
||||
columns={[
|
||||
{ title: '所属单位', width: 220, ellipsis: true, render: (_: unknown, r: ContactRow) => r.party?.name || '—' },
|
||||
{ title: '姓名', dataIndex: 'name', width: 120 },
|
||||
{ title: '电话', dataIndex: 'phone', width: 140, render: (v: string) => v || '—' },
|
||||
{ title: '职务', dataIndex: 'title', width: 140, render: (v: string) => v || '—' },
|
||||
{
|
||||
title: '操作',
|
||||
width: 140,
|
||||
render: (_: unknown, r: ContactRow) => (
|
||||
<Space>
|
||||
<Button type="link" onClick={() => openForm(r)}>
|
||||
编辑
|
||||
</Button>
|
||||
<Button type="link" danger onClick={() => del.mutate(r.id)}>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<FormDrawer
|
||||
title={editing ? '编辑联系人' : '新增联系人'}
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
okText="提交保存"
|
||||
okLoading={save.isPending}
|
||||
onOk={async () => {
|
||||
try {
|
||||
await save.mutateAsync(await form.validateFields());
|
||||
} catch (e) {
|
||||
if (e instanceof Error && e.message) message.error(e.message);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Form key={editing?.id || 'new'} form={form} layout="vertical" style={{ marginTop: 12 }}>
|
||||
<Form.Item name="partyId" label="所属单位" rules={[{ required: true, message: '请选择单位' }]}>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
options={partyOpts}
|
||||
getPopupContainer={(n) => n.parentElement || document.body}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="name" label="姓名" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="电话">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="title" label="职务">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</FormDrawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function DictsPage() {
|
||||
const qc = useQueryClient();
|
||||
const [form] = Form.useForm();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<{ id: string; dictType: string; label: string; value: string; sortNo: number } | null>(null);
|
||||
const q = useQuery({ queryKey: ['/system/dicts'], queryFn: () => api.get('/system/dicts') });
|
||||
const items = unwrapList<{ id: string; dictType: string; label: string; value: string; sortNo: number }>(q.data);
|
||||
const save = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) =>
|
||||
editing ? api.patch(`/system/dicts/${editing.id}`, body) : api.post('/system/dicts', body),
|
||||
onSuccess: () => {
|
||||
message.success(editing ? '已保存' : '已新增');
|
||||
qc.invalidateQueries({ queryKey: ['/system/dicts'] });
|
||||
setOpen(false);
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
const del = useMutation({
|
||||
mutationFn: (id: string) => api.delete(`/system/dicts/${id}`),
|
||||
onSuccess: () => {
|
||||
message.success('已删除');
|
||||
qc.invalidateQueries({ queryKey: ['/system/dicts'] });
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="数据字典"
|
||||
description="项目类型、招标方式、费用类别都可以在这里改,不用改代码。"
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
新增
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={q.isLoading}
|
||||
dataSource={items}
|
||||
columns={[
|
||||
{ title: '类型', dataIndex: 'dictType', width: 180 },
|
||||
{ title: '名称', dataIndex: 'label' },
|
||||
{ title: '值', dataIndex: 'value' },
|
||||
{ title: '排序', dataIndex: 'sortNo', width: 80 },
|
||||
{
|
||||
title: '操作',
|
||||
width: 140,
|
||||
render: (_: unknown, r) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
onClick={() => {
|
||||
setEditing(r);
|
||||
form.setFieldsValue(r);
|
||||
setOpen(true);
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Button type="link" danger onClick={() => del.mutate(r.id)}>
|
||||
删除
|
||||
</Button>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<FormDrawer
|
||||
title={editing ? '编辑字典' : '新增字典'}
|
||||
open={open}
|
||||
onClose={() => setOpen(false)}
|
||||
okText="提交保存"
|
||||
okLoading={save.isPending}
|
||||
onOk={async () => save.mutateAsync(await form.validateFields())}
|
||||
>
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 12 }}>
|
||||
<Form.Item name="dictType" label="类型" rules={[{ required: true }]}>
|
||||
<Input placeholder="如 expenseCategory、projectType" />
|
||||
</Form.Item>
|
||||
<Form.Item name="label" label="名称" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="value" label="值" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="sortNo" label="排序">
|
||||
<Input type="number" />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</FormDrawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,192 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button, Space, Spin } from 'antd';
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
import type { PDFDocumentProxy } from 'pdfjs-dist';
|
||||
import pdfWorker from 'pdfjs-dist/build/pdf.worker.min.mjs?url';
|
||||
|
||||
function toPdfBytes(data: ArrayBuffer) {
|
||||
const raw = new Uint8Array(data);
|
||||
const limit = Math.min(raw.length, 2048);
|
||||
let start = 0;
|
||||
for (let i = 0; i < limit - 3; i++) {
|
||||
if (raw[i] === 0x25 && raw[i + 1] === 0x50 && raw[i + 2] === 0x44 && raw[i + 3] === 0x46) {
|
||||
start = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const slice = raw.subarray(start);
|
||||
const copy = new Uint8Array(slice.byteLength);
|
||||
copy.set(slice);
|
||||
return copy;
|
||||
}
|
||||
|
||||
let workerReady: Promise<void> | null = null;
|
||||
|
||||
function ensurePdfWorker() {
|
||||
if (workerReady) return workerReady;
|
||||
workerReady = (async () => {
|
||||
try {
|
||||
const res = await fetch(pdfWorker);
|
||||
const buf = await res.arrayBuffer();
|
||||
const url = URL.createObjectURL(new Blob([buf], { type: 'text/javascript' }));
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = url;
|
||||
} catch {
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfWorker;
|
||||
}
|
||||
})();
|
||||
return workerReady;
|
||||
}
|
||||
|
||||
function pdfOptions(bytes: Uint8Array) {
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : '';
|
||||
return {
|
||||
data: bytes.slice(0),
|
||||
cMapUrl: `${origin}/pdfjs/cmaps/`,
|
||||
cMapPacked: true,
|
||||
standardFontDataUrl: `${origin}/pdfjs/standard_fonts/`,
|
||||
disableAutoFetch: true,
|
||||
disableStream: true,
|
||||
stopAtErrors: false,
|
||||
isEvalSupported: true,
|
||||
useSystemFonts: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function PdfPreview({ data, fileName }: { data: ArrayBuffer; fileName: string }) {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const [numPages, setNumPages] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const pdfRef = useRef<PDFDocumentProxy | null>(null);
|
||||
const pdfBytes = useMemo(() => toPdfBytes(data), [data]);
|
||||
const blobUrl = useMemo(
|
||||
() => URL.createObjectURL(new Blob([pdfBytes], { type: 'application/pdf' })),
|
||||
[pdfBytes],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => URL.revokeObjectURL(blobUrl);
|
||||
}, [blobUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let task: ReturnType<typeof pdfjsLib.getDocument> | null = null;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setNumPages(0);
|
||||
setPage(1);
|
||||
|
||||
const open = async () => {
|
||||
await ensurePdfWorker();
|
||||
if (cancelled) return;
|
||||
task = pdfjsLib.getDocument(pdfOptions(pdfBytes));
|
||||
try {
|
||||
const pdf = await task.promise;
|
||||
if (cancelled) {
|
||||
pdf.destroy();
|
||||
return;
|
||||
}
|
||||
pdfRef.current = pdf;
|
||||
setNumPages(pdf.numPages);
|
||||
setLoading(false);
|
||||
} catch {
|
||||
if (!cancelled) {
|
||||
setError('pdfjs');
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
void open();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
task?.destroy();
|
||||
pdfRef.current?.destroy();
|
||||
pdfRef.current = null;
|
||||
};
|
||||
}, [pdfBytes]);
|
||||
|
||||
useEffect(() => {
|
||||
const pdf = pdfRef.current;
|
||||
const canvas = canvasRef.current;
|
||||
if (!pdf || !canvas || !numPages) return;
|
||||
let cancelled = false;
|
||||
const render = async () => {
|
||||
const p = await pdf.getPage(page);
|
||||
if (cancelled) return;
|
||||
const viewport = p.getViewport({ scale: 1.5 });
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
canvas.width = viewport.width;
|
||||
canvas.height = viewport.height;
|
||||
await p.render({ canvasContext: ctx, viewport }).promise;
|
||||
};
|
||||
void render().catch(() => {
|
||||
if (!cancelled) setError('pdfjs');
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [page, numPages]);
|
||||
|
||||
const openInTab = () => {
|
||||
window.open(blobUrl, '_blank', 'noopener');
|
||||
};
|
||||
|
||||
const download = () => {
|
||||
const a = document.createElement('a');
|
||||
a.href = blobUrl;
|
||||
a.download = fileName || '附件.pdf';
|
||||
a.click();
|
||||
};
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div>
|
||||
<p style={{ color: '#cf1322', marginBottom: 8 }}>
|
||||
内置预览解析失败。请用「新窗口打开」或下载后查看。
|
||||
</p>
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
<Button type="primary" onClick={openInTab}>
|
||||
新窗口打开
|
||||
</Button>
|
||||
<Button onClick={download}>下载</Button>
|
||||
</Space>
|
||||
<embed src={blobUrl} type="application/pdf" style={{ width: '100%', height: '70vh', border: 0, background: '#525659' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 12, gap: 12 }}>
|
||||
<div style={{ fontWeight: 600, wordBreak: 'break-all' }}>{fileName}</div>
|
||||
<Space wrap>
|
||||
<Button size="small" onClick={openInTab}>
|
||||
新窗口打开
|
||||
</Button>
|
||||
<Button size="small" onClick={download}>
|
||||
下载
|
||||
</Button>
|
||||
{numPages > 1 ? (
|
||||
<>
|
||||
<Button size="small" disabled={page <= 1} onClick={() => setPage((n) => Math.max(1, n - 1))}>
|
||||
上一页
|
||||
</Button>
|
||||
<span>
|
||||
{page} / {numPages}
|
||||
</span>
|
||||
<Button size="small" disabled={page >= numPages} onClick={() => setPage((n) => Math.min(numPages, n + 1))}>
|
||||
下一页
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</Space>
|
||||
</div>
|
||||
<div style={{ overflow: 'auto', maxHeight: '70vh', textAlign: 'center', background: '#525659', padding: 12 }}>
|
||||
{loading ? <Spin /> : null}
|
||||
<canvas ref={canvasRef} style={{ maxWidth: '100%', height: 'auto', display: loading ? 'none' : 'inline-block' }} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
import { Avatar, Button, Form, Input, Upload, message } from 'antd';
|
||||
import { CameraOutlined, IdcardOutlined, UserOutlined } from '@ant-design/icons';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
import { downloadBizFile } from './FileAttachments';
|
||||
import { FormDrawer } from './FormDrawer';
|
||||
import { useAuth } from '../store/auth';
|
||||
|
||||
type Me = {
|
||||
id: string;
|
||||
displayName: string;
|
||||
username: string;
|
||||
avatarFileId?: string | null;
|
||||
idPhotoFileId?: string | null;
|
||||
employee?: { mobile?: string | null; name?: string | null } | null;
|
||||
};
|
||||
|
||||
async function uploadUserFile(bizType: string, bizId: string, file: File) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', file);
|
||||
fd.append('bizType', bizType);
|
||||
fd.append('bizId', bizId);
|
||||
await api.post('/files', fd);
|
||||
}
|
||||
|
||||
export function ProfileDrawer({
|
||||
open,
|
||||
mode,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
mode: 'profile' | 'password' | 'photo';
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const refresh = useAuth((s) => s.loadSession);
|
||||
const [form] = Form.useForm();
|
||||
const me = useQuery({
|
||||
queryKey: ['auth-me'],
|
||||
enabled: open,
|
||||
queryFn: () => api.get('/auth/me'),
|
||||
});
|
||||
const row = me.data?.data as Me | undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (row) form.setFieldsValue({ displayName: row.displayName });
|
||||
}, [row, form]);
|
||||
|
||||
const saveProfile = useMutation({
|
||||
mutationFn: (body: { displayName: string }) => api.patch('/auth/profile', body),
|
||||
onSuccess: async () => {
|
||||
message.success('个人资料已保存');
|
||||
await qc.invalidateQueries({ queryKey: ['auth-me'] });
|
||||
await refresh();
|
||||
onClose();
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
const savePass = useMutation({
|
||||
mutationFn: (body: { oldPassword: string; newPassword: string }) => api.patch('/auth/password', body),
|
||||
onSuccess: () => {
|
||||
message.success('密码已修改,下次请用新密码登录');
|
||||
form.resetFields();
|
||||
onClose();
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
|
||||
const title = mode === 'password' ? '修改密码' : mode === 'photo' ? '上传个人证件照' : '修改个人资料';
|
||||
|
||||
return (
|
||||
<FormDrawer
|
||||
title={title}
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
width={480}
|
||||
onOk={() => form.submit()}
|
||||
okText={mode === 'password' ? '确认改密' : '保存'}
|
||||
okLoading={saveProfile.isPending || savePass.isPending}
|
||||
>
|
||||
{mode === 'password' ? (
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onFinish={(v) => {
|
||||
if (v.newPassword !== v.confirm) {
|
||||
message.error('两次新密码不一致');
|
||||
return;
|
||||
}
|
||||
savePass.mutate({ oldPassword: v.oldPassword, newPassword: v.newPassword });
|
||||
}}
|
||||
>
|
||||
<Form.Item name="oldPassword" label="当前密码" rules={[{ required: true, message: '请填写当前密码' }]}>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="newPassword"
|
||||
label="新密码"
|
||||
rules={[
|
||||
{ required: true, message: '请填写新密码' },
|
||||
{ min: 6, message: '至少 6 位' },
|
||||
]}
|
||||
>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
<Form.Item name="confirm" label="确认新密码" rules={[{ required: true, message: '请再填一次新密码' }]}>
|
||||
<Input.Password />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
) : (
|
||||
<Form form={form} layout="vertical" onFinish={(v) => saveProfile.mutate({ displayName: v.displayName })}>
|
||||
<Form.Item label="头像">
|
||||
<Upload
|
||||
showUploadList={false}
|
||||
accept="image/*"
|
||||
beforeUpload={() => true}
|
||||
customRequest={async (opt) => {
|
||||
if (!row?.id) return;
|
||||
try {
|
||||
await uploadUserFile('USER_AVATAR', row.id, opt.file as File);
|
||||
message.success('头像已更新');
|
||||
await qc.invalidateQueries({ queryKey: ['auth-me'] });
|
||||
await refresh();
|
||||
opt.onSuccess?.({}, new XMLHttpRequest());
|
||||
} catch (e) {
|
||||
message.error((e as Error).message);
|
||||
opt.onError?.(e as Error);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button icon={<CameraOutlined />}>上传头像</Button>
|
||||
</Upload>
|
||||
</Form.Item>
|
||||
<Form.Item name="displayName" label="姓名" rules={[{ required: true, message: '请填写姓名' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="登录手机号">
|
||||
<Input value={row?.username || row?.employee?.mobile || ''} disabled />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
label="个人证件照"
|
||||
required
|
||||
extra="必须上传本人证件照。上传后会同步到组织人事 · 在职员工档案,人事可查看。"
|
||||
>
|
||||
<div style={{ display: 'flex', gap: 12, alignItems: 'center' }}>
|
||||
<Upload
|
||||
showUploadList={false}
|
||||
accept="image/*"
|
||||
beforeUpload={() => true}
|
||||
customRequest={async (opt) => {
|
||||
if (!row?.id) return;
|
||||
try {
|
||||
await uploadUserFile('USER_ID_PHOTO', row.id, opt.file as File);
|
||||
message.success('证件照已上传');
|
||||
await qc.invalidateQueries({ queryKey: ['auth-me'] });
|
||||
await refresh();
|
||||
opt.onSuccess?.({}, new XMLHttpRequest());
|
||||
} catch (e) {
|
||||
message.error((e as Error).message);
|
||||
opt.onError?.(e as Error);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button type="primary" icon={<IdcardOutlined />}>
|
||||
上传个人证件照
|
||||
</Button>
|
||||
</Upload>
|
||||
{row?.idPhotoFileId ? (
|
||||
<Button type="link" onClick={() => downloadBizFile(row.idPhotoFileId!, '证件照')}>
|
||||
已上传,查看
|
||||
</Button>
|
||||
) : (
|
||||
<span style={{ color: '#cf1322' }}>还未上传</span>
|
||||
)}
|
||||
</div>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)}
|
||||
</FormDrawer>
|
||||
);
|
||||
}
|
||||
|
||||
export function UserAvatar({ size = 28 }: { size?: number }) {
|
||||
const id = useAuth((s) => s.id);
|
||||
const avatarFileId = useAuth((s) => s.avatarFileId);
|
||||
const [src, setSrc] = useState<string>();
|
||||
useEffect(() => {
|
||||
if (!avatarFileId) {
|
||||
setSrc(undefined);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
let url = '';
|
||||
void (async () => {
|
||||
const blob = (await api.get(`/files/${avatarFileId}`, { responseType: 'blob' })) as unknown as Blob;
|
||||
url = URL.createObjectURL(blob);
|
||||
if (alive) setSrc(url);
|
||||
})();
|
||||
return () => {
|
||||
alive = false;
|
||||
if (url) URL.revokeObjectURL(url);
|
||||
};
|
||||
}, [avatarFileId, id]);
|
||||
return <Avatar size={size} src={src} icon={<UserOutlined />} />;
|
||||
}
|
||||
@@ -0,0 +1,915 @@
|
||||
import { useEffect } from 'react';
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
DatePicker,
|
||||
Descriptions,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Progress,
|
||||
Select,
|
||||
Space,
|
||||
Table,
|
||||
Tag,
|
||||
Typography,
|
||||
Upload,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom';
|
||||
import dayjs from 'dayjs';
|
||||
import { api } from '../api/client';
|
||||
import { PageHeader } from './ResourcePage';
|
||||
import { FormDrawer } from './FormDrawer';
|
||||
import { BizFileList, downloadBizFile } from './FileAttachments';
|
||||
import { BUDGET_CATEGORY_LABEL, EXPENSE_STATUS_META, money } from './financeStatus';
|
||||
import {
|
||||
ACCEPT_KIND_LABEL,
|
||||
TASK_STATUS_META,
|
||||
projectStatusMeta,
|
||||
} from './projectStatus';
|
||||
|
||||
type UserRef = { id: string; displayName: string; username?: string };
|
||||
type Usage = {
|
||||
hours: number;
|
||||
labor: number;
|
||||
travel: number;
|
||||
techTravel?: number;
|
||||
purchase: number;
|
||||
presalesCost?: number;
|
||||
businessUsed?: number;
|
||||
techUsed?: number;
|
||||
contractAmount?: number;
|
||||
suggested?: { business: number; tech: number };
|
||||
hourRate: number;
|
||||
limits: {
|
||||
labor: number | null;
|
||||
travel: number | null;
|
||||
purchase: number | null;
|
||||
business?: number | null;
|
||||
tech?: number | null;
|
||||
};
|
||||
};
|
||||
|
||||
type ProjectDetail = {
|
||||
id: string;
|
||||
projectNo: string;
|
||||
name: string;
|
||||
status: string;
|
||||
projectType?: string;
|
||||
amount?: string | number;
|
||||
presalesCost: string | number;
|
||||
hourRate: string | number;
|
||||
createdAt?: string;
|
||||
files?: { id: string; fileName: string; size: number; mimeType: string; bizType?: string }[];
|
||||
internalAcceptedAt?: string | null;
|
||||
customerAcceptedAt?: string | null;
|
||||
contract?: {
|
||||
id: string;
|
||||
contractNo: string;
|
||||
amount: string | number;
|
||||
bidCase?: { id: string; bidNo: string; name: string } | null;
|
||||
milestones: {
|
||||
id: string;
|
||||
name: string;
|
||||
ratio: string | number;
|
||||
invoice?: { id: string; status: string } | null;
|
||||
}[];
|
||||
} | null;
|
||||
tasks: {
|
||||
id: string;
|
||||
name: string;
|
||||
department?: string | null;
|
||||
status: string;
|
||||
dueAt?: string | null;
|
||||
dept?: { id: string; name: string } | null;
|
||||
assignee?: UserRef | null;
|
||||
}[];
|
||||
timesheets: {
|
||||
id: string;
|
||||
hours: string | number;
|
||||
costAmount: string | number;
|
||||
workDate: string;
|
||||
note?: string | null;
|
||||
user?: UserRef;
|
||||
task?: { name: string } | null;
|
||||
}[];
|
||||
budgets: { category: string; amount: string | number }[];
|
||||
acceptances: {
|
||||
id: string;
|
||||
kind: string;
|
||||
result: string;
|
||||
comment?: string | null;
|
||||
decidedAt: string;
|
||||
actor?: UserRef;
|
||||
}[];
|
||||
acceptMaterials?: {
|
||||
id: string;
|
||||
name: string;
|
||||
remark?: string | null;
|
||||
required: boolean;
|
||||
sortNo: number;
|
||||
uploaded: boolean;
|
||||
files: { id: string; fileName: string; size: number; mimeType: string }[];
|
||||
}[];
|
||||
acceptMaterialsReady?: boolean;
|
||||
expenses?: {
|
||||
id: string;
|
||||
claimNo: string;
|
||||
amount: string | number;
|
||||
status: string;
|
||||
budgetCategory?: string | null;
|
||||
purpose?: string | null;
|
||||
createdAt?: string;
|
||||
applicant?: UserRef;
|
||||
}[];
|
||||
purchases?: {
|
||||
id: string;
|
||||
title: string;
|
||||
amount: string | number;
|
||||
status: string;
|
||||
createdAt?: string;
|
||||
applicant?: UserRef | null;
|
||||
}[];
|
||||
usage: Usage;
|
||||
myActions: {
|
||||
canSetBudget: boolean;
|
||||
canManageWbs: boolean;
|
||||
canFillTimesheet: boolean;
|
||||
canAcceptInternal: boolean;
|
||||
canAcceptCustomer: boolean;
|
||||
canEditMaterials?: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
function budgetBar(used: number, limit: number | null) {
|
||||
if (limit === null) return <Typography.Text type="secondary">未设预算</Typography.Text>;
|
||||
const pct = limit <= 0 ? (used > 0 ? 100 : 0) : Math.min(100, Math.round((used / limit) * 100));
|
||||
return (
|
||||
<div>
|
||||
<Progress percent={pct} status={used - limit > 0.001 ? 'exception' : 'normal'} />
|
||||
<Typography.Text>
|
||||
已用 {money(used)} / 预算 {money(limit)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const PURCHASE_STATUS_META: Record<string, { color: string; text: string }> = {
|
||||
DRAFT: { color: 'default', text: '草稿' },
|
||||
PENDING: { color: 'processing', text: '待确认' },
|
||||
APPROVED: { color: 'success', text: '已通过' },
|
||||
REJECTED: { color: 'error', text: '被打回' },
|
||||
};
|
||||
|
||||
export default function ProjectDetailPage({
|
||||
id: idProp,
|
||||
embedded,
|
||||
focus = 'full',
|
||||
}: {
|
||||
id?: string;
|
||||
embedded?: boolean;
|
||||
focus?: 'full' | 'acceptance';
|
||||
} = {}) {
|
||||
const { id: routeId } = useParams();
|
||||
const id = idProp || routeId;
|
||||
const navigate = useNavigate();
|
||||
const qc = useQueryClient();
|
||||
const [budgetForm] = Form.useForm();
|
||||
const [taskForm] = Form.useForm();
|
||||
const [sheetForm] = Form.useForm();
|
||||
const [materialForm] = Form.useForm();
|
||||
|
||||
const q = useQuery({
|
||||
queryKey: ['project', id],
|
||||
enabled: Boolean(id),
|
||||
queryFn: () => api.get(`/projects/${id}`),
|
||||
});
|
||||
const depts = useQuery({
|
||||
queryKey: ['/departments'],
|
||||
queryFn: () => api.get('/departments'),
|
||||
});
|
||||
const staff = useQuery({
|
||||
queryKey: ['/staff'],
|
||||
queryFn: () => api.get('/staff'),
|
||||
});
|
||||
const row = q.data?.data as ProjectDetail | undefined;
|
||||
const acceptanceOnly = focus === 'acceptance';
|
||||
|
||||
useEffect(() => {
|
||||
if (!row) return;
|
||||
budgetForm.setFieldsValue({
|
||||
amount: Number(row.amount) || row.usage.contractAmount || undefined,
|
||||
projectType: row.projectType || undefined,
|
||||
business: row.usage.limits.business ?? row.usage.suggested?.business ?? undefined,
|
||||
tech: row.usage.limits.tech ?? row.usage.suggested?.tech ?? undefined,
|
||||
});
|
||||
}, [row, budgetForm]);
|
||||
|
||||
const invalidate = () => qc.invalidateQueries();
|
||||
const budgetMut = useMutation({
|
||||
mutationFn: async (v: {
|
||||
amount?: number;
|
||||
projectType?: string;
|
||||
business?: number;
|
||||
tech?: number;
|
||||
}) => {
|
||||
if (v.amount !== undefined || v.projectType) {
|
||||
await api.patch(`/projects/${id}`, { amount: v.amount, projectType: v.projectType });
|
||||
}
|
||||
const items = [
|
||||
v.business !== undefined && v.business !== null ? { category: 'BUSINESS', amount: v.business } : null,
|
||||
v.tech !== undefined && v.tech !== null ? { category: 'TECH', amount: v.tech } : null,
|
||||
].filter(Boolean);
|
||||
if (!items.length) return;
|
||||
return api.put(`/projects/${id}/budgets`, { items });
|
||||
},
|
||||
onSuccess: async () => {
|
||||
message.success('预算已保存');
|
||||
await invalidate();
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
const syncMut = useMutation({
|
||||
mutationFn: () => {
|
||||
const amount = Number(budgetForm.getFieldValue('amount'));
|
||||
return api.post(`/projects/${id}/budgets/sync`, Number.isFinite(amount) && amount > 0 ? { amount } : {});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
message.success('已按总额均分商务、技术预算,花费已从工时/报销/采购同步');
|
||||
await invalidate();
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
const materialMut = useMutation({
|
||||
mutationFn: (v: { name: string; required?: boolean }) => api.post(`/projects/${id}/accept-materials`, v),
|
||||
onSuccess: async () => {
|
||||
message.success('已加入清单');
|
||||
materialForm.resetFields();
|
||||
await invalidate();
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
const seedMatMut = useMutation({
|
||||
mutationFn: () => api.post(`/projects/${id}/accept-materials/seed`),
|
||||
onSuccess: async () => {
|
||||
message.success('已带入常用验收材料');
|
||||
await invalidate();
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
const taskMut = useMutation({
|
||||
mutationFn: (v: Record<string, unknown>) =>
|
||||
api.post(`/projects/${id}/tasks`, {
|
||||
...v,
|
||||
dueAt: v.dueAt ? dayjs(v.dueAt as string).format('YYYY-MM-DD') : undefined,
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
message.success('已拆任务');
|
||||
taskForm.resetFields();
|
||||
await invalidate();
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
const doneMut = useMutation({
|
||||
mutationFn: (taskId: string) => api.post(`/projects/${id}/tasks/${taskId}/done`),
|
||||
onSuccess: async () => {
|
||||
message.success('任务已完成');
|
||||
await invalidate();
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
const sheetMut = useMutation({
|
||||
mutationFn: (v: Record<string, unknown>) =>
|
||||
api.post('/timesheets', {
|
||||
projectId: id,
|
||||
...v,
|
||||
workDate: v.workDate ? dayjs(v.workDate as string).format('YYYY-MM-DD') : undefined,
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
message.success('工时已计入技术成本');
|
||||
sheetForm.resetFields();
|
||||
await invalidate();
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
const acceptMut = useMutation({
|
||||
mutationFn: (body: { kind: 'INTERNAL' | 'CUSTOMER'; result: 'APPROVED' | 'REJECTED'; comment?: string }) =>
|
||||
api.post(`/projects/${id}/accept`, body),
|
||||
onSuccess: async (res: { data?: { invoicesCreated?: number; status?: string } }) => {
|
||||
const n = res.data?.invoicesCreated || 0;
|
||||
message.success(n ? `验收通过,已生成 ${n} 张待开票` : '已办理验收');
|
||||
await invalidate();
|
||||
await qc.invalidateQueries({ queryKey: ['/invoices'] });
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
|
||||
if (q.isError) {
|
||||
return (
|
||||
<Alert
|
||||
type="error"
|
||||
message={(q.error as Error).message || '无法打开项目'}
|
||||
action={
|
||||
<Button onClick={() => navigate('/project/list')} size="small">
|
||||
返回
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (!row) return <Typography.Paragraph>加载中…</Typography.Paragraph>;
|
||||
|
||||
const meta = projectStatusMeta(row.status);
|
||||
const deptOpts = ((depts.data?.data ?? []) as { id: string; name: string; code: string }[])
|
||||
.filter((d) => d.code !== 'ROOT')
|
||||
.map((d) => ({ value: d.id, label: d.name }));
|
||||
const staffOpts = ((staff.data?.data ?? []) as { id: string; name: string }[]).map((s) => ({
|
||||
value: s.id,
|
||||
label: s.name,
|
||||
}));
|
||||
|
||||
const decide = (kind: 'INTERNAL' | 'CUSTOMER', result: 'APPROVED' | 'REJECTED') => {
|
||||
if (result === 'REJECTED') {
|
||||
Modal.confirm({
|
||||
title: `${ACCEPT_KIND_LABEL[kind]}不通过`,
|
||||
content: (
|
||||
<Input.TextArea id="accept-comment" rows={3} placeholder="请填写意见" />
|
||||
),
|
||||
okButtonProps: { danger: true },
|
||||
onOk: () => {
|
||||
const comment = (document.getElementById('accept-comment') as HTMLTextAreaElement | null)?.value;
|
||||
if (!comment?.trim()) {
|
||||
message.error('不通过请填写意见');
|
||||
return Promise.reject();
|
||||
}
|
||||
return acceptMut.mutateAsync({ kind, result: 'REJECTED', comment });
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
acceptMut.mutate({ kind, result: 'APPROVED' });
|
||||
};
|
||||
|
||||
const page = (
|
||||
<>
|
||||
<PageHeader
|
||||
title={row.name}
|
||||
description={row.projectNo}
|
||||
extra={
|
||||
<Space>
|
||||
{acceptanceOnly ? null : (
|
||||
<Button>
|
||||
<Link to={`/office/expenses/new?costType=PROJECT&projectId=${row.id}`}>报销</Link>
|
||||
</Button>
|
||||
)}
|
||||
{embedded ? null : (
|
||||
<Button>
|
||||
<Link to={acceptanceOnly ? '/project/acceptance' : '/project/list'}>
|
||||
{acceptanceOnly ? '返回验收' : '返回台账'}
|
||||
</Link>
|
||||
</Button>
|
||||
)}
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
<Space style={{ marginBottom: 16 }} wrap>
|
||||
<Tag color={meta.color}>{meta.text}</Tag>
|
||||
{row.contract ? <Link to={`/contract/${row.contract.id}`}>{row.contract.contractNo}</Link> : null}
|
||||
{row.contract?.bidCase ? (
|
||||
<Link to={`/bid/cases/${row.contract.bidCase.id}`}>{row.contract.bidCase.bidNo}</Link>
|
||||
) : null}
|
||||
</Space>
|
||||
|
||||
{row.myActions.canAcceptInternal ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message="内部验收:技术确认交付物后,再交给商务做客户验收。"
|
||||
action={
|
||||
<Space>
|
||||
<Button type="primary" onClick={() => decide('INTERNAL', 'APPROVED')}>
|
||||
内部通过
|
||||
</Button>
|
||||
<Button danger onClick={() => decide('INTERNAL', 'REJECTED')}>
|
||||
不通过
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{row.myActions.canAcceptCustomer ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
style={{ marginBottom: 16 }}
|
||||
message={
|
||||
row.acceptMaterialsReady
|
||||
? '客户验收通过后,未开票的收款条款会生成待开票。'
|
||||
: '客户验收前请先在下面写好材料清单,并逐份上传。材料不齐不能点通过。'
|
||||
}
|
||||
action={
|
||||
<Space>
|
||||
<Button type="primary" disabled={!row.acceptMaterialsReady} onClick={() => decide('CUSTOMER', 'APPROVED')}>
|
||||
客户通过
|
||||
</Button>
|
||||
<Button danger onClick={() => decide('CUSTOMER', 'REJECTED')}>
|
||||
不通过
|
||||
</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{acceptanceOnly ? null : (
|
||||
<>
|
||||
<Descriptions bordered size="small" column={2} style={{ marginBottom: 16 }}>
|
||||
<Descriptions.Item label="项目类别">{row.projectType || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="项目总额">{money(row.amount || row.usage.contractAmount || row.contract?.amount)}</Descriptions.Item>
|
||||
<Descriptions.Item label="均分规则">总额一半归商务、一半归技术。商务差旅进商务;技术部门差旅、工时和采购进技术。</Descriptions.Item>
|
||||
<Descriptions.Item label="创建时间">{row.createdAt ? dayjs(row.createdAt).format('YYYY-MM-DD HH:mm:ss') : '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="已填工时">{row.usage.hours} 小时(人工按薪酬自动折算)</Descriptions.Item>
|
||||
</Descriptions>
|
||||
|
||||
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 12, marginBottom: 16 }}>
|
||||
<Card size="small" title="商务预算" extra={money(row.usage.limits.business)}>
|
||||
{budgetBar(row.usage.businessUsed ?? Number(row.presalesCost) + row.usage.travel, row.usage.limits.business ?? null)}
|
||||
<Descriptions bordered size="small" column={1} style={{ marginTop: 12 }}>
|
||||
<Descriptions.Item label="售前成本">{money(row.presalesCost)}(投标费用自动带入)</Descriptions.Item>
|
||||
<Descriptions.Item label="商务差旅">{money(row.usage.travel)}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
<Card size="small" title="技术预算" extra={money(row.usage.limits.tech)}>
|
||||
{budgetBar(row.usage.techUsed ?? row.usage.labor + row.usage.purchase + (row.usage.techTravel || 0), row.usage.limits.tech ?? null)}
|
||||
<Descriptions bordered size="small" column={1} style={{ marginTop: 12 }}>
|
||||
<Descriptions.Item label="人工 / 工时">{money(row.usage.labor)}</Descriptions.Item>
|
||||
<Descriptions.Item label="技术差旅">{money(row.usage.techTravel)}</Descriptions.Item>
|
||||
<Descriptions.Item label="采购">{money(row.usage.purchase)}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{row.myActions.canSetBudget ? (
|
||||
<Form
|
||||
form={budgetForm}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 24, rowGap: 12 }}
|
||||
onFinish={(v) => budgetMut.mutate(v)}
|
||||
>
|
||||
<Form.Item name="projectType" label="项目类别">
|
||||
<Select
|
||||
style={{ width: 120 }}
|
||||
options={[
|
||||
{ label: '软件', value: '软件' },
|
||||
{ label: '三维', value: '三维' },
|
||||
{ label: '摄制', value: '摄制' },
|
||||
{ label: '物资', value: '物资' },
|
||||
{ label: '集成', value: '集成' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="amount" label="项目总额">
|
||||
<InputNumber min={0} precision={2} placeholder="可改总额" />
|
||||
</Form.Item>
|
||||
<Form.Item name="business" label="商务预算">
|
||||
<InputNumber min={0} precision={2} />
|
||||
</Form.Item>
|
||||
<Form.Item name="tech" label="技术预算">
|
||||
<InputNumber min={0} precision={2} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" loading={budgetMut.isPending}>
|
||||
保存
|
||||
</Button>
|
||||
<Button loading={syncMut.isPending} onClick={() => syncMut.mutate()}>
|
||||
按总额均分并同步花费
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
) : null}
|
||||
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||
项目花费
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
挂到本项目的报销会出现在这里。技术部门的车票机票住宿进技术预算,商务差旅进商务预算。点单号打开报销单。
|
||||
</Typography.Paragraph>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={row.expenses || []}
|
||||
locale={{ emptyText: '还没有挂到本项目的报销。点右上角「报销」登记。' }}
|
||||
columns={[
|
||||
{
|
||||
title: '单号',
|
||||
width: 150,
|
||||
render: (_: unknown, r: NonNullable<ProjectDetail['expenses']>[0]) => (
|
||||
<Link to={`/office/expenses/${r.id}`}>{r.claimNo}</Link>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '类别',
|
||||
width: 80,
|
||||
render: (_: unknown, r: NonNullable<ProjectDetail['expenses']>[0]) =>
|
||||
BUDGET_CATEGORY_LABEL[r.budgetCategory || ''] || r.budgetCategory || '报销',
|
||||
},
|
||||
{ title: '事由', dataIndex: 'purpose', ellipsis: true },
|
||||
{
|
||||
title: '金额',
|
||||
width: 110,
|
||||
render: (_: unknown, r: NonNullable<ProjectDetail['expenses']>[0]) => money(r.amount),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
width: 90,
|
||||
render: (_: unknown, r: NonNullable<ProjectDetail['expenses']>[0]) => {
|
||||
const s = EXPENSE_STATUS_META[r.status] || { color: 'default', text: r.status };
|
||||
return <Tag color={s.color}>{s.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '申请人',
|
||||
width: 100,
|
||||
render: (_: unknown, r: NonNullable<ProjectDetail['expenses']>[0]) => r.applicant?.displayName || '—',
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
width: 120,
|
||||
render: (_: unknown, r: NonNullable<ProjectDetail['expenses']>[0]) =>
|
||||
r.createdAt ? dayjs(r.createdAt).format('YYYY-MM-DD') : '—',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<Typography.Text strong style={{ display: 'block', margin: '16px 0 8px' }}>
|
||||
采购申请
|
||||
</Typography.Text>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={row.purchases || []}
|
||||
locale={{ emptyText: '本项目还没有采购申请。' }}
|
||||
columns={[
|
||||
{ title: '名称', dataIndex: 'title', ellipsis: true },
|
||||
{
|
||||
title: '金额',
|
||||
width: 110,
|
||||
render: (_: unknown, r: NonNullable<ProjectDetail['purchases']>[0]) => money(r.amount),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
width: 90,
|
||||
render: (_: unknown, r: NonNullable<ProjectDetail['purchases']>[0]) => {
|
||||
const s = PURCHASE_STATUS_META[r.status] || { color: 'default', text: r.status };
|
||||
return <Tag color={s.color}>{s.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '申请人',
|
||||
width: 100,
|
||||
render: (_: unknown, r: NonNullable<ProjectDetail['purchases']>[0]) => r.applicant?.displayName || '—',
|
||||
},
|
||||
{
|
||||
title: '时间',
|
||||
width: 120,
|
||||
render: (_: unknown, r: NonNullable<ProjectDetail['purchases']>[0]) =>
|
||||
r.createdAt ? dayjs(r.createdAt).format('YYYY-MM-DD') : '—',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Typography.Title level={5} style={{ marginTop: 0 }}>
|
||||
验收材料
|
||||
</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
项目做完、跟客户对接后,先写清要交哪些材料,再按表逐份上传。必备材料全部上传后才能客户验收。
|
||||
{row.acceptMaterials?.length
|
||||
? ` 已上传 ${(row.acceptMaterials || []).filter((m) => m.uploaded).length} / ${row.acceptMaterials.length}。`
|
||||
: ''}
|
||||
</Typography.Paragraph>
|
||||
{row.myActions.canEditMaterials ? (
|
||||
<Form
|
||||
form={materialForm}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 12, rowGap: 12 }}
|
||||
onFinish={(v) => materialMut.mutate(v)}
|
||||
>
|
||||
<Form.Item name="name" rules={[{ required: true, message: '请填写材料名称' }]}>
|
||||
<Input placeholder="例如:验收报告、交付清单" style={{ width: 260 }} />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" loading={materialMut.isPending}>
|
||||
加入清单
|
||||
</Button>
|
||||
{!(row.acceptMaterials || []).length ? (
|
||||
<Button loading={seedMatMut.isPending} onClick={() => seedMatMut.mutate()}>
|
||||
带入常用清单
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
) : null}
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={row.acceptMaterials || []}
|
||||
locale={{ emptyText: '还没有材料。先写需要哪些,再依次上传。' }}
|
||||
columns={[
|
||||
{
|
||||
title: '序号',
|
||||
width: 60,
|
||||
render: (_: unknown, __: unknown, i: number) => i + 1,
|
||||
},
|
||||
{
|
||||
title: '材料名称',
|
||||
render: (_: unknown, r) =>
|
||||
row.myActions.canEditMaterials ? (
|
||||
<Input
|
||||
defaultValue={r.name}
|
||||
onBlur={async (e) => {
|
||||
const name = e.target.value.trim();
|
||||
if (!name || name === r.name) return;
|
||||
await api.patch(`/projects/${id}/accept-materials/${r.id}`, { name });
|
||||
message.success('已改名称');
|
||||
await invalidate();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
r.name
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '必备',
|
||||
width: 80,
|
||||
render: (_: unknown, r) =>
|
||||
row.myActions.canEditMaterials ? (
|
||||
<Button
|
||||
type="link"
|
||||
onClick={async () => {
|
||||
await api.patch(`/projects/${id}/accept-materials/${r.id}`, { required: !r.required });
|
||||
await invalidate();
|
||||
}}
|
||||
>
|
||||
{r.required ? '是' : '否'}
|
||||
</Button>
|
||||
) : r.required ? (
|
||||
'是'
|
||||
) : (
|
||||
'否'
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '文件',
|
||||
width: 220,
|
||||
render: (_: unknown, r) => {
|
||||
const f = r.files?.[0];
|
||||
return (
|
||||
<Space wrap>
|
||||
{f ? (
|
||||
<Button type="link" onClick={() => downloadBizFile(f.id, f.fileName)}>
|
||||
{f.fileName}
|
||||
</Button>
|
||||
) : (
|
||||
<Tag color={r.required ? 'warning' : 'default'}>{r.required ? '未上传' : '选传'}</Tag>
|
||||
)}
|
||||
{row.myActions.canEditMaterials ? (
|
||||
<Upload
|
||||
showUploadList={false}
|
||||
beforeUpload={() => true}
|
||||
customRequest={async (opt) => {
|
||||
try {
|
||||
const fd = new FormData();
|
||||
fd.append('file', opt.file as File);
|
||||
fd.append('bizType', 'PROJECT_ACCEPT');
|
||||
fd.append('bizId', r.id);
|
||||
await api.post('/files', fd);
|
||||
message.success('已上传');
|
||||
await invalidate();
|
||||
} catch (e) {
|
||||
message.error((e as Error).message || '上传失败');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button type="link">{f ? '更换' : '上传'}</Button>
|
||||
</Upload>
|
||||
) : null}
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_: unknown, r) =>
|
||||
row.myActions.canEditMaterials ? (
|
||||
<Button
|
||||
type="link"
|
||||
danger
|
||||
onClick={async () => {
|
||||
await api.delete(`/projects/${id}/accept-materials/${r.id}`);
|
||||
message.success('已从清单去掉');
|
||||
await invalidate();
|
||||
}}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
) : null,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{acceptanceOnly ? null : (
|
||||
<>
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Typography.Text strong>历史附件</Typography.Text>
|
||||
<BizFileList files={row.files} empty="没有从生产带过来的附件。" />
|
||||
</div>
|
||||
|
||||
<Typography.Title level={5}>任务分解</Typography.Title>
|
||||
{row.myActions.canManageWbs ? (
|
||||
<Form
|
||||
form={taskForm}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 12, rowGap: 12 }}
|
||||
onFinish={(v) => taskMut.mutate(v)}
|
||||
>
|
||||
<Form.Item name="name" rules={[{ required: true, message: '请填任务' }]}>
|
||||
<Input placeholder="任务名称" />
|
||||
</Form.Item>
|
||||
<Form.Item name="departmentId" rules={[{ required: true, message: '请选部门' }]}>
|
||||
<Select options={deptOpts} placeholder="部门" style={{ width: 180 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="assigneeId">
|
||||
<Select allowClear showSearch optionFilterProp="label" options={staffOpts} placeholder="负责人" style={{ width: 140 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="dueAt">
|
||||
<DatePicker placeholder="截止" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={taskMut.isPending}>
|
||||
添加任务
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
) : null}
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
style={{ marginBottom: 24 }}
|
||||
dataSource={row.tasks}
|
||||
columns={[
|
||||
{ title: '任务', dataIndex: 'name' },
|
||||
{ title: '部门', render: (_: unknown, r: ProjectDetail['tasks'][0]) => r.dept?.name || r.department || '—' },
|
||||
{ title: '负责人', width: 110, render: (_: unknown, r: ProjectDetail['tasks'][0]) => r.assignee?.displayName || '—' },
|
||||
{
|
||||
title: '截止',
|
||||
width: 120,
|
||||
render: (_: unknown, r: ProjectDetail['tasks'][0]) => (r.dueAt ? dayjs(r.dueAt).format('YYYY-MM-DD') : '—'),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
width: 100,
|
||||
render: (_: unknown, r: ProjectDetail['tasks'][0]) => {
|
||||
const s = TASK_STATUS_META[r.status] || { color: 'default', text: r.status };
|
||||
return <Tag color={s.color}>{s.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 90,
|
||||
render: (_: unknown, r: ProjectDetail['tasks'][0]) =>
|
||||
row.myActions.canManageWbs && r.status !== 'DONE' ? (
|
||||
<Button type="link" size="small" onClick={() => doneMut.mutate(r.id)}>
|
||||
完成
|
||||
</Button>
|
||||
) : null,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
||||
<Typography.Title level={5}>工时填报</Typography.Title>
|
||||
{row.myActions.canFillTimesheet ? (
|
||||
<Form
|
||||
form={sheetForm}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 12, rowGap: 12 }}
|
||||
onFinish={(v) => sheetMut.mutate(v)}
|
||||
>
|
||||
<Form.Item name="taskId">
|
||||
<Select
|
||||
allowClear
|
||||
options={row.tasks.map((t) => ({ value: t.id, label: t.name }))}
|
||||
placeholder="任务(可选)"
|
||||
style={{ width: 180 }}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="workDate" rules={[{ required: true, message: '请选日期' }]}>
|
||||
<DatePicker />
|
||||
</Form.Item>
|
||||
<Form.Item name="hours" rules={[{ required: true, message: '请填工时' }]}>
|
||||
<InputNumber min={0.5} max={24} step={0.5} placeholder="小时" />
|
||||
</Form.Item>
|
||||
<Form.Item name="note">
|
||||
<Input placeholder="说明" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={sheetMut.isPending}>
|
||||
提交工时
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
) : null}
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
style={{ marginBottom: 24 }}
|
||||
dataSource={row.timesheets}
|
||||
columns={[
|
||||
{ title: '人员', width: 110, render: (_: unknown, r: ProjectDetail['timesheets'][0]) => r.user?.displayName || '—' },
|
||||
{ title: '任务', render: (_: unknown, r: ProjectDetail['timesheets'][0]) => r.task?.name || '—' },
|
||||
{
|
||||
title: '日期',
|
||||
width: 120,
|
||||
render: (_: unknown, r: ProjectDetail['timesheets'][0]) => dayjs(r.workDate).format('YYYY-MM-DD'),
|
||||
},
|
||||
{ title: '工时', dataIndex: 'hours', width: 80 },
|
||||
{ title: '技术成本', width: 110, render: (_: unknown, r: ProjectDetail['timesheets'][0]) => money(r.costAmount) },
|
||||
{ title: '说明', dataIndex: 'note' },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{acceptanceOnly ? null : (
|
||||
<>
|
||||
<Typography.Title level={5}>验收与开票</Typography.Title>
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
style={{ marginBottom: 16 }}
|
||||
dataSource={row.acceptances}
|
||||
locale={{ emptyText: '尚未验收' }}
|
||||
columns={[
|
||||
{ title: '类型', width: 120, render: (_: unknown, r: ProjectDetail['acceptances'][0]) => ACCEPT_KIND_LABEL[r.kind] || r.kind },
|
||||
{
|
||||
title: '结果',
|
||||
width: 90,
|
||||
render: (_: unknown, r: ProjectDetail['acceptances'][0]) => (
|
||||
<Tag color={r.result === 'APPROVED' ? 'success' : 'error'}>{r.result === 'APPROVED' ? '通过' : '不通过'}</Tag>
|
||||
),
|
||||
},
|
||||
{ title: '办理人', width: 110, render: (_: unknown, r: ProjectDetail['acceptances'][0]) => r.actor?.displayName || '—' },
|
||||
{
|
||||
title: '时间',
|
||||
width: 170,
|
||||
render: (_: unknown, r: ProjectDetail['acceptances'][0]) => dayjs(r.decidedAt).format('YYYY-MM-DD HH:mm'),
|
||||
},
|
||||
{ title: '意见', dataIndex: 'comment' },
|
||||
]}
|
||||
/>
|
||||
{row.contract?.milestones?.length ? (
|
||||
<Table
|
||||
rowKey="id"
|
||||
size="small"
|
||||
pagination={false}
|
||||
dataSource={row.contract.milestones}
|
||||
columns={[
|
||||
{ title: '收款条款', dataIndex: 'name' },
|
||||
{ title: '比例', dataIndex: 'ratio', width: 80, render: (v: string) => `${v}%` },
|
||||
{
|
||||
title: '开票',
|
||||
width: 120,
|
||||
render: (_: unknown, m: NonNullable<ProjectDetail['contract']>['milestones'][0]) =>
|
||||
m.invoice ? <Tag>{m.invoice.status === 'TO_ISSUE' ? '待开' : m.invoice.status}</Tag> : '未生成',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
if (embedded) return page;
|
||||
return (
|
||||
<FormDrawer title={row.name} extra={row.projectNo} open hideOk width="min(1120px, 86vw)" createdAt={row.createdAt} onClose={() => navigate('/project/list')}>
|
||||
{page}
|
||||
</FormDrawer>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,387 @@
|
||||
import { Button, Form, Input, InputNumber, Progress, Select, Tag, message } from 'antd';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { FormDrawer } from './FormDrawer';
|
||||
import ProjectDetailPage from './ProjectDetailPage';
|
||||
import { ResourcePage } from './ResourcePage';
|
||||
import { money } from './financeStatus';
|
||||
import { projectStatusMeta } from './projectStatus';
|
||||
import { api } from '../api/client';
|
||||
|
||||
type Usage = {
|
||||
labor: number;
|
||||
travel: number;
|
||||
techTravel?: number;
|
||||
purchase: number;
|
||||
businessUsed?: number;
|
||||
techUsed?: number;
|
||||
limits: {
|
||||
labor: number | null;
|
||||
travel: number | null;
|
||||
purchase: number | null;
|
||||
business?: number | null;
|
||||
tech?: number | null;
|
||||
};
|
||||
};
|
||||
|
||||
function bar(used: number, limit: number | null) {
|
||||
if (limit === null || limit === undefined) return '未设预算';
|
||||
const pct = limit <= 0 ? (used > 0 ? 100 : 0) : Math.min(100, Math.round((used / limit) * 100));
|
||||
return (
|
||||
<div>
|
||||
<Progress percent={pct} size="small" status={used - limit > 0.001 ? 'exception' : 'normal'} />
|
||||
<span>
|
||||
{money(used)} / {money(limit)}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ProjectListPage({
|
||||
title,
|
||||
description,
|
||||
bucket,
|
||||
mode = 'list',
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
bucket?: string;
|
||||
mode?: 'list' | 'budget' | 'acceptance';
|
||||
}) {
|
||||
const [openRow, setOpenRow] = useState<Record<string, unknown>>();
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [projectType, setProjectType] = useState<string>();
|
||||
const [form] = Form.useForm();
|
||||
const qc = useQueryClient();
|
||||
const params = new URLSearchParams();
|
||||
if (bucket) params.set('bucket', bucket);
|
||||
if (projectType) params.set('projectType', projectType);
|
||||
const endpoint = params.toString() ? `/projects?${params}` : '/projects';
|
||||
const typeOpts = [
|
||||
{ label: '软件', value: '软件' },
|
||||
{ label: '三维', value: '三维' },
|
||||
{ label: '摄制', value: '摄制' },
|
||||
{ label: '物资', value: '物资' },
|
||||
{ label: '集成', value: '集成' },
|
||||
];
|
||||
const contracts = useQuery({
|
||||
queryKey: ['/contracts', 'project-create'],
|
||||
queryFn: () => api.get('/contracts?pageSize=200'),
|
||||
enabled: bucket === 'ledger',
|
||||
});
|
||||
const contractItems = ((contracts.data as { data?: { items?: Record<string, unknown>[] } })?.data?.items ||
|
||||
[]) as {
|
||||
id: string;
|
||||
contractNo?: string;
|
||||
name: string;
|
||||
amount?: number | string;
|
||||
partyA?: string | null;
|
||||
kind?: string;
|
||||
projects?: unknown[];
|
||||
}[];
|
||||
const contractOpts = contractItems
|
||||
.filter((c) => !c.projects?.length)
|
||||
.map((c) => ({
|
||||
value: c.id,
|
||||
label: `${c.contractNo || '未编号'} ${c.name}${c.kind === 'DIRECT' ? '(直签)' : ''}`,
|
||||
}));
|
||||
const createMut = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) => api.post('/projects', body),
|
||||
onSuccess: () => {
|
||||
message.success('已建立项目台账');
|
||||
qc.invalidateQueries({ queryKey: [endpoint] });
|
||||
qc.invalidateQueries({ queryKey: ['/contracts', 'project-create'] });
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
const budgetCols =
|
||||
mode === 'budget'
|
||||
? [
|
||||
{
|
||||
title: '商务',
|
||||
width: 180,
|
||||
render: (_: unknown, row: Record<string, unknown>) => {
|
||||
const u = row.usage as Usage | undefined;
|
||||
return bar(u?.businessUsed ?? (u?.travel ?? 0), u?.limits?.business ?? null);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '技术',
|
||||
width: 180,
|
||||
render: (_: unknown, row: Record<string, unknown>) => {
|
||||
const u = row.usage as Usage | undefined;
|
||||
return bar(u?.techUsed ?? ((u?.labor ?? 0) + (u?.purchase ?? 0) + (u?.techTravel ?? 0)), u?.limits?.tech ?? null);
|
||||
},
|
||||
},
|
||||
]
|
||||
: [];
|
||||
const acceptance = mode === 'acceptance';
|
||||
const ledgerCols = acceptance
|
||||
? [
|
||||
{ title: '项目号', dataIndex: 'projectNo', width: 150 },
|
||||
{
|
||||
title: '类别',
|
||||
dataIndex: 'projectType',
|
||||
width: 80,
|
||||
render: (v: string) => v || '—',
|
||||
},
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
width: 280,
|
||||
ellipsis: true,
|
||||
render: (v: string, row: Record<string, unknown>) => (
|
||||
<Button type="link" onClick={() => setOpenRow(row)}>
|
||||
{v}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '验收材料',
|
||||
width: 110,
|
||||
render: (_: unknown, row: Record<string, unknown>) => {
|
||||
const n = Number(row.acceptMaterialCount || 0);
|
||||
return n ? `${n} 项` : '尚未列入';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 120,
|
||||
render: (v: string) => {
|
||||
const s = projectStatusMeta(v);
|
||||
return <Tag color={s.color}>{s.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 110,
|
||||
render: (_: unknown, row: Record<string, unknown>) => (
|
||||
<Button type="link" onClick={() => setOpenRow(row)}>
|
||||
验收材料
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
]
|
||||
: [
|
||||
{ title: '项目号', dataIndex: 'projectNo', width: 150 },
|
||||
{
|
||||
title: '类别',
|
||||
dataIndex: 'projectType',
|
||||
width: 80,
|
||||
render: (v: string) => v || '—',
|
||||
},
|
||||
{
|
||||
title: '名称',
|
||||
dataIndex: 'name',
|
||||
width: 240,
|
||||
ellipsis: true,
|
||||
render: (v: string, row: Record<string, unknown>) => (
|
||||
<Button type="link" onClick={() => setOpenRow(row)}>
|
||||
{v}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '合同号',
|
||||
width: 150,
|
||||
render: (_: unknown, row: Record<string, unknown>) => {
|
||||
const c = row.contract as { id?: string; contractNo?: string } | null;
|
||||
return c?.contractNo || '—';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '招标单位',
|
||||
width: 180,
|
||||
ellipsis: true,
|
||||
render: (_: unknown, row: Record<string, unknown>) => {
|
||||
const c = row.contract as { bidCase?: { party?: { name?: string } } } | null;
|
||||
return (row.partyName as string) || c?.bidCase?.party?.name || '—';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '预算成本',
|
||||
width: 110,
|
||||
render: (_: unknown, row: Record<string, unknown>) => money((row.budgetTotal as number) ?? row.presalesCost),
|
||||
},
|
||||
{
|
||||
title: '合同额',
|
||||
width: 110,
|
||||
render: (_: unknown, row: Record<string, unknown>) => {
|
||||
const c = row.contract as { amount?: string | number } | null;
|
||||
return money((row.amount as number) || c?.amount);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '已发生成本',
|
||||
width: 120,
|
||||
render: (_: unknown, row: Record<string, unknown>) => {
|
||||
const u = row.usage as Usage | undefined;
|
||||
const used =
|
||||
(u?.businessUsed ?? 0) + (u?.techUsed ?? (u?.labor ?? 0) + (u?.travel ?? 0) + (u?.purchase ?? 0));
|
||||
return money(used);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '报销',
|
||||
width: 70,
|
||||
render: (_: unknown, row: Record<string, unknown>) => Number(row.expenseCount || 0),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 120,
|
||||
render: (v: string) => {
|
||||
const s = projectStatusMeta(v);
|
||||
return <Tag color={s.color}>{s.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 170,
|
||||
render: (v: string) => (v ? String(v).replace('T', ' ').slice(0, 19) : '—'),
|
||||
},
|
||||
...budgetCols,
|
||||
{
|
||||
title: '操作',
|
||||
width: 90,
|
||||
render: (_: unknown, row: Record<string, unknown>) => (
|
||||
<Button type="link" onClick={() => setOpenRow(row)}>
|
||||
办理
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<>
|
||||
<ResourcePage
|
||||
title={title}
|
||||
description={description}
|
||||
searchPlaceholder="搜索项目号、名称"
|
||||
exportName={title}
|
||||
extra={
|
||||
bucket === 'ledger' ? (
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ status: 'NOT_STARTED' });
|
||||
setCreating(true);
|
||||
}}
|
||||
>
|
||||
新增
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
emptyText={
|
||||
acceptance
|
||||
? '台账里把验收材料加入清单后,项目会出现在这里。不必在本页新增。'
|
||||
: '还没有项目。直签或补登可用右上角「新增」。'
|
||||
}
|
||||
toolbarExtra={
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="项目类别"
|
||||
style={{ minWidth: 140 }}
|
||||
value={projectType}
|
||||
options={typeOpts}
|
||||
onChange={setProjectType}
|
||||
/>
|
||||
}
|
||||
filters={[
|
||||
{
|
||||
key: 'status',
|
||||
label: '状态',
|
||||
options: [
|
||||
{ label: '未开始', value: 'NOT_STARTED' },
|
||||
{ label: '执行中', value: 'ACTIVE' },
|
||||
{ label: '待验收', value: 'INTERNAL_ACCEPTED' },
|
||||
{ label: '已验收', value: 'ACCEPTED' },
|
||||
{ label: '已完成', value: 'DONE' },
|
||||
],
|
||||
},
|
||||
]}
|
||||
onRowClick={(row) => setOpenRow(row)}
|
||||
endpoint={endpoint}
|
||||
columns={ledgerCols}
|
||||
/>
|
||||
<FormDrawer
|
||||
title="新建项目"
|
||||
open={creating}
|
||||
onClose={() => setCreating(false)}
|
||||
okText="提交保存"
|
||||
okLoading={createMut.isPending}
|
||||
onOk={async () => {
|
||||
const v = await form.validateFields();
|
||||
await createMut.mutateAsync(v);
|
||||
setCreating(false);
|
||||
}}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
layout="vertical"
|
||||
onValuesChange={(changed) => {
|
||||
if (!changed.contractId) return;
|
||||
const c = contractItems.find((x) => x.id === changed.contractId);
|
||||
if (!c) return;
|
||||
form.setFieldsValue({
|
||||
name: form.getFieldValue('name') || c.name,
|
||||
partyName: c.partyA || undefined,
|
||||
amount: c.amount ? Number(c.amount) : undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
<Form.Item name="name" label="项目名称" rules={[{ required: true, message: '请填写项目名称' }]}>
|
||||
<Input placeholder="直签、补登历史项目都可在这里建" />
|
||||
</Form.Item>
|
||||
<Form.Item name="contractId" label="关联合同" extra="直签合同、其他合同未立项的都可以挂上。没有合同可空着。">
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="可选,优先选直签合同"
|
||||
options={contractOpts}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="partyName" label="甲方 / 客户">
|
||||
<Input placeholder="没有合同时报招标单位或甲方" />
|
||||
</Form.Item>
|
||||
<Form.Item name="projectType" label="项目类别" rules={[{ required: true, message: '请选择项目类别' }]}>
|
||||
<Select options={typeOpts} placeholder="软件 / 三维 / 摄制 / 物资 / 集成" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="amount"
|
||||
label="项目总额"
|
||||
extra="有合同金额会按商务、技术各一半生成预算;没有合同可手填总额。"
|
||||
>
|
||||
<InputNumber min={0} precision={2} style={{ width: '100%' }} placeholder="合同额或项目总额" />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态" rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'NOT_STARTED', label: '未开始' },
|
||||
{ value: 'ACTIVE', label: '执行中' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</FormDrawer>
|
||||
<FormDrawer
|
||||
title={acceptance ? '验收材料' : '项目办理'}
|
||||
open={Boolean(openRow)}
|
||||
onClose={() => setOpenRow(undefined)}
|
||||
hideOk
|
||||
width="min(1120px, 86vw)"
|
||||
extra={
|
||||
acceptance
|
||||
? '只看本项目要交的验收材料。清单在台账办理里加入后,项目会出现在本页。'
|
||||
: '点项目名称或办理即可打开详情。报销、预算、花费都在这里看。'
|
||||
}
|
||||
createdAt={openRow?.createdAt as string | undefined}
|
||||
>
|
||||
{openRow ? <ProjectDetailPage id={String(openRow.id)} embedded focus={acceptance ? 'acceptance' : 'full'} /> : null}
|
||||
</FormDrawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
import { Button, Space, message } from 'antd';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { api } from '../api/client';
|
||||
|
||||
type Hit = { lat: number; lng: number; title?: string; address?: string };
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
TMap?: {
|
||||
Map: new (el: HTMLElement, opts: Record<string, unknown>) => TMapInst;
|
||||
LatLng: new (lat: number, lng: number) => unknown;
|
||||
MultiMarker: new (opts: Record<string, unknown>) => { setGeometries: (g: unknown[]) => void; destroy?: () => void };
|
||||
MultiCircle: new (opts: Record<string, unknown>) => { setGeometries: (g: unknown[]) => void; destroy?: () => void };
|
||||
CircleStyle: new (opts: Record<string, unknown>) => unknown;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
type TMapInst = {
|
||||
on: (ev: string, fn: (e: { latLng?: { getLat: () => number; getLng: () => number } }) => void) => void;
|
||||
setCenter: (p: unknown) => void;
|
||||
destroy?: () => void;
|
||||
};
|
||||
|
||||
function loadGl(key: string) {
|
||||
if (window.TMap) return Promise.resolve();
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const existed = document.querySelector(`script[data-tmap="gl"]`) as HTMLScriptElement | null;
|
||||
if (existed) {
|
||||
existed.addEventListener('load', () => resolve());
|
||||
existed.addEventListener('error', () => reject(new Error('地图脚本加载失败')));
|
||||
return;
|
||||
}
|
||||
const s = document.createElement('script');
|
||||
s.src = `https://map.qq.com/api/gljs?v=1.exp&key=${encodeURIComponent(key)}`;
|
||||
s.async = true;
|
||||
s.dataset.tmap = 'gl';
|
||||
s.onload = () => resolve();
|
||||
s.onerror = () => reject(new Error('地图脚本加载失败'));
|
||||
document.head.appendChild(s);
|
||||
});
|
||||
}
|
||||
|
||||
export function PunchLocationMap({
|
||||
lat,
|
||||
lng,
|
||||
radiusMeters,
|
||||
onPick,
|
||||
}: {
|
||||
lat?: number | string | null;
|
||||
lng?: number | string | null;
|
||||
radiusMeters?: number;
|
||||
onPick: (hit: Hit) => void;
|
||||
}) {
|
||||
const box = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<TMapInst | null>(null);
|
||||
const markerRef = useRef<{ setGeometries: (g: unknown[]) => void; destroy?: () => void } | null>(null);
|
||||
const circleRef = useRef<{ setGeometries: (g: unknown[]) => void; destroy?: () => void } | null>(null);
|
||||
const [key, setKey] = useState('');
|
||||
const [picker, setPicker] = useState(false);
|
||||
const latN = lat == null || lat === '' ? NaN : Number(lat);
|
||||
const lngN = lng == null || lng === '' ? NaN : Number(lng);
|
||||
const hasPoint = Number.isFinite(latN) && Number.isFinite(lngN);
|
||||
const radius = Math.max(50, Number(radiusMeters) || 300);
|
||||
|
||||
useEffect(() => {
|
||||
void api.get('/office/geo/map-config').then((res) => {
|
||||
const k = (res as { data?: { key?: string } }).data?.key || '';
|
||||
setKey(k);
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!key || !box.current) return;
|
||||
let dead = false;
|
||||
void (async () => {
|
||||
try {
|
||||
await loadGl(key);
|
||||
if (dead || !box.current || !window.TMap) return;
|
||||
const center = hasPoint ? new window.TMap.LatLng(latN, lngN) : new window.TMap.LatLng(32.041, 118.78);
|
||||
if (!mapRef.current) {
|
||||
const map = new window.TMap.Map(box.current, { center, zoom: hasPoint ? 16 : 12 });
|
||||
mapRef.current = map;
|
||||
map.on('click', (e) => {
|
||||
const p = e.latLng;
|
||||
if (!p) return;
|
||||
void applyPick(p.getLat(), p.getLng());
|
||||
});
|
||||
} else {
|
||||
mapRef.current.setCenter(center);
|
||||
}
|
||||
const TMap = window.TMap;
|
||||
if (!markerRef.current) {
|
||||
markerRef.current = new TMap.MultiMarker({
|
||||
map: mapRef.current,
|
||||
geometries: hasPoint ? [{ id: 'punch', position: center }] : [],
|
||||
});
|
||||
} else if (hasPoint) {
|
||||
markerRef.current.setGeometries([{ id: 'punch', position: center }]);
|
||||
}
|
||||
if (!circleRef.current) {
|
||||
circleRef.current = new TMap.MultiCircle({
|
||||
map: mapRef.current,
|
||||
styles: {
|
||||
default: new TMap.CircleStyle({
|
||||
color: 'rgba(124,58,237,0.18)',
|
||||
showBorder: true,
|
||||
borderColor: '#7c3aed',
|
||||
borderWidth: 2,
|
||||
}),
|
||||
},
|
||||
geometries: hasPoint ? [{ id: 'r', center, radius }] : [],
|
||||
});
|
||||
} else {
|
||||
circleRef.current.setGeometries(hasPoint ? [{ id: 'r', center, radius }] : []);
|
||||
}
|
||||
} catch {
|
||||
/* locpicker 仍可用 */
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
dead = true;
|
||||
};
|
||||
}, [key, latN, lngN, radius, hasPoint]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
markerRef.current?.destroy?.();
|
||||
circleRef.current?.destroy?.();
|
||||
mapRef.current?.destroy?.();
|
||||
markerRef.current = null;
|
||||
circleRef.current = null;
|
||||
mapRef.current = null;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const onMsg = (ev: MessageEvent) => {
|
||||
const loc = ev.data as { module?: string; latlng?: { lat: number; lng: number }; poiaddress?: string; poiname?: string };
|
||||
if (!loc || loc.module !== 'locationPicker' || !loc.latlng) return;
|
||||
void applyPick(Number(loc.latlng.lat), Number(loc.latlng.lng), loc.poiname, loc.poiaddress);
|
||||
setPicker(false);
|
||||
};
|
||||
window.addEventListener('message', onMsg);
|
||||
return () => window.removeEventListener('message', onMsg);
|
||||
}, []);
|
||||
|
||||
async function applyPick(lat: number, lng: number, title?: string, address?: string) {
|
||||
let hit: Hit = { lat, lng, title, address };
|
||||
try {
|
||||
const res = (await api.post('/office/geo/reverse', { lat, lng })) as {
|
||||
data?: { address?: string; title?: string };
|
||||
};
|
||||
hit = {
|
||||
lat,
|
||||
lng,
|
||||
title: title || res.data?.title,
|
||||
address: res.data?.address || address,
|
||||
};
|
||||
} catch {
|
||||
/* 坐标已够用 */
|
||||
}
|
||||
onPick(hit);
|
||||
message.success('已选中打卡点,请看范围圈是否盖住楼');
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Space wrap style={{ marginBottom: 8 }}>
|
||||
<Button type="primary" onClick={() => setPicker(true)} disabled={!key}>
|
||||
地图选点
|
||||
</Button>
|
||||
<span style={{ color: 'var(--mute)', fontSize: 12 }}>
|
||||
在地图上点一下,紫色圈是打卡有效范围。调上面的半径会跟着变。
|
||||
</span>
|
||||
</Space>
|
||||
<div ref={box} style={{ width: '100%', height: 280, borderRadius: 8, overflow: 'hidden', background: '#eef2ff' }} />
|
||||
{picker && key ? (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
zIndex: 4000,
|
||||
background: 'rgba(15,23,42,.45)',
|
||||
display: 'grid',
|
||||
placeItems: 'center',
|
||||
padding: 24,
|
||||
}}
|
||||
>
|
||||
<div style={{ width: 'min(720px, 100%)', height: 'min(560px, 80vh)', background: '#fff', borderRadius: 12, overflow: 'hidden', display: 'flex', flexDirection: 'column' }}>
|
||||
<div style={{ padding: '8px 12px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<strong>点选打卡位置</strong>
|
||||
<Button onClick={() => setPicker(false)}>关闭</Button>
|
||||
</div>
|
||||
<iframe
|
||||
title="地图选点"
|
||||
style={{ flex: 1, border: 0 }}
|
||||
src={`https://apis.map.qq.com/tools/locpicker?search=1&type=1&key=${encodeURIComponent(key)}&referer=fengyingoa`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,357 @@
|
||||
import { Button, DatePicker, Form, Input, Popconfirm, Select, Space, Table, Tag, message } from 'antd';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import dayjs from 'dayjs';
|
||||
import { api } from '../api/client';
|
||||
import { FormDrawer } from './FormDrawer';
|
||||
import { PageHeader, unwrapList } from './ResourcePage';
|
||||
import { ApplyBucketBar } from './ApplyBucketBar';
|
||||
|
||||
export const QUAL_BUCKET_META: Record<string, { color: string; text: string }> = {
|
||||
valid: { color: 'success', text: '有效' },
|
||||
expiring: { color: 'gold', text: '即将到期' },
|
||||
expired: { color: 'error', text: '已过期' },
|
||||
};
|
||||
|
||||
export const BORROW_BUCKET_META: Record<string, { color: string; text: string }> = {
|
||||
borrowed: { color: 'blue', text: '外带中' },
|
||||
due: { color: 'gold', text: '应还' },
|
||||
overdue: { color: 'error', text: '未还逾期' },
|
||||
returned: { color: 'success', text: '已归还' },
|
||||
};
|
||||
|
||||
export const SEAL_BUCKET_META: Record<string, { color: string; text: string }> = {
|
||||
pending: { color: 'processing', text: '待审批' },
|
||||
approved: { color: 'success', text: '已通过' },
|
||||
rejected: { color: 'error', text: '已驳回' },
|
||||
out: { color: 'blue', text: '外带中' },
|
||||
overdue: { color: 'error', text: '外带未还' },
|
||||
returned: { color: 'success', text: '已归还' },
|
||||
};
|
||||
|
||||
export default function QualificationListPage() {
|
||||
const [params] = useSearchParams();
|
||||
const bucket = params.get('bucket') || '';
|
||||
const endpoint = bucket ? `/qualifications?bucket=${bucket}` : '/qualifications';
|
||||
const qc = useQueryClient();
|
||||
const [form] = Form.useForm();
|
||||
const [drawer, setDrawer] = useState<null | 'create' | { id: string }>(null);
|
||||
const q = useQuery({
|
||||
queryKey: [endpoint],
|
||||
queryFn: () => api.get(endpoint),
|
||||
});
|
||||
const { items } = unwrapList(q.data);
|
||||
const mut = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) => api.post('/qualifications', body),
|
||||
onSuccess: () => {
|
||||
message.success('已登记');
|
||||
qc.invalidateQueries();
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
|
||||
const openCreate = () => {
|
||||
form.resetFields();
|
||||
setDrawer('create');
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="资质台账"
|
||||
description="30 天内到期会标成即将到期,过期单独列出,方便投标当天核对。"
|
||||
extra={
|
||||
<ApplyBucketBar
|
||||
basePath="/seal/qualifications"
|
||||
buckets={[
|
||||
{ value: 'valid', label: '有效' },
|
||||
{ value: 'expiring', label: '即将到期' },
|
||||
{ value: 'expired', label: '已过期' },
|
||||
]}
|
||||
extra={
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
登记
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormDrawer
|
||||
title={drawer === 'create' ? '登记资质' : '编辑资质'}
|
||||
open={Boolean(drawer)}
|
||||
onClose={() => setDrawer(null)}
|
||||
okText="提交保存"
|
||||
okLoading={mut.isPending}
|
||||
onOk={async () => {
|
||||
const v = await form.validateFields();
|
||||
const body = {
|
||||
...v,
|
||||
expiresAt: v.expiresAt ? dayjs(v.expiresAt).format('YYYY-MM-DD') : undefined,
|
||||
};
|
||||
if (drawer === 'create') await mut.mutateAsync(body);
|
||||
else if (drawer && typeof drawer === 'object') {
|
||||
await api.patch(`/qualifications/${drawer.id}`, body);
|
||||
message.success('已保存');
|
||||
qc.invalidateQueries();
|
||||
}
|
||||
setDrawer(null);
|
||||
}}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="name" label="资质名称" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="code" label="编码" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="keeper" label="保管人">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="expiresAt" label="到期日">
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</FormDrawer>
|
||||
<Table
|
||||
className="ops-table"
|
||||
rowKey={(r) => String(r.id)}
|
||||
loading={q.isLoading}
|
||||
dataSource={items}
|
||||
scroll={{ x: 'max-content' }}
|
||||
tableLayout="fixed"
|
||||
columns={[
|
||||
{ title: '资质', dataIndex: 'name', width: 220, ellipsis: true },
|
||||
{ title: '编码', dataIndex: 'code', width: 140 },
|
||||
{ title: '保管人', dataIndex: 'keeper', width: 100 },
|
||||
{
|
||||
title: '到期日',
|
||||
dataIndex: 'expiresAt',
|
||||
width: 120,
|
||||
render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD') : '—'),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 170,
|
||||
render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '—'),
|
||||
},
|
||||
{
|
||||
title: '预警',
|
||||
dataIndex: 'bucket',
|
||||
width: 110,
|
||||
render: (v: string) => {
|
||||
const s = QUAL_BUCKET_META[v] || { color: 'default', text: v || '—' };
|
||||
return <Tag color={s.color}>{s.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 140,
|
||||
render: (_: unknown, r: Record<string, unknown>) => (
|
||||
<Space>
|
||||
<Button
|
||||
type="link"
|
||||
onClick={() => {
|
||||
form.setFieldsValue({
|
||||
name: r.name,
|
||||
code: r.code,
|
||||
keeper: r.keeper,
|
||||
expiresAt: r.expiresAt ? dayjs(String(r.expiresAt)) : undefined,
|
||||
});
|
||||
setDrawer({ id: String(r.id) });
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="确认删除这条资质?"
|
||||
onConfirm={async () => {
|
||||
await api.delete(`/qualifications/${r.id}`);
|
||||
message.success('已删除');
|
||||
qc.invalidateQueries();
|
||||
}}
|
||||
>
|
||||
<Button type="link" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function BorrowListPage() {
|
||||
const [params] = useSearchParams();
|
||||
const bucket = params.get('bucket') || '';
|
||||
const endpoint = bucket ? `/credential-borrows?bucket=${bucket}` : '/credential-borrows';
|
||||
const qc = useQueryClient();
|
||||
const [form] = Form.useForm();
|
||||
const [creating, setCreating] = useState(false);
|
||||
const q = useQuery({ queryKey: [endpoint], queryFn: () => api.get(endpoint) });
|
||||
const quals = useQuery({ queryKey: ['/qualifications'], queryFn: () => api.get('/qualifications') });
|
||||
const staff = useQuery({ queryKey: ['/staff'], queryFn: () => api.get('/staff') });
|
||||
const bids = useQuery({ queryKey: ['/bid-cases'], queryFn: () => api.get('/bid-cases') });
|
||||
const { items } = unwrapList(q.data);
|
||||
const createMut = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) => api.post('/credential-borrows', body),
|
||||
onSuccess: () => {
|
||||
message.success('已登记外带');
|
||||
qc.invalidateQueries();
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
const retMut = useMutation({
|
||||
mutationFn: (id: string) => api.post(`/credential-borrows/${id}/return`),
|
||||
onSuccess: () => {
|
||||
message.success('已归还');
|
||||
qc.invalidateQueries();
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
|
||||
const openCreate = () => {
|
||||
form.resetFields();
|
||||
setCreating(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="证照借用"
|
||||
description="外带必须登记预计归还。到期未还会在总览里预警。"
|
||||
extra={
|
||||
<ApplyBucketBar
|
||||
basePath="/seal/borrows"
|
||||
buckets={[
|
||||
{ value: 'borrowed', label: '外带中' },
|
||||
{ value: 'due', label: '应还' },
|
||||
{ value: 'overdue', label: '逾期未还' },
|
||||
{ value: 'returned', label: '已归还' },
|
||||
]}
|
||||
extra={
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
外带登记
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormDrawer
|
||||
title="登记证照外带"
|
||||
open={creating}
|
||||
onClose={() => setCreating(false)}
|
||||
okText="提交保存"
|
||||
okLoading={createMut.isPending}
|
||||
onOk={async () => {
|
||||
const v = await form.validateFields();
|
||||
await createMut.mutateAsync({ ...v, dueAt: v.dueAt ? dayjs(v.dueAt).format('YYYY-MM-DD') : undefined });
|
||||
setCreating(false);
|
||||
}}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="itemName" label="证照 / 物品" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="qualificationId" label="关联资质">
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
options={((quals.data?.data?.items ?? []) as { id: string; name: string }[]).map((x) => ({
|
||||
value: x.id,
|
||||
label: x.name,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="borrowerId" label="借用人" rules={[{ required: true }]}>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
options={((staff.data?.data ?? []) as { id: string; name: string }[]).map((s) => ({
|
||||
value: s.id,
|
||||
label: s.name,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="bidCaseId" label="关联投标">
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
options={((bids.data?.data?.items ?? []) as { id: string; bidNo: string; name: string }[]).map((b) => ({
|
||||
value: b.id,
|
||||
label: `${b.bidNo} ${b.name}`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="dueAt" label="预计归还" rules={[{ required: true }]}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="remark" label="说明">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</FormDrawer>
|
||||
<Table
|
||||
rowKey={(r) => String(r.id)}
|
||||
loading={q.isLoading}
|
||||
dataSource={items}
|
||||
columns={[
|
||||
{ title: '物品', dataIndex: 'itemName' },
|
||||
{ title: '借用人', dataIndex: 'borrower', width: 110 },
|
||||
{
|
||||
title: '资质',
|
||||
width: 180,
|
||||
render: (_: unknown, row: Record<string, unknown>) =>
|
||||
(row.qualification as { name?: string } | null)?.name || '—',
|
||||
},
|
||||
{
|
||||
title: '应还',
|
||||
dataIndex: 'dueAt',
|
||||
width: 120,
|
||||
render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD') : '—'),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'bucket',
|
||||
width: 110,
|
||||
render: (v: string) => {
|
||||
const s = BORROW_BUCKET_META[v] || { color: 'default', text: v };
|
||||
return <Tag color={s.color}>{s.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 140,
|
||||
render: (_: unknown, row: Record<string, unknown>) => (
|
||||
<Space>
|
||||
{row.status !== 'RETURNED' ? (
|
||||
<Button type="link" size="small" onClick={() => retMut.mutate(String(row.id))}>
|
||||
归还
|
||||
</Button>
|
||||
) : null}
|
||||
<Popconfirm
|
||||
title="确认删除这条借用记录?"
|
||||
onConfirm={async () => {
|
||||
await api.delete(`/credential-borrows/${row.id}`);
|
||||
message.success('已删除');
|
||||
qc.invalidateQueries();
|
||||
}}
|
||||
>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
const PALETTE = ['#5b4dff', '#7c6cff', '#3d8bfd', '#2bb673', '#f5a524', '#e5484d', '#8b8fa3', '#14b8a6'];
|
||||
|
||||
export type Slice = { label: string; value: number; color?: string };
|
||||
|
||||
function withColor(items: Slice[]): Required<Slice>[] {
|
||||
return items.map((s, i) => ({ ...s, color: s.color || PALETTE[i % PALETTE.length], value: Number(s.value) || 0 }));
|
||||
}
|
||||
|
||||
export function ChartCard({ title, children }: { title: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rpt-card">
|
||||
<div className="rpt-card-title">{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PieChart({ items, size = 180 }: { items: Slice[]; size?: number }) {
|
||||
const data = withColor(items).filter((s) => s.value > 0);
|
||||
const total = data.reduce((s, i) => s + i.value, 0) || 1;
|
||||
const r = size / 2 - 8;
|
||||
const cx = size / 2;
|
||||
const cy = size / 2;
|
||||
let a = -Math.PI / 2;
|
||||
const arcs = data.map((s) => {
|
||||
const sweep = (s.value / total) * Math.PI * 2;
|
||||
const x1 = cx + r * Math.cos(a);
|
||||
const y1 = cy + r * Math.sin(a);
|
||||
a += sweep;
|
||||
const x2 = cx + r * Math.cos(a);
|
||||
const y2 = cy + r * Math.sin(a);
|
||||
const large = sweep > Math.PI ? 1 : 0;
|
||||
return { ...s, d: `M ${cx} ${cy} L ${x1} ${y1} A ${r} ${r} 0 ${large} 1 ${x2} ${y2} Z` };
|
||||
});
|
||||
return (
|
||||
<div className="rpt-pie">
|
||||
<svg width={size} height={size} viewBox={`0 0 ${size} ${size}`}>
|
||||
{arcs.length ? (
|
||||
arcs.map((s) => <path key={s.label} d={s.d} fill={s.color} />)
|
||||
) : (
|
||||
<circle cx={cx} cy={cy} r={r} fill="#ecebff" />
|
||||
)}
|
||||
<circle cx={cx} cy={cy} r={r * 0.52} fill="#fff" />
|
||||
</svg>
|
||||
<ul>
|
||||
{withColor(items).map((s) => (
|
||||
<li key={s.label}>
|
||||
<i style={{ background: s.color }} />
|
||||
<span>{s.label}</span>
|
||||
<b>{s.value}</b>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FunnelChart({ items }: { items: Slice[] }) {
|
||||
const data = withColor(items);
|
||||
const max = Math.max(...data.map((s) => s.value), 1);
|
||||
return (
|
||||
<div className="rpt-funnel">
|
||||
{data.map((s) => {
|
||||
const w = 36 + (s.value / max) * 64;
|
||||
return (
|
||||
<div key={s.label} className="rpt-funnel-row">
|
||||
<div className="rpt-funnel-bar" style={{ width: `${w}%`, background: s.color }}>
|
||||
{s.value}
|
||||
</div>
|
||||
<span>{s.label}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BarChart({ items, unit = '' }: { items: Slice[]; unit?: string }) {
|
||||
const data = withColor(items);
|
||||
const max = Math.max(...data.map((s) => Math.abs(s.value)), 1);
|
||||
return (
|
||||
<div className="rpt-bars">
|
||||
{data.map((s) => (
|
||||
<div key={s.label} className="rpt-bar-row">
|
||||
<span className="rpt-bar-label" title={s.label}>
|
||||
{s.label}
|
||||
</span>
|
||||
<div className="rpt-bar-track">
|
||||
<div
|
||||
className="rpt-bar-fill"
|
||||
style={{
|
||||
width: `${(Math.abs(s.value) / max) * 100}%`,
|
||||
background: s.value < 0 ? '#e5484d' : s.color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<b>
|
||||
{s.value}
|
||||
{unit}
|
||||
</b>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function TreeMap({ items }: { items: Slice[] }) {
|
||||
const data = withColor(items).filter((s) => s.value > 0).sort((a, b) => b.value - a.value);
|
||||
const total = data.reduce((s, i) => s + i.value, 0) || 1;
|
||||
return (
|
||||
<div className="rpt-tree">
|
||||
{data.map((s) => (
|
||||
<div
|
||||
key={s.label}
|
||||
className="rpt-tree-cell"
|
||||
style={{ flexGrow: s.value, flexBasis: `${Math.max((s.value / total) * 100, 12)}%`, background: s.color }}
|
||||
>
|
||||
<b>{s.label}</b>
|
||||
<span>{s.value}</span>
|
||||
</div>
|
||||
))}
|
||||
{data.length === 0 ? <div className="rpt-tree-empty">暂无数据</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LineChart({ items }: { items: Slice[] }) {
|
||||
const data = withColor(items);
|
||||
const w = 520;
|
||||
const h = 180;
|
||||
const pad = 28;
|
||||
const max = Math.max(...data.map((s) => s.value), 1);
|
||||
const min = Math.min(...data.map((s) => s.value), 0);
|
||||
const span = max - min || 1;
|
||||
const pts = data.map((s, i) => {
|
||||
const x = pad + (i * (w - pad * 2)) / Math.max(data.length - 1, 1);
|
||||
const y = h - pad - ((s.value - min) / span) * (h - pad * 2);
|
||||
return { ...s, x, y };
|
||||
});
|
||||
const line = pts.map((p) => `${p.x},${p.y}`).join(' ');
|
||||
return (
|
||||
<svg className="rpt-line" viewBox={`0 0 ${w} ${h}`} width="100%" height={h}>
|
||||
<polyline fill="none" stroke="#5b4dff" strokeWidth="2.5" points={line} />
|
||||
{pts.map((p) => (
|
||||
<g key={p.label}>
|
||||
<circle cx={p.x} cy={p.y} r="4" fill="#5b4dff" />
|
||||
<text x={p.x} y={h - 8} textAnchor="middle" fontSize="10" fill="#6b6f80">
|
||||
{p.label.length > 6 ? `${p.label.slice(0, 6)}…` : p.label}
|
||||
</text>
|
||||
</g>
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,382 @@
|
||||
import type { Key, ReactNode } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Alert, Button, Checkbox, DatePicker, Form, Input, Modal, Radio, Select, Space, Table, Typography } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import type { MouseEvent } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '../api/client';
|
||||
import { fmtCreated } from '../time';
|
||||
import { colsFromTable, downloadExcel, type ExportCol } from './exportExcel';
|
||||
|
||||
function colTitle(c: { title?: unknown }) {
|
||||
return typeof c.title === 'string' ? c.title : '';
|
||||
}
|
||||
|
||||
/** 列都给宽度,避免名称列被撑出大片空白、右侧挤成一团。没有创建时间的列表自动补上。 */
|
||||
export function withListDefaults(columns: ColumnsType<Record<string, unknown>>): ColumnsType<Record<string, unknown>> {
|
||||
const mapped = columns.map((c) => {
|
||||
if ('children' in c && c.children) return c;
|
||||
const title = colTitle(c);
|
||||
const next = { ...c, ellipsis: c.ellipsis ?? true };
|
||||
if (!next.width) {
|
||||
if (/名称|事由|用途|标题|说明/.test(title)) next.width = 240;
|
||||
else if (/编号|单号|合同号/.test(title)) next.width = 170;
|
||||
else next.width = 120;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
const hasCreated = mapped.some(
|
||||
(c) => colTitle(c).includes('创建时间') || (c as { dataIndex?: string }).dataIndex === 'createdAt',
|
||||
);
|
||||
if (hasCreated) return mapped;
|
||||
const created = {
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 170,
|
||||
render: (v: string) => fmtCreated(v),
|
||||
};
|
||||
const actionIdx = mapped.findIndex((c) => /操作/.test(colTitle(c)));
|
||||
if (actionIdx >= 0) return [...mapped.slice(0, actionIdx), created, ...mapped.slice(actionIdx)];
|
||||
return [...mapped, created];
|
||||
}
|
||||
|
||||
const { Title, Paragraph } = Typography;
|
||||
|
||||
export function PageHeader({
|
||||
title,
|
||||
extra,
|
||||
description,
|
||||
}: {
|
||||
title: string;
|
||||
extra?: ReactNode;
|
||||
description?: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="ops-page-head">
|
||||
<div>
|
||||
<Title level={4} className="yzl-page-title" style={{ margin: 0 }}>
|
||||
{title}
|
||||
</Title>
|
||||
{description ? (
|
||||
<Paragraph type="secondary" style={{ margin: '8px 0 0' }}>
|
||||
{description}
|
||||
</Paragraph>
|
||||
) : null}
|
||||
</div>
|
||||
{extra}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function unwrapList(data: unknown): { items: Record<string, unknown>[]; total?: number } {
|
||||
const body = (data as { data?: unknown })?.data ?? data;
|
||||
if (Array.isArray(body)) return { items: body as Record<string, unknown>[] };
|
||||
if (body && typeof body === 'object' && 'items' in body) {
|
||||
const page = body as { items: Record<string, unknown>[]; total?: number };
|
||||
return { items: page.items ?? [], total: page.total };
|
||||
}
|
||||
return { items: [] };
|
||||
}
|
||||
|
||||
function withPage(endpoint: string, page: number, pageSize: number, keyword: string) {
|
||||
const u = new URL(endpoint, 'http://local');
|
||||
u.searchParams.set('page', String(page));
|
||||
u.searchParams.set('pageSize', String(pageSize));
|
||||
if (keyword.trim()) u.searchParams.set('keyword', keyword.trim());
|
||||
else u.searchParams.delete('keyword');
|
||||
return `${u.pathname}${u.search}`;
|
||||
}
|
||||
|
||||
export const LIST_PAGE_SIZE = 10;
|
||||
|
||||
export type ListFilter = {
|
||||
key: string;
|
||||
label: string;
|
||||
type?: 'select' | 'dateRange';
|
||||
options?: { label: string; value: string }[];
|
||||
};
|
||||
|
||||
export function ResourcePage({
|
||||
title,
|
||||
description,
|
||||
endpoint,
|
||||
columns,
|
||||
extra,
|
||||
searchPlaceholder = '搜索单号、名称',
|
||||
emptyText = '还没有记录。用右上角按钮新增。',
|
||||
onRowClick,
|
||||
toolbarExtra,
|
||||
filters,
|
||||
exportName,
|
||||
exportColumns,
|
||||
}: {
|
||||
title: string;
|
||||
description?: string;
|
||||
endpoint: string;
|
||||
columns: ColumnsType<Record<string, unknown>>;
|
||||
extra?: ReactNode;
|
||||
searchPlaceholder?: string;
|
||||
emptyText?: string;
|
||||
onRowClick?: (row: Record<string, unknown>) => void;
|
||||
toolbarExtra?: ReactNode;
|
||||
filters?: ListFilter[];
|
||||
exportName?: string;
|
||||
exportColumns?: ExportCol[];
|
||||
}) {
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(LIST_PAGE_SIZE);
|
||||
const [draft, setDraft] = useState('');
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [filterVals, setFilterVals] = useState<Record<string, string | undefined>>({});
|
||||
const [exportOpen, setExportOpen] = useState(false);
|
||||
const [exportKeys, setExportKeys] = useState<string[]>([]);
|
||||
const [exportScope, setExportScope] = useState<'selected' | 'filtered' | 'all'>('filtered');
|
||||
const [selectedKeys, setSelectedKeys] = useState<React.Key[]>([]);
|
||||
const url = withPage(endpoint, page, pageSize, keyword);
|
||||
const q = useQuery({
|
||||
queryKey: [endpoint, page, pageSize, keyword],
|
||||
queryFn: () => api.get(url),
|
||||
retry: 1,
|
||||
});
|
||||
const { items: rawItems, total } = unwrapList(q.data);
|
||||
const items = useMemo(() => {
|
||||
return rawItems.filter((row) =>
|
||||
(filters || []).every((f) => {
|
||||
const v = filterVals[f.key];
|
||||
if (!v) return true;
|
||||
if (f.type === 'dateRange') {
|
||||
const [a, b] = v.split('/');
|
||||
const raw = String(row[f.key] || '');
|
||||
if (!raw) return false;
|
||||
const day = raw.slice(0, 10);
|
||||
if (a && day < a) return false;
|
||||
if (b && day > b) return false;
|
||||
return true;
|
||||
}
|
||||
return String(row[f.key] ?? '') === v;
|
||||
}),
|
||||
);
|
||||
}, [rawItems, filters, filterVals]);
|
||||
const listCols = useMemo(() => withListDefaults(columns), [columns]);
|
||||
const xcols = exportColumns || colsFromTable(listCols as { title?: unknown; dataIndex?: unknown; key?: unknown }[]);
|
||||
const matchFilters = (row: Record<string, unknown>) =>
|
||||
(filters || []).every((f) => {
|
||||
const v = filterVals[f.key];
|
||||
if (!v) return true;
|
||||
if (f.type === 'dateRange') {
|
||||
const [a, b] = v.split('/');
|
||||
const raw = String(row[f.key] || '');
|
||||
if (!raw) return false;
|
||||
const day = raw.slice(0, 10);
|
||||
if (a && day < a) return false;
|
||||
if (b && day > b) return false;
|
||||
return true;
|
||||
}
|
||||
return String(row[f.key] ?? '') === v;
|
||||
});
|
||||
const doExport = async () => {
|
||||
const picked = xcols.filter((c) => exportKeys.includes(c.key));
|
||||
if (!picked.length) return;
|
||||
let rows = items;
|
||||
if (exportScope === 'selected') {
|
||||
const set = new Set(selectedKeys.map(String));
|
||||
rows = items.filter((r) => set.has(String(r.id ?? r.code ?? r.name)));
|
||||
if (!rows.length) {
|
||||
return;
|
||||
}
|
||||
} else if (exportScope === 'all') {
|
||||
const all = unwrapList(await api.get(withPage(endpoint, 1, 5000, keyword))).items;
|
||||
rows = all.filter(matchFilters);
|
||||
}
|
||||
downloadExcel(exportName || title, picked, rows);
|
||||
setExportOpen(false);
|
||||
};
|
||||
return (
|
||||
<>
|
||||
<PageHeader title={title} description={description} extra={extra} />
|
||||
<div className="ops-toolbar" style={{ flexWrap: 'wrap', gap: 8 }}>
|
||||
<Input.Search
|
||||
allowClear
|
||||
enterButton
|
||||
value={draft}
|
||||
placeholder={searchPlaceholder}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
onSearch={(v) => {
|
||||
setKeyword(v);
|
||||
setPage(1);
|
||||
}}
|
||||
className="yzl-search"
|
||||
style={{ maxWidth: 360 }}
|
||||
/>
|
||||
{(filters || []).map((f) =>
|
||||
f.type === 'dateRange' ? (
|
||||
<DatePicker.RangePicker
|
||||
key={f.key}
|
||||
style={{ width: 240 }}
|
||||
placeholder={[`${f.label}起`, `${f.label}止`]}
|
||||
onChange={(vals) => {
|
||||
setFilterVals((prev) => ({
|
||||
...prev,
|
||||
[f.key]: vals?.[0] && vals[1] ? `${vals[0].format('YYYY-MM-DD')}/${vals[1].format('YYYY-MM-DD')}` : undefined,
|
||||
}));
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
key={f.key}
|
||||
allowClear
|
||||
placeholder={f.label}
|
||||
style={{ minWidth: 140 }}
|
||||
value={filterVals[f.key]}
|
||||
options={f.options}
|
||||
onChange={(v) => {
|
||||
setFilterVals((prev) => ({ ...prev, [f.key]: v }));
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
)}
|
||||
{toolbarExtra}
|
||||
<Button
|
||||
onClick={() => {
|
||||
setExportKeys(xcols.map((c) => c.key));
|
||||
setExportScope(selectedKeys.length ? 'selected' : 'filtered');
|
||||
setExportOpen(true);
|
||||
}}
|
||||
>
|
||||
导出 Excel
|
||||
</Button>
|
||||
</div>
|
||||
<Modal
|
||||
title={`导出 ${title}`}
|
||||
open={exportOpen}
|
||||
onCancel={() => setExportOpen(false)}
|
||||
onOk={() => void doExport()}
|
||||
okText="开始导出"
|
||||
>
|
||||
<p style={{ marginBottom: 8 }}>先勾选要导出的列,再选范围:勾选行、当前筛选结果,或全部。</p>
|
||||
<Radio.Group
|
||||
value={exportScope}
|
||||
onChange={(e) => setExportScope(e.target.value)}
|
||||
style={{ marginBottom: 12, display: 'flex', flexDirection: 'column', gap: 6 }}
|
||||
>
|
||||
<Radio value="selected" disabled={!selectedKeys.length}>
|
||||
仅导出勾选的 {selectedKeys.length} 行
|
||||
</Radio>
|
||||
<Radio value="filtered">导出当前筛选结果(本页)</Radio>
|
||||
<Radio value="all">导出全部(含其他页,仍按筛选)</Radio>
|
||||
</Radio.Group>
|
||||
<div>
|
||||
<Checkbox
|
||||
indeterminate={exportKeys.length > 0 && exportKeys.length < xcols.length}
|
||||
checked={exportKeys.length === xcols.length}
|
||||
onChange={(e) => setExportKeys(e.target.checked ? xcols.map((c) => c.key) : [])}
|
||||
>
|
||||
全选列
|
||||
</Checkbox>
|
||||
<Checkbox.Group
|
||||
style={{ display: 'flex', flexDirection: 'column', marginTop: 8, gap: 6 }}
|
||||
value={exportKeys}
|
||||
onChange={(v) => setExportKeys(v as string[])}
|
||||
options={xcols.map((c) => ({ label: c.title, value: c.key }))}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
{q.isError ? (
|
||||
<Alert
|
||||
type="error"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message="列表加载失败"
|
||||
description={(q.error as Error).message || '请稍后重试'}
|
||||
action={<Button size="small" onClick={() => void q.refetch()}>重试</Button>}
|
||||
/>
|
||||
) : null}
|
||||
<Table
|
||||
className={onRowClick ? 'ops-table ops-table-click' : 'ops-table'}
|
||||
rowKey={(r) => String(r.id ?? r.code ?? r.name)}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedKeys,
|
||||
onChange: (keys) => setSelectedKeys(keys as Key[]),
|
||||
}}
|
||||
loading={q.isLoading && !q.isError}
|
||||
columns={listCols}
|
||||
scroll={{ x: 'max-content' }}
|
||||
tableLayout="fixed"
|
||||
dataSource={items}
|
||||
onRow={
|
||||
onRowClick
|
||||
? (row) => ({
|
||||
onClick: (e: MouseEvent) => {
|
||||
if ((e.target as HTMLElement).closest('a,button,.ant-btn')) return;
|
||||
onRowClick(row);
|
||||
},
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
locale={{ emptyText }}
|
||||
pagination={{
|
||||
current: page,
|
||||
pageSize,
|
||||
total: total ?? items.length,
|
||||
showSizeChanger: true,
|
||||
showQuickJumper: true,
|
||||
pageSizeOptions: [10, 20, 50, 100],
|
||||
showTotal: (n) => `共 ${n} 条`,
|
||||
onChange: (p, ps) => {
|
||||
setPage(p);
|
||||
setPageSize(ps);
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function SimpleCreateForm({
|
||||
endpoint,
|
||||
fields,
|
||||
onOk,
|
||||
}: {
|
||||
endpoint: string;
|
||||
fields: { name: string; label: string; required?: boolean }[];
|
||||
onOk?: () => void;
|
||||
}) {
|
||||
const [form] = Form.useForm();
|
||||
const qc = useQueryClient();
|
||||
const mut = useMutation({
|
||||
mutationFn: (values: Record<string, string>) => api.post(endpoint, values),
|
||||
onSuccess: () => {
|
||||
form.resetFields();
|
||||
qc.invalidateQueries();
|
||||
onOk?.();
|
||||
},
|
||||
});
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
onFinish={(v) => mut.mutate(v)}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
{fields.map((f) => (
|
||||
<Form.Item
|
||||
key={f.name}
|
||||
name={f.name}
|
||||
label={f.label}
|
||||
rules={f.required ? [{ required: true }] : undefined}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
))}
|
||||
<Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" htmlType="submit" loading={mut.isPending}>
|
||||
新增
|
||||
</Button>
|
||||
</Space>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Editor, Toolbar } from '@wangeditor/editor-for-react';
|
||||
import type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor';
|
||||
import '@wangeditor/editor/dist/css/style.css';
|
||||
import { message } from 'antd';
|
||||
import { uploadBizFile } from './FileAttachments';
|
||||
|
||||
type Props = {
|
||||
value?: string;
|
||||
onChange?: (html: string) => void;
|
||||
bizId?: string;
|
||||
placeholder?: string;
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
export default function RichEditor({ value = '', onChange, bizId, placeholder, readOnly }: Props) {
|
||||
const [editor, setEditor] = useState<IDomEditor | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (editor == null) return;
|
||||
editor.destroy();
|
||||
setEditor(null);
|
||||
};
|
||||
}, [editor]);
|
||||
|
||||
const toolbarConfig: Partial<IToolbarConfig> = {
|
||||
excludeKeys: ['insertVideo', 'uploadVideo', 'group-video', 'emotion'],
|
||||
};
|
||||
const editorConfig: Partial<IEditorConfig> = {
|
||||
placeholder: placeholder || '请输入正文',
|
||||
readOnly: Boolean(readOnly) || !bizId,
|
||||
MENU_CONF: {
|
||||
uploadImage: {
|
||||
async customUpload(file: File, insertFn: (url: string, alt: string, href: string) => void) {
|
||||
if (!bizId) {
|
||||
message.warning('请先保存草稿再插入图片');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const row = await uploadBizFile('NOTICE', bizId, file);
|
||||
insertFn(`/api/v1/files/${row.id}`, file.name, `/api/v1/files/${row.id}`);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '图片上传失败');
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="notice-editor">
|
||||
<Toolbar editor={editor} defaultConfig={toolbarConfig} mode="default" style={{ borderBottom: '1px solid #e8e8ef' }} />
|
||||
<Editor
|
||||
defaultConfig={editorConfig}
|
||||
value={value}
|
||||
onCreated={setEditor}
|
||||
onChange={(ed) => {
|
||||
const next = ed.getHtml();
|
||||
if (next !== value) onChange?.(next);
|
||||
}}
|
||||
mode="default"
|
||||
style={{ height: 420, overflowY: 'hidden' }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import { Button, Checkbox, DatePicker, Form, Input, Popconfirm, Select, Space, Table, Tag, message } from 'antd';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import { Link, useSearchParams } from 'react-router-dom';
|
||||
import dayjs from 'dayjs';
|
||||
import { api } from '../api/client';
|
||||
import { useAuth } from '../store/auth';
|
||||
import { FormDrawer } from './FormDrawer';
|
||||
import { PageHeader, unwrapList } from './ResourcePage';
|
||||
import { SEAL_BUCKET_META } from './QualificationListPage';
|
||||
import { ApplyBucketBar, PROCESS_BUCKETS } from './ApplyBucketBar';
|
||||
|
||||
type SealRow = {
|
||||
id: string;
|
||||
sealType: string;
|
||||
reason: string;
|
||||
takeOut: boolean;
|
||||
status: string;
|
||||
bucket: string;
|
||||
returnAt?: string | null;
|
||||
returnedAt?: string | null;
|
||||
bidCase?: { id: string; bidNo: string } | null;
|
||||
contract?: { id: string; contractNo: string } | null;
|
||||
applicant?: { displayName: string } | null;
|
||||
myActions?: { canReview: boolean; canReturn: boolean };
|
||||
createdAt?: string;
|
||||
};
|
||||
|
||||
const SEAL_TYPE_OPTIONS = ['公章', '合同章', '法人章', '法人签名章', '项目负责人章', '其他章'].map((v) => ({
|
||||
value: v,
|
||||
label: v,
|
||||
}));
|
||||
|
||||
export default function SealRequestListPage({ mode = 'register' }: { mode?: 'apply' | 'register' }) {
|
||||
const [params] = useSearchParams();
|
||||
const bucket = params.get('bucket') || '';
|
||||
const mine = mode === 'apply';
|
||||
const endpoint = mine
|
||||
? `/seal-requests?scope=mine${bucket ? `&bucket=${bucket}` : ''}`
|
||||
: bucket
|
||||
? `/seal-requests?bucket=${bucket}`
|
||||
: '/seal-requests';
|
||||
const userId = useAuth((s) => s.id);
|
||||
const qc = useQueryClient();
|
||||
const [form] = Form.useForm();
|
||||
const [creating, setCreating] = useState(false);
|
||||
const q = useQuery({ queryKey: [endpoint], queryFn: () => api.get(endpoint) });
|
||||
const bids = useQuery({ queryKey: ['/bid-cases'], queryFn: () => api.get('/bid-cases') });
|
||||
const contracts = useQuery({ queryKey: ['/contracts'], queryFn: () => api.get('/contracts') });
|
||||
const staff = useQuery({ queryKey: ['/staff'], queryFn: () => api.get('/staff') });
|
||||
const staffOpts = ((staff.data as { data?: { id: string; name: string; department?: string }[] })?.data || []).map(
|
||||
(s) => ({
|
||||
value: s.id,
|
||||
label: `${s.name}${s.department ? `(${s.department})` : ''}`,
|
||||
}),
|
||||
);
|
||||
const { items } = unwrapList(q.data) as { items: SealRow[] };
|
||||
const createMut = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) => api.post('/seal-requests', body),
|
||||
onSuccess: () => {
|
||||
message.success('已提交用章申请');
|
||||
qc.invalidateQueries();
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
const actMut = useMutation({
|
||||
mutationFn: ({
|
||||
id,
|
||||
action,
|
||||
result,
|
||||
comment,
|
||||
}: {
|
||||
id: string;
|
||||
action: 'review' | 'return';
|
||||
result?: 'APPROVED' | 'REJECTED';
|
||||
comment?: string;
|
||||
}) =>
|
||||
action === 'return'
|
||||
? api.post(`/seal-requests/${id}/return`)
|
||||
: api.post(`/seal-requests/${id}/reviews`, { result, comment }),
|
||||
onSuccess: () => {
|
||||
message.success('已办理');
|
||||
qc.invalidateQueries();
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
|
||||
const openCreate = () => {
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ applicantId: userId });
|
||||
setCreating(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title={mine ? '用章申请' : '用章登记'}
|
||||
description={
|
||||
mine
|
||||
? '个人发起用章。可关联合同或投标,外带需填写预计归还日期。'
|
||||
: '证照用章登记台账。外带经审批后未按期归还会预警。'
|
||||
}
|
||||
extra={
|
||||
<ApplyBucketBar
|
||||
basePath={mine ? '/office/seal-apply' : '/seal/requests'}
|
||||
buckets={[
|
||||
...PROCESS_BUCKETS,
|
||||
{ value: 'out', label: '外带中' },
|
||||
{ value: 'overdue', label: '逾期未还' },
|
||||
{ value: 'returned', label: '已归还' },
|
||||
]}
|
||||
extra={
|
||||
<Button type="primary" onClick={openCreate}>
|
||||
申请用章
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
<FormDrawer
|
||||
title="申请用章"
|
||||
open={creating}
|
||||
onClose={() => setCreating(false)}
|
||||
okText="提交保存"
|
||||
okLoading={createMut.isPending}
|
||||
onOk={async () => {
|
||||
const v = await form.validateFields();
|
||||
await createMut.mutateAsync({
|
||||
...v,
|
||||
returnAt: v.returnAt ? dayjs(v.returnAt).format('YYYY-MM-DD') : undefined,
|
||||
});
|
||||
setCreating(false);
|
||||
}}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="applicantId" label="申请人" rules={[{ required: true, message: '请选择申请人' }]}>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="选择公司内部员工"
|
||||
options={staffOpts}
|
||||
disabled
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="approverId" label="审批人" rules={[{ required: true, message: '请选择审批人' }]}>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="选择本次用章审批人"
|
||||
options={staffOpts}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="sealType" label="印章类型" rules={[{ required: true }]}>
|
||||
<Select options={SEAL_TYPE_OPTIONS} placeholder="公章 / 合同章 / 法人章" />
|
||||
</Form.Item>
|
||||
<Form.Item name="reason" label="事由" rules={[{ required: true }]}>
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
<Form.Item name="bidCaseId" label="关联投标">
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
options={((bids.data?.data?.items ?? []) as { id: string; bidNo: string; name: string }[]).map((b) => ({
|
||||
value: b.id,
|
||||
label: `${b.bidNo} ${b.name}`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="contractId" label="关联合同">
|
||||
<Select
|
||||
allowClear
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
options={((contracts.data?.data?.items ?? []) as { id: string; contractNo: string; name: string }[]).map(
|
||||
(c) => ({ value: c.id, label: `${c.contractNo} ${c.name}` }),
|
||||
)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="takeOut" valuePropName="checked">
|
||||
<Checkbox>外带</Checkbox>
|
||||
</Form.Item>
|
||||
<Form.Item noStyle shouldUpdate>
|
||||
{() =>
|
||||
form.getFieldValue('takeOut') ? (
|
||||
<Form.Item name="returnAt" label="预计归还" rules={[{ required: true, message: '外带请填写预计归还' }]}>
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
) : null
|
||||
}
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</FormDrawer>
|
||||
<Table
|
||||
className="ops-table"
|
||||
rowKey="id"
|
||||
loading={q.isLoading}
|
||||
dataSource={items}
|
||||
pagination={{
|
||||
defaultPageSize: 10,
|
||||
showSizeChanger: true,
|
||||
showTotal: (n) => `共 ${n} 条`,
|
||||
}}
|
||||
scroll={{ x: 'max-content' }}
|
||||
tableLayout="fixed"
|
||||
columns={[
|
||||
{ title: '印章', dataIndex: 'sealType', width: 110 },
|
||||
{
|
||||
title: '申请人',
|
||||
width: 90,
|
||||
render: (_: unknown, r: SealRow) => r.applicant?.displayName || '—',
|
||||
},
|
||||
{ title: '事由', dataIndex: 'reason', width: 240, ellipsis: true },
|
||||
{
|
||||
title: '关联',
|
||||
width: 160,
|
||||
render: (_: unknown, r: SealRow) =>
|
||||
r.contract ? (
|
||||
<Link to={`/contract/${r.contract.id}`}>{r.contract.contractNo}</Link>
|
||||
) : r.bidCase ? (
|
||||
<Link to={`/bid/cases/${r.bidCase.id}`}>{r.bidCase.bidNo}</Link>
|
||||
) : (
|
||||
'—'
|
||||
),
|
||||
},
|
||||
{ title: '外带', dataIndex: 'takeOut', width: 70, render: (v: boolean) => (v ? '是' : '否') },
|
||||
{
|
||||
title: '应还',
|
||||
dataIndex: 'returnAt',
|
||||
width: 120,
|
||||
render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD') : '—'),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 170,
|
||||
render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '—'),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'bucket',
|
||||
width: 110,
|
||||
render: (v: string) => {
|
||||
const s = SEAL_BUCKET_META[v] || { color: 'default', text: v };
|
||||
return <Tag color={s.color}>{s.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 220,
|
||||
render: (_: unknown, r: SealRow) => (
|
||||
<Space>
|
||||
{r.myActions?.canReview ? (
|
||||
<>
|
||||
<Button type="link" size="small" onClick={() => actMut.mutate({ id: r.id, action: 'review', result: 'APPROVED' })}>
|
||||
通过
|
||||
</Button>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
onClick={() => {
|
||||
const comment = window.prompt('驳回意见');
|
||||
if (comment) actMut.mutate({ id: r.id, action: 'review', result: 'REJECTED', comment });
|
||||
}}
|
||||
>
|
||||
驳回
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
{r.myActions?.canReturn ? (
|
||||
<Button type="link" size="small" onClick={() => actMut.mutate({ id: r.id, action: 'return' })}>
|
||||
归还
|
||||
</Button>
|
||||
) : null}
|
||||
<Popconfirm
|
||||
title="确认删除这条用章记录?"
|
||||
onConfirm={async () => {
|
||||
await api.delete(`/seal-requests/${r.id}`);
|
||||
message.success('已删除');
|
||||
qc.invalidateQueries();
|
||||
}}
|
||||
>
|
||||
<Button type="link" size="small" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,577 @@
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Descriptions,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Table,
|
||||
Tabs,
|
||||
Tag,
|
||||
AutoComplete,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '../api/client';
|
||||
import { PageHeader } from './ResourcePage';
|
||||
import { StatusTag } from '../StatusTag';
|
||||
|
||||
type SignRow = {
|
||||
signName?: string;
|
||||
SignName?: string;
|
||||
auditStatus?: string | number;
|
||||
AuditStatus?: string | number;
|
||||
createDate?: string;
|
||||
CreateDate?: string;
|
||||
reason?: string;
|
||||
Reason?: string;
|
||||
signSource?: number;
|
||||
SignSource?: number;
|
||||
signType?: number;
|
||||
SignType?: number;
|
||||
businessType?: string;
|
||||
BusinessType?: string;
|
||||
};
|
||||
type TplRow = {
|
||||
templateCode?: string;
|
||||
TemplateCode?: string;
|
||||
templateName?: string;
|
||||
TemplateName?: string;
|
||||
auditStatus?: string | number;
|
||||
AuditStatus?: string | number;
|
||||
templateContent?: string;
|
||||
TemplateContent?: string;
|
||||
templateType?: number;
|
||||
TemplateType?: number;
|
||||
createDate?: string;
|
||||
CreateDate?: string;
|
||||
reason?: string;
|
||||
Reason?: string;
|
||||
};
|
||||
|
||||
const SIGN_SOURCE: Record<number, string> = {
|
||||
0: '企事业单位全称或简称',
|
||||
1: '工信部备案网站全称或简称',
|
||||
2: 'App 全称或简称',
|
||||
3: '公众号或小程序全称或简称',
|
||||
4: '电商平台店铺名',
|
||||
5: '商标全称或简称',
|
||||
};
|
||||
|
||||
const SIGN_TYPE: Record<number, string> = { 0: '验证码', 1: '通用' };
|
||||
const TPL_TYPE: Record<number, string> = {
|
||||
0: '验证码',
|
||||
1: '通知短信',
|
||||
2: '推广短信',
|
||||
3: '国际/港澳台',
|
||||
7: '数字短信',
|
||||
};
|
||||
|
||||
function auditText(v: unknown) {
|
||||
const s = String(v ?? '');
|
||||
if (s === '1' || s === 'AUDIT_STATE_PASS' || s === 'PASS') return { color: 'success', text: '已通过' };
|
||||
if (s === '0' || s === 'AUDIT_STATE_INIT' || s === 'AUDITING') return { color: 'processing', text: '审核中' };
|
||||
if (s === '2' || s === 'AUDIT_STATE_NOT_PASS' || s === 'NOT_PASS') return { color: 'error', text: '未通过' };
|
||||
if (s === '3' || s === 'CANCEL' || s === 'AUDIT_STATE_CANCEL') return { color: 'default', text: '已取消' };
|
||||
return { color: 'default', text: s ? zh(s) : '—' };
|
||||
}
|
||||
|
||||
function unwrapList<T>(data: unknown, keys: string[]): T[] {
|
||||
const body = (data as { data?: unknown })?.data ?? data;
|
||||
if (!body || typeof body !== 'object') return [];
|
||||
const obj = body as Record<string, unknown>;
|
||||
for (const k of keys) {
|
||||
const v = obj[k];
|
||||
if (Array.isArray(v)) return v as T[];
|
||||
}
|
||||
if (Array.isArray(body)) return body as T[];
|
||||
return [];
|
||||
}
|
||||
|
||||
function zh(s: string) {
|
||||
return s;
|
||||
}
|
||||
|
||||
function dash(v?: string | null) {
|
||||
return v && String(v).trim() ? v : '未绑定';
|
||||
}
|
||||
|
||||
export default function SmsPage() {
|
||||
const qc = useQueryClient();
|
||||
const cfg = useQuery({ queryKey: ['/sms/config'], queryFn: () => api.get('/sms/config') });
|
||||
const signs = useQuery({
|
||||
queryKey: ['/sms/signs'],
|
||||
queryFn: () => api.get('/sms/signs'),
|
||||
retry: false,
|
||||
});
|
||||
const tpls = useQuery({
|
||||
queryKey: ['/sms/templates'],
|
||||
queryFn: () => api.get('/sms/templates'),
|
||||
retry: false,
|
||||
});
|
||||
const quals = useQuery({
|
||||
queryKey: ['/sms/qualifications'],
|
||||
queryFn: () => api.get('/sms/qualifications'),
|
||||
retry: false,
|
||||
});
|
||||
const logs = useQuery({ queryKey: ['/sms/logs'], queryFn: () => api.get('/sms/logs') });
|
||||
const events = useQuery({ queryKey: ['/sms/events'], queryFn: () => api.get('/sms/events') });
|
||||
const [signForm] = Form.useForm();
|
||||
const [tplForm] = Form.useForm();
|
||||
const info = (cfg.data?.data ?? {}) as {
|
||||
configured?: boolean;
|
||||
defaultSign?: string;
|
||||
note?: string;
|
||||
region?: string;
|
||||
endpoint?: string;
|
||||
ramLogin?: string;
|
||||
accessKeyIdMasked?: string;
|
||||
secretConfigured?: boolean;
|
||||
securityPhone?: string;
|
||||
securityEmail?: string;
|
||||
presets?: { code: string; name: string }[];
|
||||
};
|
||||
const signRows = unwrapList<SignRow>(signs.data, ['smsSignList', 'SmsSignList']);
|
||||
const tplRows = unwrapList<TplRow>(tpls.data, ['smsTemplateList', 'SmsTemplateList']);
|
||||
const qualRows = unwrapList<{
|
||||
qualificationId?: string;
|
||||
QualificationId?: string;
|
||||
qualificationName?: string;
|
||||
QualificationName?: string;
|
||||
companyName?: string;
|
||||
CompanyName?: string;
|
||||
state?: string;
|
||||
State?: string;
|
||||
}>(quals.data, ['qualificationInfos', 'QualificationInfos', 'records']);
|
||||
const logRows = unwrapList<{
|
||||
id: string;
|
||||
phones: string;
|
||||
signName: string;
|
||||
templateCode: string;
|
||||
eventKey?: string;
|
||||
code: string;
|
||||
message?: string;
|
||||
createdAt: string;
|
||||
}>(logs.data, ['items']);
|
||||
const eventPack = (events.data?.data ?? {}) as {
|
||||
configured?: boolean;
|
||||
defaultSign?: string;
|
||||
presets?: { code: string; name: string }[];
|
||||
items?: {
|
||||
key: string;
|
||||
group: string;
|
||||
name: string;
|
||||
when: string;
|
||||
to: string;
|
||||
enabled: boolean;
|
||||
automatic?: boolean;
|
||||
templateCode: string;
|
||||
signName: string;
|
||||
sample: Record<string, string>;
|
||||
vars?: string;
|
||||
}[];
|
||||
};
|
||||
const eventRows = eventPack.items || [];
|
||||
const eventMut = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) => api.post('/sms/events', body),
|
||||
onSuccess: () => {
|
||||
message.success('已保存步骤关联');
|
||||
qc.invalidateQueries({ queryKey: ['/sms/events'] });
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
const aliyunErr = (signs.error as Error | undefined)?.message || (tpls.error as Error | undefined)?.message;
|
||||
|
||||
const signMut = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) => api.post('/sms/signs', body),
|
||||
onSuccess: () => {
|
||||
message.success('已提交签名申请,阿里云审核中');
|
||||
qc.invalidateQueries({ queryKey: ['/sms/signs'] });
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
const tplMut = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) => api.post('/sms/templates', body),
|
||||
onSuccess: () => {
|
||||
message.success('已提交模板申请,阿里云审核中');
|
||||
qc.invalidateQueries({ queryKey: ['/sms/templates'] });
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
|
||||
const passedQuals = qualRows
|
||||
.map((q) => ({
|
||||
value: String(q.qualificationId || q.QualificationId || ''),
|
||||
label: String(q.qualificationName || q.QualificationName || q.companyName || q.CompanyName || q.qualificationId || ''),
|
||||
}))
|
||||
.filter((q) => q.value);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="短信设置"
|
||||
description="登录验证码、投标待办等步骤在这里开关。手工群发请到个人办公「发送短信」。签名和模板仍走阿里云审核。"
|
||||
extra={<Tag color={info.configured ? 'success' : 'error'}>{info.configured ? '已配置密钥' : '未配置密钥'}</Tag>}
|
||||
/>
|
||||
{!info.configured ? <Alert type="warning" showIcon message="尚未配置阿里云短信密钥" style={{ marginBottom: 12 }} /> : null}
|
||||
{aliyunErr ? (
|
||||
<Alert type="error" showIcon message="阿里云短信接口不可用" description={aliyunErr} style={{ marginBottom: 12 }} />
|
||||
) : null}
|
||||
|
||||
<Tabs
|
||||
items={[
|
||||
{
|
||||
key: 'events',
|
||||
label: '步骤关联',
|
||||
children: (
|
||||
<>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message="只有登录和投标步骤可自动发短信;公告、日报、报销、人事、用章、工作安排等一律只能到「发送工作通知短信」手工发送。投标短信只发给当前步骤实际选定的办理人。"
|
||||
/>
|
||||
<Table
|
||||
className="ops-table"
|
||||
rowKey="key"
|
||||
loading={events.isLoading}
|
||||
dataSource={eventRows}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{ title: '分组', dataIndex: 'group', width: 90 },
|
||||
{ title: '步骤', dataIndex: 'name', width: 140 },
|
||||
{ title: '何时发', dataIndex: 'when' },
|
||||
{ title: '发给谁', dataIndex: 'to', width: 140 },
|
||||
{
|
||||
title: '变量',
|
||||
dataIndex: 'vars',
|
||||
width: 180,
|
||||
render: (_: unknown, r) => r.vars || Object.keys(r.sample || {}).join('、') || '—',
|
||||
},
|
||||
{
|
||||
title: '模板',
|
||||
dataIndex: 'templateCode',
|
||||
width: 220,
|
||||
render: (_: unknown, r) => (
|
||||
<AutoComplete
|
||||
value={r.templateCode}
|
||||
style={{ width: '100%' }}
|
||||
options={(eventPack.presets || info.presets || []).map((p) => ({
|
||||
value: p.code,
|
||||
label: `${p.name}(${p.code})`,
|
||||
}))}
|
||||
onBlur={(e) => {
|
||||
const v = (e.target as HTMLInputElement).value?.trim();
|
||||
if (v && v !== r.templateCode) eventMut.mutate({ eventKey: r.key, templateCode: v });
|
||||
}}
|
||||
onSelect={(v) => eventMut.mutate({ eventKey: r.key, templateCode: String(v) })}
|
||||
disabled={r.automatic === false}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '启用',
|
||||
dataIndex: 'enabled',
|
||||
width: 80,
|
||||
render: (v: boolean, r) => (
|
||||
<Switch
|
||||
checked={v}
|
||||
disabled={r.automatic === false}
|
||||
loading={eventMut.isPending}
|
||||
onChange={(enabled) => eventMut.mutate({ eventKey: r.key, enabled })}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'account',
|
||||
label: '账号设置',
|
||||
children: (
|
||||
<Card size="small" className="sms-account" title="阿里云 RAM / 短信通道">
|
||||
<Descriptions bordered size="small" column={1}>
|
||||
<Descriptions.Item label="RAM 登录名称">{info.ramLogin || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="登录密码">不写入本系统,仅控制台登录使用</Descriptions.Item>
|
||||
<Descriptions.Item label="AccessKey ID">{info.accessKeyIdMasked || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="AccessKey Secret">
|
||||
{info.secretConfigured ? '已配置(密文,不在页面展示)' : '未配置'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="地域">{info.region || 'cn-hangzhou'}</Descriptions.Item>
|
||||
<Descriptions.Item label="接口地址">{info.endpoint || 'dysmsapi.aliyuncs.com'}</Descriptions.Item>
|
||||
<Descriptions.Item label="默认短信签名">{info.defaultSign || '—'}</Descriptions.Item>
|
||||
<Descriptions.Item label="SecurityPhoneDevice">{dash(info.securityPhone)}</Descriptions.Item>
|
||||
<Descriptions.Item label="SecurityEmailDevice">{dash(info.securityEmail)}</Descriptions.Item>
|
||||
</Descriptions>
|
||||
<p style={{ marginTop: 12, color: 'var(--mute)', fontSize: 12 }}>{info.note}</p>
|
||||
</Card>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'signs',
|
||||
label: '签名',
|
||||
children: (
|
||||
<>
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
signForm.resetFields();
|
||||
signForm.setFieldsValue({
|
||||
signName: info.defaultSign,
|
||||
signSource: 0,
|
||||
signType: 1,
|
||||
qualificationId: passedQuals[0]?.value,
|
||||
remark: '江苏风影随行科技办公平台向内部员工发送工作通知,签名为本公司全称或简称。',
|
||||
});
|
||||
Modal.confirm({
|
||||
title: '申请短信签名',
|
||||
width: 520,
|
||||
icon: null,
|
||||
content: (
|
||||
<Form form={signForm} layout="vertical" style={{ marginTop: 12 }}>
|
||||
<Form.Item name="signName" label="签名名称" rules={[{ required: true }]} extra="2–12 个字,不要带括号和测试字样。">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="signSource" label="签名来源" rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={Object.entries(SIGN_SOURCE).map(([value, label]) => ({
|
||||
value: Number(value),
|
||||
label,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="signType" label="签名类型">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 1, label: '通用' },
|
||||
{ value: 0, label: '验证码' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="qualificationId"
|
||||
label="企业资质"
|
||||
extra="从阿里云「资质管理」已通过的记录里选。没有请先在控制台做资质认证。"
|
||||
>
|
||||
{passedQuals.length ? (
|
||||
<Select options={passedQuals} allowClear placeholder="选择已通过的资质" />
|
||||
) : (
|
||||
<Input placeholder="填写资质编号 QualificationId" />
|
||||
)}
|
||||
</Form.Item>
|
||||
<Form.Item name="remark" label="申请说明">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
onOk: async () => signMut.mutateAsync(await signForm.validateFields()),
|
||||
});
|
||||
}}
|
||||
>
|
||||
申请签名
|
||||
</Button>
|
||||
<Button onClick={() => signs.refetch()}>刷新列表</Button>
|
||||
</Space>
|
||||
<Table
|
||||
className="ops-table"
|
||||
rowKey={(r) => String(r.signName || r.SignName)}
|
||||
loading={signs.isLoading}
|
||||
dataSource={signRows}
|
||||
pagination={false}
|
||||
locale={{ emptyText: '还没有签名,点申请签名。' }}
|
||||
columns={[
|
||||
{ title: '签名', render: (_: unknown, r: SignRow) => r.signName || r.SignName },
|
||||
{
|
||||
title: '来源',
|
||||
width: 180,
|
||||
render: (_: unknown, r: SignRow) =>
|
||||
SIGN_SOURCE[Number(r.signSource ?? r.SignSource)] || r.businessType || r.BusinessType || '—',
|
||||
},
|
||||
{
|
||||
title: '类型',
|
||||
width: 90,
|
||||
render: (_: unknown, r: SignRow) => SIGN_TYPE[Number(r.signType ?? r.SignType)] || '—',
|
||||
},
|
||||
{
|
||||
title: '审核',
|
||||
width: 110,
|
||||
render: (_: unknown, r: SignRow) => {
|
||||
const s = auditText(r.auditStatus ?? r.AuditStatus);
|
||||
return <Tag color={s.color}>{s.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{ title: '申请时间', width: 170, render: (_: unknown, r: SignRow) => r.createDate || r.CreateDate || '—' },
|
||||
{ title: '原因', render: (_: unknown, r: SignRow) => r.reason || r.Reason || '—' },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'templates',
|
||||
label: '模板',
|
||||
children: (
|
||||
<>
|
||||
<Space style={{ marginBottom: 12 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
tplForm.resetFields();
|
||||
tplForm.setFieldsValue({
|
||||
templateName: '办公工作通知',
|
||||
templateType: 1,
|
||||
relatedSignName: info.defaultSign,
|
||||
templateContent: '办公通知:${content}',
|
||||
templateRule: '{"content":"others"}',
|
||||
applySceneContent: 'https://oa.fysxkj.com',
|
||||
remark:
|
||||
'江苏风影随行科技办公平台向内部员工发送会议、审批待办、工作汇报、用章、报销等到岗通知。变量 content 为通知摘要,例如「请明日上午9点参加项目例会」。',
|
||||
});
|
||||
Modal.confirm({
|
||||
title: '向阿里云申请短信模板',
|
||||
width: 640,
|
||||
icon: null,
|
||||
content: (
|
||||
<Form form={tplForm} layout="vertical" style={{ marginTop: 12 }}>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
style={{ marginBottom: 12 }}
|
||||
message={'有变量必须填 TemplateRule,例如 {"content":"others"}。模板通过后不能改内容,只能重新申请。'}
|
||||
/>
|
||||
<Form.Item name="templateName" label="模板名称" rules={[{ required: true }]}>
|
||||
<Input maxLength={30} />
|
||||
</Form.Item>
|
||||
<Form.Item name="templateType" label="短信类型" rules={[{ required: true }]}>
|
||||
<Select
|
||||
options={Object.entries(TPL_TYPE).map(([value, label]) => ({
|
||||
value: Number(value),
|
||||
label,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="relatedSignName"
|
||||
label="关联签名"
|
||||
extra="只能选已通过的签名。此处关联仅用于审核,发送时仍用步骤关联里的签名。"
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="templateContent"
|
||||
label="模板内容"
|
||||
rules={[{ required: true }]}
|
||||
extra="变量写成 ${content}。通知类 others 最长 35 字。"
|
||||
>
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="templateRule"
|
||||
label="变量属性 TemplateRule"
|
||||
extra='模板有变量时必填 JSON。常用:others 通知正文/名称;user_nick 姓名;other_number2 单号;numberCaptcha 验证码;time 时间;address 地址;name 个人姓名。'
|
||||
>
|
||||
<Input.TextArea rows={2} placeholder='{"content":"others"}' />
|
||||
</Form.Item>
|
||||
<Form.Item name="applySceneContent" label="业务场景">
|
||||
<Input placeholder="https://oa.fysxkj.com" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="remark"
|
||||
label="申请说明"
|
||||
extra="写清使用场景,并给出填入变量后的完整短信示例,否则容易被拒。"
|
||||
>
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
),
|
||||
onOk: async () => {
|
||||
const v = await tplForm.validateFields();
|
||||
const content = String(v.templateContent || '');
|
||||
const hasVar = /\$\{[a-zA-Z0-9_]+\}/.test(content);
|
||||
if (hasVar && !String(v.templateRule || '').trim()) {
|
||||
throw new Error('模板含变量时必须填写 TemplateRule');
|
||||
}
|
||||
return tplMut.mutateAsync(v);
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
申请模板
|
||||
</Button>
|
||||
<Button onClick={() => tpls.refetch()}>刷新列表</Button>
|
||||
</Space>
|
||||
<Table
|
||||
className="ops-table"
|
||||
rowKey={(r) => String(r.templateCode || r.TemplateCode)}
|
||||
loading={tpls.isLoading}
|
||||
dataSource={tplRows}
|
||||
pagination={false}
|
||||
locale={{ emptyText: '还没有模板,点申请模板,或先用已有编号发送。' }}
|
||||
columns={[
|
||||
{ title: '模板编号', width: 160, render: (_: unknown, r: TplRow) => r.templateCode || r.TemplateCode },
|
||||
{ title: '名称', render: (_: unknown, r: TplRow) => r.templateName || r.TemplateName },
|
||||
{
|
||||
title: '类型',
|
||||
width: 110,
|
||||
render: (_: unknown, r: TplRow) => TPL_TYPE[Number(r.templateType ?? r.TemplateType)] || '—',
|
||||
},
|
||||
{
|
||||
title: '审核',
|
||||
width: 110,
|
||||
render: (_: unknown, r: TplRow) => {
|
||||
const s = auditText(r.auditStatus ?? r.AuditStatus);
|
||||
return <Tag color={s.color}>{s.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{ title: '内容', ellipsis: true, render: (_: unknown, r: TplRow) => r.templateContent || r.TemplateContent || '—' },
|
||||
{ title: '原因', ellipsis: true, render: (_: unknown, r: TplRow) => r.reason || r.Reason || '—' },
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'logs',
|
||||
label: '发送记录',
|
||||
children: (
|
||||
<Table
|
||||
className="ops-table"
|
||||
rowKey="id"
|
||||
loading={logs.isLoading}
|
||||
dataSource={logRows.length ? logRows : ((logs.data?.data ?? []) as typeof logRows)}
|
||||
pagination={false}
|
||||
locale={{ emptyText: '还没有从本系统发出的短信。' }}
|
||||
columns={[
|
||||
{ title: '手机号', dataIndex: 'phones', width: 180 },
|
||||
{ title: '签名', dataIndex: 'signName', width: 160 },
|
||||
{ title: '模板', dataIndex: 'templateCode', width: 150 },
|
||||
{ title: '步骤', dataIndex: 'eventKey', width: 140, render: (v: string) => v || '手工发送' },
|
||||
{
|
||||
title: '结果',
|
||||
dataIndex: 'code',
|
||||
width: 90,
|
||||
render: (v: string) => <StatusTag value={v} />,
|
||||
},
|
||||
{ title: '说明', dataIndex: 'message', render: (v: string) => v || '—' },
|
||||
{
|
||||
title: '时间',
|
||||
dataIndex: 'createdAt',
|
||||
width: 170,
|
||||
render: (v: string) => (v ? String(v).replace('T', ' ').slice(0, 19) : '—'),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { Button, Result } from 'antd';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
export function NotFoundPage() {
|
||||
return (
|
||||
<Result
|
||||
status="404"
|
||||
title="页面不存在"
|
||||
extra={
|
||||
<Button type="primary">
|
||||
<Link to="/workbench">返回工作台</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function ForbiddenPage() {
|
||||
return (
|
||||
<Result
|
||||
status="403"
|
||||
title="没有权限"
|
||||
extra={
|
||||
<Button type="primary">
|
||||
<Link to="/workbench">返回工作台</Link>
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import { Button, DatePicker, Form, Input, InputNumber, Popconfirm, Select, Space, Table, Tag, message } from 'antd';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link } from 'react-router-dom';
|
||||
import dayjs from 'dayjs';
|
||||
import { api } from '../api/client';
|
||||
import { PageHeader } from './ResourcePage';
|
||||
import { TASK_STATUS_META } from './projectStatus';
|
||||
|
||||
type Task = {
|
||||
id: string;
|
||||
name: string;
|
||||
department?: string | null;
|
||||
status: string;
|
||||
dueAt?: string | null;
|
||||
project?: { id: string; projectNo: string; name: string };
|
||||
dept?: { name: string } | null;
|
||||
assignee?: { displayName: string } | null;
|
||||
};
|
||||
|
||||
export default function TaskListPage() {
|
||||
const qc = useQueryClient();
|
||||
const q = useQuery({
|
||||
queryKey: ['/project-tasks'],
|
||||
queryFn: () => api.get('/project-tasks'),
|
||||
});
|
||||
const items = (q.data?.data ?? []) as Task[];
|
||||
const doneMut = useMutation({
|
||||
mutationFn: (row: Task) => api.post(`/projects/${row.project?.id}/tasks/${row.id}/done`),
|
||||
onSuccess: () => {
|
||||
message.success('已完成');
|
||||
qc.invalidateQueries();
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="任务分解" description="按可配置部门拆 WBS。请到项目详情里新建任务。" />
|
||||
<Table
|
||||
className="ops-table"
|
||||
rowKey="id"
|
||||
loading={q.isLoading}
|
||||
dataSource={items}
|
||||
scroll={{ x: 'max-content' }}
|
||||
tableLayout="fixed"
|
||||
columns={[
|
||||
{
|
||||
title: '项目',
|
||||
width: 180,
|
||||
render: (_: unknown, r: Task) =>
|
||||
r.project ? <Link to={`/project/${r.project.id}`}>{r.project.projectNo}</Link> : '—',
|
||||
},
|
||||
{ title: '任务', dataIndex: 'name', width: 240, ellipsis: true },
|
||||
{
|
||||
title: '部门',
|
||||
width: 140,
|
||||
render: (_: unknown, r: Task) => r.dept?.name || r.department || '—',
|
||||
},
|
||||
{
|
||||
title: '负责人',
|
||||
width: 110,
|
||||
render: (_: unknown, r: Task) => r.assignee?.displayName || '—',
|
||||
},
|
||||
{
|
||||
title: '截止',
|
||||
dataIndex: 'dueAt',
|
||||
width: 120,
|
||||
render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD') : '—'),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 100,
|
||||
render: (v: string) => {
|
||||
const s = TASK_STATUS_META[v] || { color: 'default', text: v };
|
||||
return <Tag color={s.color}>{s.text}</Tag>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 140,
|
||||
render: (_: unknown, r: Task) => (
|
||||
<Space>
|
||||
{r.project ? <Link to={`/project/${r.project.id}`}>办理</Link> : null}
|
||||
{r.status !== 'DONE' && r.project ? (
|
||||
<Button type="link" size="small" onClick={() => doneMut.mutate(r)}>
|
||||
完成
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function TimesheetPage() {
|
||||
const qc = useQueryClient();
|
||||
const [form] = Form.useForm();
|
||||
const projectId = Form.useWatch('projectId', form);
|
||||
const projects = useQuery({
|
||||
queryKey: ['/projects'],
|
||||
queryFn: () => api.get('/projects'),
|
||||
});
|
||||
const detail = useQuery({
|
||||
queryKey: ['project', projectId],
|
||||
enabled: Boolean(projectId),
|
||||
queryFn: () => api.get(`/projects/${projectId}`),
|
||||
});
|
||||
const q = useQuery({
|
||||
queryKey: ['/timesheets'],
|
||||
queryFn: () => api.get('/timesheets'),
|
||||
});
|
||||
const items = (q.data?.data ?? []) as {
|
||||
id: string;
|
||||
hours: string | number;
|
||||
workDate: string;
|
||||
note?: string | null;
|
||||
costAmount?: string | number;
|
||||
project?: { id: string; projectNo: string; name: string };
|
||||
task?: { name: string } | null;
|
||||
user?: { displayName: string };
|
||||
}[];
|
||||
const projectOpts = ((projects.data?.data?.items ?? []) as { id: string; projectNo: string; name: string }[]).map(
|
||||
(p) => ({ value: p.id, label: `${p.projectNo} ${p.name}` }),
|
||||
);
|
||||
const taskOpts = ((detail.data?.data?.tasks ?? []) as { id: string; name: string }[]).map((t) => ({
|
||||
value: t.id,
|
||||
label: t.name,
|
||||
}));
|
||||
const mut = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) => api.post('/timesheets', body),
|
||||
onSuccess: () => {
|
||||
message.success('已记入技术成本');
|
||||
form.resetFields();
|
||||
qc.invalidateQueries();
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="工时填报" description="工时按填报人薪酬自动折算人工成本,超预算红线会被拦截。" />
|
||||
<Form
|
||||
form={form}
|
||||
layout="inline"
|
||||
style={{ marginBottom: 16, rowGap: 12 }}
|
||||
onFinish={(v) =>
|
||||
mut.mutate({
|
||||
...v,
|
||||
workDate: v.workDate ? dayjs(v.workDate).format('YYYY-MM-DD') : undefined,
|
||||
})
|
||||
}
|
||||
>
|
||||
<Form.Item name="projectId" rules={[{ required: true, message: '请选择项目' }]}>
|
||||
<Select showSearch optionFilterProp="label" options={projectOpts} placeholder="项目" style={{ width: 240 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="taskId">
|
||||
<Select allowClear options={taskOpts} placeholder="任务(可选)" style={{ width: 180 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="workDate" rules={[{ required: true, message: '请选日期' }]}>
|
||||
<DatePicker />
|
||||
</Form.Item>
|
||||
<Form.Item name="hours" rules={[{ required: true, message: '请填工时' }]}>
|
||||
<InputNumber min={0.5} max={24} step={0.5} placeholder="小时" />
|
||||
</Form.Item>
|
||||
<Form.Item name="note">
|
||||
<Input placeholder="说明" />
|
||||
</Form.Item>
|
||||
<Form.Item>
|
||||
<Button type="primary" htmlType="submit" loading={mut.isPending}>
|
||||
提交工时
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
<Table
|
||||
rowKey="id"
|
||||
loading={q.isLoading}
|
||||
dataSource={items}
|
||||
columns={[
|
||||
{
|
||||
title: '项目',
|
||||
render: (_: unknown, r: (typeof items)[0]) =>
|
||||
r.project ? <Link to={`/project/${r.project.id}`}>{r.project.projectNo}</Link> : '—',
|
||||
},
|
||||
{ title: '任务', render: (_: unknown, r: (typeof items)[0]) => r.task?.name || '—' },
|
||||
{ title: '人员', width: 110, render: (_: unknown, r: (typeof items)[0]) => r.user?.displayName || '—' },
|
||||
{
|
||||
title: '日期',
|
||||
dataIndex: 'workDate',
|
||||
width: 120,
|
||||
render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD') : '—'),
|
||||
},
|
||||
{ title: '工时', dataIndex: 'hours', width: 80 },
|
||||
{ title: '技术成本', dataIndex: 'costAmount', width: 110 },
|
||||
{ title: '说明', dataIndex: 'note' },
|
||||
{
|
||||
title: '操作',
|
||||
width: 80,
|
||||
render: (_: unknown, r: (typeof items)[0]) => (
|
||||
<Popconfirm
|
||||
title="确认删除这条工时?"
|
||||
onConfirm={async () => {
|
||||
await api.delete(`/timesheets/${r.id}`);
|
||||
message.success('已删除');
|
||||
qc.invalidateQueries({ queryKey: ['/timesheets'] });
|
||||
}}
|
||||
>
|
||||
<Button type="link" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { api } from '../api/client';
|
||||
|
||||
type Weather = {
|
||||
greeting?: string;
|
||||
city?: string;
|
||||
weather?: string;
|
||||
temperature?: number;
|
||||
advice?: string;
|
||||
summary?: string;
|
||||
};
|
||||
|
||||
export function WeatherGreeting() {
|
||||
const [coords, setCoords] = useState<{ lat: number; lng: number } | null>(null);
|
||||
useEffect(() => {
|
||||
const fallback = window.setTimeout(() => setCoords((c) => c ?? { lat: 0, lng: 0 }), 4000);
|
||||
if (!navigator.geolocation) return () => window.clearTimeout(fallback);
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
(p) => {
|
||||
window.clearTimeout(fallback);
|
||||
setCoords({ lat: p.coords.latitude, lng: p.coords.longitude });
|
||||
},
|
||||
() => {
|
||||
window.clearTimeout(fallback);
|
||||
setCoords({ lat: 0, lng: 0 });
|
||||
},
|
||||
{ timeout: 8000, maximumAge: 600000 },
|
||||
);
|
||||
return () => window.clearTimeout(fallback);
|
||||
}, []);
|
||||
const q = useQuery({
|
||||
queryKey: ['/office/weather', coords?.lat, coords?.lng],
|
||||
enabled: coords !== null,
|
||||
queryFn: () =>
|
||||
api.get('/office/weather', {
|
||||
params: coords && coords.lat && coords.lng ? { lat: coords.lat, lng: coords.lng } : {},
|
||||
}),
|
||||
staleTime: 10 * 60 * 1000,
|
||||
});
|
||||
const d = q.data?.data as Weather | undefined;
|
||||
|
||||
return (
|
||||
<div className="wb-weather">
|
||||
<div className="wb-hello">{d?.greeting || '新的一天开始了,祝您工作愉快。'}</div>
|
||||
<div className="wb-wx">
|
||||
{d?.summary || (q.isLoading ? '正在读取天气…' : '天气暂不可用')}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { Button, DatePicker, Form, Input, Popconfirm, Select, Table, Tag, message } from 'antd';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useState } from 'react';
|
||||
import dayjs from 'dayjs';
|
||||
import { api } from '../api/client';
|
||||
import { FormDrawer } from './FormDrawer';
|
||||
import { PageHeader, unwrapList } from './ResourcePage';
|
||||
import { fmtCreated, fmtDay } from '../time';
|
||||
|
||||
type Row = {
|
||||
id: string;
|
||||
title: string;
|
||||
content: string;
|
||||
status: string;
|
||||
dueAt?: string | null;
|
||||
createdAt: string;
|
||||
assignee?: { displayName?: string } | null;
|
||||
};
|
||||
|
||||
export default function WorkAssignPage() {
|
||||
const qc = useQueryClient();
|
||||
const q = useQuery({ queryKey: ['/office/work-assignments'], queryFn: () => api.get('/office/work-assignments') });
|
||||
const staff = useQuery({ queryKey: ['/staff'], queryFn: () => api.get('/staff') });
|
||||
const { items } = unwrapList(q.data) as { items: Row[] };
|
||||
const [form] = Form.useForm();
|
||||
const [creating, setCreating] = useState(false);
|
||||
const staffOpts = ((staff.data as { data?: { id: string; name: string; department?: string }[] })?.data || []).map(
|
||||
(s) => ({
|
||||
value: s.id,
|
||||
label: `${s.name}${s.department ? `(${s.department})` : ''}`,
|
||||
}),
|
||||
);
|
||||
const mut = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) => api.post('/office/work-assignments', body),
|
||||
onSuccess: () => {
|
||||
message.success('已分配工作');
|
||||
qc.invalidateQueries({ queryKey: ['/office/work-assignments'] });
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="工作安排"
|
||||
description="仅总监、项目经理和公司负责人可见,用于给下属分配工作。执行人会在待办事项中收到。"
|
||||
extra={
|
||||
<Button
|
||||
type="primary"
|
||||
onClick={() => {
|
||||
form.resetFields();
|
||||
setCreating(true);
|
||||
}}
|
||||
>
|
||||
分配工作
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<FormDrawer
|
||||
title="分配工作"
|
||||
open={creating}
|
||||
onClose={() => setCreating(false)}
|
||||
okText="提交"
|
||||
okLoading={mut.isPending}
|
||||
onOk={async () => {
|
||||
const v = await form.validateFields();
|
||||
await mut.mutateAsync({
|
||||
...v,
|
||||
dueAt: v.dueAt ? dayjs(v.dueAt).toISOString() : undefined,
|
||||
});
|
||||
setCreating(false);
|
||||
}}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item name="title" label="工作标题" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="assigneeId" label="执行人" rules={[{ required: true, message: '请选择下属' }]}>
|
||||
<Select showSearch optionFilterProp="label" options={staffOpts} />
|
||||
</Form.Item>
|
||||
<Form.Item name="dueAt" label="截止日期">
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="content" label="工作说明">
|
||||
<Input.TextArea rows={4} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</FormDrawer>
|
||||
<Table
|
||||
className="ops-table"
|
||||
rowKey="id"
|
||||
loading={q.isLoading}
|
||||
dataSource={items}
|
||||
pagination={false}
|
||||
columns={[
|
||||
{ title: '标题', dataIndex: 'title', width: 220 },
|
||||
{ title: '执行人', width: 110, render: (_: unknown, r: Row) => r.assignee?.displayName || '—' },
|
||||
{ title: '说明', dataIndex: 'content', ellipsis: true, render: (v: string) => v || '—' },
|
||||
{ title: '截止', dataIndex: 'dueAt', width: 120, render: (v: string) => (v ? fmtDay(v) : '—') },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (v: string) => <Tag color={v === 'DONE' ? 'success' : 'processing'}>{v === 'DONE' ? '已完成' : '进行中'}</Tag>,
|
||||
},
|
||||
{ title: '创建时间', dataIndex: 'createdAt', width: 170, render: (v: string) => fmtCreated(v) },
|
||||
{
|
||||
title: '操作',
|
||||
width: 90,
|
||||
render: (_: unknown, r: Row) => (
|
||||
<Popconfirm
|
||||
title="确认删除这条工作安排?"
|
||||
onConfirm={async () => {
|
||||
await api.delete(`/office/work-assignments/${r.id}`);
|
||||
message.success('已删除');
|
||||
qc.invalidateQueries({ queryKey: ['/office/work-assignments'] });
|
||||
}}
|
||||
>
|
||||
<Button type="link" danger>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
export const BID_STATUS_META: Record<string, { color: string; text: string }> = {
|
||||
TECH_INITIAL: { color: 'processing', text: '技术初审' },
|
||||
FETCH: { color: 'processing', text: '取标' },
|
||||
TECH_FINAL: { color: 'processing', text: '技术终审' },
|
||||
DRAFTING: { color: 'gold', text: '制标' },
|
||||
REVIEW1: { color: 'gold', text: '商务一审' },
|
||||
REVIEW2: { color: 'gold', text: '商务二审' },
|
||||
PRINT: { color: 'gold', text: '印封' },
|
||||
LIST: { color: 'blue', text: '投标执行' },
|
||||
PENDING: { color: 'blue', text: '待开标' },
|
||||
WON: { color: 'success', text: '中标' },
|
||||
LOST: { color: 'default', text: '未中标' },
|
||||
FAILED: { color: 'default', text: '流标废标' },
|
||||
TERMINATED: { color: 'error', text: '已终止' },
|
||||
};
|
||||
|
||||
export const REVIEW_RESULT: Record<string, { color: string; text: string }> = {
|
||||
PENDING: { color: 'processing', text: '待办理' },
|
||||
APPROVED: { color: 'success', text: '通过' },
|
||||
REJECTED: { color: 'error', text: '驳回' },
|
||||
CANCELLED: { color: 'default', text: '已取消' },
|
||||
};
|
||||
|
||||
export const ACTION_LABEL: Record<string, string> = {
|
||||
CREATE: '发起筛选',
|
||||
CREATE_SPECIAL: '发起特殊筛选',
|
||||
ASSIGN_FETCHER: '派取标人',
|
||||
INITIAL_APPROVE: '初审同意',
|
||||
INITIAL_REJECT: '初审驳回',
|
||||
INITIAL_PASSED: '初审会签通过',
|
||||
FETCH_DONE: '完成取标',
|
||||
FINAL_APPROVE: '终审同意',
|
||||
FINAL_REJECT: '终审否决',
|
||||
FINAL_PASSED: '终审通过',
|
||||
MANUAL_TERMINATE: '终止投标',
|
||||
RESTORE_TERMINATED: '恢复终止投标',
|
||||
ASSIGN_DRAFTER: '派制标人',
|
||||
SUBMIT_DRAFT: '提交标书',
|
||||
REVIEW1_APPROVE: '一审同意',
|
||||
REVIEW1_REJECT: '一审打回制标',
|
||||
REVIEW1_PASSED: '一审通过',
|
||||
REVIEW2_APPROVE: '二审同意',
|
||||
REVIEW2_REJECT: '二审打回制标',
|
||||
REVIEW2_PASSED: '二审通过',
|
||||
PRINT_DONE: '完成印封',
|
||||
EXECUTION_DONE: '补全投标执行',
|
||||
ROLLBACK: '退回上一步',
|
||||
UPDATE_COMPETITOR_WIN: '登记对方中标信息',
|
||||
UPDATE_LEGAL_ENTITY: '修改参与主体',
|
||||
};
|
||||
|
||||
export const STAGE_LABEL: Record<string, string> = {
|
||||
TECH_INITIAL: '技术初审',
|
||||
TECH_FINAL: '技术终审',
|
||||
REVIEW1: '商务一审',
|
||||
REVIEW2: '商务二审',
|
||||
};
|
||||
|
||||
export const ASSIGN_ROLE_LABEL: Record<string, string> = {
|
||||
DRAFTER: '制标人',
|
||||
REVIEW1: '一审人',
|
||||
BIDDER: '投标人',
|
||||
FETCHER: '取标人',
|
||||
COORDINATOR: '特殊协调人',
|
||||
};
|
||||
|
||||
export const COST_LABEL: Record<string, string> = {
|
||||
DEPT: '部门费用',
|
||||
ADMIN: '行政费用',
|
||||
MARKETING: '营销费用',
|
||||
BID: '投标成本',
|
||||
PROJECT: '正式项目',
|
||||
};
|
||||
|
||||
export function statusMeta(status: string) {
|
||||
return BID_STATUS_META[status] || { color: 'default', text: status };
|
||||
}
|
||||
|
||||
export function listPath(status: string, sourceType?: string) {
|
||||
if (sourceType === 'SPECIAL') {
|
||||
if (['TECH_INITIAL', 'FETCH', 'TECH_FINAL'].includes(status)) return '/bid/special-screening';
|
||||
if (['DRAFTING', 'REVIEW1', 'REVIEW2', 'PRINT'].includes(status)) return '/bid/special-drafting';
|
||||
}
|
||||
if (['TECH_INITIAL', 'FETCH', 'TECH_FINAL'].includes(status)) return '/bid/screening';
|
||||
if (['DRAFTING', 'REVIEW1', 'REVIEW2', 'PRINT'].includes(status)) return '/bid/drafting';
|
||||
if (['LIST', 'PENDING'].includes(status)) return '/bid/execution';
|
||||
if (['WON', 'LOST', 'FAILED'].includes(status)) return '/bid/result';
|
||||
if (status === 'TERMINATED') return '/bid/terminated';
|
||||
return '/bid/screening';
|
||||
}
|
||||
|
||||
/** 优先公告招标编号;没有正式编号时再显示系统号,避免表格出现「—」或「无」。 */
|
||||
export function tenderNo(b?: { externalNo?: string | null; bidNo?: string | null } | null) {
|
||||
if (!b) return '—';
|
||||
const clean = (raw?: string | null) => {
|
||||
const n = (raw || '').trim().replace(/[))]+$/, '');
|
||||
if (!n || n === '无' || n === '-' || n === '—') return '';
|
||||
return n;
|
||||
};
|
||||
const official = clean(b.externalNo);
|
||||
const sys = clean(b.bidNo);
|
||||
const isInternal = (n: string) => /^BID-\d{4}-/.test(n) || /^TB-/.test(n) || /^XM-/.test(n);
|
||||
if (official && !isInternal(official)) return official;
|
||||
if (sys && !isInternal(sys)) return sys;
|
||||
return official || sys || '—';
|
||||
}
|
||||
|
||||
export const OPEN_METHOD_OPTIONS = [
|
||||
{ label: '线下开标', value: '线下开标' },
|
||||
{ label: '线上开标', value: '线上开标' },
|
||||
{ label: '可邮寄', value: '可邮寄' },
|
||||
{ label: '其他', value: '其他' },
|
||||
];
|
||||
|
||||
export const TENDER_METHOD_OPTIONS = [
|
||||
{ label: '公开招标', value: '公开招标' },
|
||||
{ label: '邀请招标', value: '邀请招标' },
|
||||
{ label: '询价', value: '询价' },
|
||||
{ label: '竞谈【综合评分】', value: '竞谈【综合评分】' },
|
||||
{ label: '竞谈【最低价中】', value: '竞谈【最低价中】' },
|
||||
{ label: '单一来源', value: '单一来源' },
|
||||
{ label: '其他', value: '其他' },
|
||||
];
|
||||
|
||||
const TENDER_METHOD_VALUES = new Set(TENDER_METHOD_OPTIONS.map((o) => o.value));
|
||||
|
||||
/** 把旧名称归到现在的 7 个选项。 */
|
||||
export function normalizeTenderMethod(stored?: string | null) {
|
||||
const s = (stored || '').trim();
|
||||
if (!s) return '';
|
||||
if (TENDER_METHOD_VALUES.has(s)) return s;
|
||||
if (s.includes('最低价')) return '竞谈【最低价中】';
|
||||
if (s.includes('综合评分') || s.includes('综合评审') || s.includes('竞谈综合')) return '竞谈【综合评分】';
|
||||
if (s.includes('竞争性谈判') || s === '竞谈') return '竞谈【综合评分】';
|
||||
if (s.includes('公开招标')) return '公开招标';
|
||||
if (s.includes('邀请招标')) return '邀请招标';
|
||||
if (s.includes('询价')) return '询价';
|
||||
if (s.includes('单一来源')) return '单一来源';
|
||||
return '其他';
|
||||
}
|
||||
|
||||
/** 库里没存招标方式时,从项目名称里认。 */
|
||||
export function inferTenderMethod(name?: string | null, stored?: string | null) {
|
||||
const fromStore = normalizeTenderMethod(stored);
|
||||
if (fromStore) return fromStore;
|
||||
const n = name || '';
|
||||
if (n.includes('询价')) return '询价';
|
||||
if (n.includes('邀请招标')) return '邀请招标';
|
||||
if (n.includes('单一来源')) return '单一来源';
|
||||
if (n.includes('最低价')) return '竞谈【最低价中】';
|
||||
if (n.includes('综合评分') || n.includes('综合评审') || n.includes('竞谈综合')) return '竞谈【综合评分】';
|
||||
if (n.includes('竞争性谈判') || n.includes('竞谈')) return '竞谈【综合评分】';
|
||||
if (n.includes('公开招标')) return '公开招标';
|
||||
return '';
|
||||
}
|
||||
|
||||
export const NAS_BID_DIR = '/files/商务部/标书文件';
|
||||
export const NAS_BID_PLACEHOLDER = '/files/商务部/标书文件/20260821XX项目标书文件';
|
||||
|
||||
export const TYPE_REVIEW_ROLES: Record<string, string[]> = {
|
||||
软件: ['rd_director', 'tech_director'],
|
||||
三维: ['3d_director', 'tech_director'],
|
||||
摄制: ['video_director', 'tech_director'],
|
||||
物资: ['material_director', 'tech_director'],
|
||||
集成: ['rd_director', '3d_director', 'video_director', 'material_director'],
|
||||
};
|
||||
|
||||
export function stepIndex(status: string) {
|
||||
const order = [
|
||||
'TECH_INITIAL',
|
||||
'FETCH',
|
||||
'TECH_FINAL',
|
||||
'DRAFTING',
|
||||
'REVIEW1',
|
||||
'REVIEW2',
|
||||
'PRINT',
|
||||
'LIST',
|
||||
'PENDING',
|
||||
'WON',
|
||||
];
|
||||
if (status === 'TERMINATED') return 2;
|
||||
if (status === 'LOST' || status === 'FAILED') return 8;
|
||||
const i = order.indexOf(status);
|
||||
return i >= 0 ? i : 0;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
export const CONTRACT_STATUS_META: Record<string, { color: string; text: string }> = {
|
||||
DRAFT: { color: 'default', text: '草稿' },
|
||||
PENDING: { color: 'processing', text: '待审批' },
|
||||
APPROVED: { color: 'success', text: '已通过' },
|
||||
REJECTED: { color: 'error', text: '已驳回' },
|
||||
};
|
||||
|
||||
export const CONTRACT_ACTION_LABEL: Record<string, string> = {
|
||||
CREATE: '生成合同草稿',
|
||||
UPDATE: '修改合同',
|
||||
MILESTONES: '更新收款条款',
|
||||
SEAL_REQUEST: '申请用章',
|
||||
SUBMIT: '提交审批',
|
||||
APPROVE: '审批同意',
|
||||
REJECT: '审批驳回',
|
||||
APPROVED: '审批通过',
|
||||
};
|
||||
|
||||
export function contractStatusMeta(status: string) {
|
||||
return CONTRACT_STATUS_META[status] || { color: 'default', text: status };
|
||||
}
|
||||
|
||||
/** 正式合同号;系统自动号或空的显示待填。 */
|
||||
export function officialContractNo(raw?: string | null) {
|
||||
const n = (raw || '').trim();
|
||||
if (!n || n === '待填' || n === '-' || n === '—') return '';
|
||||
return n;
|
||||
}
|
||||
|
||||
export function contractNoText(raw?: string | null) {
|
||||
return officialContractNo(raw) || '待填正式编号';
|
||||
}
|
||||
|
||||
export function contractHandler(row: {
|
||||
createdBy?: { displayName?: string; username?: string } | null;
|
||||
bidCase?: { createdBy?: { displayName?: string } | null } | null;
|
||||
}) {
|
||||
const by = row.createdBy;
|
||||
if (by?.displayName && by.displayName !== '系统管理员' && by.username !== 'admin') {
|
||||
return by.displayName;
|
||||
}
|
||||
return row.bidCase?.createdBy?.displayName || '—';
|
||||
}
|
||||
|
||||
export { officeItemPath } from './projectStatus';
|
||||
@@ -0,0 +1,59 @@
|
||||
function xmlEsc(v: unknown) {
|
||||
return String(v ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
function cellText(v: unknown) {
|
||||
if (v == null || v === '') return '';
|
||||
if (typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean') return String(v);
|
||||
if (v instanceof Date) return v.toISOString().slice(0, 19).replace('T', ' ');
|
||||
if (typeof v === 'object' && v && 'name' in v) return String((v as { name?: unknown }).name ?? '');
|
||||
if (typeof v === 'object' && v && 'displayName' in v) return String((v as { displayName?: unknown }).displayName ?? '');
|
||||
return String(v);
|
||||
}
|
||||
|
||||
export type ExportCol = { key: string; title: string; pick?: (row: Record<string, unknown>) => unknown };
|
||||
|
||||
export function pickCell(row: Record<string, unknown>, col: ExportCol) {
|
||||
if (col.pick) return cellText(col.pick(row));
|
||||
const raw = row[col.key];
|
||||
return cellText(raw);
|
||||
}
|
||||
|
||||
export function downloadExcel(fileName: string, columns: ExportCol[], rows: Record<string, unknown>[]) {
|
||||
const headers = columns.map((c) => `<Cell><Data ss:Type="String">${xmlEsc(c.title)}</Data></Cell>`).join('');
|
||||
const body = rows
|
||||
.map((row) => {
|
||||
const cells = columns
|
||||
.map((c) => `<Cell><Data ss:Type="String">${xmlEsc(pickCell(row, c))}</Data></Cell>`)
|
||||
.join('');
|
||||
return `<Row>${cells}</Row>`;
|
||||
})
|
||||
.join('');
|
||||
const xml = `<?xml version="1.0" encoding="UTF-8"?>
|
||||
<?mso-application progid="Excel.Sheet"?>
|
||||
<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet" xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">
|
||||
<Worksheet ss:Name="导出"><Table>
|
||||
<Row>${headers}</Row>
|
||||
${body}
|
||||
</Table></Worksheet>
|
||||
</Workbook>`;
|
||||
const blob = new Blob([`\uFEFF${xml}`], { type: 'application/vnd.ms-excel;charset=utf-8' });
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(blob);
|
||||
a.download = `${fileName.replace(/[\\/:*?"<>|]/g, '_')}.xls`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(a.href);
|
||||
}
|
||||
|
||||
export function colsFromTable(columns: { title?: unknown; dataIndex?: unknown; key?: unknown }[]): ExportCol[] {
|
||||
return columns
|
||||
.map((c) => ({
|
||||
key: String(c.dataIndex || c.key || ''),
|
||||
title: typeof c.title === 'string' ? c.title : String(c.title || ''),
|
||||
}))
|
||||
.filter((c) => c.key && c.title && c.title !== '操作');
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
export const BUDGET_CATEGORY_LABEL: Record<string, string> = {
|
||||
LABOR: '人工',
|
||||
TRAVEL: '差旅(商务)',
|
||||
PURCHASE: '采购',
|
||||
BUSINESS: '商务',
|
||||
TECH: '差旅(技术)',
|
||||
};
|
||||
|
||||
export const EXPENSE_LINE_KIND_LABEL: Record<string, string> = {
|
||||
AIR: '机票',
|
||||
HSR: '高铁票',
|
||||
TRAIN: '火车票',
|
||||
TAXI: '打车',
|
||||
HOTEL: '住宿',
|
||||
MEAL: '餐饮',
|
||||
OFFICE: '办公',
|
||||
OTHER: '其他',
|
||||
};
|
||||
|
||||
export const EXPENSE_LINE_KIND_OPTIONS = Object.entries(EXPENSE_LINE_KIND_LABEL).map(([value, label]) => ({
|
||||
value,
|
||||
label,
|
||||
}));
|
||||
|
||||
export const COST_TYPE_LABEL: Record<string, string> = {
|
||||
BID: '投标',
|
||||
PROJECT: '正式项目',
|
||||
DEPT: '部门',
|
||||
ADMIN: '行政',
|
||||
MARKETING: '营销',
|
||||
};
|
||||
|
||||
export const EXPENSE_STATUS_META: Record<string, { color: string; text: string }> = {
|
||||
DRAFT: { color: 'default', text: '草稿' },
|
||||
PENDING: { color: 'processing', text: '待确认' },
|
||||
APPROVED: { color: 'success', text: '已通过' },
|
||||
REJECTED: { color: 'error', text: '被打回' },
|
||||
};
|
||||
|
||||
export const LOAN_STATUS_META: Record<string, { color: string; text: string }> = {
|
||||
DRAFT: { color: 'default', text: '草稿' },
|
||||
PENDING: { color: 'processing', text: '待确认' },
|
||||
OPEN: { color: 'warning', text: '未还' },
|
||||
OFFSET: { color: 'success', text: '已冲账' },
|
||||
REJECTED: { color: 'error', text: '被打回' },
|
||||
};
|
||||
|
||||
export const BOND_TYPE_LABEL: Record<string, string> = {
|
||||
TENDER: '投标保证金',
|
||||
PERFORMANCE: '履约保证金',
|
||||
WARRANTY: '项目质保金',
|
||||
};
|
||||
|
||||
export const BOND_BUCKET_META: Record<string, { color: string; text: string }> = {
|
||||
unpaid: { color: 'default', text: '未缴纳' },
|
||||
paid: { color: 'blue', text: '已缴纳' },
|
||||
due: { color: 'gold', text: '应退' },
|
||||
overdue: { color: 'error', text: '逾期' },
|
||||
returned: { color: 'success', text: '已退回' },
|
||||
};
|
||||
|
||||
export const INVOICE_STATUS_META: Record<string, { color: string; text: string }> = {
|
||||
TO_ISSUE: { color: 'default', text: '待开' },
|
||||
ISSUED: { color: 'processing', text: '已开' },
|
||||
RECEIVED: { color: 'success', text: '已回' },
|
||||
OVERDUE: { color: 'error', text: '逾期' },
|
||||
};
|
||||
|
||||
export function expenseCategoryLabel(v?: string | number | null) {
|
||||
if (v === undefined || v === null || v === '') return '—';
|
||||
const key = String(v);
|
||||
const map: Record<string, string> = {
|
||||
meal: '餐饮',
|
||||
meals: '餐饮',
|
||||
food: '餐饮',
|
||||
catering: '餐饮',
|
||||
traffic: '交通',
|
||||
transport: '交通',
|
||||
transportation: '交通',
|
||||
travel: '交通',
|
||||
hotel: '住宿',
|
||||
stay: '住宿',
|
||||
lodging: '住宿',
|
||||
office: '办公',
|
||||
entertain: '招待',
|
||||
entertainment: '招待',
|
||||
other: '其他',
|
||||
交通: '交通',
|
||||
餐饮: '餐饮',
|
||||
住宿: '住宿',
|
||||
办公: '办公',
|
||||
招待: '招待',
|
||||
其他: '其他',
|
||||
};
|
||||
return map[key] || map[key.toLowerCase()] || key;
|
||||
}
|
||||
|
||||
export function money(v?: string | number | null) {
|
||||
if (v === undefined || v === null || v === '') return '—';
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : String(v);
|
||||
}
|
||||
|
||||
export function applicantName(row: Record<string, unknown> | { applicant?: { displayName?: string; employee?: { name?: string; department?: { name?: string } } } | null }) {
|
||||
const a = (row as { applicant?: { displayName?: string; employee?: { name?: string } } | null }).applicant;
|
||||
return a?.displayName || a?.employee?.name || '—';
|
||||
}
|
||||
|
||||
export function applicantDept(row: Record<string, unknown>) {
|
||||
const a = row.applicant as { employee?: { department?: { name?: string } } } | null;
|
||||
return a?.employee?.department?.name || '—';
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
export const REST_LABEL: Record<string, string> = {
|
||||
SINGLE: '单休',
|
||||
DOUBLE: '双休',
|
||||
};
|
||||
|
||||
export const GENDER_LABEL: Record<string, string> = {
|
||||
MALE: '男',
|
||||
FEMALE: '女',
|
||||
};
|
||||
|
||||
export const MARITAL_LABEL: Record<string, string> = {
|
||||
SINGLE: '未婚',
|
||||
MARRIED: '已婚',
|
||||
DIVORCED: '离异',
|
||||
OTHER: '其他',
|
||||
};
|
||||
|
||||
export const PERSON_CATEGORY_LABEL: Record<string, string> = {
|
||||
REGULAR: '正式人员',
|
||||
INFORMAL: '非正式人员',
|
||||
INTERN: '实习',
|
||||
PART_TIME: '兼职',
|
||||
OUTSOURCE: '外包',
|
||||
};
|
||||
|
||||
export const JOB_TYPE_LABEL: Record<string, string> = {
|
||||
TECH: '技术',
|
||||
BIZ: '商务',
|
||||
ADMIN: '行政',
|
||||
FINANCE: '财务',
|
||||
MANAGE: '管理',
|
||||
OTHER: '其他',
|
||||
};
|
||||
|
||||
export const EDUCATION_LABEL: Record<string, string> = {
|
||||
HIGH_SCHOOL: '高中及以下',
|
||||
COLLEGE: '专科',
|
||||
BACHELOR: '本科',
|
||||
MASTER: '硕士',
|
||||
DOCTOR: '博士',
|
||||
};
|
||||
|
||||
export const ID_TYPE_LABEL: Record<string, string> = {
|
||||
ID_CARD: '居民身份证',
|
||||
PASSPORT: '护照',
|
||||
HK_MACAO: '港澳居民来往内地通行证',
|
||||
TAIWAN: '台湾居民来往大陆通行证',
|
||||
RESIDENCE: '港澳台居民居住证',
|
||||
FOREIGN_PR: '外国人永久居留身份证',
|
||||
OTHER: '其他',
|
||||
};
|
||||
|
||||
export const EMP_STATUS_META: Record<string, { color: string; text: string }> = {
|
||||
INTERN: { color: 'blue', text: '实习' },
|
||||
PROBATION: { color: 'gold', text: '试用' },
|
||||
REGULAR: { color: 'success', text: '正式' },
|
||||
LEFT: { color: 'default', text: '离职' },
|
||||
};
|
||||
|
||||
export const LIFE_KIND_LABEL: Record<string, string> = {
|
||||
HIRE: '入职',
|
||||
INTERN: '实习',
|
||||
PROBATION: '试用',
|
||||
REGULARIZE: '转正',
|
||||
TRANSFER: '调岗',
|
||||
PROMOTE: '晋升',
|
||||
LEAVE: '离职',
|
||||
};
|
||||
|
||||
export const DEPT_TYPE_LABEL: Record<string, string> = {
|
||||
ADMIN: '行政',
|
||||
BUSINESS: '商务',
|
||||
TECH: '技术',
|
||||
FINANCE: '财务',
|
||||
HR: '人事',
|
||||
};
|
||||
|
||||
export const DEPT_TITLE_BY_TYPE: Record<string, string[]> = {
|
||||
ADMIN: ['负责人', '系统管理员', '普通岗位'],
|
||||
BUSINESS: ['商务总监', '商务专员'],
|
||||
TECH: ['项目经理', '普通岗位'],
|
||||
FINANCE: ['财务经理', '会计', '出纳'],
|
||||
HR: ['人事经理', '人事专员'],
|
||||
};
|
||||
|
||||
export const DEPT_TITLE_BY_CATEGORY: Record<string, string[]> = {
|
||||
CAT_SOFTWARE: ['研发总监', '研发技术岗', '项目经理'],
|
||||
CAT_3D: ['三维总监', '三维技术岗'],
|
||||
CAT_FILM: ['摄制总监', '摄制技术岗'],
|
||||
CAT_MATERIAL: ['物资总监', '物资技术岗'],
|
||||
};
|
||||
|
||||
export const LEAVE_KIND_LABEL: Record<string, string> = {
|
||||
STATUTORY: '法定节假日',
|
||||
ANNUAL: '带薪年休假',
|
||||
SICK: '病假',
|
||||
WORK_INJURY: '工伤假',
|
||||
MATERNITY: '产假',
|
||||
PRENATAL: '产前检查假',
|
||||
BREASTFEEDING: '哺乳假',
|
||||
PATERNITY: '陪产假',
|
||||
PARENTAL: '育儿假',
|
||||
ONLY_CHILD_CARE: '独生子女护理假',
|
||||
MARRIAGE: '婚假',
|
||||
BEREAVEMENT: '丧假',
|
||||
PERSONAL: '事假',
|
||||
COMP: '调休',
|
||||
};
|
||||
|
||||
export const LEAVE_KIND_HINT: Record<string, string> = {
|
||||
STATUTORY: '全国统一 11 天,视同出勤,全额发薪。',
|
||||
ANNUAL: '按累计社会工龄:满 1 年 5 天、满 10 年 10 天、满 20 年 15 天,视同出勤。',
|
||||
SICK: '医疗期病假,不低于当地最低工资 80%,不按全薪。',
|
||||
WORK_INJURY: '停工留薪期一般不超过 12 个月,原待遇由单位按月支付。',
|
||||
MATERNITY: '国家基础 98 天,各地可延长,津贴多由生育保险支付。',
|
||||
PRENATAL: '孕期产检所需时间视同出勤。',
|
||||
BREASTFEEDING: '婴儿未满 1 周岁,工作日内每日 1 小时哺乳时间。',
|
||||
PATERNITY: '男方护理假,天数以本省条例为准,视同出勤。',
|
||||
PARENTAL: '子女幼年期间父母双方每年各享有,天数以本省条例为准。',
|
||||
ONLY_CHILD_CARE: '部分省市规定,独生子女父母住院护理,带薪。',
|
||||
MARRIAGE: '国家基础 3 天,多数省市有延长。',
|
||||
BEREAVEMENT: '直系亲属身故通常 1–3 天。',
|
||||
PERSONAL: '无薪,按企业内部制度。薪酬按月薪÷21.75×天数扣款。',
|
||||
COMP: '加班转入的调休,从剩余调休时长中扣减。',
|
||||
};
|
||||
|
||||
export const LEAVE_KIND_OPTIONS: { value: string; label: string }[] = [
|
||||
{ value: 'ANNUAL', label: '带薪年休假' },
|
||||
{ value: 'SICK', label: '病假' },
|
||||
{ value: 'WORK_INJURY', label: '工伤假' },
|
||||
{ value: 'MATERNITY', label: '产假' },
|
||||
{ value: 'PRENATAL', label: '产前检查假' },
|
||||
{ value: 'BREASTFEEDING', label: '哺乳假' },
|
||||
{ value: 'PATERNITY', label: '陪产假' },
|
||||
{ value: 'PARENTAL', label: '育儿假' },
|
||||
{ value: 'ONLY_CHILD_CARE', label: '独生子女护理假' },
|
||||
{ value: 'MARRIAGE', label: '婚假' },
|
||||
{ value: 'BEREAVEMENT', label: '丧假' },
|
||||
{ value: 'PERSONAL', label: '事假' },
|
||||
{ value: 'COMP', label: '调休' },
|
||||
];
|
||||
|
||||
export const LEAVE_KIND_CODES = Object.keys(LEAVE_KIND_LABEL);
|
||||
|
||||
export const HR_APPLY_OPTIONS: { value: string; label: string }[] = [
|
||||
...LEAVE_KIND_OPTIONS,
|
||||
{ value: 'BUSINESS', label: '出差' },
|
||||
{ value: 'OUT', label: '外出' },
|
||||
{ value: 'OVERTIME', label: '加班' },
|
||||
{ value: 'MAKEUP', label: '补卡' },
|
||||
{ value: 'PROMOTE', label: '晋升' },
|
||||
{ value: 'TRANSFER', label: '转岗' },
|
||||
{ value: 'RESIGN', label: '离职' },
|
||||
];
|
||||
|
||||
export const HR_APPLY_LABEL: Record<string, string> = {
|
||||
...LEAVE_KIND_LABEL,
|
||||
BUSINESS: '出差',
|
||||
OVERTIME: '加班',
|
||||
MAKEUP: '补卡',
|
||||
OUT: '外出',
|
||||
REGULARIZE: '转正',
|
||||
PROMOTE: '晋升',
|
||||
TRANSFER: '转岗',
|
||||
RESIGN: '离职',
|
||||
OTHER: '其他',
|
||||
};
|
||||
|
||||
export function isLeaveApplyKind(kind?: string) {
|
||||
return Boolean(kind && (LEAVE_KIND_CODES.includes(kind) || kind === 'BUSINESS' || kind === 'OUT'));
|
||||
}
|
||||
|
||||
export const LEAVE_STATUS_META: Record<string, { color: string; text: string }> = {
|
||||
PENDING: { color: 'processing', text: '待审批' },
|
||||
APPROVED: { color: 'success', text: '已通过' },
|
||||
REJECTED: { color: 'error', text: '已驳回' },
|
||||
};
|
||||
|
||||
export const PAYROLL_STATUS_META: Record<string, { color: string; text: string }> = {
|
||||
DRAFT: { color: 'warning', text: '待确认' },
|
||||
CONFIRMED: { color: 'success', text: '已确认' },
|
||||
};
|
||||
|
||||
export function money(v?: string | number | null) {
|
||||
if (v === undefined || v === null || v === '') return '—';
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) : String(v);
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Button, Modal, Space, Typography, message } from 'antd';
|
||||
import { RemoteTrack, Room, RoomEvent, Track } from 'livekit-client';
|
||||
import { api } from '../../api/client';
|
||||
import { useAuth } from '../../store/auth';
|
||||
|
||||
export type CallInfo = { id: string; kind: string; title?: string; initiatorId: string; conversationId?: string | null; memberIds?: string[] };
|
||||
|
||||
/** Desktop LiveKit call surface. OA owns the call state; LiveKit owns media and reconnects. */
|
||||
export function ImCallModal({ call, onClose }: { call: CallInfo; peerId?: string; onClose: () => void }) {
|
||||
const me = useAuth((s) => s.id);
|
||||
const localRef = useRef<HTMLVideoElement>(null);
|
||||
const remoteRef = useRef<HTMLVideoElement>(null);
|
||||
const audioRef = useRef<HTMLAudioElement>(null);
|
||||
const roomRef = useRef<Room | null>(null);
|
||||
const started = useRef(false);
|
||||
const [accepted, setAccepted] = useState(call.initiatorId === me);
|
||||
const [status, setStatus] = useState(call.initiatorId === me ? '等待对方接听…' : '对方邀请你通话');
|
||||
const [seconds, setSeconds] = useState(0);
|
||||
const [connected, setConnected] = useState(false);
|
||||
const video = call.kind !== 'audio';
|
||||
|
||||
useEffect(() => {
|
||||
if (!accepted || started.current) return;
|
||||
started.current = true;
|
||||
let stopped = false;
|
||||
let timer: number | undefined;
|
||||
let statePoll: number | undefined;
|
||||
const room = new Room({ adaptiveStream: true, dynacast: true });
|
||||
roomRef.current = room;
|
||||
const stop = (text?: string) => {
|
||||
if (stopped) return;
|
||||
stopped = true;
|
||||
if (timer) window.clearInterval(timer);
|
||||
if (statePoll) window.clearInterval(statePoll);
|
||||
room.disconnect();
|
||||
if (text) setStatus(text);
|
||||
};
|
||||
room
|
||||
.on(RoomEvent.ParticipantConnected, () => { setConnected(true); setStatus('通话中'); })
|
||||
.on(RoomEvent.ParticipantDisconnected, () => stop('通话已结束'))
|
||||
.on(RoomEvent.Reconnecting, () => setStatus('网络恢复中…'))
|
||||
.on(RoomEvent.Reconnected, () => setStatus('通话中'))
|
||||
.on(RoomEvent.Disconnected, () => { if (!stopped) setStatus('通话已断开'); })
|
||||
.on(RoomEvent.TrackSubscribed, (track: RemoteTrack) => {
|
||||
if (track.kind === Track.Kind.Video && remoteRef.current) track.attach(remoteRef.current);
|
||||
if (track.kind === Track.Kind.Audio && audioRef.current) track.attach(audioRef.current);
|
||||
setConnected(true);
|
||||
setStatus('通话中');
|
||||
});
|
||||
const start = async () => {
|
||||
try {
|
||||
await api.post(`/im/calls/${call.id}/join`);
|
||||
const tokenRes = await api.post(`/im/calls/${call.id}/livekit-token`);
|
||||
const auth = (tokenRes as { data?: { url?: string; token?: string } }).data;
|
||||
if (!auth?.url || !auth.token) throw new Error('音视频服务未配置');
|
||||
await room.connect(auth.url, auth.token);
|
||||
await room.localParticipant.setMicrophoneEnabled(true);
|
||||
if (video) {
|
||||
const publication = await room.localParticipant.setCameraEnabled(true);
|
||||
if (publication?.track && localRef.current) publication.track.attach(localRef.current);
|
||||
}
|
||||
if (room.remoteParticipants.size > 0) { setConnected(true); setStatus('通话中'); }
|
||||
timer = window.setInterval(() => setSeconds((v) => v + 1), 1000);
|
||||
statePoll = window.setInterval(async () => {
|
||||
try {
|
||||
const res = await api.get(`/im/calls/${call.id}`);
|
||||
const data = (res as { data?: { status?: string; peerRejected?: boolean; peerJoined?: boolean } }).data;
|
||||
if (data?.status === 'ended') stop(data.peerRejected ? '对方拒绝接听' : '通话已结束');
|
||||
else if (data?.peerJoined) { setConnected(true); setStatus('通话中'); }
|
||||
} catch { /* transient network failure */ }
|
||||
}, 2000);
|
||||
} catch (e) {
|
||||
setStatus(e instanceof Error ? e.message : '无法接通音视频服务');
|
||||
}
|
||||
};
|
||||
void start();
|
||||
return () => stop();
|
||||
}, [accepted, call.id, video]);
|
||||
|
||||
useEffect(() => {
|
||||
if (connected || !accepted) return;
|
||||
const timeout = window.setTimeout(async () => {
|
||||
try { await api.post(`/im/calls/${call.id}/end`); } catch { /* ignore */ }
|
||||
setStatus('无人接听');
|
||||
}, 45_000);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [accepted, connected, call.id]);
|
||||
|
||||
const hangup = async (reject = false) => {
|
||||
try { await api.post(`/im/calls/${call.id}/${reject ? 'reject' : 'end'}`); } catch { /* ignore */ }
|
||||
roomRef.current?.disconnect();
|
||||
onClose();
|
||||
};
|
||||
const duration = `${String(Math.floor(seconds / 60)).padStart(2, '0')}:${String(seconds % 60).padStart(2, '0')}`;
|
||||
|
||||
return (
|
||||
<Modal open title={call.title || (video ? '视频通话' : '语音通话')} onCancel={() => void hangup(!accepted)} footer={null} width={video ? 720 : 420} destroyOnClose>
|
||||
<Typography.Paragraph type="secondary">{connected ? `${status} · ${duration}` : status}</Typography.Paragraph>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: video ? '1fr 1fr' : '1fr', gap: 12 }}>
|
||||
{video ? <video ref={localRef} autoPlay muted playsInline style={{ width: '100%', background: '#111', borderRadius: 8, minHeight: 220 }} /> : null}
|
||||
{video ? <video ref={remoteRef} autoPlay playsInline style={{ width: '100%', background: '#111', borderRadius: 8, minHeight: 220 }} /> : null}
|
||||
<audio ref={audioRef} autoPlay />
|
||||
</div>
|
||||
<Space style={{ marginTop: 16 }}>
|
||||
{!accepted ? <Button danger onClick={() => void hangup(true)}>拒绝</Button> : null}
|
||||
{!accepted ? <Button type="primary" onClick={() => setAccepted(true)}>接听</Button> : null}
|
||||
{accepted ? <Button danger type="primary" onClick={() => void hangup()}>挂断</Button> : null}
|
||||
</Space>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function useIncomingCall(onRing: (call: CallInfo) => void) {
|
||||
const id = useAuth((s) => s.id);
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
let last = '';
|
||||
const t = window.setInterval(async () => {
|
||||
try {
|
||||
const res = await api.get('/im/calls/incoming');
|
||||
const rows = ((res as { data?: CallInfo[] }).data || []) as CallInfo[];
|
||||
const hit = rows.find((c) => c.initiatorId !== id && c.id !== last);
|
||||
if (hit) { last = hit.id; onRing(hit); message.info(hit.title || '来电'); }
|
||||
} catch { /* transient network failure */ }
|
||||
}, 2500);
|
||||
return () => window.clearInterval(t);
|
||||
}, [id, onRing]);
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Button, Form, Input, List, Modal, Select, Space, Typography, message } from 'antd';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { api } from '../../api/client';
|
||||
import { wgs84ToGcj02 } from '../../geo/gcj02';
|
||||
import { useAuth } from '../../store/auth';
|
||||
import { PageHeader } from '../ResourcePage';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { ImCallModal, useIncomingCall, type CallInfo } from './ImCallModal';
|
||||
|
||||
type Conv = {
|
||||
id: string;
|
||||
type: string;
|
||||
name: string;
|
||||
peerId?: string;
|
||||
peerName?: string;
|
||||
lastText?: string;
|
||||
lastAt?: string;
|
||||
unread?: number;
|
||||
memberCount?: number;
|
||||
};
|
||||
|
||||
type Msg = {
|
||||
id: string;
|
||||
fromUserId: string;
|
||||
body: string;
|
||||
kind: string;
|
||||
contentType?: string;
|
||||
createdAt: string;
|
||||
readCount?: number;
|
||||
};
|
||||
|
||||
type Staff = { id: string; name: string; department?: string };
|
||||
|
||||
export default function ImPage() {
|
||||
const me = useAuth((s) => s.id);
|
||||
const qc = useQueryClient();
|
||||
const [params] = useSearchParams();
|
||||
const [active, setActive] = useState<Conv | null>(null);
|
||||
const [text, setText] = useState('');
|
||||
const [groupOpen, setGroupOpen] = useState(false);
|
||||
const [call, setCall] = useState<CallInfo | null>(null);
|
||||
const bottom = useRef<HTMLDivElement>(null);
|
||||
const [form] = Form.useForm();
|
||||
|
||||
const convQ = useQuery({
|
||||
queryKey: ['/im/conversations'],
|
||||
queryFn: () => api.get('/im/conversations'),
|
||||
refetchInterval: 5000,
|
||||
});
|
||||
const convs = ((convQ.data as { data?: Conv[] })?.data || []) as Conv[];
|
||||
|
||||
useEffect(() => {
|
||||
const peerId = params.get('peerId');
|
||||
if (!peerId) return;
|
||||
const hit = convs.find((c) => c.peerId === peerId);
|
||||
setActive((cur) => {
|
||||
if (cur && cur.peerId !== peerId && cur.id) return cur;
|
||||
if (hit) return hit;
|
||||
if (cur?.peerId === peerId) return cur;
|
||||
const name = params.get('name') || '同事';
|
||||
return { id: '', type: 'direct', name, peerId, peerName: name };
|
||||
});
|
||||
}, [params, convs]);
|
||||
|
||||
const msgQ = useQuery({
|
||||
queryKey: ['/im/messages', active?.id, active?.peerId],
|
||||
queryFn: () =>
|
||||
api.get('/im/messages', {
|
||||
params: active?.id ? { conversationId: active.id } : { peerId: active?.peerId },
|
||||
}),
|
||||
enabled: Boolean(active),
|
||||
refetchInterval: 2500,
|
||||
});
|
||||
const items = (((msgQ.data as { data?: { items?: Msg[] } })?.data?.items || []) as Msg[]);
|
||||
|
||||
const staffQ = useQuery({ queryKey: ['/staff'], queryFn: () => api.get('/staff') });
|
||||
const staff = ((staffQ.data as { data?: Staff[] })?.data || []) as Staff[];
|
||||
|
||||
useEffect(() => {
|
||||
bottom.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
}, [items.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'PrintScreen' && active) {
|
||||
void api.post('/im/events/screenshot', { conversationId: active.id, peerId: active.peerId });
|
||||
}
|
||||
};
|
||||
window.addEventListener('keyup', onKey);
|
||||
return () => window.removeEventListener('keyup', onKey);
|
||||
}, [active]);
|
||||
|
||||
const onRing = useCallback((c: CallInfo) => setCall(c), []);
|
||||
useIncomingCall(onRing);
|
||||
|
||||
const sendMut = useMutation({
|
||||
mutationFn: (body: Record<string, unknown>) => api.post('/im/messages', body),
|
||||
onSuccess: async () => {
|
||||
setText('');
|
||||
await qc.invalidateQueries({ queryKey: ['/im/messages'] });
|
||||
await qc.invalidateQueries({ queryKey: ['/im/conversations'] });
|
||||
},
|
||||
onError: (e: Error) => message.error(e.message),
|
||||
});
|
||||
|
||||
const send = () => {
|
||||
const body = text.trim();
|
||||
if (!body || !active) return;
|
||||
sendMut.mutate({ conversationId: active.id, peerId: active.peerId, body });
|
||||
};
|
||||
|
||||
const sendLocation = async () => {
|
||||
if (!active) return;
|
||||
const loc = await new Promise<GeolocationPosition | null>((resolve) => {
|
||||
if (!navigator.geolocation) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
navigator.geolocation.getCurrentPosition((p) => resolve(p), () => resolve(null), {
|
||||
enableHighAccuracy: true,
|
||||
timeout: 16000,
|
||||
maximumAge: 0,
|
||||
});
|
||||
});
|
||||
if (!loc) {
|
||||
message.error('没有拿到定位,请允许浏览器位置权限');
|
||||
return;
|
||||
}
|
||||
const gcj = wgs84ToGcj02(loc.coords.latitude, loc.coords.longitude);
|
||||
let address = `${gcj.lat.toFixed(5)}, ${gcj.lng.toFixed(5)}`;
|
||||
try {
|
||||
const geo = await api.post('/office/geo/reverse', { lat: gcj.lat, lng: gcj.lng });
|
||||
const data = (geo as { data?: { address?: string } }).data;
|
||||
if (data?.address) address = data.address;
|
||||
} catch {
|
||||
/* 坐标仍可发 */
|
||||
}
|
||||
sendMut.mutate({
|
||||
conversationId: active.id,
|
||||
peerId: active.peerId,
|
||||
contentType: 'location',
|
||||
body: address,
|
||||
meta: { lat: gcj.lat, lng: gcj.lng, address },
|
||||
});
|
||||
};
|
||||
|
||||
const startCall = async (kind: 'audio' | 'video' | 'meeting') => {
|
||||
if (!active) return;
|
||||
try {
|
||||
const res = await api.post('/im/calls', {
|
||||
conversationId: active.id,
|
||||
peerId: active.peerId,
|
||||
kind,
|
||||
title: kind === 'meeting' ? `${active.name} 会议` : undefined,
|
||||
});
|
||||
const data = (res as { data?: CallInfo }).data;
|
||||
if (data) setCall(data);
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '无法发起通话');
|
||||
}
|
||||
};
|
||||
|
||||
const createGroup = async (v: { name: string; memberIds: string[] }) => {
|
||||
try {
|
||||
const res = await api.post('/im/groups', v);
|
||||
const data = (res as { data?: Conv }).data;
|
||||
setGroupOpen(false);
|
||||
form.resetFields();
|
||||
await qc.invalidateQueries({ queryKey: ['/im/conversations'] });
|
||||
if (data) setActive({ ...data, type: 'group', name: v.name });
|
||||
} catch (e) {
|
||||
message.error(e instanceof Error ? e.message : '建群失败');
|
||||
}
|
||||
};
|
||||
|
||||
const staffOpts = useMemo(
|
||||
() => staff.filter((s) => s.id !== me).map((s) => ({ value: s.id, label: `${s.name}${s.department ? `(${s.department})` : ''}` })),
|
||||
[staff, me],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader
|
||||
title="消息"
|
||||
description="同事私聊、群聊、已读、定位和音视频。截屏会通知对方。大文件仍走附件,不走聊天通道。"
|
||||
extra={
|
||||
<Button type="primary" onClick={() => setGroupOpen(true)}>
|
||||
发起群聊
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '280px 1fr',
|
||||
minHeight: 560,
|
||||
border: '1px solid rgba(199,196,215,0.55)',
|
||||
borderRadius: 12,
|
||||
overflow: 'hidden',
|
||||
background: '#fff',
|
||||
}}
|
||||
>
|
||||
<div style={{ borderRight: '1px solid #eee', overflow: 'auto' }}>
|
||||
<List
|
||||
dataSource={convs}
|
||||
locale={{ emptyText: '还没有会话。从员工通讯点同事,或发起群聊。' }}
|
||||
renderItem={(c) => (
|
||||
<List.Item
|
||||
onClick={() => setActive(c)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
background: active?.id === c.id ? 'rgba(107,56,212,0.08)' : undefined,
|
||||
padding: '10px 16px',
|
||||
}}
|
||||
>
|
||||
<List.Item.Meta
|
||||
title={
|
||||
<Space>
|
||||
<span>{c.name || c.peerName}</span>
|
||||
{c.type === 'group' ? <Typography.Text type="secondary">群</Typography.Text> : null}
|
||||
{c.unread ? <Typography.Text type="danger">{c.unread}</Typography.Text> : null}
|
||||
</Space>
|
||||
}
|
||||
description={c.lastText || ' '}
|
||||
/>
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div style={{ display: 'flex', flexDirection: 'column' }}>
|
||||
{active ? (
|
||||
<>
|
||||
<div style={{ padding: '10px 16px', borderBottom: '1px solid #eee', display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Typography.Text strong>
|
||||
{active.name || active.peerName}
|
||||
{active.type === 'group' ? `(${active.memberCount || ''}人)` : ''}
|
||||
</Typography.Text>
|
||||
<Space>
|
||||
<Button size="small" onClick={() => void startCall('audio')}>
|
||||
语音
|
||||
</Button>
|
||||
<Button size="small" onClick={() => void startCall('video')}>
|
||||
视频
|
||||
</Button>
|
||||
{active.type === 'group' ? (
|
||||
<Button size="small" onClick={() => void startCall('meeting')}>
|
||||
会议
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
</div>
|
||||
<div style={{ flex: 1, overflow: 'auto', padding: 16 }}>
|
||||
{items.map((m) => {
|
||||
const mine = m.fromUserId === me;
|
||||
if (m.kind === 'screenshot' || m.contentType === 'system') {
|
||||
return (
|
||||
<div key={m.id} style={{ textAlign: 'center', color: '#b2b2b2', fontSize: 12, margin: '10px 0' }}>
|
||||
{m.body || '有人截屏了'}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
let extra = '';
|
||||
if (m.contentType === 'location' || m.kind === 'location') extra = '📍 ';
|
||||
return (
|
||||
<div key={m.id} style={{ display: 'flex', justifyContent: mine ? 'flex-end' : 'flex-start', marginBottom: 10 }}>
|
||||
<div
|
||||
style={{
|
||||
maxWidth: 420,
|
||||
padding: '8px 12px',
|
||||
borderRadius: 12,
|
||||
background: mine ? '#6b38d4' : '#f4f2fa',
|
||||
color: mine ? '#fff' : '#191c1e',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
{extra}
|
||||
{m.body}
|
||||
</div>
|
||||
<div style={{ fontSize: 11, opacity: 0.7, marginTop: 4 }}>
|
||||
{m.createdAt?.slice(0, 16).replace('T', ' ')}
|
||||
{mine ? ` · ${m.readCount ? `已读 ${m.readCount}` : '未读'}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div ref={bottom} />
|
||||
</div>
|
||||
<div style={{ padding: 12, borderTop: '1px solid #eee' }}>
|
||||
<Space.Compact style={{ width: '100%' }}>
|
||||
<Button onClick={() => void sendLocation()}>定位</Button>
|
||||
<Input value={text} onChange={(e) => setText(e.target.value)} onPressEnter={send} placeholder="发一条消息,Enter 发送" />
|
||||
<Button type="primary" loading={sendMut.isPending} onClick={send}>
|
||||
发送
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<div style={{ margin: 'auto', color: '#64748b' }}>选左侧会话,或从员工通讯发起私聊</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Modal title="发起群聊" open={groupOpen} onCancel={() => setGroupOpen(false)} onOk={() => form.submit()} okText="创建">
|
||||
<Form form={form} layout="vertical" onFinish={createGroup}>
|
||||
<Form.Item name="name" label="群名称" rules={[{ required: true, message: '请填写群名称' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="memberIds" label="成员" rules={[{ required: true, message: '请选择同事' }]}>
|
||||
<Select mode="multiple" showSearch optionFilterProp="label" options={staffOpts} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
{call ? <ImCallModal call={call} peerId={active?.peerId} onClose={() => setCall(null)} /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
export const PROJECT_STATUS_META: Record<string, { color: string; text: string }> = {
|
||||
NOT_STARTED: { color: 'default', text: '未开始' },
|
||||
ACTIVE: { color: 'processing', text: '执行中' },
|
||||
INTERNAL_ACCEPTED: { color: 'gold', text: '待验收' },
|
||||
ACCEPTED: { color: 'success', text: '已验收' },
|
||||
DONE: { color: 'cyan', text: '已完成' },
|
||||
};
|
||||
|
||||
export const BUDGET_CATEGORY_LABEL: Record<string, string> = {
|
||||
LABOR: '人工',
|
||||
TRAVEL: '差旅(商务)',
|
||||
PURCHASE: '采购',
|
||||
BUSINESS: '商务',
|
||||
TECH: '差旅(技术)',
|
||||
};
|
||||
|
||||
export const TASK_STATUS_META: Record<string, { color: string; text: string }> = {
|
||||
OPEN: { color: 'processing', text: '进行中' },
|
||||
DONE: { color: 'success', text: '已完成' },
|
||||
};
|
||||
|
||||
export const ACCEPT_KIND_LABEL: Record<string, string> = {
|
||||
INTERNAL: '内部验收',
|
||||
CUSTOMER: '客户验收',
|
||||
};
|
||||
|
||||
export function projectStatusMeta(status: string) {
|
||||
return PROJECT_STATUS_META[status] || { color: 'default', text: status };
|
||||
}
|
||||
|
||||
export function officeItemPath(row: { bizType?: string | null; bizId?: string | null }) {
|
||||
if (!row.bizId) return null;
|
||||
if (row.bizType === 'CONTRACT_APPROVAL' || row.bizType === 'CONTRACT_DRAFT') return `/contract/${row.bizId}`;
|
||||
if (row.bizType === 'EXPENSE_APPROVAL') return `/finance/expenses/${row.bizId}`;
|
||||
if (row.bizType === 'LOAN_APPROVAL') return `/finance/loans/${row.bizId}`;
|
||||
if (row.bizType === 'LOAN_OFFSET') return `/office/loans/${row.bizId}`;
|
||||
if (row.bizType === 'PROJECT_TIMESHEET' || row.bizType === 'PROJECT_ACCEPT') return `/project/${row.bizId}`;
|
||||
if (row.bizType === 'SEAL_APPROVAL' || row.bizType === 'SEAL_RETURN') return '/seal/requests';
|
||||
if (row.bizType === 'CREDENTIAL_RETURN') return '/seal/borrows';
|
||||
if (row.bizType === 'BID_OPEN') return `/office/bid-open/${row.bizId}`;
|
||||
if (row.bizType === 'WORK_ASSIGN') return '/office/todos';
|
||||
if (row.bizType === 'OFFICE_APPLY') return '/office/apply';
|
||||
if (row.bizType === 'TRAVEL_EXPENSE' || row.bizType === 'EXPENSE_DRAFT') return `/office/expenses/${row.bizId}`;
|
||||
if (row.bizType === 'HR_LEAVE') return `/office/leaves/${row.bizId}`;
|
||||
if ((row.bizType || '').startsWith('BID')) return `/bid/cases/${row.bizId}`;
|
||||
return null;
|
||||
}
|
||||