Files
dukang/apps/admin-web/src/pages/BankAccountsPage.tsx
T
2026-09-08 10:07:34 +08:00

390 lines
12 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react';
import {
Button,
Form,
Input,
Modal,
Popconfirm,
Select,
Space,
Table,
Tag,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import {
FINANCE_BANK_ACCOUNT_TYPE_LABELS,
FINANCE_BANK_ACCOUNT_TYPES,
type FinanceBankAccountDto,
type FinanceBankAccountType,
} from '@dukang/shared-types';
import { request, type Paginated } from '../lib/api';
import { ADMIN_OPTIONS_PAGE_SIZE } from '../lib/constants';
import { downloadBase64File } from '../lib/exportExcel';
import { useAdminList } from '../lib/useAdminList';
import { useAdminListColumns } from '../lib/useAdminListColumns';
import { AdminListHeader } from '../components/AdminListHeader';
type CityOption = { id: string; name: string };
type ExportResult = {
filename: string;
mimeType: string;
contentBase64: string;
count: number;
};
type OtherForm = {
name?: string;
bankAccountName: string;
bankAccountNo: string;
bankBranch?: string;
remark?: string;
};
const TYPE_COLORS: Record<FinanceBankAccountType, string> = {
STORE: 'blue',
WINERY: 'gold',
PARTNER: 'purple',
LOGISTICS: 'cyan',
OTHER: 'default',
};
function otherNumericId(id: string) {
return id.startsWith('OTHER:') ? id.slice('OTHER:'.length) : id;
}
export default function BankAccountsPage() {
const [filterForm] = Form.useForm<{
type?: FinanceBankAccountType;
cityId?: string;
keyword?: string;
}>();
const [otherForm] = Form.useForm<OtherForm>();
const [remarkForm] = Form.useForm<{ remark?: string }>();
const [filters, setFilters] = useState({ type: '', cityId: '', keyword: '' });
const [cities, setCities] = useState<CityOption[]>([]);
const [otherOpen, setOtherOpen] = useState(false);
const [editing, setEditing] = useState<FinanceBankAccountDto | null>(null);
const [remarkTarget, setRemarkTarget] = useState<FinanceBankAccountDto | null>(null);
const [saving, setSaving] = useState(false);
const [exporting, setExporting] = useState<'xlsx' | 'pdf' | null>(null);
useEffect(() => {
void request<Paginated<CityOption>>(`/admin/cities?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setCities(res.items ?? []))
.catch(() => setCities([]));
}, []);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<FinanceBankAccountDto>(
'/admin/finance/bank-accounts',
() => {
const qs = new URLSearchParams();
if (filters.type) qs.set('type', filters.type);
if (filters.cityId) qs.set('cityId', filters.cityId);
if (filters.keyword) qs.set('keyword', filters.keyword);
return qs;
},
[filters.type, filters.cityId, filters.keyword],
);
function openCreate() {
setEditing(null);
otherForm.resetFields();
setOtherOpen(true);
}
function openEdit(row: FinanceBankAccountDto) {
setEditing(row);
otherForm.setFieldsValue({
name: row.ownerName === row.bankAccountName ? undefined : row.ownerName,
bankAccountName: row.bankAccountName,
bankAccountNo: row.bankAccountNo,
bankBranch: row.bankBranch ?? undefined,
remark: row.remark ?? undefined,
});
setOtherOpen(true);
}
function openRemark(row: FinanceBankAccountDto) {
setRemarkTarget(row);
remarkForm.setFieldsValue({ remark: row.remark ?? undefined });
}
async function saveOther() {
const values = await otherForm.validateFields();
setSaving(true);
try {
if (editing) {
await request(`/admin/finance/bank-accounts/other/${otherNumericId(editing.id)}`, {
method: 'PUT',
body: JSON.stringify(values),
});
message.success('已保存');
} else {
await request('/admin/finance/bank-accounts', {
method: 'POST',
body: JSON.stringify(values),
});
message.success('已新增');
}
setOtherOpen(false);
await reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
async function saveRemark() {
if (!remarkTarget) return;
const values = await remarkForm.validateFields();
setSaving(true);
try {
await request(`/admin/finance/bank-accounts/${encodeURIComponent(remarkTarget.id)}/remark`, {
method: 'PUT',
body: JSON.stringify({ remark: values.remark ?? '' }),
});
message.success('备注已保存');
setRemarkTarget(null);
await reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '保存失败');
} finally {
setSaving(false);
}
}
async function removeOther(row: FinanceBankAccountDto) {
try {
await request(`/admin/finance/bank-accounts/other/${otherNumericId(row.id)}`, { method: 'DELETE' });
message.success('已删除');
await reload();
} catch (e) {
message.error(e instanceof Error ? e.message : '删除失败');
}
}
async function exportFile(format: 'xlsx' | 'pdf') {
setExporting(format);
try {
const qs = new URLSearchParams();
qs.set('format', format);
if (filters.type) qs.set('type', filters.type);
if (filters.cityId) qs.set('cityId', filters.cityId);
if (filters.keyword) qs.set('keyword', filters.keyword);
const result = await request<ExportResult>(`/admin/finance/bank-accounts/export?${qs}`);
downloadBase64File(result.contentBase64, result.filename, result.mimeType);
message.success(`已导出 ${result.count} 条`);
} catch (e) {
message.error(e instanceof Error ? e.message : '导出失败');
} finally {
setExporting(null);
}
}
const typeOptions = useMemo(
() => FINANCE_BANK_ACCOUNT_TYPES.map((value) => ({ value, label: FINANCE_BANK_ACCOUNT_TYPE_LABELS[value] })),
[],
);
const baseColumns: ColumnsType<FinanceBankAccountDto> = [
{
title: '类型',
dataIndex: 'type',
width: 88,
render: (type: FinanceBankAccountType) => (
<Tag color={TYPE_COLORS[type]}>{FINANCE_BANK_ACCOUNT_TYPE_LABELS[type]}</Tag>
),
},
{ title: '归属', dataIndex: 'ownerName', width: 180 },
{ title: '城市', dataIndex: 'cityName', width: 100, render: (v?: string | null) => v || '—' },
{ title: '户名', dataIndex: 'bankAccountName', width: 140 },
{ title: '银行账号', dataIndex: 'bankAccountNo', width: 180 },
{ title: '开户行', dataIndex: 'bankBranch', width: 180, render: (v?: string | null) => v || '—' },
{
title: '默认',
dataIndex: 'isDefault',
width: 72,
render: (v: boolean | undefined, row) => (row.type === 'STORE' ? (v ? '是' : '否') : '—'),
},
{ title: '备注', dataIndex: 'remark', width: 200, render: (v?: string | null) => v || '—' },
{
title: '操作',
key: 'actions',
width: 160,
fixed: 'right',
render: (_, row) => (
<Space size={0}>
<Button type="link" size="small" onClick={() => openRemark(row)}>
备注
</Button>
{row.editable ? (
<>
<Button type="link" size="small" onClick={() => openEdit(row)}>
编辑
</Button>
<Popconfirm
title={`确认删除账户「${row.ownerName}」?`}
okText="删除"
okButtonProps={{ danger: true }}
onConfirm={() => void removeOther(row)}
>
<Button type="link" size="small" danger>
删除
</Button>
</Popconfirm>
</>
) : null}
</Space>
),
},
];
const { columns, settingsButton, settingsModal } = useAdminListColumns('finance-bank-accounts', baseColumns, {
page,
pageSize,
});
return (
<div>
{settingsModal}
<AdminListHeader
title="银行账户"
description="汇总门店、酒厂、合伙人、物流的有效收款账户;可登记不挂门店的其他账户。打款仍以各业务源账户为准。"
settings={settingsButton}
actions={
<Button type="primary" onClick={openCreate}>
新增账户
</Button>
}
/>
<Form
form={filterForm}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters({
type: v.type || '',
cityId: v.cityId || '',
keyword: v.keyword?.trim() || '',
});
setPage(1);
}}
>
<Form.Item name="type" label="类型">
<Select allowClear style={{ width: 140 }} options={typeOptions} />
</Form.Item>
<Form.Item name="cityId" label="城市">
<Select
allowClear
showSearch
optionFilterProp="label"
style={{ width: 160 }}
options={cities.map((c) => ({ value: c.id, label: c.name }))}
/>
</Form.Item>
<Form.Item name="keyword" label="查找">
<Input allowClear style={{ width: 220 }} placeholder="户名 / 账号 / 开户行 / 归属 / 备注" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
查询
</Button>
</Form.Item>
<Form.Item>
<Button
onClick={() => {
filterForm.resetFields();
setFilters({ type: '', cityId: '', keyword: '' });
setPage(1);
}}
>
重置
</Button>
</Form.Item>
<Form.Item>
<Button loading={exporting === 'xlsx'} onClick={() => void exportFile('xlsx')}>
导出 Excel
</Button>
</Form.Item>
<Form.Item>
<Button loading={exporting === 'pdf'} onClick={() => void exportFile('pdf')}>
导出 PDF
</Button>
</Form.Item>
</Form>
<Table
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 'max-content' }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Modal
title={editing ? '编辑账户' : '新增账户'}
open={otherOpen}
onCancel={() => setOtherOpen(false)}
onOk={() => void saveOther()}
confirmLoading={saving}
destroyOnClose
>
<Form form={otherForm} layout="vertical">
<Form.Item name="name" label="归属名称">
<Input placeholder="选填,如供应商或内部账户名称" maxLength={128} />
</Form.Item>
<Form.Item name="bankAccountName" label="户名" rules={[{ required: true, message: '请填写户名' }]}>
<Input maxLength={64} />
</Form.Item>
<Form.Item
name="bankAccountNo"
label="银行账号"
rules={[
{ required: true, message: '请填写银行账号' },
{ pattern: /^\d{8,32}$/, message: '银行账号须为 8~32 位数字' },
]}
>
<Input maxLength={32} />
</Form.Item>
<Form.Item name="bankBranch" label="开户行">
<Input maxLength={128} />
</Form.Item>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={3} maxLength={256} />
</Form.Item>
</Form>
</Modal>
<Modal
title="编辑备注"
open={!!remarkTarget}
onCancel={() => setRemarkTarget(null)}
onOk={() => void saveRemark()}
confirmLoading={saving}
destroyOnClose
>
<Form form={remarkForm} layout="vertical">
<Form.Item name="remark" label="备注">
<Input.TextArea rows={4} maxLength={256} placeholder="仅财务目录可见,不改写来源账户" />
</Form.Item>
</Form>
</Modal>
</div>
);
}