feat(web): 耗材管理页(库存/预警/采购建议,#16)
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import { get, post, patch, del } from '../api/http';
|
||||
|
||||
export interface Consumable {
|
||||
id: string;
|
||||
name: string;
|
||||
category: string;
|
||||
spec?: string;
|
||||
quantity: number;
|
||||
unit?: string;
|
||||
minQuantity: number;
|
||||
expiryDate?: string;
|
||||
supplier?: string;
|
||||
note?: string;
|
||||
createdAt?: string;
|
||||
}
|
||||
|
||||
export const listConsumables = (params?: any) => get<Consumable[]>('/consumables', { params });
|
||||
export const createConsumable = (data: Partial<Consumable>) => post<Consumable>('/consumables', data);
|
||||
export const updateConsumable = (id: string, data: Partial<Consumable>) =>
|
||||
patch<Consumable>(`/consumables/${id}`, data);
|
||||
export const deleteConsumable = (id: string) => del(`/consumables/${id}`);
|
||||
export const getConsumableAlerts = () => get<any[]>('/consumables/alerts');
|
||||
export const getPurchaseSuggestions = () => get<any[]>('/consumables/purchase-suggestions');
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
BookOutlined,
|
||||
ProfileOutlined,
|
||||
ExperimentOutlined,
|
||||
ShoppingOutlined,
|
||||
SettingOutlined,
|
||||
LogoutOutlined,
|
||||
UserOutlined,
|
||||
@@ -36,6 +37,7 @@ const menuData = [
|
||||
{ path: '/knowledge', name: '知识库', icon: <BookOutlined />, permission: 'knowledge:read' },
|
||||
{ path: '/batches', name: '批次管理', icon: <ProfileOutlined />, permission: 'batch:read' },
|
||||
{ path: '/lamp-tests', name: 'LAMP 检测', icon: <ExperimentOutlined />, permission: 'lamp:read' },
|
||||
{ path: '/consumables', name: '耗材管理', icon: <ShoppingOutlined />, permission: 'consumable:read' },
|
||||
];
|
||||
|
||||
// 角色中文名
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import {
|
||||
Button, Card, DatePicker, Form, Input, InputNumber, Modal, Popconfirm, Select, Space, Tag, message,
|
||||
} from 'antd';
|
||||
import { PlusOutlined } from '@ant-design/icons';
|
||||
import { ProTable, type ActionType, type ProColumns } from '@ant-design/pro-components';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
listConsumables,
|
||||
createConsumable,
|
||||
updateConsumable,
|
||||
deleteConsumable,
|
||||
getConsumableAlerts,
|
||||
getPurchaseSuggestions,
|
||||
type Consumable,
|
||||
} from '../dal/consumable';
|
||||
import { authService } from '../services/auth';
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
lamp_reagent: 'LAMP 试剂',
|
||||
lamp_consumable: 'LAMP 耗材',
|
||||
disinfectant: '消毒剂',
|
||||
other: '其他',
|
||||
};
|
||||
|
||||
const canWrite = () => authService.hasPermission('consumable:write');
|
||||
|
||||
type ConsumableForm = Omit<Partial<Consumable>, 'expiryDate'> & { expiryDate?: dayjs.Dayjs };
|
||||
|
||||
function AlertsBlock() {
|
||||
const [alerts, setAlerts] = useState<any[]>([]);
|
||||
const [suggestions, setSuggestions] = useState<any[]>([]);
|
||||
useEffect(() => {
|
||||
Promise.all([getConsumableAlerts().catch(() => []), getPurchaseSuggestions().catch(() => [])]).then(
|
||||
([a, s]) => {
|
||||
setAlerts(a);
|
||||
setSuggestions(s);
|
||||
},
|
||||
);
|
||||
}, []);
|
||||
if (alerts.length === 0 && suggestions.length === 0) return null;
|
||||
return (
|
||||
<Space direction="vertical" style={{ width: '100%', marginBottom: 16 }}>
|
||||
{alerts.length > 0 ? (
|
||||
<Card title="库存/效期预警" size="small">
|
||||
{alerts.map((a) => (
|
||||
<div key={a.id}>
|
||||
<Tag color={a.alertType === 'low_stock' ? 'orange' : 'red'}>
|
||||
{a.alertType === 'low_stock' ? '低库存' : '临期/过期'}
|
||||
</Tag>
|
||||
<b>{a.name}</b>
|
||||
<span style={{ marginLeft: 8, color: '#666' }}>
|
||||
库存 {a.quantity} {a.unit || ''}(安全阈值 {a.minQuantity})
|
||||
{a.expiryDate ? ` · 效期 ${dayjs(a.expiryDate).format('YYYY-MM-DD')}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
) : null}
|
||||
{suggestions.length > 0 ? (
|
||||
<Card title="采购建议" size="small">
|
||||
{suggestions.map((s) => (
|
||||
<div key={s.id}>
|
||||
<Tag color="blue">建议采购</Tag>
|
||||
<b>{s.name}</b>
|
||||
<span style={{ marginLeft: 8, color: '#666' }}>
|
||||
建议补足 {s.suggest} {s.unit || ''}(当前 {s.quantity} / 阈值 {s.minQuantity})
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
) : null}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ConsumablesPage() {
|
||||
const actionRef = useRef<ActionType>();
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [form] = Form.useForm<ConsumableForm>();
|
||||
const [editing, setEditing] = useState<Consumable | null>(null);
|
||||
|
||||
const columns: ProColumns<Consumable>[] = [
|
||||
{ title: '序号', valueType: 'indexBorder', search: false, width: 60 },
|
||||
{ title: '名称', dataIndex: 'name' },
|
||||
{
|
||||
title: '类别',
|
||||
dataIndex: 'category',
|
||||
valueType: 'select',
|
||||
valueEnum: Object.fromEntries(Object.entries(CATEGORY_LABELS).map(([k, v]) => [k, { text: v }])),
|
||||
render: (_, r) => <Tag>{CATEGORY_LABELS[r.category] || r.category}</Tag>,
|
||||
},
|
||||
{ title: '规格', dataIndex: 'spec', search: false, render: (_, r) => r.spec || '-' },
|
||||
{ title: '库存', dataIndex: 'quantity', search: false },
|
||||
{ title: '单位', dataIndex: 'unit', search: false, render: (_, r) => r.unit || '-' },
|
||||
{
|
||||
title: '安全阈值',
|
||||
dataIndex: 'minQuantity',
|
||||
search: false,
|
||||
render: (_, r) =>
|
||||
r.quantity < r.minQuantity ? <Tag color="orange">低于阈值</Tag> : r.minQuantity,
|
||||
},
|
||||
{ title: '效期', dataIndex: 'expiryDate', search: false, render: (_, r) => (r.expiryDate ? dayjs(r.expiryDate).format('YYYY-MM-DD') : '-') },
|
||||
{
|
||||
title: '操作',
|
||||
valueType: 'option',
|
||||
render: (_, r) => [
|
||||
<a
|
||||
key="edit"
|
||||
onClick={() => {
|
||||
setEditing(r);
|
||||
form.setFieldsValue({ ...r, expiryDate: r.expiryDate ? dayjs(r.expiryDate) : undefined });
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</a>,
|
||||
...(canWrite()
|
||||
? [
|
||||
<Popconfirm
|
||||
key="del"
|
||||
title="确认删除该耗材?"
|
||||
onConfirm={async () => {
|
||||
await deleteConsumable(r.id);
|
||||
message.success('已删除');
|
||||
actionRef.current?.reload();
|
||||
}}
|
||||
>
|
||||
<a style={{ color: '#ff4d4f' }}>删除</a>
|
||||
</Popconfirm>,
|
||||
]
|
||||
: []),
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<AlertsBlock />
|
||||
<ProTable<Consumable>
|
||||
actionRef={actionRef}
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
search={{ labelWidth: 'auto' }}
|
||||
request={async (params) => {
|
||||
const res = await listConsumables({ category: params.category });
|
||||
return { data: res, total: res.length, success: true };
|
||||
}}
|
||||
toolBarRender={() =>
|
||||
canWrite()
|
||||
? [
|
||||
<Button
|
||||
key="new"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ category: 'lamp_reagent', quantity: 0, minQuantity: 0 });
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
新增耗材
|
||||
</Button>,
|
||||
]
|
||||
: []
|
||||
}
|
||||
/>
|
||||
<Modal
|
||||
title={editing ? '编辑耗材' : '新增耗材'}
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={async () => {
|
||||
const v = await form.validateFields();
|
||||
const payload = {
|
||||
...v,
|
||||
expiryDate: v.expiryDate ? v.expiryDate.toISOString() : undefined,
|
||||
};
|
||||
if (editing) await updateConsumable(editing.id, payload);
|
||||
else await createConsumable(payload);
|
||||
message.success('保存成功');
|
||||
setModalOpen(false);
|
||||
actionRef.current?.reload();
|
||||
}}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item label="名称" name="name" rules={[{ required: true, message: '请输入名称' }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="类别" name="category" rules={[{ required: true, message: '请选择类别' }]}>
|
||||
<Select options={Object.entries(CATEGORY_LABELS).map(([value, label]) => ({ value, label }))} />
|
||||
</Form.Item>
|
||||
<Form.Item label="规格" name="spec">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="库存数量" name="quantity" rules={[{ required: true, message: '请输入库存' }]}>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="单位" name="unit">
|
||||
<Input placeholder="如:盒/支/瓶" />
|
||||
</Form.Item>
|
||||
<Form.Item label="安全库存阈值" name="minQuantity" rules={[{ required: true, message: '请输入安全阈值' }]}>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="效期" name="expiryDate">
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item label="供应商" name="supplier">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="备注" name="note">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -13,6 +13,7 @@ import Log from './pages/Log';
|
||||
import Knowledge from './pages/Knowledge';
|
||||
import Batches from './pages/Batches';
|
||||
import LampTests from './pages/LampTests';
|
||||
import Consumables from './pages/Consumables';
|
||||
import NotFound from './pages/NotFound';
|
||||
import { BasicLayout } from './layout/BasicLayout';
|
||||
import { authService } from './services/auth';
|
||||
@@ -52,6 +53,7 @@ export const router = createBrowserRouter([
|
||||
{ path: 'knowledge', element: <RequirePermission permission="knowledge:read"><Knowledge /></RequirePermission> },
|
||||
{ path: 'batches', element: <RequirePermission permission="batch:read"><Batches /></RequirePermission> },
|
||||
{ path: 'lamp-tests', element: <RequirePermission permission="lamp:read"><LampTests /></RequirePermission> },
|
||||
{ path: 'consumables', element: <RequirePermission permission="consumable:read"><Consumables /></RequirePermission> },
|
||||
{ path: '404', element: <NotFound /> },
|
||||
],
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user