390 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|