Files
lislgosms/src/components/ui/ManualRechargeDialog.tsx
T

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>
);
}