Files
Public/apps/web/src/pages/QualificationListPage.tsx
T
daiyongkang 76f266645d Initial commit: 风影 OA 全栈源码(API/Web/Flutter/IM)
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。
排除 node_modules、构建产物、安装包与 .env 密钥。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-02 10:04:03 +00:00

358 lines
12 KiB
TypeScript

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>
),
},
]}
/>
</>
);
}