361 lines
12 KiB
TypeScript
361 lines
12 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
|
import { View, Text, Input, Switch, Image } from '@tarojs/components';
|
|
import '../../styles/invoice.css';
|
|
import Taro, { useDidShow, usePullDownRefresh } from '@tarojs/taro';
|
|
import PageShell from '../../components/PageShell';
|
|
import SubPageHeader from '../../components/SubPageHeader';
|
|
import { request, toast } from '../../lib/api';
|
|
import { usePageView } from '../../lib/usePageView';
|
|
import {
|
|
INVOICE_TITLE_TYPE_LABELS,
|
|
type InvoiceTitleType,
|
|
type UpsertInvoiceTitleRequest,
|
|
type UserInvoiceTitleDto,
|
|
} from '@dukang/shared-types';
|
|
import iconEdit from '../../assets/icons/编辑.png';
|
|
import iconDefault from '../../assets/icons/默认.png';
|
|
import iconDelete from '../../assets/icons/删除.png';
|
|
|
|
const isH5 = process.env.TARO_ENV === 'h5';
|
|
|
|
const ACTION_ICONS = {
|
|
edit: iconEdit,
|
|
star: iconDefault,
|
|
trash: iconDelete,
|
|
} as const;
|
|
|
|
type EditDraft = UpsertInvoiceTitleRequest & { id?: string };
|
|
|
|
function TitleActionIcon({
|
|
kind,
|
|
label,
|
|
onClick,
|
|
}: {
|
|
kind: keyof typeof ACTION_ICONS;
|
|
label: string;
|
|
onClick: () => void;
|
|
}) {
|
|
return (
|
|
<View
|
|
className="invoice-title-icon-btn"
|
|
hoverClass="invoice-title-icon-btn--hover"
|
|
aria-label={label}
|
|
onClick={onClick}
|
|
>
|
|
<Image className="invoice-title-icon-img" src={ACTION_ICONS[kind]} mode="aspectFit" />
|
|
</View>
|
|
);
|
|
}
|
|
|
|
const EMPTY_DRAFT: EditDraft = {
|
|
titleType: 'PERSONAL',
|
|
titleName: '',
|
|
taxNo: '',
|
|
email: '',
|
|
phone: '',
|
|
addressPhone: '',
|
|
bankAccount: '',
|
|
isDefault: false,
|
|
};
|
|
|
|
export default function InvoiceTitlesPage() {
|
|
usePageView('user_invoice_titles_view');
|
|
const [titles, setTitles] = useState<UserInvoiceTitleDto[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [sheetOpen, setSheetOpen] = useState(false);
|
|
const [draft, setDraft] = useState<EditDraft>(EMPTY_DRAFT);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
const load = useCallback(() => {
|
|
setLoading(true);
|
|
return request<UserInvoiceTitleDto[]>('/trade/invoice-titles')
|
|
.then((data) => setTitles(Array.isArray(data) ? data : []))
|
|
.catch((e) => {
|
|
toast(e instanceof Error ? e.message : '加载失败');
|
|
setTitles([]);
|
|
})
|
|
.finally(() => setLoading(false));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
useDidShow(() => {
|
|
void load();
|
|
});
|
|
|
|
usePullDownRefresh(() => {
|
|
void load().finally(() => Taro.stopPullDownRefresh());
|
|
});
|
|
|
|
function openCreate() {
|
|
setDraft({ ...EMPTY_DRAFT });
|
|
setSheetOpen(true);
|
|
}
|
|
|
|
function openEdit(t: UserInvoiceTitleDto) {
|
|
setDraft({
|
|
id: t.id,
|
|
titleType: t.titleType,
|
|
titleName: t.titleName,
|
|
taxNo: t.taxNo ?? '',
|
|
email: t.email ?? '',
|
|
phone: t.phone ?? '',
|
|
addressPhone: t.addressPhone ?? '',
|
|
bankAccount: t.bankAccount ?? '',
|
|
isDefault: t.isDefault,
|
|
});
|
|
setSheetOpen(true);
|
|
}
|
|
|
|
async function save() {
|
|
if (saving) return;
|
|
const titleName = draft.titleName.trim();
|
|
if (!titleName) {
|
|
toast('请填写发票抬头名称');
|
|
return;
|
|
}
|
|
if (draft.titleType === 'ENTERPRISE' && !draft.taxNo?.trim()) {
|
|
toast('企业抬头须填写税号');
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
const payload: UpsertInvoiceTitleRequest = {
|
|
titleType: draft.titleType,
|
|
titleName,
|
|
taxNo: draft.taxNo?.trim() || null,
|
|
email: draft.email?.trim() || null,
|
|
phone: draft.phone?.trim() || null,
|
|
addressPhone: draft.addressPhone?.trim() || null,
|
|
bankAccount: draft.bankAccount?.trim() || null,
|
|
isDefault: !!draft.isDefault,
|
|
};
|
|
if (draft.id) {
|
|
await request(`/trade/invoice-titles/${draft.id}`, {
|
|
method: 'PUT',
|
|
data: payload,
|
|
});
|
|
toast('已更新', 'success');
|
|
} else {
|
|
await request('/trade/invoice-titles', { method: 'POST', data: payload });
|
|
toast('已添加', 'success');
|
|
}
|
|
setSheetOpen(false);
|
|
await load();
|
|
} catch (e) {
|
|
toast(e instanceof Error ? e.message : '保存失败');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
async function setDefault(t: UserInvoiceTitleDto) {
|
|
try {
|
|
await request(`/trade/invoice-titles/${t.id}`, {
|
|
method: 'PUT',
|
|
data: {
|
|
titleType: t.titleType,
|
|
titleName: t.titleName,
|
|
taxNo: t.taxNo,
|
|
email: t.email,
|
|
phone: t.phone,
|
|
addressPhone: t.addressPhone,
|
|
bankAccount: t.bankAccount,
|
|
isDefault: true,
|
|
},
|
|
});
|
|
toast('已设为默认', 'success');
|
|
await load();
|
|
} catch (e) {
|
|
toast(e instanceof Error ? e.message : '设置失败');
|
|
}
|
|
}
|
|
|
|
async function remove(id: string) {
|
|
const ok = await Taro.showModal({ title: '提示', content: '确认删除该发票抬头?' });
|
|
if (!ok.confirm) return;
|
|
try {
|
|
await request(`/trade/invoice-titles/${id}`, { method: 'DELETE' });
|
|
toast('已删除', 'success');
|
|
await load();
|
|
} catch (e) {
|
|
toast(e instanceof Error ? e.message : '删除失败');
|
|
}
|
|
}
|
|
|
|
return (
|
|
<PageShell variant="sub" className="invoice-titles-page">
|
|
{isH5 ? (
|
|
<SubPageHeader
|
|
title="发票抬头"
|
|
onBack={() => Taro.navigateBack().catch(() => Taro.switchTab({ url: '/pages/mine/index' }))}
|
|
/>
|
|
) : null}
|
|
<View className="invoice-titles-body">
|
|
{loading ? <View className="u-empty">加载中…</View> : null}
|
|
{!loading && titles.length === 0 ? (
|
|
<View className="u-empty">暂无发票抬头,点击右上角添加</View>
|
|
) : null}
|
|
{!loading &&
|
|
titles.map((t) => (
|
|
<View key={t.id} className="invoice-title-card">
|
|
<View className="invoice-title-card-head">
|
|
<Text className="invoice-title-name">{t.titleName}</Text>
|
|
<Text className="invoice-title-type">
|
|
{INVOICE_TITLE_TYPE_LABELS[t.titleType] ?? t.titleType}
|
|
</Text>
|
|
{t.isDefault ? <Text className="invoice-title-default">默认</Text> : null}
|
|
</View>
|
|
{t.taxNo ? <Text className="invoice-title-line">税号:{t.taxNo}</Text> : null}
|
|
{t.email ? <Text className="invoice-title-line">邮箱:{t.email}</Text> : null}
|
|
{t.phone ? <Text className="invoice-title-line">电话:{t.phone}</Text> : null}
|
|
{t.addressPhone ? (
|
|
<Text className="invoice-title-line">地址电话:{t.addressPhone}</Text>
|
|
) : null}
|
|
{t.bankAccount ? (
|
|
<Text className="invoice-title-line">开户行账号:{t.bankAccount}</Text>
|
|
) : null}
|
|
<View className="invoice-title-actions">
|
|
<TitleActionIcon kind="edit" label="编辑" onClick={() => openEdit(t)} />
|
|
{!t.isDefault ? (
|
|
<TitleActionIcon kind="star" label="设为默认" onClick={() => void setDefault(t)} />
|
|
) : null}
|
|
<TitleActionIcon kind="trash" label="删除" onClick={() => void remove(t.id)} />
|
|
</View>
|
|
</View>
|
|
))}
|
|
</View>
|
|
|
|
<View className="invoice-titles-fab" onClick={openCreate}>
|
|
<Text>+ 添加抬头</Text>
|
|
</View>
|
|
|
|
{sheetOpen ? (
|
|
<View className="invoice-title-sheet-mask">
|
|
<View className="invoice-title-sheet-mask-backdrop" onClick={() => !saving && setSheetOpen(false)} />
|
|
<View className="invoice-title-sheet">
|
|
<Text className="invoice-title-sheet-title">
|
|
{draft.id ? '编辑发票抬头' : '添加发票抬头'}
|
|
</Text>
|
|
|
|
<View className="invoice-title-field">
|
|
<Text className="invoice-title-label">抬头类型</Text>
|
|
<View className="invoice-title-type-row">
|
|
{(['PERSONAL', 'ENTERPRISE'] as InvoiceTitleType[]).map((tp) => (
|
|
<Text
|
|
key={tp}
|
|
className={`invoice-title-type-chip${
|
|
draft.titleType === tp ? ' active' : ''
|
|
}`}
|
|
onClick={() => setDraft({ ...draft, titleType: tp })}
|
|
>
|
|
{INVOICE_TITLE_TYPE_LABELS[tp]}
|
|
</Text>
|
|
))}
|
|
</View>
|
|
</View>
|
|
|
|
<View className="invoice-title-field">
|
|
<Text className="invoice-title-label">抬头名称</Text>
|
|
<Input
|
|
className="invoice-title-input"
|
|
maxlength={128}
|
|
placeholder="个人姓名或企业名称"
|
|
value={draft.titleName}
|
|
onInput={(e) => setDraft({ ...draft, titleName: e.detail.value })}
|
|
/>
|
|
</View>
|
|
|
|
{draft.titleType === 'ENTERPRISE' ? (
|
|
<View className="invoice-title-field">
|
|
<Text className="invoice-title-label">税号</Text>
|
|
<Input
|
|
className="invoice-title-input"
|
|
maxlength={32}
|
|
placeholder="企业税号"
|
|
value={draft.taxNo || ''}
|
|
onInput={(e) => setDraft({ ...draft, taxNo: e.detail.value })}
|
|
/>
|
|
</View>
|
|
) : null}
|
|
|
|
<View className="invoice-title-field">
|
|
<Text className="invoice-title-label">接收邮箱</Text>
|
|
<Input
|
|
className="invoice-title-input"
|
|
maxlength={128}
|
|
placeholder="电子发票将发送至此邮箱"
|
|
value={draft.email || ''}
|
|
onInput={(e) => setDraft({ ...draft, email: e.detail.value })}
|
|
/>
|
|
</View>
|
|
|
|
<View className="invoice-title-field">
|
|
<Text className="invoice-title-label">联系电话</Text>
|
|
<Input
|
|
className="invoice-title-input"
|
|
type="number"
|
|
maxlength={20}
|
|
placeholder="选填"
|
|
value={draft.phone || ''}
|
|
onInput={(e) => setDraft({ ...draft, phone: e.detail.value })}
|
|
/>
|
|
</View>
|
|
|
|
{draft.titleType === 'ENTERPRISE' ? (
|
|
<>
|
|
<View className="invoice-title-field">
|
|
<Text className="invoice-title-label">地址电话</Text>
|
|
<Input
|
|
className="invoice-title-input"
|
|
maxlength={256}
|
|
placeholder="专用发票需要,选填"
|
|
value={draft.addressPhone || ''}
|
|
onInput={(e) => setDraft({ ...draft, addressPhone: e.detail.value })}
|
|
/>
|
|
</View>
|
|
<View className="invoice-title-field">
|
|
<Text className="invoice-title-label">开户行账号</Text>
|
|
<Input
|
|
className="invoice-title-input"
|
|
maxlength={256}
|
|
placeholder="专用发票需要,选填"
|
|
value={draft.bankAccount || ''}
|
|
onInput={(e) => setDraft({ ...draft, bankAccount: e.detail.value })}
|
|
/>
|
|
</View>
|
|
</>
|
|
) : null}
|
|
|
|
<View className="invoice-title-default-row">
|
|
<Text className="invoice-title-label">设为默认抬头</Text>
|
|
<Switch
|
|
checked={!!draft.isDefault}
|
|
color="#A61D24"
|
|
onChange={(e) => setDraft({ ...draft, isDefault: !!e.detail.value })}
|
|
/>
|
|
</View>
|
|
|
|
<View className="invoice-title-sheet-actions">
|
|
<Text
|
|
className="invoice-title-sheet-cancel"
|
|
onClick={() => !saving && setSheetOpen(false)}
|
|
>
|
|
取消
|
|
</Text>
|
|
<Text
|
|
className={`invoice-title-sheet-save${saving ? ' is-disabled' : ''}`}
|
|
onClick={() => { if (!saving) void save(); }}
|
|
>
|
|
{saving ? '保存中…' : '保存'}
|
|
</Text>
|
|
</View>
|
|
</View>
|
|
</View>
|
|
) : null}
|
|
</PageShell>
|
|
);
|
|
}
|