MaterialLockersPage.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561
  1. import React, { useCallback, useEffect, useMemo, useState } from 'react';
  2. import { Table, Button, Form, Input, Modal, Image, Row, Col, Tree, Select, Tag } from 'antd';
  3. import type { ColumnsType } from 'antd/es/table';
  4. import type { DataNode } from 'antd/es/tree';
  5. import { Plus } from 'lucide-react';
  6. import { toast } from 'sonner';
  7. import { useTranslation } from 'react-i18next';
  8. import { materialLockerApi, CabinetVO } from '../../../api/material/lockers';
  9. import { workstationApi } from '../../../api/workstation';
  10. import { handleTree } from '../../../utils/tree';
  11. import UploadImg from '../../lockCabinet/UploadImg';
  12. import MaterialLockerDetailPage from './MaterialLockerDetailPage';
  13. import { pickRecords, pickTotal, buildPageParams, fetchAllWorkstations } from '../materialListUtils';
  14. import {
  15. MaterialPageRoot,
  16. MaterialToolbarCard,
  17. MaterialTableCard,
  18. MaterialPaginationBar,
  19. MaterialEditButton,
  20. MaterialDeleteButton,
  21. MaterialViewButton,
  22. materialConfirmDelete,
  23. getMaterialFormModalProps,
  24. materialFormLayout,
  25. } from '../MaterialPageLayout';
  26. /** 物资柜状态:0 正常 1 借出 2 异常 */
  27. const CABINET_STATUS_OPTIONS = [
  28. { value: 0, label: '正常' },
  29. { value: 1, label: '借出' },
  30. { value: 2, label: '异常' },
  31. ];
  32. const CABINET_STATUS_LABEL: Record<number, string> = {
  33. 0: '正常',
  34. 1: '借出',
  35. 2: '异常',
  36. };
  37. /** 异常类型:0 正常 1 物资异常 2 物资柜异常 3 错还柜子 4 物资借出 5 超时未关门 */
  38. const EXCEPTION_TYPE_LABEL: Record<number, string> = {
  39. 0: '正常',
  40. 1: '物资异常',
  41. 2: '物资柜异常',
  42. 3: '错还柜子',
  43. 4: '物资借出',
  44. 5: '超时未关门',
  45. };
  46. function firstDefined(row: Record<string, unknown>, keys: string[]) {
  47. for (const k of keys) {
  48. const v = row[k];
  49. if (v !== undefined && v !== null && v !== '') return v;
  50. }
  51. return undefined;
  52. }
  53. function toFiniteInt(v: unknown): number | undefined {
  54. if (v === undefined || v === null || v === '') return undefined;
  55. if (typeof v === 'string' && v.trim() === '') return undefined;
  56. const n = Number(v);
  57. return Number.isFinite(n) ? Math.trunc(n) : undefined;
  58. }
  59. function getCabinetStatusValue(row: Record<string, unknown>): number | undefined {
  60. const raw = firstDefined(row, ['status', 'cabinetStatus', 'runStatus', 'cabinetRunStatus']);
  61. if (raw === '正常') return 0;
  62. if (raw === '借出') return 1;
  63. if (raw === '异常') return 2;
  64. return toFiniteInt(raw);
  65. }
  66. function renderCabinetStatusTag(row: Record<string, unknown>) {
  67. const raw = firstDefined(row, ['status', 'cabinetStatus', 'runStatus', 'cabinetRunStatus']);
  68. const code = getCabinetStatusValue(row);
  69. if (code !== undefined && CABINET_STATUS_LABEL[code] !== undefined) {
  70. const color = code === 0 ? 'blue' : code === 1 ? 'processing' : 'error';
  71. return <Tag color={color}>{CABINET_STATUS_LABEL[code]}</Tag>;
  72. }
  73. if (typeof raw === 'string' && raw.trim() !== '') {
  74. return <Tag color="default">{raw}</Tag>;
  75. }
  76. return <Tag color="default">—</Tag>;
  77. }
  78. function getExceptionTypeCode(row: Record<string, unknown>): number | undefined {
  79. return toFiniteInt(
  80. firstDefined(row, [
  81. 'exReason',
  82. 'exceptionType',
  83. 'abnormalType',
  84. 'exceptionTypeCode',
  85. 'abnormalTypeCode',
  86. 'doorExceptionType',
  87. 'exceptionKind',
  88. ]),
  89. );
  90. }
  91. function exceptionTypeTagColor(code: number): string {
  92. if (code === 0) return 'blue';
  93. if (code === 1 || code === 2) return 'error';
  94. if (code === 3 || code === 5) return 'warning';
  95. if (code === 4) return 'processing';
  96. return 'default';
  97. }
  98. function renderExceptionTypeTag(row: Record<string, unknown>) {
  99. const code = getExceptionTypeCode(row);
  100. if (code !== undefined && EXCEPTION_TYPE_LABEL[code] !== undefined) {
  101. return (
  102. <Tag color={exceptionTypeTagColor(code)}>{EXCEPTION_TYPE_LABEL[code]}</Tag>
  103. );
  104. }
  105. const ex = firstDefined(row, [
  106. 'exceptionTypeName',
  107. 'exceptionName',
  108. 'exceptionDesc',
  109. 'abnormalTypeName',
  110. 'exceptionMsg',
  111. 'errorMessage',
  112. 'abnormalReason',
  113. ]);
  114. if (ex !== undefined && ex !== null && ex !== '') {
  115. return <Tag color="default">{String(ex)}</Tag>;
  116. }
  117. return <Tag color="default">—</Tag>;
  118. }
  119. function filterWorkstationTree(nodes: any[], kw: string): any[] {
  120. const q = kw.trim().toLowerCase();
  121. if (!q) return nodes;
  122. const match = (n: any) => {
  123. const code = String(n.workstationCode ?? '').toLowerCase();
  124. const name = String(n.workstationName ?? '').toLowerCase();
  125. return code.includes(q) || name.includes(q);
  126. };
  127. return nodes
  128. .map((n) => {
  129. const children = n.children?.length ? filterWorkstationTree(n.children, kw) : [];
  130. if (match(n) || children.length) return { ...n, children: children.length ? children : undefined };
  131. return null;
  132. })
  133. .filter(Boolean) as any[];
  134. }
  135. function toAntdTreeData(nodes: any[]): DataNode[] {
  136. return nodes.map((n) => ({
  137. key: String(n.id),
  138. title: (n.workstationCode || n.workstationName || String(n.id)) as React.ReactNode,
  139. children: n.children?.length ? toAntdTreeData(n.children) : undefined,
  140. }));
  141. }
  142. /** 区域树打平为下拉选项(value 为岗位/区域 id) */
  143. function flattenWorkstationOptions(nodes: any[]): { label: string; value: number }[] {
  144. const out: { label: string; value: number }[] = [];
  145. const walk = (arr: any[]) => {
  146. for (const n of arr) {
  147. const id = Number(n.id ?? n.workstationId);
  148. if (Number.isFinite(id) && id > 0) {
  149. const code = n.workstationCode ? String(n.workstationCode) : '';
  150. const name = n.workstationName ? String(n.workstationName) : '';
  151. const label = [code, name].filter(Boolean).join(' / ') || String(id);
  152. out.push({ value: id, label });
  153. }
  154. if (n.children?.length) walk(n.children);
  155. }
  156. };
  157. walk(nodes);
  158. return out;
  159. }
  160. export default function MaterialLockersPage() {
  161. const { t } = useTranslation();
  162. const [loading, setLoading] = useState(false);
  163. const [list, setList] = useState<any[]>([]);
  164. const [total, setTotal] = useState(0);
  165. const [pageNo, setPageNo] = useState(1);
  166. /** 与点位管理列表一致:固定每页条数,分页条不提供「每页几条 / 前往页」 */
  167. const pageSize = 10;
  168. const [draftQuery, setDraftQuery] = useState({ cabinetName: '', status: undefined as number | undefined });
  169. const [appliedQuery, setAppliedQuery] = useState({ cabinetName: '', status: undefined as number | undefined });
  170. const [modalOpen, setModalOpen] = useState(false);
  171. const [editing, setEditing] = useState<CabinetVO | null>(null);
  172. const [form] = Form.useForm();
  173. const [areaSearch, setAreaSearch] = useState('');
  174. const [areaTreeRaw, setAreaTreeRaw] = useState<any[]>([]);
  175. const [selectedAreaKey, setSelectedAreaKey] = useState<string>('all');
  176. /** 非空时展示物资柜详情页(由列表「查看」进入) */
  177. const [lockerDetail, setLockerDetail] = useState<{ id: number; name: string } | null>(null);
  178. const workstationIdFilter = selectedAreaKey === 'all' ? undefined : Number(selectedAreaKey);
  179. const filteredAreaTree = useMemo(() => filterWorkstationTree(areaTreeRaw, areaSearch), [areaTreeRaw, areaSearch]);
  180. const areaTreeData: DataNode[] = useMemo(
  181. () => [{ key: 'all', title: '全部区域' }, ...toAntdTreeData(filteredAreaTree)],
  182. [filteredAreaTree],
  183. );
  184. const workstationOptions = useMemo(() => {
  185. const base = flattenWorkstationOptions(areaTreeRaw);
  186. if (!editing) return base;
  187. const wid = Number(editing.workstationId);
  188. if (Number.isFinite(wid) && wid > 0 && !base.some((o) => o.value === wid)) {
  189. const label =
  190. firstDefined(editing as unknown as Record<string, unknown>, ['workstationCode', 'workstationName']) ??
  191. `区域 #${wid}`;
  192. return [{ value: wid, label: String(label) }, ...base];
  193. }
  194. return base;
  195. }, [areaTreeRaw, editing]);
  196. const loadAreaTree = useCallback(async () => {
  197. try {
  198. const flat = await fetchAllWorkstations(workstationApi.listMarsDept);
  199. const normalized = flat.map((w: any) => {
  200. const id = Number(w.workstationId ?? w.id);
  201. return {
  202. ...w,
  203. id: Number.isFinite(id) ? id : 0,
  204. parentId: w.parentId != null && w.parentId !== '' ? Number(w.parentId) : 0,
  205. };
  206. });
  207. const tree = handleTree(normalized, 'id', 'parentId', 'children');
  208. setAreaTreeRaw(tree);
  209. } catch (e: any) {
  210. toast.error(e?.message || '加载区域失败');
  211. setAreaTreeRaw([]);
  212. }
  213. }, []);
  214. useEffect(() => {
  215. loadAreaTree();
  216. }, [loadAreaTree]);
  217. const fetchList = useCallback(async () => {
  218. setLoading(true);
  219. try {
  220. const res = await materialLockerApi.listMaterialsCabinet({
  221. ...buildPageParams(pageNo, pageSize),
  222. cabinetName: appliedQuery.cabinetName || undefined,
  223. status: appliedQuery.status,
  224. workstationId: workstationIdFilter,
  225. });
  226. setList(pickRecords(res));
  227. setTotal(pickTotal(res));
  228. } catch (e: any) {
  229. toast.error(e?.message || '加载物资柜失败');
  230. setList([]);
  231. } finally {
  232. setLoading(false);
  233. }
  234. }, [pageNo, appliedQuery.cabinetName, appliedQuery.status, workstationIdFilter]);
  235. useEffect(() => {
  236. fetchList();
  237. }, [fetchList]);
  238. const handleQuery = () => {
  239. setAppliedQuery({ ...draftQuery });
  240. setPageNo(1);
  241. };
  242. const resetQuery = () => {
  243. const empty = { cabinetName: '', status: undefined as number | undefined };
  244. setDraftQuery(empty);
  245. setAppliedQuery(empty);
  246. setPageNo(1);
  247. };
  248. const openCreate = () => {
  249. setEditing(null);
  250. form.resetFields();
  251. form.setFieldsValue({
  252. cabinetName: '',
  253. remark: '',
  254. cabinetPicture: '',
  255. workstationId:
  256. workstationIdFilter != null && Number.isFinite(workstationIdFilter) && workstationIdFilter > 0
  257. ? workstationIdFilter
  258. : undefined,
  259. });
  260. setModalOpen(true);
  261. };
  262. const openEdit = (row: any) => {
  263. setEditing(row);
  264. form.setFieldsValue({
  265. cabinetName: row.cabinetName,
  266. remark: row.remark ?? '',
  267. cabinetPicture: row.cabinetPicture ?? '',
  268. workstationId: row.workstationId != null && row.workstationId !== '' ? Number(row.workstationId) : undefined,
  269. });
  270. setModalOpen(true);
  271. };
  272. const openLockerDetail = (row: any) => {
  273. const id = row.cabinetId ?? row.id;
  274. if (id == null) return;
  275. setLockerDetail({
  276. id: Number(id),
  277. name: String(row.cabinetName ?? ''),
  278. });
  279. };
  280. const handleSubmit = async () => {
  281. try {
  282. const v = await form.validateFields();
  283. setLoading(true);
  284. const editId = editing ? (editing.cabinetId ?? editing.id) : undefined;
  285. if (editId != null && editing) {
  286. await materialLockerApi.updateMaterialsCabinet({
  287. ...editing,
  288. cabinetId: editId,
  289. cabinetName: v.cabinetName,
  290. cabinetCode: editing.cabinetCode || v.cabinetName,
  291. cabinetType: editing.cabinetType || 'default',
  292. remark: v.remark,
  293. cabinetPicture: v.cabinetPicture || '',
  294. workstationId: v.workstationId,
  295. } as CabinetVO);
  296. toast.success(t('common.updateSuccess'));
  297. } else {
  298. await materialLockerApi.addMaterialsCabinet({
  299. cabinetName: v.cabinetName,
  300. cabinetCode: v.cabinetName,
  301. cabinetType: 'default',
  302. status: 0,
  303. remark: v.remark,
  304. cabinetPicture: v.cabinetPicture || undefined,
  305. workstationId: v.workstationId,
  306. } as CabinetVO);
  307. toast.success(t('common.addSuccess'));
  308. }
  309. setModalOpen(false);
  310. fetchList();
  311. } catch (e: any) {
  312. if (e?.errorFields) return;
  313. toast.error(e?.message || t('common.operationFailed'));
  314. } finally {
  315. setLoading(false);
  316. }
  317. };
  318. const handleDelete = (row: any) => {
  319. const id = row.cabinetId ?? row.id;
  320. if (!id) return;
  321. materialConfirmDelete(t, {
  322. count: 1,
  323. onOk: async () => {
  324. await materialLockerApi.deleteMaterialsCabinet(id);
  325. toast.success(t('common.deleteSuccess'));
  326. fetchList();
  327. },
  328. });
  329. };
  330. const areaCol = (row: any) =>
  331. firstDefined(row, ['workstationCode', 'workstationName', 'areaCode', 'areaName']) ?? '—';
  332. const columns: ColumnsType<any> = [
  333. { title: '物资柜编号', dataIndex: 'cabinetId', width: 110, align: 'center', render: (v, row) => v ?? row.id ?? '—' },
  334. { title: '物资柜名称', dataIndex: 'cabinetName', ellipsis: true, align: 'center' },
  335. {
  336. title: '所属区域',
  337. key: 'area',
  338. ellipsis: true,
  339. align: 'center',
  340. render: (_, row) => areaCol(row),
  341. },
  342. {
  343. title: '物资柜图片',
  344. dataIndex: 'cabinetPicture',
  345. width: 100,
  346. align: 'center',
  347. render: (url: string) =>
  348. url ? <Image src={url} width={50} height={50} style={{ objectFit: 'cover' }} /> : '—',
  349. },
  350. {
  351. title: '物资柜状态',
  352. key: 'cabinetStatus',
  353. width: 100,
  354. align: 'center',
  355. render: (_, row) => renderCabinetStatusTag(row),
  356. },
  357. {
  358. title: '异常类型',
  359. key: 'exceptionType',
  360. width: 140,
  361. align: 'center',
  362. render: (_, row) => renderExceptionTypeTag(row),
  363. },
  364. {
  365. title: '查看',
  366. key: 'view',
  367. width: 100,
  368. align: 'center',
  369. fixed: 'right',
  370. render: (_, row) => <MaterialViewButton label="查看" onClick={() => openLockerDetail(row)} />,
  371. },
  372. {
  373. title: t('table.operation'),
  374. width: 160,
  375. align: 'center',
  376. fixed: 'right',
  377. render: (_, row) => (
  378. <div className="flex items-center gap-2 justify-center">
  379. <MaterialEditButton label={t('common.edit')} onClick={() => openEdit(row)} />
  380. <MaterialDeleteButton label={t('common.delete')} onClick={() => handleDelete(row)} />
  381. </div>
  382. ),
  383. },
  384. ];
  385. const listBody = (
  386. <div className="flex gap-4 items-start">
  387. <div className="w-[260px] shrink-0 bg-white rounded-xl border border-gray-200/50 shadow-sm p-4 flex flex-col gap-3 min-h-[420px]">
  388. <Input.Search allowClear placeholder="请输入区域名称" value={areaSearch} onChange={(e) => setAreaSearch(e.target.value)} />
  389. <div className="flex-1 overflow-auto border border-gray-100 rounded-lg p-2">
  390. <Tree
  391. blockNode
  392. showLine
  393. selectedKeys={[selectedAreaKey]}
  394. treeData={areaTreeData}
  395. onSelect={(keys) => {
  396. if (keys.length && typeof keys[0] === 'string') {
  397. setSelectedAreaKey(keys[0]);
  398. setPageNo(1);
  399. }
  400. }}
  401. />
  402. </div>
  403. </div>
  404. <div className="flex-1 min-w-0 space-y-4">
  405. <MaterialToolbarCard
  406. filterRow={
  407. <div className="flex items-center gap-3 flex-wrap">
  408. <div className="flex items-center gap-2">
  409. <span className="text-sm font-medium text-gray-700 whitespace-nowrap">物资柜名称:</span>
  410. <Input
  411. allowClear
  412. placeholder="请输入物资柜名称"
  413. className="w-[180px]"
  414. value={draftQuery.cabinetName}
  415. onChange={(e) => setDraftQuery((q) => ({ ...q, cabinetName: e.target.value }))}
  416. onPressEnter={handleQuery}
  417. />
  418. </div>
  419. <div className="flex items-center gap-2">
  420. <span className="text-sm font-medium text-gray-700 whitespace-nowrap">物资柜状态:</span>
  421. <Select
  422. allowClear
  423. placeholder="请选择物资柜状态"
  424. className="w-[180px]"
  425. options={CABINET_STATUS_OPTIONS}
  426. value={draftQuery.status}
  427. onChange={(v) => setDraftQuery((q) => ({ ...q, status: v ?? undefined }))}
  428. />
  429. </div>
  430. </div>
  431. }
  432. onSearch={handleQuery}
  433. onReset={resetQuery}
  434. toolbarRight={
  435. <Button type="primary" icon={<Plus />} onClick={openCreate}>
  436. {t('common.addNew')}
  437. </Button>
  438. }
  439. />
  440. <MaterialTableCard>
  441. <Table
  442. rowKey={(r) => r.cabinetId ?? r.id}
  443. loading={loading}
  444. columns={columns}
  445. dataSource={list}
  446. pagination={false}
  447. scroll={{ x: 'max-content' }}
  448. rowClassName={(_, index) => (index !== undefined && index % 2 === 1 ? 'isolation-work-list-row-alt' : '')}
  449. />
  450. </MaterialTableCard>
  451. <MaterialPaginationBar
  452. total={total}
  453. current={pageNo}
  454. pageSize={pageSize}
  455. onPageChange={setPageNo}
  456. loading={loading}
  457. listLength={list.length}
  458. />
  459. </div>
  460. </div>
  461. );
  462. if (lockerDetail) {
  463. return (
  464. <MaterialPageRoot>
  465. <MaterialLockerDetailPage
  466. cabinetId={lockerDetail.id}
  467. cabinetName={lockerDetail.name}
  468. onBack={() => setLockerDetail(null)}
  469. />
  470. </MaterialPageRoot>
  471. );
  472. }
  473. return (
  474. <MaterialPageRoot>
  475. {listBody}
  476. <Modal
  477. title={editing ? '编辑物资柜信息' : '新增物资柜信息'}
  478. open={modalOpen}
  479. onOk={handleSubmit}
  480. onCancel={() => setModalOpen(false)}
  481. confirmLoading={loading}
  482. {...getMaterialFormModalProps(t, 560)}
  483. >
  484. <Form form={form} {...materialFormLayout}>
  485. <Row gutter={16}>
  486. <Col span={24}>
  487. <Form.Item
  488. name="cabinetName"
  489. label="物资柜名称"
  490. rules={[{ required: true, message: '请输入物资柜名称' }]}
  491. >
  492. <Input allowClear placeholder="请输入物资柜名称" />
  493. </Form.Item>
  494. </Col>
  495. <Col span={24}>
  496. <Form.Item
  497. name="workstationId"
  498. label="所属区域"
  499. rules={[{ required: true, message: '请选择所属区域' }]}
  500. >
  501. <Select
  502. allowClear
  503. showSearch
  504. optionFilterProp="label"
  505. placeholder="选择所属区域"
  506. options={workstationOptions}
  507. />
  508. </Form.Item>
  509. </Col>
  510. <Col span={24}>
  511. <Form.Item name="cabinetPicture" label="物资柜图片">
  512. <UploadImg width="120px" height="120px" />
  513. </Form.Item>
  514. </Col>
  515. <Col span={24}>
  516. <Form.Item name="remark" label="备注">
  517. <Input.TextArea allowClear rows={4} placeholder="请输入内容" />
  518. </Form.Item>
  519. </Col>
  520. </Row>
  521. </Form>
  522. </Modal>
  523. </MaterialPageRoot>
  524. );
  525. }