163 lines
6.6 KiB
TypeScript
163 lines
6.6 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { adminApi, type ManualRechargePreflight, type ManualRechargeResult } from '@/api/adminApi';
|
|
import { formatCents, isValidMoneyInput, yuanToMoneyUnits } from '@/utils/currency';
|
|
import { Button } from './Button';
|
|
import { Input } from './Input';
|
|
import { Modal } from './Modal';
|
|
import { Select } from './Select';
|
|
import { Textarea } from './Textarea';
|
|
|
|
export type ManualRechargeTarget = {
|
|
id: string;
|
|
name: string;
|
|
code: string;
|
|
balanceCents: number;
|
|
};
|
|
|
|
type ManualRechargeDialogProps = {
|
|
initialTargetId?: string;
|
|
lockTarget?: boolean;
|
|
onClose: () => void;
|
|
onCompleted: () => void | Promise<void>;
|
|
open: boolean;
|
|
targets: ManualRechargeTarget[];
|
|
};
|
|
|
|
export function ManualRechargeDialog({ initialTargetId, lockTarget = false, onClose, onCompleted, open, targets }: ManualRechargeDialogProps) {
|
|
const [tenantId, setTenantId] = useState('');
|
|
const [amount, setAmount] = useState('');
|
|
const [remark, setRemark] = useState('');
|
|
const [review, setReview] = useState<ManualRechargePreflight | null>(null);
|
|
const [result, setResult] = useState<ManualRechargeResult | null>(null);
|
|
const [idempotencyKey, setIdempotencyKey] = useState('');
|
|
const [error, setError] = useState('');
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
setTenantId(initialTargetId ?? targets[0]?.id ?? '');
|
|
setAmount('');
|
|
setRemark('');
|
|
setReview(null);
|
|
setResult(null);
|
|
setIdempotencyKey('');
|
|
setError('');
|
|
setSubmitting(false);
|
|
}, [initialTargetId, open]);
|
|
|
|
if (!open) return null;
|
|
const selectedTarget = targets.find((target) => target.id === tenantId);
|
|
|
|
function resetReview() {
|
|
setReview(null);
|
|
setResult(null);
|
|
setIdempotencyKey('');
|
|
setError('');
|
|
}
|
|
|
|
function closeAndDestroyDraft() {
|
|
if (submitting) return;
|
|
setAmount('');
|
|
setRemark('');
|
|
resetReview();
|
|
onClose();
|
|
}
|
|
|
|
async function preflight() {
|
|
if (!tenantId || !Number.isFinite(Number(amount)) || !isValidMoneyInput(amount, { allowNegative: true, allowZero: false })) {
|
|
setError('请填写非 0 的充值金额;金额支持负数冲正。');
|
|
return;
|
|
}
|
|
setSubmitting(true);
|
|
setError('');
|
|
try {
|
|
const preview = await adminApi.preflightManualRecharge({ tenantId, amountCents: yuanToMoneyUnits(amount) });
|
|
setReview(preview);
|
|
setIdempotencyKey(`manual-recharge:${crypto.randomUUID()}`);
|
|
} catch (failure) {
|
|
setError(failure instanceof Error ? failure.message : '人工充值资格核对失败');
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
|
|
async function submit() {
|
|
if (!review || !idempotencyKey) return;
|
|
setSubmitting(true);
|
|
setError('');
|
|
try {
|
|
const nextResult = await adminApi.createManualRecharge({
|
|
tenantId,
|
|
amountCents: review.amountCents,
|
|
expectedAccountUpdatedAt: review.expectedAccountUpdatedAt,
|
|
idempotencyKey,
|
|
remark,
|
|
});
|
|
setResult(nextResult);
|
|
await onCompleted();
|
|
} catch (failure) {
|
|
setError(failure instanceof Error ? failure.message : '人工充值失败');
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
}
|
|
|
|
const footer = (requestClose: () => void) => result ? (
|
|
<Button onClick={closeAndDestroyDraft}>完成</Button>
|
|
) : review ? (
|
|
<>
|
|
<Button disabled={submitting} onClick={resetReview} variant="ghost">返回修改</Button>
|
|
<Button disabled={submitting} onClick={() => { void submit(); }}>{submitting ? '入账中...' : review.direction === 'topup' ? '确认充值' : '确认冲正'}</Button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Button disabled={submitting} onClick={requestClose} variant="ghost">取消</Button>
|
|
<Button disabled={submitting} onClick={() => { void preflight(); }}>{submitting ? '核对中...' : '下一步:核对信息'}</Button>
|
|
</>
|
|
);
|
|
|
|
return (
|
|
<Modal
|
|
dirty={!result && !submitting && Boolean(amount.trim() || remark.trim() || review)}
|
|
footer={({ requestClose }) => footer(requestClose)}
|
|
onClose={closeAndDestroyDraft}
|
|
open
|
|
size="md"
|
|
title={result ? '人工充值结果' : review ? '确认人工充值' : '企业人工充值'}
|
|
>
|
|
{result ? (
|
|
<div className="manual-recharge-result" role="status">
|
|
<strong>{result.amountCents > 0 ? '充值已入账' : '余额冲正已入账'}</strong>
|
|
<span>订单号:{result.orderNo}</span>
|
|
<span>充值后余额:¥{formatCents(result.balanceAfterCents)}</span>
|
|
<span>操作单号:{result.operationId}{result.replayed ? '(幂等重放)' : ''}</span>
|
|
</div>
|
|
) : review ? (
|
|
<div className="manual-recharge-review">
|
|
<p>请核对以下资金变更,确认后将立即写入企业账户。</p>
|
|
<dl>
|
|
<div><dt>充值对象</dt><dd><strong>{review.tenant.name}</strong><span>{review.tenant.code} · {review.tenant.id}</span></dd></div>
|
|
<div><dt>操作方向</dt><dd>{review.direction === 'topup' ? '余额充值' : '余额冲正'}</dd></div>
|
|
<div><dt>当前现金余额</dt><dd>¥{formatCents(review.balanceCents)}</dd></div>
|
|
<div><dt>本次变动</dt><dd className={review.amountCents > 0 ? 'is-positive' : 'is-negative'}>{review.amountCents > 0 ? '+' : '-'}¥{formatCents(Math.abs(review.amountCents))}</dd></div>
|
|
<div><dt>预计现金余额</dt><dd><strong>¥{formatCents(review.balanceAfterCents)}</strong></dd></div>
|
|
</dl>
|
|
{remark.trim() ? <p className="manual-recharge-review__remark"><strong>备注:</strong>{remark.trim()}</p> : null}
|
|
</div>
|
|
) : (
|
|
<div className="admin-system-modal-form">
|
|
{lockTarget ? (
|
|
<Input disabled label="企业名称" value={selectedTarget?.name ?? ''} />
|
|
) : (
|
|
<Select label="企业名称" onChange={(event) => { setTenantId(event.target.value); resetReview(); }} options={targets.map((target) => ({ label: target.name, value: target.id }))} required value={tenantId} />
|
|
)}
|
|
<Input disabled label="当前现金余额" prefix="¥" value={formatCents(selectedTarget?.balanceCents ?? 0)} />
|
|
<Input label="充值金额" onChange={(event) => { setAmount(event.target.value); resetReview(); }} prefix="¥" required step="0.0001" type="number" value={amount} />
|
|
<Textarea className="admin-system-modal-form__wide" label="充值备注" onChange={(event) => { setRemark(event.target.value); resetReview(); }} rows={4} value={remark} />
|
|
</div>
|
|
)}
|
|
{error ? <p className="form-error" role="alert">{error}</p> : null}
|
|
</Modal>
|
|
);
|
|
}
|