76f266645d
含 Phase 1.1 IM seq 排序、断线重连、多端已读同步与微信式语音转文字 UI。 排除 node_modules、构建产物、安装包与 .env 密钥。 Co-authored-by: Cursor <cursoragent@cursor.com>
358 lines
12 KiB
TypeScript
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>
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
</>
|
|
);
|
|
}
|