webadmin增加财务模块

This commit is contained in:
2026-07-14 15:16:41 +08:00
parent 450dc44d0e
commit d61a43cc8c
15 changed files with 1352 additions and 132 deletions
+7 -3
View File
@@ -28,6 +28,7 @@ import ProductDetailTemplatesPage from './pages/ProductDetailTemplatesPage';
import ResourcesPage from './pages/ResourcesPage';
import StoreBillsPage from './pages/StoreBillsPage';
import PartnerBillsPage from './pages/PartnerBillsPage';
import WineryBillsPage from './pages/WineryBillsPage';
import TicketsPage from './pages/TicketsPage';
import UserLogsPage from './pages/UserLogsPage';
import HqLogsPage from './pages/HqLogsPage';
@@ -79,9 +80,12 @@ export default function App() {
<Route path="/redeem-records" element={<RedeemRecordsPage />} />
<Route path="/redeem-pending" element={<PendingRedeemPage />} />
<Route path="/redeem/debug" element={<RedeemDebugPage />} />
<Route path="/store-bills" element={<StoreBillsPage />} />
<Route path="/store-payouts" element={<Navigate to="/store-bills" replace />} />
<Route path="/partner-bills" element={<PartnerBillsPage />} />
<Route path="/finance/store-bills" element={<StoreBillsPage />} />
<Route path="/finance/partner-bills" element={<PartnerBillsPage />} />
<Route path="/finance/winery-bills" element={<WineryBillsPage />} />
<Route path="/store-bills" element={<Navigate to="/finance/store-bills" replace />} />
<Route path="/store-payouts" element={<Navigate to="/finance/store-bills" replace />} />
<Route path="/partner-bills" element={<Navigate to="/finance/partner-bills" replace />} />
<Route path="/tickets" element={<TicketsPage />} />
<Route path="/logs/users" element={<UserLogsPage />} />
<Route path="/logs/stores" element={<StoreLogsPage />} />
+12 -3
View File
@@ -16,6 +16,7 @@ import {
FileTextOutlined,
LockOutlined,
SettingOutlined,
AccountBookOutlined,
} from '@ant-design/icons';
import { clearAuth, request, type HqProfile } from '../lib/api';
import { bindAdminEllipsisTitle } from '../lib/ellipsis-title';
@@ -44,7 +45,6 @@ const MENU_ITEMS: MenuProps['items'] = [
children: [
{ key: '/stores', label: '门店列表' },
{ key: '/store-accounts', label: '门店账户' },
{ key: '/store-bills', label: '门店账单' },
{ key: '/store-media', label: '门店资源' },
],
},
@@ -56,7 +56,16 @@ const MENU_ITEMS: MenuProps['items'] = [
{ key: '/cities', label: '城市' },
{ key: '/city-partners', label: '城市合伙人' },
{ key: '/city-warehouses', label: '仓库' },
{ key: '/partner-bills', label: '合伙人结算' },
],
},
{
key: 'finance-group',
icon: <AccountBookOutlined />,
label: '财务',
children: [
{ key: '/finance/store-bills', label: '门店账单' },
{ key: '/finance/partner-bills', label: '合伙人账单' },
{ key: '/finance/winery-bills', label: '酒厂账单' },
],
},
{
@@ -144,7 +153,7 @@ export default function AdminLayout() {
theme="dark"
mode="inline"
selectedKeys={[selectedKey]}
defaultOpenKeys={['products-group', 'stores-group', 'partners-group', 'benefit-group', 'logs-group', 'deliveries-group']}
defaultOpenKeys={['products-group', 'stores-group', 'partners-group', 'finance-group', 'benefit-group', 'logs-group', 'deliveries-group']}
items={MENU_ITEMS}
onClick={({ key }) => {
if (key.startsWith('/')) navigate(key);
+10
View File
@@ -57,6 +57,16 @@ export type Paginated<T> = {
total: number;
page: number;
pageSize: number;
summary?: {
count: number;
redeemAmount?: number;
payoutAmount?: number;
orderAmount?: number;
orderCommission?: number;
redeemCommission?: number;
wineryAmount?: number;
totalAmount: number;
};
};
export type DashboardStats = {
+10
View File
@@ -0,0 +1,10 @@
/** Download CSV (UTF-8 BOM) so Excel opens Chinese columns correctly. */
export function downloadExcelCsv(csv: string, filename: string) {
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename.endsWith('.csv') ? filename : `${filename}.csv`;
a.click();
URL.revokeObjectURL(url);
}
+259 -58
View File
@@ -1,9 +1,23 @@
import { useState } from 'react';
import { Button, Form, Input, Select, Table, Typography, message, Modal, DatePicker } from 'antd';
import { useEffect, useState } from 'react';
import {
Button,
Card,
DatePicker,
Form,
Modal,
Select,
Space,
Statistic,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import dayjs from 'dayjs';
import { request } from '../lib/api';
import { fmtTime } from '../lib/constants';
import dayjs, { type Dayjs } from 'dayjs';
import { request, type Paginated } from '../lib/api';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { downloadExcelCsv } from '../lib/exportExcel';
import { useAdminList } from '../lib/useAdminList';
type Row = {
@@ -15,34 +29,66 @@ type Row = {
status: string;
periodStart: string;
periodEnd: string;
partner?: { companyName: string };
partner?: { companyName?: string; phone?: string };
partnerAccount?: { companyName?: string; phone?: string };
};
type PartnerOption = { id: string; companyName: string; phone?: string };
const STATUS_LABELS: Record<string, string> = {
DRAFT: '草稿',
CONFIRMED: '已确认',
PAID: '已打款',
};
const STATUS_COLORS: Record<string, string> = {
DRAFT: 'default',
CONFIRMED: 'blue',
PAID: 'green',
};
export default function PartnerBillsPage() {
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const [partners, setPartners] = useState<PartnerOption[]>([]);
const { data, loading, page, pageSize, setPage, setPageSize, reload } = useAdminList<Row>(
'/admin/partner-bills',
() => {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
if (filters.year) qs.set('year', filters.year);
if (filters.month) qs.set('month', filters.month);
return qs;
},
[filters],
);
const [genOpen, setGenOpen] = useState(false);
const [genForm] = Form.useForm();
const [exporting, setExporting] = useState(false);
async function generateBill(values: { partnerId: string; month: dayjs.Dayjs }) {
await request('/admin/partner-bills/generate', {
method: 'POST',
body: JSON.stringify({
partnerId: values.partnerId,
year: values.month.year(),
month: values.month.month() + 1,
}),
});
message.success('账单已生成');
useEffect(() => {
void request<Paginated<PartnerOption>>(`/admin/partners?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
.then((res) => setPartners(res.items))
.catch(() => {});
}, []);
async function generateBill(values: { partnerId?: string; month: Dayjs; all?: boolean }) {
const year = values.month.year();
const month = values.month.month() + 1;
if (values.all || !values.partnerId) {
const result = await request<{ success: number; failed: number; total: number }>(
'/admin/partner-bills/generate-all',
{ method: 'POST', body: JSON.stringify({ year, month }) },
);
message.success(`已生成 ${result.success}/${result.total} 份账单${result.failed ? `,失败 ${result.failed}` : ''}`);
} else {
await request('/admin/partner-bills/generate', {
method: 'POST',
body: JSON.stringify({ partnerId: values.partnerId, year, month }),
});
message.success('账单已生成');
}
setGenOpen(false);
reload();
}
@@ -62,61 +108,216 @@ export default function PartnerBillsPage() {
reload();
}
async function exportCsv() {
const result = await request<{ csv: string }>('/admin/partner-bills/export');
const blob = new Blob([result.csv], { type: 'text/csv;charset=utf-8' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'partner-bills.csv';
a.click();
URL.revokeObjectURL(url);
async function exportExcel() {
setExporting(true);
try {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.partnerId) qs.set('partnerId', filters.partnerId);
if (filters.year) qs.set('year', filters.year);
if (filters.month) qs.set('month', filters.month);
const result = await request<{ csv: string; count: number }>(`/admin/partner-bills/export?${qs}`);
const suffix = filters.year && filters.month ? `${filters.year}-${filters.month}` : 'all';
downloadExcelCsv(result.csv, `合伙人账单_${suffix}.csv`);
message.success(`已导出 ${result.count}`);
} finally {
setExporting(false);
}
}
const summary = data?.summary;
const partnerName = (r: Row) => r.partner?.companyName || r.partnerAccount?.companyName || '—';
const columns: ColumnsType<Row> = [
{ title: '账单号', dataIndex: 'billNo', width: 180 },
{ title: '合伙人', dataIndex: ['partner', 'companyName'] },
{ title: '订单佣金', dataIndex: 'orderCommission', width: 100, render: (v) => `¥${v}` },
{ title: '核销佣金', dataIndex: 'redeemCommission', width: 100, render: (v) => `¥${v}` },
{ title: '合计', dataIndex: 'totalAmount', width: 100, render: (v) => `¥${v}` },
{ title: '状态', dataIndex: 'status', width: 90 },
{ title: '周期', width: 200, render: (_, r) => `${fmtTime(r.periodStart).slice(0, 10)} ~ ${fmtTime(r.periodEnd).slice(0, 10)}` },
{ title: '账单号', dataIndex: 'billNo', width: 180, ellipsis: true },
{
title: '操作', width: 180,
title: '合伙人',
width: 160,
ellipsis: true,
render: (_, r) => partnerName(r),
},
{
title: '账期',
width: 200,
render: (_, r) =>
`${fmtTime(r.periodStart).slice(0, 10)} ~ ${fmtTime(r.periodEnd).slice(0, 10)}`,
},
{
title: '酒单佣金',
dataIndex: 'orderCommission',
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '核销佣金',
dataIndex: 'redeemCommission',
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '合计应付',
dataIndex: 'totalAmount',
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (s) => <Tag color={STATUS_COLORS[s] || 'default'}>{STATUS_LABELS[s] || s}</Tag>,
},
{
title: '操作',
width: 160,
fixed: 'right',
render: (_, row) => (
<>
{row.status === 'DRAFT' && <Button type="link" size="small" onClick={() => confirmBill(row.id)}></Button>}
{row.status === 'CONFIRMED' && <Button type="link" size="small" onClick={() => markPaid(row.id)}></Button>}
</>
<Space size={0}>
{row.status === 'DRAFT' && (
<Button type="link" size="small" onClick={() => void confirmBill(row.id)}>
</Button>
)}
{row.status === 'CONFIRMED' && (
<Button type="link" size="small" onClick={() => void markPaid(row.id)}>
</Button>
)}
</Space>
),
},
];
return (
<div>
<Typography.Title level={4}>T+30</Typography.Title>
<div style={{ marginBottom: 16, display: 'flex', gap: 8 }}>
<Button type="primary" onClick={() => setGenOpen(true)}></Button>
<Button onClick={exportCsv}> CSV</Button>
</div>
<Form layout="inline" style={{ marginBottom: 16 }} onFinish={(v) => { setFilters(v); setPage(1); }}>
<Form.Item name="status" label="状态">
<Select allowClear style={{ width: 120 }} options={[
{ value: 'DRAFT', label: '草稿' },
{ value: 'CONFIRMED', label: '已确认' },
{ value: 'PAID', label: '已打款' },
]} />
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary">
T+30 × ×
</Typography.Text>
</Space>
{summary && (
<Card size="small" style={{ marginBottom: 16 }}>
<Space size="large" wrap>
<Statistic title="账单数" value={summary.count} />
<Statistic title="酒单佣金合计" value={summary.orderCommission ?? 0} prefix="¥" precision={2} />
<Statistic title="核销佣金合计" value={summary.redeemCommission ?? 0} prefix="¥" precision={2} />
<Statistic title="应付合计" value={summary.totalAmount} prefix="¥" precision={2} />
</Space>
</Card>
)}
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v: { partnerId?: string; status?: string; month?: Dayjs }) => {
setFilters({
partnerId: v.partnerId || '',
status: v.status || '',
year: v.month ? String(v.month.year()) : '',
month: v.month ? String(v.month.month() + 1) : '',
});
setPage(1);
}}
>
<Form.Item name="partnerId" label="合伙人">
<Select
allowClear
showSearch
placeholder="全部合伙人"
style={{ width: 220 }}
optionFilterProp="label"
options={partners.map((p) => ({
value: p.id,
label: p.companyName || p.phone || p.id,
}))}
/>
</Form.Item>
<Form.Item name="month" label="账期月">
<DatePicker picker="month" />
</Form.Item>
<Form.Item name="status" label="状态">
<Select
allowClear
style={{ width: 120 }}
options={Object.entries(STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
<Form.Item>
<Button
onClick={() => {
form.resetFields();
setFilters({});
setPage(1);
}}
>
</Button>
</Form.Item>
<Form.Item>
<Button type="primary" onClick={() => setGenOpen(true)}>
</Button>
</Form.Item>
<Form.Item>
<Button loading={exporting} onClick={() => void exportExcel()}>
Excel
</Button>
</Form.Item>
<Form.Item name="partnerId" label="合伙人ID"><Input allowClear /></Form.Item>
<Button type="primary" htmlType="submit"></Button>
</Form>
<Table rowKey="id" loading={loading} columns={columns} dataSource={data?.items ?? []} scroll={{ x: 1100 }}
pagination={{ current: page, pageSize, total: data?.total ?? 0, showSizeChanger: true, onChange: (p, ps) => { setPage(p); setPageSize(ps); } }} />
<Modal title="生成合伙人账单" open={genOpen} onCancel={() => setGenOpen(false)} footer={null}>
<Form form={genForm} layout="vertical" onFinish={generateBill}>
<Form.Item name="partnerId" label="合伙人 ID" rules={[{ required: true }]}><Input /></Form.Item>
<Form.Item name="month" label="账单月份" rules={[{ required: true }]}><DatePicker picker="month" style={{ width: '100%' }} /></Form.Item>
<Button type="primary" htmlType="submit" block></Button>
<Table
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1200 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
<Modal title="生成合伙人账单(T+30" open={genOpen} onCancel={() => setGenOpen(false)} footer={null}>
<Form
form={genForm}
layout="vertical"
initialValues={{ month: dayjs().subtract(1, 'month') }}
onFinish={generateBill}
>
<Form.Item name="month" label="账单月份" rules={[{ required: true }]}>
<DatePicker picker="month" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="partnerId" label="指定合伙人(留空则全部主合伙人)">
<Select
allowClear
showSearch
optionFilterProp="label"
placeholder="全部主合伙人"
options={partners.map((p) => ({
value: p.id,
label: p.companyName || p.phone || p.id,
}))}
/>
</Form.Item>
<Button type="primary" htmlType="submit" block>
</Button>
</Form>
</Modal>
</div>
+110 -17
View File
@@ -1,8 +1,24 @@
import { useEffect, useState } from 'react';
import { Button, Descriptions, Drawer, Form, Select, Space, Table, Tag, Typography, message } from 'antd';
import {
Button,
Card,
DatePicker,
Descriptions,
Drawer,
Form,
Select,
Space,
Statistic,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { Dayjs } from 'dayjs';
import { request, type Paginated } from '../lib/api';
import { ADMIN_OPTIONS_PAGE_SIZE, fmtTime } from '../lib/constants';
import { downloadExcelCsv } from '../lib/exportExcel';
import { useAdminList } from '../lib/useAdminList';
type Row = {
@@ -15,7 +31,7 @@ type Row = {
paidAt?: string;
createdAt: string;
store?: { id: string; name: string; cityName: string; phone?: string };
redeemRecord?: { redeemNo: string; amount?: number };
redeemRecord?: { redeemNo: string; amount?: number; createdAt?: string };
};
type StoreOption = { id: string; name: string; phone: string };
@@ -40,12 +56,15 @@ export default function StoreBillsPage() {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.storeId) qs.set('storeId', filters.storeId);
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
if (filters.dateTo) qs.set('dateTo', filters.dateTo);
return qs;
},
[filters],
);
const [detail, setDetail] = useState<Record<string, unknown> | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [exporting, setExporting] = useState(false);
useEffect(() => {
void request<Paginated<StoreOption>>(`/admin/stores?pageSize=${ADMIN_OPTIONS_PAGE_SIZE}`)
@@ -62,7 +81,33 @@ export default function StoreBillsPage() {
reload();
}
async function exportExcel() {
setExporting(true);
try {
const qs = new URLSearchParams();
if (filters.status) qs.set('status', filters.status);
if (filters.storeId) qs.set('storeId', filters.storeId);
if (filters.dateFrom) qs.set('dateFrom', filters.dateFrom);
if (filters.dateTo) qs.set('dateTo', filters.dateTo);
const result = await request<{ csv: string; count: number }>(
`/admin/store-payouts/export?${qs}`,
);
downloadExcelCsv(result.csv, `门店账单_${filters.dateFrom || 'all'}_${filters.dateTo || 'all'}.csv`);
message.success(`已导出 ${result.count}`);
} finally {
setExporting(false);
}
}
const summary = data?.summary;
const columns: ColumnsType<Row> = [
{
title: '核销日期',
width: 110,
render: (_, r) =>
(r.redeemRecord?.createdAt || r.createdAt || '').toString().slice(0, 10) || '—',
},
{ title: '门店', dataIndex: ['store', 'name'], width: 140, ellipsis: true },
{ title: '登录手机', dataIndex: ['store', 'phone'], width: 120, render: (v) => v || '—' },
{ title: '城市', dataIndex: ['store', 'cityName'], width: 90 },
@@ -75,10 +120,16 @@ export default function StoreBillsPage() {
},
{ title: '核销面额', dataIndex: 'redeemAmount', width: 100, render: (v) => `¥${v}` },
{
title: '到账金额',
title: '核销比例',
dataIndex: 'settlementRate',
width: 90,
render: (v) => (v != null ? `${Math.round(Number(v) * 100)}%` : '—'),
},
{
title: '应付门店',
dataIndex: 'payoutAmount',
width: 100,
render: (v, row) => `¥${v}${row.settlementRate ? ` (${Math.round(row.settlementRate * 100)}%)` : ''}`,
render: (v) => `¥${v}`,
},
{
title: '打款状态',
@@ -86,8 +137,7 @@ export default function StoreBillsPage() {
width: 100,
render: (s) => <Tag color={PAYOUT_STATUS_COLORS[s] || 'default'}>{PAYOUT_STATUS_LABELS[s] || s}</Tag>,
},
{ title: '预计打款', dataIndex: 'expectedPayAt', width: 160, render: fmtTime },
{ title: '实际打款', dataIndex: 'paidAt', width: 160, render: (v) => (v ? fmtTime(String(v)) : '—') },
{ title: '预计打款(T+1)', dataIndex: 'expectedPayAt', width: 160, render: fmtTime },
{
title: '操作',
width: 140,
@@ -117,17 +167,39 @@ export default function StoreBillsPage() {
return (
<div>
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}></Typography.Title>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary">
T+1
T+1 = ×
</Typography.Text>
</Space>
{summary && (
<Card size="small" style={{ marginBottom: 16 }}>
<Space size="large" wrap>
<Statistic title="明细笔数" value={summary.count} />
<Statistic title="核销面额合计" value={summary.redeemAmount ?? 0} prefix="¥" precision={2} />
<Statistic title="应付门店合计" value={summary.payoutAmount ?? summary.totalAmount} prefix="¥" precision={2} />
</Space>
</Card>
)}
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v) => {
setFilters(v);
onFinish={(v: {
storeId?: string;
status?: string;
dateRange?: [Dayjs, Dayjs];
}) => {
setFilters({
storeId: v.storeId || '',
status: v.status || '',
dateFrom: v.dateRange?.[0]?.format('YYYY-MM-DD') || '',
dateTo: v.dateRange?.[1]?.format('YYYY-MM-DD') || '',
});
setPage(1);
}}
>
@@ -152,20 +224,39 @@ export default function StoreBillsPage() {
options={Object.entries(PAYOUT_STATUS_LABELS).map(([value, label]) => ({ value, label }))}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit"></Button>
<Form.Item name="dateRange" label="核销日期">
<DatePicker.RangePicker />
</Form.Item>
<Form.Item>
<Button onClick={() => { form.resetFields(); setFilters({}); setPage(1); }}></Button>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
<Form.Item>
<Button
onClick={() => {
form.resetFields();
setFilters({});
setPage(1);
}}
>
</Button>
</Form.Item>
<Form.Item>
<Button loading={exporting} onClick={() => void exportExcel()}>
Excel
</Button>
</Form.Item>
</Form>
<Table
rowKey="id"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1200 }}
scroll={{ x: 1300 }}
pagination={{
current: page,
pageSize,
@@ -177,15 +268,18 @@ export default function StoreBillsPage() {
},
}}
/>
<Drawer title="账单详情" width={520} open={drawerOpen} onClose={() => setDrawerOpen(false)}>
{detail && (
<Descriptions column={1} bordered size="small">
<Descriptions.Item label="门店">{String((detail.store as { name?: string })?.name ?? '—')}</Descriptions.Item>
<Descriptions.Item label="门店">
{String((detail.store as { name?: string })?.name ?? '—')}
</Descriptions.Item>
<Descriptions.Item label="核销单号">
{String((detail.redeemRecord as { redeemNo?: string })?.redeemNo ?? '—')}
</Descriptions.Item>
<Descriptions.Item label="核销面额">¥{String(detail.redeemAmount)}</Descriptions.Item>
<Descriptions.Item label="到账金额">¥{String(detail.payoutAmount)}</Descriptions.Item>
<Descriptions.Item label="应付金额">¥{String(detail.payoutAmount)}</Descriptions.Item>
<Descriptions.Item label="结算比例">
{detail.settlementRate ? `${Math.round(Number(detail.settlementRate) * 100)}%` : '—'}
</Descriptions.Item>
@@ -196,7 +290,6 @@ export default function StoreBillsPage() {
<Descriptions.Item label="实际打款">
{detail.paidAt ? fmtTime(String(detail.paidAt)) : '—'}
</Descriptions.Item>
<Descriptions.Item label="创建时间">{fmtTime(String(detail.createdAt))}</Descriptions.Item>
</Descriptions>
)}
</Drawer>
@@ -0,0 +1,205 @@
import { useState } from 'react';
import {
Button,
Card,
DatePicker,
Form,
Select,
Space,
Statistic,
Table,
Tag,
Typography,
message,
} from 'antd';
import type { ColumnsType } from 'antd/es/table';
import type { Dayjs } from 'dayjs';
import { WINERY_SETTLEMENT_RATE } from '@dukang/shared-types';
import { fmtTime } from '../lib/constants';
import { downloadExcelCsv } from '../lib/exportExcel';
import { request } from '../lib/api';
import { useAdminList } from '../lib/useAdminList';
type Row = {
orderId: string;
orderNo: string;
deliveryType: string;
cityName?: string;
receiverCity?: string;
payAmount: number;
wineryRate: number;
wineryAmount: number;
paidAt: string;
productName?: string;
quantity?: number;
};
const DELIVERY_LABELS: Record<string, string> = {
LOCAL: '同城',
CROSS_CITY: '跨城',
};
export default function WineryBillsPage() {
const [form] = Form.useForm();
const [filters, setFilters] = useState<Record<string, string>>({});
const { data, loading, page, pageSize, setPage, setPageSize } = useAdminList<Row>(
'/admin/winery-bills',
() => {
const qs = new URLSearchParams();
if (filters.year) qs.set('year', filters.year);
if (filters.month) qs.set('month', filters.month);
if (filters.deliveryType) qs.set('deliveryType', filters.deliveryType);
return qs;
},
[filters],
);
const [exporting, setExporting] = useState(false);
async function exportExcel() {
setExporting(true);
try {
const qs = new URLSearchParams();
if (filters.year) qs.set('year', filters.year);
if (filters.month) qs.set('month', filters.month);
if (filters.deliveryType) qs.set('deliveryType', filters.deliveryType);
const result = await request<{ csv: string; count: number }>(`/admin/winery-bills/export?${qs}`);
const suffix = filters.year && filters.month ? `${filters.year}-${filters.month}` : 'all';
downloadExcelCsv(result.csv, `酒厂账单_${suffix}.csv`);
message.success(`已导出 ${result.count}`);
} finally {
setExporting(false);
}
}
const summary = data?.summary;
const ratePct = Math.round(WINERY_SETTLEMENT_RATE * 100);
const columns: ColumnsType<Row> = [
{ title: '订单号', dataIndex: 'orderNo', width: 180, ellipsis: true },
{
title: '配送类型',
dataIndex: 'deliveryType',
width: 90,
render: (v) => <Tag>{DELIVERY_LABELS[v] || v}</Tag>,
},
{ title: '开城城市', dataIndex: 'cityName', width: 100, render: (v) => v || '—' },
{ title: '收货城市', dataIndex: 'receiverCity', width: 100, render: (v) => v || '—' },
{ title: '商品', dataIndex: 'productName', width: 140, ellipsis: true },
{ title: '数量', dataIndex: 'quantity', width: 70 },
{
title: '酒单实付',
dataIndex: 'payAmount',
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '酒厂比例',
dataIndex: 'wineryRate',
width: 90,
render: (v) => `${Math.round(Number(v) * 100)}%`,
},
{
title: '酒厂应付',
dataIndex: 'wineryAmount',
width: 110,
render: (v) => `¥${Number(v).toFixed(2)}`,
},
{
title: '支付时间',
dataIndex: 'paidAt',
width: 170,
render: (v) => (v ? fmtTime(v) : '—'),
},
];
return (
<div>
<Space direction="vertical" size={0} style={{ marginBottom: 16 }}>
<Typography.Title level={4} style={{ margin: 0 }}>
</Typography.Title>
<Typography.Text type="secondary">
T+30 / = × {ratePct}%
</Typography.Text>
</Space>
{summary && (
<Card size="small" style={{ marginBottom: 16 }}>
<Space size="large" wrap>
<Statistic title="酒单数" value={summary.count} />
<Statistic title="酒单实付合计" value={summary.orderAmount ?? 0} prefix="¥" precision={2} />
<Statistic title="酒厂应付合计" value={summary.wineryAmount ?? summary.totalAmount} prefix="¥" precision={2} />
</Space>
</Card>
)}
<Form
form={form}
layout="inline"
style={{ marginBottom: 16 }}
onFinish={(v: { month?: Dayjs; deliveryType?: string }) => {
setFilters({
year: v.month ? String(v.month.year()) : '',
month: v.month ? String(v.month.month() + 1) : '',
deliveryType: v.deliveryType || '',
});
setPage(1);
}}
>
<Form.Item name="month" label="账期月">
<DatePicker picker="month" />
</Form.Item>
<Form.Item name="deliveryType" label="配送类型">
<Select
allowClear
style={{ width: 120 }}
options={[
{ value: 'LOCAL', label: '同城' },
{ value: 'CROSS_CITY', label: '跨城' },
]}
/>
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit">
</Button>
</Form.Item>
<Form.Item>
<Button
onClick={() => {
form.resetFields();
setFilters({});
setPage(1);
}}
>
</Button>
</Form.Item>
<Form.Item>
<Button loading={exporting} onClick={() => void exportExcel()}>
Excel
</Button>
</Form.Item>
</Form>
<Table
rowKey="orderId"
className="admin-table-nowrap"
loading={loading}
columns={columns}
dataSource={data?.items ?? []}
scroll={{ x: 1200 }}
pagination={{
current: page,
pageSize,
total: data?.total ?? 0,
showSizeChanger: true,
onChange: (p, ps) => {
setPage(p);
setPageSize(ps);
},
}}
/>
</div>
);
}