356 lines
12 KiB
JavaScript
356 lines
12 KiB
JavaScript
import AccountBalanceWalletOutlined from "@mui/icons-material/AccountBalanceWalletOutlined";
|
|
import PaidOutlined from "@mui/icons-material/PaidOutlined";
|
|
import MenuItem from "@mui/material/MenuItem";
|
|
import TextField from "@mui/material/TextField";
|
|
import { useCallback, useEffect, useState } from "react";
|
|
import { adjustSalaryWallet, listSalaryWalletHistory } from "@/features/host-org/api";
|
|
import styles from "@/features/host-org/host-org.module.css";
|
|
import {
|
|
AdminFormAmountField,
|
|
AdminFormDialog,
|
|
AdminFormFieldGrid,
|
|
AdminFormReadOnlyField,
|
|
AdminFormSection,
|
|
} from "@/shared/ui/AdminFormDialog.jsx";
|
|
import { AdminActionIconButton } from "@/shared/ui/AdminListLayout.jsx";
|
|
import { DataState } from "@/shared/ui/DataState.jsx";
|
|
import { DataTable } from "@/shared/ui/DataTable.jsx";
|
|
import { SideDrawer } from "@/shared/ui/SideDrawer.jsx";
|
|
import { useToast } from "@/shared/ui/ToastProvider.jsx";
|
|
import { formatMillis } from "@/shared/utils/time.js";
|
|
|
|
const historyPageSize = 20;
|
|
|
|
const emptyHistory = { items: [], page: 1, pageSize: historyPageSize, total: 0 };
|
|
|
|
const emptyAdjustForm = () => ({
|
|
amount: "",
|
|
reason: "",
|
|
type: "increase",
|
|
});
|
|
|
|
const historyColumns = [
|
|
{
|
|
key: "createdAtMs",
|
|
label: "时间",
|
|
render: (item) => formatMillis(item.createdAtMs),
|
|
width: "minmax(170px, 0.9fr)",
|
|
},
|
|
{
|
|
key: "amountMinor",
|
|
label: "金额",
|
|
render: (item) => <SalaryHistoryAmount item={item} />,
|
|
width: "minmax(120px, 0.65fr)",
|
|
},
|
|
{
|
|
key: "balance",
|
|
label: "余额",
|
|
render: (item) => formatSalaryWalletAmount(item.availableAfter),
|
|
width: "minmax(120px, 0.65fr)",
|
|
},
|
|
{
|
|
key: "bizType",
|
|
label: "类型",
|
|
render: (item) => salaryWalletBizLabel(item.bizType),
|
|
width: "minmax(150px, 0.8fr)",
|
|
},
|
|
{
|
|
key: "operator",
|
|
label: "操作人",
|
|
render: (item) => salaryWalletOperatorName(item),
|
|
width: "minmax(150px, 0.8fr)",
|
|
},
|
|
{
|
|
key: "reason",
|
|
label: "原因",
|
|
render: (item) => item.reason || "-",
|
|
width: "minmax(220px, 1.15fr)",
|
|
},
|
|
];
|
|
|
|
export function SalaryWalletCell({ role, userId, userLabel, wallet }) {
|
|
const [open, setOpen] = useState(false);
|
|
const balance = Number(wallet?.availableAmount || 0);
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
className={styles.salaryWalletButton}
|
|
type="button"
|
|
onClick={() => setOpen(true)}
|
|
>
|
|
<span className={styles.salaryWalletAmount}>{formatSalaryWalletAmount(balance)}</span>
|
|
</button>
|
|
<SalaryWalletHistoryDrawer
|
|
open={open}
|
|
role={role}
|
|
userId={userId}
|
|
userLabel={userLabel}
|
|
onClose={() => setOpen(false)}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
export function SalaryWalletAdjustButton({ disabled = false, onAdjusted, role, userId, userLabel }) {
|
|
const { showToast } = useToast();
|
|
const [open, setOpen] = useState(false);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
const [form, setForm] = useState(emptyAdjustForm);
|
|
|
|
const close = () => {
|
|
if (submitting) {
|
|
return;
|
|
}
|
|
setOpen(false);
|
|
setForm(emptyAdjustForm());
|
|
};
|
|
|
|
const submit = async (event) => {
|
|
event.preventDefault();
|
|
let amountMinor;
|
|
try {
|
|
amountMinor = parseSalaryUSDMinor(form.amount);
|
|
if (!String(form.reason || "").trim()) {
|
|
throw new Error("请填写原因");
|
|
}
|
|
} catch (err) {
|
|
showToast(err.message || "工资操作参数不正确", "error");
|
|
return;
|
|
}
|
|
|
|
setSubmitting(true);
|
|
try {
|
|
const result = await adjustSalaryWallet({
|
|
amountMinor,
|
|
commandId: makeSalaryWalletCommandId(role, userId),
|
|
reason: String(form.reason || "").trim(),
|
|
role,
|
|
targetUserId: userId,
|
|
type: form.type,
|
|
});
|
|
showToast("工资钱包已更新", "success");
|
|
setOpen(false);
|
|
setForm(emptyAdjustForm());
|
|
await onAdjusted?.(result);
|
|
} catch (err) {
|
|
showToast(err.message || "工资钱包操作失败", "error");
|
|
} finally {
|
|
setSubmitting(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<>
|
|
<AdminActionIconButton
|
|
disabled={disabled || !role || !userId}
|
|
label="工资操作"
|
|
onClick={() => setOpen(true)}
|
|
>
|
|
<PaidOutlined fontSize="small" />
|
|
</AdminActionIconButton>
|
|
<AdminFormDialog
|
|
disabled={disabled}
|
|
loading={submitting}
|
|
open={open}
|
|
size="large"
|
|
submitDisabled={disabled || submitting || !role || !userId}
|
|
submitLabel="确认"
|
|
title="工资操作"
|
|
onClose={close}
|
|
onSubmit={submit}
|
|
>
|
|
<AdminFormSection title="目标身份">
|
|
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
|
<AdminFormReadOnlyField label="身份" value={salaryWalletRoleLabel(role)} />
|
|
<AdminFormReadOnlyField label="用户" value={userLabel || userId || "-"} />
|
|
</AdminFormFieldGrid>
|
|
</AdminFormSection>
|
|
<AdminFormSection title="操作信息">
|
|
<AdminFormFieldGrid columns="repeat(2, minmax(0, 1fr))">
|
|
<TextField
|
|
disabled={disabled || submitting}
|
|
label="类型"
|
|
required
|
|
select
|
|
value={form.type}
|
|
onChange={(event) => setForm((current) => ({ ...current, type: event.target.value }))}
|
|
>
|
|
<MenuItem value="increase">工资增加</MenuItem>
|
|
<MenuItem value="decrease">工资减少</MenuItem>
|
|
</TextField>
|
|
<AdminFormAmountField
|
|
disabled={disabled || submitting}
|
|
label="金额"
|
|
required
|
|
unit="USD"
|
|
value={form.amount}
|
|
onChange={(event) => setForm((current) => ({ ...current, amount: event.target.value }))}
|
|
/>
|
|
</AdminFormFieldGrid>
|
|
<TextField
|
|
disabled={disabled || submitting}
|
|
label="原因"
|
|
multiline
|
|
minRows={3}
|
|
required
|
|
value={form.reason}
|
|
onChange={(event) => setForm((current) => ({ ...current, reason: event.target.value }))}
|
|
/>
|
|
</AdminFormSection>
|
|
</AdminFormDialog>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function SalaryWalletHistoryDrawer({ onClose, open, role, userId, userLabel }) {
|
|
const [page, setPage] = useState(1);
|
|
const [data, setData] = useState(emptyHistory);
|
|
const [error, setError] = useState("");
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
const loadHistory = useCallback(async () => {
|
|
if (!open || !role || !userId) {
|
|
return;
|
|
}
|
|
setError("");
|
|
setLoading(true);
|
|
try {
|
|
const nextData = await listSalaryWalletHistory({
|
|
page,
|
|
page_size: historyPageSize,
|
|
role,
|
|
user_id: userId,
|
|
});
|
|
setData(nextData || emptyHistory);
|
|
} catch (err) {
|
|
setError(err.message || "加载工资钱包历史失败");
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [open, page, role, userId]);
|
|
|
|
useEffect(() => {
|
|
if (!open) {
|
|
return;
|
|
}
|
|
void loadHistory();
|
|
}, [loadHistory, open]);
|
|
|
|
useEffect(() => {
|
|
if (open) {
|
|
setPage(1);
|
|
}
|
|
}, [open, role, userId]);
|
|
|
|
const total = data.total || 0;
|
|
|
|
return (
|
|
<SideDrawer
|
|
className={styles.salaryWalletDrawer}
|
|
contentClassName={styles.salaryWalletDrawerBody}
|
|
open={open}
|
|
title={`工资钱包历史 - ${userLabel || userId || ""}`}
|
|
width="wide"
|
|
onClose={onClose}
|
|
>
|
|
<div className={styles.salaryWalletSummary}>
|
|
<AccountBalanceWalletOutlined fontSize="small" />
|
|
<span>{salaryWalletRoleLabel(role)}</span>
|
|
</div>
|
|
<DataState error={error} loading={loading} onRetry={loadHistory}>
|
|
<DataTable
|
|
columns={historyColumns}
|
|
emptyLabel="暂无工资钱包历史"
|
|
items={data.items || []}
|
|
minWidth="930px"
|
|
pagination={
|
|
total > 0
|
|
? {
|
|
page,
|
|
pageSize: data.pageSize || historyPageSize,
|
|
total,
|
|
onPageChange: setPage,
|
|
}
|
|
: undefined
|
|
}
|
|
rowKey={(item) => item.entryId || item.transactionId}
|
|
/>
|
|
</DataState>
|
|
</SideDrawer>
|
|
);
|
|
}
|
|
|
|
function SalaryHistoryAmount({ item }) {
|
|
const delta = Number(item.availableDelta ?? item.amountMinor ?? 0);
|
|
const expense = delta < 0;
|
|
return (
|
|
<span className={expense ? styles.salaryWalletExpense : styles.salaryWalletIncome}>
|
|
{expense ? "-" : "+"}
|
|
{formatSalaryWalletAmount(Math.abs(delta))}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export function formatSalaryWalletAmount(value) {
|
|
const minor = Number(value || 0);
|
|
return `$${(minor / 100).toLocaleString("en-US", {
|
|
maximumFractionDigits: 2,
|
|
minimumFractionDigits: 2,
|
|
})}`;
|
|
}
|
|
|
|
function parseSalaryUSDMinor(value) {
|
|
const raw = String(value || "").trim();
|
|
if (!/^\d+(\.\d{1,2})?$/.test(raw)) {
|
|
throw new Error("请输入正确的 USD 金额,最多两位小数");
|
|
}
|
|
const [integerPart, decimalPart = ""] = raw.split(".");
|
|
const amountMinor = Number(integerPart) * 100 + Number(decimalPart.padEnd(2, "0"));
|
|
if (!Number.isSafeInteger(amountMinor) || amountMinor <= 0) {
|
|
throw new Error("请输入大于 0 的工资金额");
|
|
}
|
|
return amountMinor;
|
|
}
|
|
|
|
function salaryWalletRoleLabel(role) {
|
|
switch (role) {
|
|
case "host":
|
|
return "Host";
|
|
case "agency":
|
|
return "Agency";
|
|
case "bd":
|
|
return "BD";
|
|
case "bd_leader":
|
|
return "BD Leader";
|
|
default:
|
|
return role || "-";
|
|
}
|
|
}
|
|
|
|
function salaryWalletBizLabel(value) {
|
|
switch (value) {
|
|
case "manual_credit":
|
|
return "手工调整";
|
|
case "salary_settlement":
|
|
case "host_salary_settlement":
|
|
case "team_salary_settlement":
|
|
return "工资结算";
|
|
case "transfer":
|
|
return "转账";
|
|
case "withdraw":
|
|
return "提现";
|
|
default:
|
|
return value || "-";
|
|
}
|
|
}
|
|
|
|
function salaryWalletOperatorName(item) {
|
|
const operator = item.operator || {};
|
|
return operator.name || operator.username || item.operatorUserId || "-";
|
|
}
|
|
|
|
function makeSalaryWalletCommandId(role, userId) {
|
|
return `salary-wallet-${role || "role"}-${userId || "user"}-${Date.now()}`;
|
|
}
|