diff --git a/finance/src/FinanceApp.jsx b/finance/src/FinanceApp.jsx index 1ac8e92..b10f6ea 100644 --- a/finance/src/FinanceApp.jsx +++ b/finance/src/FinanceApp.jsx @@ -3,24 +3,24 @@ import Dialog from "@mui/material/Dialog"; import DialogContent from "@mui/material/DialogContent"; import DialogTitle from "@mui/material/DialogTitle"; import { - approveFinanceApplication, - approveFinanceWithdrawalApplication, - createFinanceApplication, - exportFinanceRechargeBills, - fetchFinanceApplicationOptions, - fetchFinanceRechargeApps, - fetchFinanceRechargeOverview, - fetchFinanceRechargeSummaries, - fetchFinanceSession, - filterOperationsByPermissions, - listFinanceApplications, - listFinanceRechargeDetails, - listFinanceRechargeRegions, - listMyFinanceApplications, - listFinanceWithdrawalApplications, - refreshFinanceGoogleRechargePaid, - rejectFinanceApplication, - rejectFinanceWithdrawalApplication + approveFinanceApplication, + approveFinanceWithdrawalApplication, + createFinanceApplication, + exportFinanceRechargeBills, + fetchFinanceApplicationOptions, + fetchFinanceRechargeApps, + fetchFinanceRechargeOverview, + fetchFinanceRechargeSummaries, + fetchFinanceSession, + filterOperationsByPermissions, + listFinanceApplications, + listFinanceRechargeDetails, + listFinanceRechargeRegions, + listMyFinanceApplications, + listFinanceWithdrawalApplications, + refreshFinanceGoogleRechargePaid, + rejectFinanceApplication, + rejectFinanceWithdrawalApplication, } from "./api.js"; import { FinanceApplicationForm } from "./components/FinanceApplicationForm.jsx"; import { FinanceApplicationList } from "./components/FinanceApplicationList.jsx"; @@ -33,10 +33,10 @@ import { FinanceShell } from "./components/FinanceShell.jsx"; import { FinanceWithdrawalApplicationList } from "./components/FinanceWithdrawalApplicationList.jsx"; import { DEFAULT_APPLICATION_FORM } from "./constants.js"; import { - EMPTY_RECHARGE_OVERVIEW, - EMPTY_RECHARGE_SUMMARY, - buildApplicationPayload, - validateApplicationPayload + EMPTY_RECHARGE_OVERVIEW, + EMPTY_RECHARGE_SUMMARY, + buildApplicationPayload, + validateApplicationPayload, } from "./format.js"; import { downloadCsv } from "@/shared/api/download"; import { TimeRangeFilter } from "@/shared/ui/TimeRangeFilter.jsx"; @@ -49,736 +49,810 @@ const CHINA_OFFSET_MS = 8 * 3_600_000; const FINANCE_VIEWS = new Set(["overview", "rechargeDetails", "recon"]); function chinaDayStart(timestamp) { - return Math.floor((timestamp + CHINA_OFFSET_MS) / DAY_MS) * DAY_MS - CHINA_OFFSET_MS; + return Math.floor((timestamp + CHINA_OFFSET_MS) / DAY_MS) * DAY_MS - CHINA_OFFSET_MS; } function timePresets() { - const now = Date.now(); - const todayStart = chinaDayStart(now); - const chinaNow = new Date(now + CHINA_OFFSET_MS); - const monthStart = Date.UTC(chinaNow.getUTCFullYear(), chinaNow.getUTCMonth(), 1) - CHINA_OFFSET_MS; - return [ - { key: "today", label: "今天", range: { endMs: todayStart + DAY_MS, startMs: todayStart } }, - { key: "7d", label: "近7天", range: { endMs: todayStart + DAY_MS, startMs: todayStart - 6 * DAY_MS } }, - { key: "30d", label: "近30天", range: { endMs: todayStart + DAY_MS, startMs: todayStart - 29 * DAY_MS } }, - { key: "month", label: "本月", range: { endMs: todayStart + DAY_MS, startMs: monthStart } }, - { key: "all", label: "不限", range: { endMs: "", startMs: "" } } - ]; + const now = Date.now(); + const todayStart = chinaDayStart(now); + const chinaNow = new Date(now + CHINA_OFFSET_MS); + const monthStart = Date.UTC(chinaNow.getUTCFullYear(), chinaNow.getUTCMonth(), 1) - CHINA_OFFSET_MS; + return [ + { key: "today", label: "今天", range: { endMs: todayStart + DAY_MS, startMs: todayStart } }, + { key: "7d", label: "近7天", range: { endMs: todayStart + DAY_MS, startMs: todayStart - 6 * DAY_MS } }, + { key: "30d", label: "近30天", range: { endMs: todayStart + DAY_MS, startMs: todayStart - 29 * DAY_MS } }, + { key: "month", label: "本月", range: { endMs: todayStart + DAY_MS, startMs: monthStart } }, + { key: "all", label: "不限", range: { endMs: "", startMs: "" } }, + ]; } function reconMonthMeta(offset) { - const chinaNow = new Date(Date.now() + CHINA_OFFSET_MS); - const year = chinaNow.getUTCFullYear(); - const month = chinaNow.getUTCMonth() + offset; - const startMs = Date.UTC(year, month, 1) - CHINA_OFFSET_MS; - const endMs = Date.UTC(year, month + 1, 1) - CHINA_OFFSET_MS; - const startDate = new Date(startMs + CHINA_OFFSET_MS); - return { - endMs, - isCurrent: offset === 0, - label: `${startDate.getUTCFullYear()}年${startDate.getUTCMonth() + 1}月`, - startMs - }; + const chinaNow = new Date(Date.now() + CHINA_OFFSET_MS); + const year = chinaNow.getUTCFullYear(); + const month = chinaNow.getUTCMonth() + offset; + const startMs = Date.UTC(year, month, 1) - CHINA_OFFSET_MS; + const endMs = Date.UTC(year, month + 1, 1) - CHINA_OFFSET_MS; + const startDate = new Date(startMs + CHINA_OFFSET_MS); + return { + endMs, + isCurrent: offset === 0, + label: `${startDate.getUTCFullYear()}年${startDate.getUTCMonth() + 1}月`, + startMs, + }; } export function FinanceApp() { - const { showToast } = useToast(); - const [sessionState, setSessionState] = useState({ error: "", loading: true, session: null }); - const [optionsState, setOptionsState] = useState({ data: emptyOptions, error: "", loading: false }); - const [rechargeDetailsState, setRechargeDetailsState] = useState({ data: { items: [], page: 1, pageSize, total: 0 }, error: "", loading: false }); - const [rechargeSummaryState, setRechargeSummaryState] = useState({ merged: EMPTY_RECHARGE_SUMMARY, loading: false, perApp: [], prev: null }); - const [rechargeOverviewState, setRechargeOverviewState] = useState({ data: EMPTY_RECHARGE_OVERVIEW, loading: false }); - const [rechargeRegionsState, setRechargeRegionsState] = useState({ appCode: "", items: [], loading: false }); - const [rechargeAppsState, setRechargeAppsState] = useState({ items: [], loaded: false }); - const [googlePaidRefreshing, setGooglePaidRefreshing] = useState(false); - const [reconMonthOffset, setReconMonthOffset] = useState(0); - const [reconState, setReconState] = useState({ loading: false, overview: EMPTY_RECHARGE_OVERVIEW, summary: EMPTY_RECHARGE_SUMMARY }); - const [myApplicationsState, setMyApplicationsState] = useState({ data: { items: [], page: 1, pageSize, total: 0 }, error: "", loading: false }); - const [applicationsState, setApplicationsState] = useState({ data: { items: [], page: 1, pageSize, total: 0 }, error: "", loading: false }); - const [withdrawalsState, setWithdrawalsState] = useState({ data: { items: [], page: 1, pageSize, total: 0 }, error: "", loading: false }); - const [activeView, setActiveView] = useState("create"); - const [rechargeDetailFilters, setRechargeDetailFilters] = useState(() => ({ - appCode: "", - currency: "usd", - keyword: "", - page: 1, - paidState: "", - rechargeType: "", - regionId: "", - timeRange: timePresets().find((preset) => preset.key === "30d").range - })); - const [rechargeExporting, setRechargeExporting] = useState(false); - const [myFilters, setMyFilters] = useState({ appCode: "", keyword: "", operation: "", page: 1, status: "" }); - const [filters, setFilters] = useState({ appCode: "", keyword: "", operation: "", page: 1, status: "pending" }); - const [withdrawalFilters, setWithdrawalFilters] = useState({ appCode: "", keyword: "", page: 1 }); - const [form, setForm] = useState(DEFAULT_APPLICATION_FORM); - const [createDialogOpen, setCreateDialogOpen] = useState(false); - const [submitting, setSubmitting] = useState(false); - const [auditTarget, setAuditTarget] = useState(null); - const [auditLoading, setAuditLoading] = useState(false); - const [withdrawalAuditTarget, setWithdrawalAuditTarget] = useState(null); - const [withdrawalAuditLoading, setWithdrawalAuditLoading] = useState(false); + const { showToast } = useToast(); + const [sessionState, setSessionState] = useState({ error: "", loading: true, session: null }); + const [optionsState, setOptionsState] = useState({ data: emptyOptions, error: "", loading: false }); + const [rechargeDetailsState, setRechargeDetailsState] = useState({ + data: { items: [], page: 1, pageSize, total: 0 }, + error: "", + loading: false, + }); + const [rechargeSummaryState, setRechargeSummaryState] = useState({ + merged: EMPTY_RECHARGE_SUMMARY, + loading: false, + perApp: [], + prev: null, + }); + const [rechargeOverviewState, setRechargeOverviewState] = useState({ + data: EMPTY_RECHARGE_OVERVIEW, + loading: false, + }); + const [rechargeRegionsState, setRechargeRegionsState] = useState({ byApp: {}, loading: false }); + const [rechargeAppsState, setRechargeAppsState] = useState({ items: [], loaded: false }); + const [googlePaidRefreshing, setGooglePaidRefreshing] = useState(false); + const [reconMonthOffset, setReconMonthOffset] = useState(0); + const [reconState, setReconState] = useState({ + loading: false, + overview: EMPTY_RECHARGE_OVERVIEW, + summary: EMPTY_RECHARGE_SUMMARY, + }); + const [myApplicationsState, setMyApplicationsState] = useState({ + data: { items: [], page: 1, pageSize, total: 0 }, + error: "", + loading: false, + }); + const [applicationsState, setApplicationsState] = useState({ + data: { items: [], page: 1, pageSize, total: 0 }, + error: "", + loading: false, + }); + const [withdrawalsState, setWithdrawalsState] = useState({ + data: { items: [], page: 1, pageSize, total: 0 }, + error: "", + loading: false, + }); + const [activeView, setActiveView] = useState("create"); + const [rechargeDetailFilters, setRechargeDetailFilters] = useState(() => ({ + appCode: "", + currency: "usd", + keyword: "", + page: 1, + paidState: "", + rechargeType: "", + regionId: "", + timeRange: timePresets().find((preset) => preset.key === "30d").range, + })); + const [rechargeExporting, setRechargeExporting] = useState(false); + const [myFilters, setMyFilters] = useState({ appCode: "", keyword: "", operation: "", page: 1, status: "" }); + const [filters, setFilters] = useState({ appCode: "", keyword: "", operation: "", page: 1, status: "pending" }); + const [withdrawalFilters, setWithdrawalFilters] = useState({ appCode: "", keyword: "", page: 1 }); + const [form, setForm] = useState(DEFAULT_APPLICATION_FORM); + const [createDialogOpen, setCreateDialogOpen] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [auditTarget, setAuditTarget] = useState(null); + const [auditLoading, setAuditLoading] = useState(false); + const [withdrawalAuditTarget, setWithdrawalAuditTarget] = useState(null); + const [withdrawalAuditLoading, setWithdrawalAuditLoading] = useState(false); - useEffect(() => { - let mounted = true; - fetchFinanceSession() - .then((session) => { - if (!mounted) { - return; + useEffect(() => { + let mounted = true; + fetchFinanceSession() + .then((session) => { + if (!mounted) { + return; + } + setSessionState({ error: "", loading: false, session }); + setActiveView( + session.canViewAppRechargeDetails + ? "overview" + : session.canAuditApplication + ? "applications" + : session.canCreateApplication + ? "create" + : "withdrawals", + ); + }) + .catch((err) => { + if (mounted) { + setSessionState({ error: err.message || "登录已失效", loading: false, session: null }); + } + }); + return () => { + mounted = false; + }; + }, []); + + const session = sessionState.session; + const canLoadOptions = Boolean(session?.canAccessFinance); + const canViewFinance = Boolean(session?.canViewAppRechargeDetails); + + // 财务三页(概览/流水/对账)共享同一套 App × 时间 × 计价 全局筛选。 + const rechargeBaseQuery = useMemo( + () => ({ + app_code: rechargeDetailFilters.appCode, + end_at_ms: rechargeDetailFilters.timeRange.endMs, + keyword: rechargeDetailFilters.keyword, + recharge_type: rechargeDetailFilters.rechargeType, + region_id: rechargeDetailFilters.appCode ? rechargeDetailFilters.regionId : "", + start_at_ms: rechargeDetailFilters.timeRange.startMs, + stat_tz: "Asia/Shanghai", + status: "succeeded", + }), + [rechargeDetailFilters], + ); + const rechargeDetailQuery = useMemo( + () => ({ + ...rechargeBaseQuery, + page: rechargeDetailFilters.page, + page_size: pageSize, + paid_state: rechargeDetailFilters.paidState, + }), + [rechargeBaseQuery, rechargeDetailFilters.page, rechargeDetailFilters.paidState], + ); + const myApplicationQuery = useMemo( + () => ({ + app_code: myFilters.appCode, + keyword: myFilters.keyword, + operation: myFilters.operation, + page: myFilters.page, + page_size: pageSize, + status: myFilters.status, + }), + [myFilters], + ); + const applicationQuery = useMemo( + () => ({ + app_code: filters.appCode, + keyword: filters.keyword, + operation: filters.operation, + page: filters.page, + page_size: pageSize, + status: filters.status, + }), + [filters], + ); + const withdrawalQuery = useMemo( + () => ({ + app_code: withdrawalFilters.appCode, + keyword: withdrawalFilters.keyword, + page: withdrawalFilters.page, + page_size: pageSize, + }), + [withdrawalFilters], + ); + + const loadOptions = useCallback(async () => { + if (!canLoadOptions) { + return; } - setSessionState({ error: "", loading: false, session }); - setActiveView( - session.canViewAppRechargeDetails - ? "overview" - : session.canAuditApplication - ? "applications" - : session.canCreateApplication - ? "create" - : "withdrawals" - ); - }) - .catch((err) => { - if (mounted) { - setSessionState({ error: err.message || "登录已失效", loading: false, session: null }); + setOptionsState((current) => ({ ...current, error: "", loading: true })); + try { + const data = await fetchFinanceApplicationOptions(); + setOptionsState({ data, error: "", loading: false }); + } catch (err) { + setOptionsState({ data: emptyOptions, error: err.message || "加载财务选项失败", loading: false }); + } + }, [canLoadOptions]); + + const loadMyApplications = useCallback(async () => { + if (!session?.canCreateApplication) { + return; + } + setMyApplicationsState((current) => ({ ...current, error: "", loading: true })); + try { + const data = await listMyFinanceApplications(myApplicationQuery); + setMyApplicationsState({ data, error: "", loading: false }); + } catch (err) { + setMyApplicationsState((current) => ({ + ...current, + error: err.message || "加载我的申请列表失败", + loading: false, + })); + } + }, [myApplicationQuery, session?.canCreateApplication]); + + // 充值详情的 App 目录来自 recharge-apps(含 Yumi/Aslan 等 legacy 外部账单源);接口未就绪时回退到财务通用 App 列表。 + const rechargeApps = useMemo( + () => + rechargeAppsState.loaded && rechargeAppsState.items.length + ? rechargeAppsState.items + : optionsState.data.apps, + [optionsState.data.apps, rechargeAppsState], + ); + + const loadRechargeApps = useCallback(async () => { + if (!canViewFinance) { + return; + } + try { + const items = await fetchFinanceRechargeApps(); + setRechargeAppsState({ items, loaded: true }); + } catch { + setRechargeAppsState({ items: [], loaded: false }); + } + }, [canViewFinance]); + + const loadRechargeDetails = useCallback(async () => { + if (!canViewFinance) { + return; + } + setRechargeDetailsState((current) => ({ ...current, error: "", loading: true })); + try { + const data = await listFinanceRechargeDetails(rechargeDetailQuery, rechargeApps); + setRechargeDetailsState({ data, error: "", loading: false }); + } catch (err) { + setRechargeDetailsState((current) => ({ + ...current, + error: err.message || "加载充值流水失败", + loading: false, + })); + } + }, [canViewFinance, rechargeApps, rechargeDetailQuery]); + + const loadRechargeSummary = useCallback(async () => { + if (!canViewFinance) { + return; + } + setRechargeSummaryState((current) => ({ ...current, loading: true })); + try { + const { merged, perApp } = await fetchFinanceRechargeSummaries(rechargeBaseQuery, rechargeApps); + // 环比:把当前时间范围整体前移一个周期再查一次;未选时间范围时不算环比。 + let prev = null; + const { endMs, startMs } = rechargeDetailFilters.timeRange; + if (startMs && endMs && endMs > startMs) { + const length = endMs - startMs; + const prevQuery = { ...rechargeBaseQuery, end_at_ms: startMs, start_at_ms: startMs - length }; + prev = (await fetchFinanceRechargeSummaries(prevQuery, rechargeApps)).merged; + } + setRechargeSummaryState({ merged, loading: false, perApp, prev }); + } catch { + setRechargeSummaryState((current) => ({ ...current, loading: false })); + } + }, [canViewFinance, rechargeApps, rechargeBaseQuery, rechargeDetailFilters.timeRange]); + + const loadRechargeOverview = useCallback(async () => { + if (!canViewFinance) { + return; + } + setRechargeOverviewState((current) => ({ ...current, loading: true })); + try { + const data = await fetchFinanceRechargeOverview(rechargeBaseQuery, rechargeApps); + setRechargeOverviewState({ data, loading: false }); + } catch { + setRechargeOverviewState((current) => ({ ...current, loading: false })); + } + }, [canViewFinance, rechargeApps, rechargeBaseQuery]); + + const loadRecon = useCallback(async () => { + if (!canViewFinance || activeView !== "recon") { + return; + } + const month = reconMonthMeta(reconMonthOffset); + const query = { + ...rechargeBaseQuery, + end_at_ms: month.endMs, + keyword: "", + recharge_type: "", + start_at_ms: month.startMs, + }; + setReconState((current) => ({ ...current, loading: true })); + try { + const [{ merged }, overview] = await Promise.all([ + fetchFinanceRechargeSummaries(query, rechargeApps), + fetchFinanceRechargeOverview(query, rechargeApps), + ]); + setReconState({ loading: false, overview, summary: merged }); + } catch { + setReconState((current) => ({ ...current, loading: false })); + } + }, [activeView, canViewFinance, rechargeApps, rechargeBaseQuery, reconMonthOffset]); + + const rechargeAppCode = rechargeDetailFilters.appCode; + // 一次拉齐所有 App 的区域目录:区域筛选下拉用当前 App 的,流水区域列在“全部 App”下要跨 App 显示名称。 + const loadRechargeRegions = useCallback(async () => { + if (!canViewFinance || !rechargeApps.length) { + return; + } + setRechargeRegionsState((current) => ({ ...current, loading: true })); + try { + const entries = await Promise.all( + rechargeApps.map(async (app) => { + try { + return [app.appCode, await listFinanceRechargeRegions(app.appCode)]; + } catch { + return [app.appCode, []]; + } + }), + ); + setRechargeRegionsState({ byApp: Object.fromEntries(entries), loading: false }); + } catch { + setRechargeRegionsState((current) => ({ ...current, loading: false })); + } + }, [canViewFinance, rechargeApps]); + + const refreshGooglePaid = useCallback( + async (transactionIds) => { + if (!rechargeAppCode || !transactionIds?.length || googlePaidRefreshing) { + return; + } + setGooglePaidRefreshing(true); + try { + const results = await refreshFinanceGoogleRechargePaid(rechargeAppCode, transactionIds); + const failed = results.filter((item) => item.error).length; + const succeeded = results.length - failed; + showToast( + failed + ? `谷歌实付同步完成:成功 ${succeeded} 笔,失败 ${failed} 笔` + : `谷歌实付同步完成:${succeeded} 笔`, + failed ? "warning" : "success", + ); + await Promise.all([loadRechargeDetails(), loadRechargeOverview()]); + } catch (err) { + showToast(err.message || "查询谷歌实付失败", "error"); + } finally { + setGooglePaidRefreshing(false); + } + }, + [googlePaidRefreshing, loadRechargeDetails, loadRechargeOverview, rechargeAppCode, showToast], + ); + + const exportRechargeBills = useCallback(async () => { + if (!rechargeAppCode || rechargeExporting) { + return; + } + setRechargeExporting(true); + try { + const timestamp = new Date() + .toLocaleString("sv-SE", { timeZone: "Asia/Shanghai" }) + .replaceAll(/[: ]/g, "-"); + await downloadCsv( + () => exportFinanceRechargeBills(rechargeAppCode, rechargeDetailQuery), + `充值明细-${rechargeAppCode}-${timestamp}.csv`, + ); + showToast("充值明细已导出", "success"); + } catch (err) { + showToast(err.message || "导出充值明细失败", "error"); + } finally { + setRechargeExporting(false); + } + }, [rechargeAppCode, rechargeDetailQuery, rechargeExporting, showToast]); + + const loadApplications = useCallback(async () => { + if (!session?.canAuditApplication) { + return; + } + setApplicationsState((current) => ({ ...current, error: "", loading: true })); + try { + const data = await listFinanceApplications(applicationQuery); + setApplicationsState({ data, error: "", loading: false }); + } catch (err) { + setApplicationsState((current) => ({ + ...current, + error: err.message || "加载申请列表失败", + loading: false, + })); + } + }, [applicationQuery, session?.canAuditApplication]); + + const loadWithdrawals = useCallback(async () => { + if (!session?.canViewWithdrawalApplications) { + return; + } + setWithdrawalsState((current) => ({ ...current, error: "", loading: true })); + try { + const data = await listFinanceWithdrawalApplications(withdrawalQuery); + setWithdrawalsState({ data, error: "", loading: false }); + } catch (err) { + setWithdrawalsState((current) => ({ + ...current, + error: err.message || "加载提现申请列表失败", + loading: false, + })); + } + }, [session?.canViewWithdrawalApplications, withdrawalQuery]); + + useEffect(() => { + loadOptions(); + }, [loadOptions]); + + useEffect(() => { + loadMyApplications(); + }, [loadMyApplications]); + + useEffect(() => { + loadRechargeApps(); + }, [loadRechargeApps]); + + useEffect(() => { + loadRechargeDetails(); + }, [loadRechargeDetails]); + + useEffect(() => { + loadRechargeSummary(); + }, [loadRechargeSummary]); + + useEffect(() => { + loadRechargeOverview(); + }, [loadRechargeOverview]); + + useEffect(() => { + loadRecon(); + }, [loadRecon]); + + useEffect(() => { + loadRechargeRegions(); + }, [loadRechargeRegions]); + + useEffect(() => { + loadApplications(); + }, [loadApplications]); + + useEffect(() => { + loadWithdrawals(); + }, [loadWithdrawals]); + + const operationOptions = useMemo( + () => filterOperationsByPermissions(optionsState.data.operations, session?.permissions || []), + [optionsState.data.operations, session?.permissions], + ); + + const submitApplication = async (event) => { + event.preventDefault(); + if (!session?.canCreateApplication) { + showToast("无发起财务申请权限", "error"); + return; + } + + const payload = buildApplicationPayload(form); + const validationMessage = validateApplicationPayload(payload); + if (validationMessage) { + showToast(validationMessage, "warning"); + return; + } + + setSubmitting(true); + try { + const result = await createFinanceApplication(cleanPayload(payload)); + setForm({ ...DEFAULT_APPLICATION_FORM, appCode: payload.appCode }); + setCreateDialogOpen(false); + showToast( + result?.dingTalkNotified === false ? "申请已提交,钉钉通知未确认" : "申请已提交并通知财务", + "success", + ); + await loadMyApplications(); + if (session.canAuditApplication) { + await loadApplications(); + } + } catch (err) { + showToast(err.message || "发起申请失败", "error"); + } finally { + setSubmitting(false); } - }); - return () => { - mounted = false; }; - }, []); - const session = sessionState.session; - const canLoadOptions = Boolean(session?.canAccessFinance); - const canViewFinance = Boolean(session?.canViewAppRechargeDetails); - - // 财务三页(概览/流水/对账)共享同一套 App × 时间 × 计价 全局筛选。 - const rechargeBaseQuery = useMemo( - () => ({ - app_code: rechargeDetailFilters.appCode, - end_at_ms: rechargeDetailFilters.timeRange.endMs, - keyword: rechargeDetailFilters.keyword, - recharge_type: rechargeDetailFilters.rechargeType, - region_id: rechargeDetailFilters.appCode ? rechargeDetailFilters.regionId : "", - start_at_ms: rechargeDetailFilters.timeRange.startMs, - stat_tz: "Asia/Shanghai", - status: "succeeded" - }), - [rechargeDetailFilters] - ); - const rechargeDetailQuery = useMemo( - () => ({ - ...rechargeBaseQuery, - page: rechargeDetailFilters.page, - page_size: pageSize, - paid_state: rechargeDetailFilters.paidState - }), - [rechargeBaseQuery, rechargeDetailFilters.page, rechargeDetailFilters.paidState] - ); - const myApplicationQuery = useMemo( - () => ({ - app_code: myFilters.appCode, - keyword: myFilters.keyword, - operation: myFilters.operation, - page: myFilters.page, - page_size: pageSize, - status: myFilters.status - }), - [myFilters] - ); - const applicationQuery = useMemo( - () => ({ - app_code: filters.appCode, - keyword: filters.keyword, - operation: filters.operation, - page: filters.page, - page_size: pageSize, - status: filters.status - }), - [filters] - ); - const withdrawalQuery = useMemo( - () => ({ - app_code: withdrawalFilters.appCode, - keyword: withdrawalFilters.keyword, - page: withdrawalFilters.page, - page_size: pageSize - }), - [withdrawalFilters] - ); - - const loadOptions = useCallback(async () => { - if (!canLoadOptions) { - return; - } - setOptionsState((current) => ({ ...current, error: "", loading: true })); - try { - const data = await fetchFinanceApplicationOptions(); - setOptionsState({ data, error: "", loading: false }); - } catch (err) { - setOptionsState({ data: emptyOptions, error: err.message || "加载财务选项失败", loading: false }); - } - }, [canLoadOptions]); - - const loadMyApplications = useCallback(async () => { - if (!session?.canCreateApplication) { - return; - } - setMyApplicationsState((current) => ({ ...current, error: "", loading: true })); - try { - const data = await listMyFinanceApplications(myApplicationQuery); - setMyApplicationsState({ data, error: "", loading: false }); - } catch (err) { - setMyApplicationsState((current) => ({ ...current, error: err.message || "加载我的申请列表失败", loading: false })); - } - }, [myApplicationQuery, session?.canCreateApplication]); - - // 充值详情的 App 目录来自 recharge-apps(含 Yumi/Aslan 等 legacy 外部账单源);接口未就绪时回退到财务通用 App 列表。 - const rechargeApps = useMemo( - () => (rechargeAppsState.loaded && rechargeAppsState.items.length ? rechargeAppsState.items : optionsState.data.apps), - [optionsState.data.apps, rechargeAppsState] - ); - - const loadRechargeApps = useCallback(async () => { - if (!canViewFinance) { - return; - } - try { - const items = await fetchFinanceRechargeApps(); - setRechargeAppsState({ items, loaded: true }); - } catch { - setRechargeAppsState({ items: [], loaded: false }); - } - }, [canViewFinance]); - - const loadRechargeDetails = useCallback(async () => { - if (!canViewFinance) { - return; - } - setRechargeDetailsState((current) => ({ ...current, error: "", loading: true })); - try { - const data = await listFinanceRechargeDetails(rechargeDetailQuery, rechargeApps); - setRechargeDetailsState({ data, error: "", loading: false }); - } catch (err) { - setRechargeDetailsState((current) => ({ ...current, error: err.message || "加载充值流水失败", loading: false })); - } - }, [canViewFinance, rechargeApps, rechargeDetailQuery]); - - const loadRechargeSummary = useCallback(async () => { - if (!canViewFinance) { - return; - } - setRechargeSummaryState((current) => ({ ...current, loading: true })); - try { - const { merged, perApp } = await fetchFinanceRechargeSummaries(rechargeBaseQuery, rechargeApps); - // 环比:把当前时间范围整体前移一个周期再查一次;未选时间范围时不算环比。 - let prev = null; - const { endMs, startMs } = rechargeDetailFilters.timeRange; - if (startMs && endMs && endMs > startMs) { - const length = endMs - startMs; - const prevQuery = { ...rechargeBaseQuery, end_at_ms: startMs, start_at_ms: startMs - length }; - prev = (await fetchFinanceRechargeSummaries(prevQuery, rechargeApps)).merged; - } - setRechargeSummaryState({ merged, loading: false, perApp, prev }); - } catch { - setRechargeSummaryState((current) => ({ ...current, loading: false })); - } - }, [canViewFinance, rechargeApps, rechargeBaseQuery, rechargeDetailFilters.timeRange]); - - const loadRechargeOverview = useCallback(async () => { - if (!canViewFinance) { - return; - } - setRechargeOverviewState((current) => ({ ...current, loading: true })); - try { - const data = await fetchFinanceRechargeOverview(rechargeBaseQuery, rechargeApps); - setRechargeOverviewState({ data, loading: false }); - } catch { - setRechargeOverviewState((current) => ({ ...current, loading: false })); - } - }, [canViewFinance, rechargeApps, rechargeBaseQuery]); - - const loadRecon = useCallback(async () => { - if (!canViewFinance || activeView !== "recon") { - return; - } - const month = reconMonthMeta(reconMonthOffset); - const query = { - ...rechargeBaseQuery, - end_at_ms: month.endMs, - keyword: "", - recharge_type: "", - start_at_ms: month.startMs + const submitAudit = async ({ decision, remark }) => { + if (!auditTarget?.id || !session?.canAuditApplication) { + return; + } + setAuditLoading(true); + try { + const submitter = decision === "approved" ? approveFinanceApplication : rejectFinanceApplication; + await submitter(auditTarget.id, { auditRemark: remark }); + showToast(decision === "approved" ? "申请已通过" : "申请已拒绝", "success"); + setAuditTarget(null); + await loadApplications(); + } catch (err) { + showToast(err.message || "审核失败", "error"); + } finally { + setAuditLoading(false); + } }; - setReconState((current) => ({ ...current, loading: true })); - try { - const [{ merged }, overview] = await Promise.all([ - fetchFinanceRechargeSummaries(query, rechargeApps), - fetchFinanceRechargeOverview(query, rechargeApps) - ]); - setReconState({ loading: false, overview, summary: merged }); - } catch { - setReconState((current) => ({ ...current, loading: false })); - } - }, [activeView, canViewFinance, rechargeApps, rechargeBaseQuery, reconMonthOffset]); - const rechargeAppCode = rechargeDetailFilters.appCode; - const loadRechargeRegions = useCallback(async () => { - if (!canViewFinance || !rechargeAppCode) { - setRechargeRegionsState({ appCode: "", items: [], loading: false }); - return; - } - setRechargeRegionsState((current) => ({ ...current, appCode: rechargeAppCode, loading: true })); - try { - const items = await listFinanceRechargeRegions(rechargeAppCode); - setRechargeRegionsState({ appCode: rechargeAppCode, items, loading: false }); - } catch { - setRechargeRegionsState({ appCode: rechargeAppCode, items: [], loading: false }); - } - }, [canViewFinance, rechargeAppCode]); + const submitWithdrawalAudit = async ({ auditImageUrl, decision, remark }) => { + if (!withdrawalAuditTarget?.id || !session?.canAuditWithdrawalApplications) { + return; + } + setWithdrawalAuditLoading(true); + try { + const submitter = + decision === "approved" ? approveFinanceWithdrawalApplication : rejectFinanceWithdrawalApplication; + await submitter(withdrawalAuditTarget.id, cleanPayload({ auditImageUrl, auditRemark: remark })); + showToast(decision === "approved" ? "提现申请已通过" : "提现申请已拒绝", "success"); + setWithdrawalAuditTarget(null); + await loadWithdrawals(); + } catch (err) { + showToast(err.message || "审核提现申请失败", "error"); + } finally { + setWithdrawalAuditLoading(false); + } + }; - const refreshGooglePaid = useCallback( - async (transactionIds) => { - if (!rechargeAppCode || !transactionIds?.length || googlePaidRefreshing) { - return; - } - setGooglePaidRefreshing(true); - try { - const results = await refreshFinanceGoogleRechargePaid(rechargeAppCode, transactionIds); - const failed = results.filter((item) => item.error).length; - const succeeded = results.length - failed; - showToast( - failed ? `谷歌实付同步完成:成功 ${succeeded} 笔,失败 ${failed} 笔` : `谷歌实付同步完成:${succeeded} 笔`, - failed ? "warning" : "success" - ); - await Promise.all([loadRechargeDetails(), loadRechargeOverview()]); - } catch (err) { - showToast(err.message || "查询谷歌实付失败", "error"); - } finally { - setGooglePaidRefreshing(false); - } - }, - [googlePaidRefreshing, loadRechargeDetails, loadRechargeOverview, rechargeAppCode, showToast] - ); + const updateFilters = (nextFilters) => { + setFilters((current) => ({ ...current, ...nextFilters, page: nextFilters.page || 1 })); + }; - const exportRechargeBills = useCallback(async () => { - if (!rechargeAppCode || rechargeExporting) { - return; - } - setRechargeExporting(true); - try { - const timestamp = new Date() - .toLocaleString("sv-SE", { timeZone: "Asia/Shanghai" }) - .replaceAll(/[: ]/g, "-"); - await downloadCsv( - () => exportFinanceRechargeBills(rechargeAppCode, rechargeDetailQuery), - `充值明细-${rechargeAppCode}-${timestamp}.csv` - ); - showToast("充值明细已导出", "success"); - } catch (err) { - showToast(err.message || "导出充值明细失败", "error"); - } finally { - setRechargeExporting(false); - } - }, [rechargeAppCode, rechargeDetailQuery, rechargeExporting, showToast]); + const updateRechargeDetailFilters = (nextFilters) => { + setRechargeDetailFilters((current) => ({ ...current, ...nextFilters, page: nextFilters.page || 1 })); + }; - const loadApplications = useCallback(async () => { - if (!session?.canAuditApplication) { - return; - } - setApplicationsState((current) => ({ ...current, error: "", loading: true })); - try { - const data = await listFinanceApplications(applicationQuery); - setApplicationsState({ data, error: "", loading: false }); - } catch (err) { - setApplicationsState((current) => ({ ...current, error: err.message || "加载申请列表失败", loading: false })); - } - }, [applicationQuery, session?.canAuditApplication]); + const updateMyFilters = (nextFilters) => { + setMyFilters((current) => ({ ...current, ...nextFilters, page: nextFilters.page || 1 })); + }; - const loadWithdrawals = useCallback(async () => { - if (!session?.canViewWithdrawalApplications) { - return; - } - setWithdrawalsState((current) => ({ ...current, error: "", loading: true })); - try { - const data = await listFinanceWithdrawalApplications(withdrawalQuery); - setWithdrawalsState({ data, error: "", loading: false }); - } catch (err) { - setWithdrawalsState((current) => ({ ...current, error: err.message || "加载提现申请列表失败", loading: false })); - } - }, [session?.canViewWithdrawalApplications, withdrawalQuery]); + const updateWithdrawalFilters = (nextFilters) => { + setWithdrawalFilters((current) => ({ ...current, ...nextFilters, page: nextFilters.page || 1 })); + }; - useEffect(() => { - loadOptions(); - }, [loadOptions]); + const closeCreateDialog = (_, reason) => { + if (submitting || reason === "backdropClick") { + return; + } + setCreateDialogOpen(false); + }; - useEffect(() => { - loadMyApplications(); - }, [loadMyApplications]); - - useEffect(() => { - loadRechargeApps(); - }, [loadRechargeApps]); - - useEffect(() => { - loadRechargeDetails(); - }, [loadRechargeDetails]); - - useEffect(() => { - loadRechargeSummary(); - }, [loadRechargeSummary]); - - useEffect(() => { - loadRechargeOverview(); - }, [loadRechargeOverview]); - - useEffect(() => { - loadRecon(); - }, [loadRecon]); - - useEffect(() => { - loadRechargeRegions(); - }, [loadRechargeRegions]); - - useEffect(() => { - loadApplications(); - }, [loadApplications]); - - useEffect(() => { - loadWithdrawals(); - }, [loadWithdrawals]); - - const operationOptions = useMemo( - () => filterOperationsByPermissions(optionsState.data.operations, session?.permissions || []), - [optionsState.data.operations, session?.permissions] - ); - - const submitApplication = async (event) => { - event.preventDefault(); - if (!session?.canCreateApplication) { - showToast("无发起财务申请权限", "error"); - return; + if (sessionState.loading) { + return ; } - const payload = buildApplicationPayload(form); - const validationMessage = validateApplicationPayload(payload); - if (validationMessage) { - showToast(validationMessage, "warning"); - return; + if (sessionState.error) { + return ; } - setSubmitting(true); - try { - const result = await createFinanceApplication(cleanPayload(payload)); - setForm({ ...DEFAULT_APPLICATION_FORM, appCode: payload.appCode }); - setCreateDialogOpen(false); - showToast(result?.dingTalkNotified === false ? "申请已提交,钉钉通知未确认" : "申请已提交并通知财务", "success"); - await loadMyApplications(); - if (session.canAuditApplication) { - await loadApplications(); - } - } catch (err) { - showToast(err.message || "发起申请失败", "error"); - } finally { - setSubmitting(false); + if (!session?.canAccessFinance) { + return ; } - }; - const submitAudit = async ({ decision, remark }) => { - if (!auditTarget?.id || !session?.canAuditApplication) { - return; - } - setAuditLoading(true); - try { - const submitter = decision === "approved" ? approveFinanceApplication : rejectFinanceApplication; - await submitter(auditTarget.id, { auditRemark: remark }); - showToast(decision === "approved" ? "申请已通过" : "申请已拒绝", "success"); - setAuditTarget(null); - await loadApplications(); - } catch (err) { - showToast(err.message || "审核失败", "error"); - } finally { - setAuditLoading(false); - } - }; + const presets = timePresets(); + const activePreset = presets.find( + (preset) => + String(preset.range.startMs || "") === String(rechargeDetailFilters.timeRange.startMs || "") && + String(preset.range.endMs || "") === String(rechargeDetailFilters.timeRange.endMs || ""), + ); + const isFinanceView = FINANCE_VIEWS.has(activeView); + const financeToolbar = + isFinanceView && session.canViewAppRechargeDetails ? ( + <> +
+ {presets.map((preset) => ( + + ))} +
+ updateRechargeDetailFilters({ timeRange })} + /> +
+ {[ + ["usd", "USD"], + ["coin", "金币"], + ].map(([value, label]) => ( + + ))} +
+ + ) : null; - const submitWithdrawalAudit = async ({ auditImageUrl, decision, remark }) => { - if (!withdrawalAuditTarget?.id || !session?.canAuditWithdrawalApplications) { - return; - } - setWithdrawalAuditLoading(true); - try { - const submitter = decision === "approved" ? approveFinanceWithdrawalApplication : rejectFinanceWithdrawalApplication; - await submitter(withdrawalAuditTarget.id, cleanPayload({ auditImageUrl, auditRemark: remark })); - showToast(decision === "approved" ? "提现申请已通过" : "提现申请已拒绝", "success"); - setWithdrawalAuditTarget(null); - await loadWithdrawals(); - } catch (err) { - showToast(err.message || "审核提现申请失败", "error"); - } finally { - setWithdrawalAuditLoading(false); - } - }; + const appBar = + isFinanceView && session.canViewAppRechargeDetails ? ( +
+ + {rechargeApps.map((app) => ( + + ))} +
+ ) : null; - const updateFilters = (nextFilters) => { - setFilters((current) => ({ ...current, ...nextFilters, page: nextFilters.page || 1 })); - }; - - const updateRechargeDetailFilters = (nextFilters) => { - setRechargeDetailFilters((current) => ({ ...current, ...nextFilters, page: nextFilters.page || 1 })); - }; - - const updateMyFilters = (nextFilters) => { - setMyFilters((current) => ({ ...current, ...nextFilters, page: nextFilters.page || 1 })); - }; - - const updateWithdrawalFilters = (nextFilters) => { - setWithdrawalFilters((current) => ({ ...current, ...nextFilters, page: nextFilters.page || 1 })); - }; - - const closeCreateDialog = (_, reason) => { - if (submitting || reason === "backdropClick") { - return; - } - setCreateDialogOpen(false); - }; - - if (sessionState.loading) { - return ; - } - - if (sessionState.error) { - return ; - } - - if (!session?.canAccessFinance) { - return ; - } - - const presets = timePresets(); - const activePreset = presets.find( - (preset) => - String(preset.range.startMs || "") === String(rechargeDetailFilters.timeRange.startMs || "") && - String(preset.range.endMs || "") === String(rechargeDetailFilters.timeRange.endMs || "") - ); - const isFinanceView = FINANCE_VIEWS.has(activeView); - const financeToolbar = - isFinanceView && session.canViewAppRechargeDetails ? ( - <> -
- {presets.map((preset) => ( - - ))} -
- updateRechargeDetailFilters({ timeRange })} - /> -
- {[ - ["usd", "USD"], - ["coin", "金币"] - ].map(([value, label]) => ( - - ))} -
- - ) : null; - - const appBar = - isFinanceView && session.canViewAppRechargeDetails ? ( -
- - {rechargeApps.map((app) => ( - - ))} -
- ) : null; - - return ( - - {appBar} - {activeView === "overview" && session.canViewAppRechargeDetails ? ( - { - updateRechargeDetailFilters({ paidState: "unsynced", rechargeType: "" }); - setActiveView("rechargeDetails"); - }} - onOpenApp={(appCode) => { - updateRechargeDetailFilters({ appCode, regionId: "" }); - setActiveView("rechargeDetails"); - }} - /> - ) : activeView === "recon" && session.canViewAppRechargeDetails ? ( - setReconMonthOffset((current) => Math.min(0, current + delta))} - /> - ) : activeView === "rechargeDetails" && session.canViewAppRechargeDetails ? ( - { - loadRechargeDetails(); - loadRechargeSummary(); - loadRechargeOverview(); - }} - /> - ) : activeView === "applications" && session.canAuditApplication ? ( - - ) : activeView === "withdrawals" && session.canViewWithdrawalApplications ? ( - - ) : ( - setCreateDialogOpen(true)} - onFiltersChange={updateMyFilters} - onReload={loadMyApplications} - /> - )} - - 发起申请 - - - - - setAuditTarget(null)} - onSubmit={submitAudit} - /> - setWithdrawalAuditTarget(null)} - onSubmit={submitWithdrawalAudit} - /> - - ); + {appBar} + {activeView === "overview" && session.canViewAppRechargeDetails ? ( + { + updateRechargeDetailFilters({ paidState: "unsynced", rechargeType: "" }); + setActiveView("rechargeDetails"); + }} + onOpenApp={(appCode) => { + updateRechargeDetailFilters({ appCode, regionId: "" }); + setActiveView("rechargeDetails"); + }} + /> + ) : activeView === "recon" && session.canViewAppRechargeDetails ? ( + setReconMonthOffset((current) => Math.min(0, current + delta))} + /> + ) : activeView === "rechargeDetails" && session.canViewAppRechargeDetails ? ( + { + loadRechargeDetails(); + loadRechargeSummary(); + loadRechargeOverview(); + }} + /> + ) : activeView === "applications" && session.canAuditApplication ? ( + + ) : activeView === "withdrawals" && session.canViewWithdrawalApplications ? ( + + ) : ( + setCreateDialogOpen(true)} + onFiltersChange={updateMyFilters} + onReload={loadMyApplications} + /> + )} + + 发起申请 + + + + + setAuditTarget(null)} + onSubmit={submitAudit} + /> + setWithdrawalAuditTarget(null)} + onSubmit={submitWithdrawalAudit} + /> + + ); } function StateScreen({ actionHref = "", actionText = "", title }) { - return ( -
-
-

{title}

- {actionHref ? {actionText} : null} -
-
- ); + return ( +
+
+

{title}

+ {actionHref ? {actionText} : null} +
+
+ ); } function cleanPayload(payload) { - return Object.fromEntries(Object.entries(payload).filter(([, value]) => value !== "")); + return Object.fromEntries(Object.entries(payload).filter(([, value]) => value !== "")); } diff --git a/finance/src/components/FinanceRechargeDetailList.jsx b/finance/src/components/FinanceRechargeDetailList.jsx index 06b44f3..9e0676b 100644 --- a/finance/src/components/FinanceRechargeDetailList.jsx +++ b/finance/src/components/FinanceRechargeDetailList.jsx @@ -37,6 +37,7 @@ export function FinanceRechargeDetailList({ onRefreshGooglePaid, onReload, regions, + regionsByApp, summary, }) { const [expandedId, setExpandedId] = useState(""); @@ -231,7 +232,7 @@ export function FinanceRechargeDetailList({ {taxDeductionText(item)} - {regionText(item, regionNames)} + {regionText(item, regionsByApp, apps, !filters.appCode)} {expanded ? ( @@ -445,12 +446,15 @@ function appName(appCode, apps) { return (apps || []).find((app) => app.appCode === appCode)?.appName || appCode || "-"; } -function regionText(item, regionNames) { +// regionText 解析区域列:全部 App 视图下带上 App 前缀(如 “Lalu-南亚区”),单 App 视图只显示区域名。 +function regionText(item, regionsByApp, apps, withAppPrefix) { const regionId = item.targetRegionId || item.sellerRegionId; if (!regionId) { return "—"; } - return regionNames.get(String(regionId)) || `区域 ${regionId}`; + const appRegions = (regionsByApp || {})[item.appCode] || []; + const name = appRegions.find((region) => String(region.regionId) === String(regionId))?.name || `区域 ${regionId}`; + return withAppPrefix ? `${appName(item.appCode, apps)}-${name}` : name; } function secondaryOrderNo(item) {