1105 lines
42 KiB
JavaScript
1105 lines
42 KiB
JavaScript
import { Fragment, useCallback, useEffect, useMemo, useState } from "react";
|
||
import { fetchPlatformGrantRecords, fetchPlatformGrantUsers } from "../api.js";
|
||
import { EChart } from "../charts/EChart.jsx";
|
||
import { createReportRevenueOption } from "../charts/options/createReportRevenueOption.js";
|
||
import { formatCoin, formatMoneyFull, formatPercent } from "../utils/format.js";
|
||
|
||
const textCollator = new Intl.Collator("zh-Hans", { numeric: true, sensitivity: "base" });
|
||
|
||
const reportTabs = [
|
||
{ key: "country", label: "国家经营报表" },
|
||
{ key: "revenue", label: "收入趋势" },
|
||
{ key: "gift", label: "礼物消费" },
|
||
{ key: "lucky", label: "幸运礼物" },
|
||
{ key: "game", label: "游戏流水" },
|
||
{ key: "robot", label: "机器人送礼" }
|
||
];
|
||
|
||
const platformGrantRecordSourceOptions = [
|
||
{ label: "全部来源", value: "all" },
|
||
{ label: "任务奖励", value: "WalletTaskRewardCredited" },
|
||
{ label: "房间流水奖励", value: "WalletRoomTurnoverRewardCredited" },
|
||
{ label: "邀请活动奖励", value: "WalletInviteActivityRewardCredited" },
|
||
{ label: "代理开通奖励", value: "WalletAgencyOpeningRewardCredited" },
|
||
{ label: "资源发放", value: "WalletBalanceChanged" },
|
||
{ label: "平台发放", value: "WalletPlatformGrantCoinCredited" }
|
||
];
|
||
|
||
const countryColumns = [
|
||
{ align: "left", key: "date_label", label: "日期", render: (row) => <DateCell row={row} />, sortable: false, stickyLeft: true },
|
||
{ align: "left", key: "country", label: "国家", render: (row) => <CountryCell row={row} />, sortable: false },
|
||
{ key: "registrations", label: "注册", render: (row) => formatOptionalCoin(row.registrations), sortable: false },
|
||
{ key: "active_users", label: "活跃用户", render: (row) => formatOptionalCoin(row.active_users), sortable: false },
|
||
{ key: "paid_users", label: "付费用户", render: (row) => formatOptionalCoin(row.paid_users), sortable: false },
|
||
{ key: "recharge_users", label: "充值用户", render: (row) => formatOptionalCoin(row.recharge_users), sortable: false },
|
||
{ key: "recharge_usd_minor", label: "总充值", render: (row) => formatOptionalMoney(row.recharge_usd_minor), sortable: false },
|
||
{ key: "new_user_recharge_usd_minor", label: "新用户充值", render: (row) => formatOptionalMoney(row.new_user_recharge_usd_minor), sortable: false },
|
||
{ key: "coin_seller_recharge_usd_minor", label: "币商充值", render: (row) => formatOptionalMoney(row.coin_seller_recharge_usd_minor), sortable: false },
|
||
{ key: "coin_seller_stock_coin", label: "币商充值金币", render: (row) => formatOptionalCoin(row.coin_seller_stock_coin), sortable: false },
|
||
{ key: "coin_seller_transfer_coin", label: "币商出货金币", render: (row) => formatOptionalCoin(row.coin_seller_transfer_coin), sortable: false },
|
||
{ key: "google_recharge_usd_minor", label: "谷歌充值", render: (row) => formatOptionalMoney(row.google_recharge_usd_minor), sortable: false },
|
||
{ key: "mifapay_recharge_usd_minor", label: "三方充值", render: (row) => formatOptionalMoney(row.mifapay_recharge_usd_minor), sortable: false },
|
||
{ key: "game_turnover", label: "游戏流水/利润率", render: (row) => <FlowRateCell rate={row.game_profit_rate} value={row.game_turnover} />, sortable: false },
|
||
{ key: "lucky_gift_turnover", label: "幸运礼物流水/利润率", render: (row) => <FlowRateCell rate={row.lucky_gift_profit_rate} value={row.lucky_gift_turnover} />, sortable: false },
|
||
{ key: "super_lucky_gift_turnover", label: "超级幸运礼物流水/利润率", render: (row) => <FlowRateCell rate={row.super_lucky_gift_profit_rate} value={row.super_lucky_gift_turnover} />, sortable: false },
|
||
{ key: "gift_coin_spent", label: "礼物流水", render: (row) => formatOptionalCoin(row.gift_coin_spent), sortable: false },
|
||
{ key: "coin_total", label: "用户金币数", render: (row) => formatOptionalCoin(row.coin_total), sortable: false },
|
||
{ key: "consumed_coin", label: "消耗金币", render: (row) => formatOptionalCoin(row.consumed_coin), sortable: false },
|
||
{ key: "output_coin", label: "产出金币", render: (row) => formatOptionalCoin(row.output_coin), sortable: false },
|
||
{ key: "consume_output_ratio", label: "消耗产出比", render: (row) => <ConsumeOutputCell row={row} />, sortable: false },
|
||
{ key: "platform_grant_coin", label: "平台发放金币", render: (row, context) => <PlatformGrantCoinCell context={context} row={row} />, sortable: false },
|
||
{ key: "manual_grant_coin", label: "人工发放金币", render: (row) => formatOptionalCoin(row.manual_grant_coin), sortable: false },
|
||
{ key: "salary_usd_minor", label: "工资总和(主播/代理)", render: (row) => formatOptionalMoney(row.salary_usd_minor), sortable: false },
|
||
{ key: "salary_transfer_coin", label: "工资兑换金币", render: (row) => formatOptionalCoin(row.salary_transfer_coin), sortable: false },
|
||
{ key: "avg_mic_online_ms", label: "平均麦上时间", render: (row) => formatDuration(row.avg_mic_online_ms), sortable: false },
|
||
{ key: "mic_online_ms", label: "麦上总时长", render: (row) => formatDuration(row.mic_online_ms), sortable: false },
|
||
{ key: "arpu_usd_minor", label: "ARPU", render: (row) => formatOptionalMoney(row.arpu_usd_minor), sortable: false },
|
||
{ key: "arppu_usd_minor", label: "ARPPU", render: (row) => formatOptionalMoney(row.arppu_usd_minor), sortable: false },
|
||
{ key: "payer_rate", label: "付费转化率", render: (row) => formatOptionalPercent(row.payer_rate), sortable: false },
|
||
{ key: "recharge_conversion_rate", label: "充值转化率", render: (row) => formatOptionalPercent(row.recharge_conversion_rate), sortable: false }
|
||
];
|
||
|
||
const countryDetailColumns = countryColumns.filter((column) => column.key !== "date_label");
|
||
|
||
const revenueColumns = [
|
||
{ align: "left", key: "label", label: "日期", render: (row) => row.label, sortValue: (row) => row.label, type: "text" },
|
||
{ key: "google", label: "Google", render: (row) => formatMoneyFull(row.google) },
|
||
{ key: "mifapay", label: "MifaPay", render: (row) => formatMoneyFull(row.mifapay) },
|
||
{ key: "coin_seller", label: "币商", render: (row) => formatMoneyFull(row.coin_seller) },
|
||
{ key: "total", label: "渠道合计", render: (row) => formatMoneyFull(row.total) },
|
||
{ key: "lineTotal", label: "总计", render: (row) => formatMoneyFull(row.lineTotal ?? row.total) }
|
||
];
|
||
|
||
const giftColumns = [
|
||
{ align: "left", key: "name", label: "礼物", render: (row) => row.name, sortValue: (row) => row.name, type: "text" },
|
||
{ key: "value", label: "金币消费", render: (row) => formatCoin(row.value) },
|
||
{ key: "share", label: "占比", render: (row) => formatPercent(row.share) },
|
||
{ key: "trend_rate", label: "近 7 日", render: (row) => <TrendValue value={row.trend_rate} /> }
|
||
];
|
||
|
||
const luckyColumns = [
|
||
{ align: "left", key: "pool_id", label: "奖池", render: (row) => row.pool_id, sortValue: (row) => row.pool_id, type: "text" },
|
||
{ key: "turnover", label: "流水", render: (row) => formatCoin(row.turnover) },
|
||
{ key: "payout", label: "返奖", render: (row) => formatCoin(row.payout) },
|
||
{ key: "profit", label: "利润", render: (row) => formatCoin(row.profit) },
|
||
{ key: "payout_rate", label: "返奖率", render: (row) => formatPercent(row.payout_rate) },
|
||
{ key: "profit_rate", label: "利润率", render: (row) => formatPercent(row.profit_rate) }
|
||
];
|
||
|
||
const gameColumns = [
|
||
{ align: "left", key: "game_id", label: "游戏", render: (row) => row.game_label || row.game_id, sortValue: (row) => row.game_label || row.game_id, type: "text" },
|
||
{ key: "turnover_coin", label: "流水 (金币)", render: (row) => formatCoin(row.turnover_coin) },
|
||
{ key: "profit_rate", label: "利润率", render: (row) => formatPercent(row.profit_rate) },
|
||
{ key: "share", label: "占比", render: (row) => formatPercent(row.share) }
|
||
];
|
||
|
||
const metricColumns = [
|
||
{ align: "left", key: "label", label: "指标", render: (row) => row.label, sortValue: (row) => row.label, type: "text" },
|
||
{ key: "value", label: "数值", render: (row) => row.value, sortable: false },
|
||
{ key: "unit", label: "单位", render: (row) => row.unit || "--", sortable: false },
|
||
{ key: "caption", label: "对比口径", render: (row) => row.caption || "--", sortable: false },
|
||
{ key: "delta", label: "变化", render: (row) => <DeltaText value={row.delta} />, sortable: false }
|
||
];
|
||
|
||
export function ReportOverview({ grantDrilldownQuery, loading = false, model, onMetricTrend, onOpenLuckyGiftPools }) {
|
||
const [activeTab, setActiveTab] = useState("country");
|
||
const summaryCards = useMemo(() => createSummaryCards(model), [model]);
|
||
|
||
return (
|
||
<section className="databi-report" aria-label="数据报表">
|
||
<section className="report-metric-grid" aria-label="核心经营指标">
|
||
{summaryCards.map((card) => (
|
||
<ReportMetricCard card={card} key={card.title} loading={loading} onOpenTrend={onMetricTrend} />
|
||
))}
|
||
</section>
|
||
|
||
<section className="report-workbench">
|
||
<div className="report-tabs" role="tablist" aria-label="报表切换">
|
||
{reportTabs.map((tab) => (
|
||
<button
|
||
aria-selected={activeTab === tab.key}
|
||
className={activeTab === tab.key ? "is-active" : ""}
|
||
key={tab.key}
|
||
role="tab"
|
||
type="button"
|
||
onClick={() => setActiveTab(tab.key)}
|
||
>
|
||
{tab.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div className="report-tab-panel" role="tabpanel">
|
||
{activeTab === "country" ? <CountryReport grantDrilldownQuery={grantDrilldownQuery} loading={loading} rows={model.reportCountryRows} /> : null}
|
||
{activeTab === "revenue" ? <RevenueReport loading={loading} rows={model.revenueSeries} /> : null}
|
||
{activeTab === "gift" ? <GiftReport loading={loading} metrics={model.businessKpis} rows={model.giftRanking} /> : null}
|
||
{activeTab === "lucky" ? <LuckyReport loading={loading} metrics={model.businessKpis} onOpenLuckyGiftPools={onOpenLuckyGiftPools} rows={model.luckyGiftPools} /> : null}
|
||
{activeTab === "game" ? <GameReport loading={loading} metrics={model.businessKpis} rows={model.gameRanking} /> : null}
|
||
{activeTab === "robot" ? <RobotReport loading={loading} rows={model.robotGiftKpis} /> : null}
|
||
</div>
|
||
</section>
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function ReportMetricCard({ card, loading, onOpenTrend }) {
|
||
const clickable = Boolean(card.trendMetric?.trend?.length && onOpenTrend);
|
||
if (loading) {
|
||
return (
|
||
<article className="report-metric-card is-loading" aria-busy="true">
|
||
<span className="skeleton-block report-skeleton-title" />
|
||
<span className="skeleton-block report-skeleton-value" />
|
||
<span className="skeleton-block report-skeleton-line" />
|
||
<span className="skeleton-block report-skeleton-line report-skeleton-line--short" />
|
||
</article>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<article
|
||
className={["report-metric-card", clickable ? "report-metric-card--clickable" : ""].filter(Boolean).join(" ")}
|
||
role={clickable ? "button" : undefined}
|
||
tabIndex={clickable ? 0 : undefined}
|
||
onClick={clickable ? () => onOpenTrend(card.trendMetric) : undefined}
|
||
onKeyDown={clickable ? (event) => {
|
||
if (event.key === "Enter" || event.key === " ") {
|
||
event.preventDefault();
|
||
onOpenTrend(card.trendMetric);
|
||
}
|
||
} : undefined}
|
||
>
|
||
<div className="report-metric-head">
|
||
<span>{card.title}</span>
|
||
{card.delta ? <DeltaText value={card.delta} /> : null}
|
||
</div>
|
||
<strong>{card.value}</strong>
|
||
<div className="report-metric-detail-grid">
|
||
{card.details.filter((detail) => detail.label !== card.title).map((detail) => (
|
||
<span className="report-metric-detail" key={detail.label}>
|
||
<small>{detail.label}</small>
|
||
<b>{detail.value}</b>
|
||
</span>
|
||
))}
|
||
</div>
|
||
</article>
|
||
);
|
||
}
|
||
|
||
function CountryReport({ grantDrilldownQuery, loading, rows }) {
|
||
const [expandedRowIds, setExpandedRowIds] = useState(() => new Set());
|
||
const [grantCountryModal, setGrantCountryModal] = useState(null);
|
||
const [grantUsersModal, setGrantUsersModal] = useState(null);
|
||
const [grantRecordsModal, setGrantRecordsModal] = useState(null);
|
||
const toggleRow = useCallback((rowId) => {
|
||
setExpandedRowIds((current) => {
|
||
const next = new Set(current);
|
||
if (next.has(rowId)) {
|
||
next.delete(rowId);
|
||
} else {
|
||
next.add(rowId);
|
||
}
|
||
return next;
|
||
});
|
||
}, []);
|
||
const tableRows = useMemo(() => rows.map((row) => {
|
||
const children = Array.isArray(row.children) ? row.children : [];
|
||
if (row.row_type !== "date_total" || !children.length) {
|
||
return row;
|
||
}
|
||
return {
|
||
...row,
|
||
expanded: expandedRowIds.has(row.row_id),
|
||
onToggle: () => toggleRow(row.row_id)
|
||
};
|
||
}), [expandedRowIds, rows, toggleRow]);
|
||
const openGrantUsers = useCallback((row) => {
|
||
setGrantCountryModal(null);
|
||
setGrantRecordsModal(null);
|
||
setGrantUsersModal({
|
||
countryLabel: row.country || "国家",
|
||
query: buildPlatformGrantQuery(grantDrilldownQuery, row)
|
||
});
|
||
}, [grantDrilldownQuery]);
|
||
const openGrantCountries = useCallback((row) => {
|
||
const countryRows = platformGrantCountryRows(row, rows);
|
||
if (!countryRows.length) {
|
||
return;
|
||
}
|
||
setGrantRecordsModal(null);
|
||
setGrantUsersModal(null);
|
||
setGrantCountryModal({
|
||
rows: countryRows,
|
||
title: row.row_type === "date_total" ? `${row.date_label} 平台发放金币` : "平台发放金币"
|
||
});
|
||
}, [rows]);
|
||
const grantContext = useMemo(() => ({
|
||
onOpenPlatformGrantCountries: openGrantCountries,
|
||
onOpenPlatformGrantUsers: openGrantUsers
|
||
}), [openGrantCountries, openGrantUsers]);
|
||
|
||
return (
|
||
<ReportPanel>
|
||
<ReportTable
|
||
columns={countryColumns}
|
||
context={grantContext}
|
||
loading={loading}
|
||
preserveOrder
|
||
renderExpandedRow={(row) => <CountryDetailTable context={grantContext} dateLabel={row.date_label} rows={row.children || []} />}
|
||
rows={tableRows}
|
||
wide
|
||
/>
|
||
{grantCountryModal ? (
|
||
<PlatformGrantCountryModal
|
||
modal={grantCountryModal}
|
||
onClose={() => setGrantCountryModal(null)}
|
||
onOpenUsers={openGrantUsers}
|
||
/>
|
||
) : null}
|
||
{grantUsersModal ? (
|
||
<PlatformGrantUsersModal
|
||
modal={grantUsersModal}
|
||
onClose={() => setGrantUsersModal(null)}
|
||
onOpenRecords={(userRow) => setGrantRecordsModal({
|
||
countryLabel: grantUsersModal.countryLabel,
|
||
query: { ...grantUsersModal.query, userId: userRow.user_id },
|
||
user: userRow
|
||
})}
|
||
/>
|
||
) : null}
|
||
{grantRecordsModal ? (
|
||
<PlatformGrantRecordsModal
|
||
modal={grantRecordsModal}
|
||
onClose={() => setGrantRecordsModal(null)}
|
||
/>
|
||
) : null}
|
||
</ReportPanel>
|
||
);
|
||
}
|
||
|
||
function RevenueReport({ loading, rows }) {
|
||
return (
|
||
<ReportPanel>
|
||
{loading ? (
|
||
<div className="report-chart-skeleton" aria-busy="true">
|
||
<span className="skeleton-block report-skeleton-line" />
|
||
<span className="skeleton-block report-skeleton-chart" />
|
||
</div>
|
||
) : rows.length ? (
|
||
<EChart className="report-revenue-chart" option={createReportRevenueOption(rows)} />
|
||
) : (
|
||
<div className="report-empty">当前无数据</div>
|
||
)}
|
||
<ReportTable columns={revenueColumns} defaultSort={{ direction: "asc", key: "label", type: "text" }} loading={loading} rows={rows} />
|
||
</ReportPanel>
|
||
);
|
||
}
|
||
|
||
function GiftReport({ loading, metrics, rows }) {
|
||
const total = rows.reduce((sum, row) => sum + Number(row.value || 0), 0) || 1;
|
||
const giftRows = rows.map((row) => ({ ...row, share: Number(row.value || 0) / total }));
|
||
return (
|
||
<ReportPanel>
|
||
<ReportMetricTable loading={loading} rows={metrics.filter((item) => item.label.includes("礼物") || item.label.includes("币商"))} />
|
||
<ReportTable columns={giftColumns} defaultSort={{ direction: "desc", key: "value", type: "number" }} loading={loading} rows={giftRows} />
|
||
</ReportPanel>
|
||
);
|
||
}
|
||
|
||
function LuckyReport({ loading, metrics, onOpenLuckyGiftPools, rows }) {
|
||
return (
|
||
<ReportPanel
|
||
action={(
|
||
<button className="report-panel-action" disabled={!rows.length} type="button" onClick={onOpenLuckyGiftPools}>
|
||
查看奖池明细
|
||
</button>
|
||
)}
|
||
>
|
||
<ReportMetricTable loading={loading} rows={metrics.filter((item) => item.label.includes("幸运礼物"))} />
|
||
<ReportTable columns={luckyColumns} defaultSort={{ direction: "desc", key: "turnover", type: "number" }} loading={loading} rows={rows} />
|
||
</ReportPanel>
|
||
);
|
||
}
|
||
|
||
function GameReport({ loading, metrics, rows }) {
|
||
const total = rows.reduce((sum, row) => sum + Number(row.turnover_coin || 0), 0) || 1;
|
||
const gameRows = rows.map((row) => ({ ...row, share: Number(row.turnover_coin || 0) / total }));
|
||
return (
|
||
<ReportPanel>
|
||
<ReportMetricTable loading={loading} rows={metrics.filter((item) => item.label.includes("游戏"))} />
|
||
<ReportTable columns={gameColumns} defaultSort={{ direction: "desc", key: "turnover_coin", type: "number" }} loading={loading} rows={gameRows} />
|
||
</ReportPanel>
|
||
);
|
||
}
|
||
|
||
function RobotReport({ loading, rows }) {
|
||
return (
|
||
<ReportPanel>
|
||
<ReportMetricTable loading={loading} rows={rows} />
|
||
</ReportPanel>
|
||
);
|
||
}
|
||
|
||
function ReportPanel({ action, children }) {
|
||
return (
|
||
<section className="report-panel">
|
||
{action ? <div className="report-panel-head">{action}</div> : null}
|
||
{children}
|
||
</section>
|
||
);
|
||
}
|
||
|
||
function ReportMetricTable({ loading, rows }) {
|
||
return (
|
||
<div className="report-metric-table-block">
|
||
<ReportTable columns={metricColumns} loading={loading} rows={rows} />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ReportTable({ columns, context, defaultSort, loading, preserveOrder = false, renderExpandedRow, rows, wide = false }) {
|
||
const sortableDefault = defaultSort || firstSortableColumn(columns);
|
||
const [sortState, setSortState] = useState(sortableDefault);
|
||
const sortedRows = useMemo(() => preserveOrder ? rows : sortRows(rows, sortState, columns), [columns, preserveOrder, rows, sortState]);
|
||
|
||
const handleSort = (column) => {
|
||
if (preserveOrder || column.sortable === false) {
|
||
return;
|
||
}
|
||
setSortState((current) => ({
|
||
direction: current?.key === column.key && current.direction === "desc" ? "asc" : "desc",
|
||
key: column.key,
|
||
type: column.type || "number"
|
||
}));
|
||
};
|
||
|
||
return (
|
||
<div className="report-table-wrap">
|
||
<table className={["report-table", wide ? "report-table--wide" : ""].filter(Boolean).join(" ")}>
|
||
<thead>
|
||
<tr>
|
||
<th>#</th>
|
||
{columns.map((column) => {
|
||
const isSortable = !preserveOrder && column.sortable !== false;
|
||
const isActive = sortState?.key === column.key;
|
||
const headerClassName = [
|
||
isSortable ? "is-sortable" : "",
|
||
column.stickyLeft ? "is-sticky-left" : ""
|
||
].filter(Boolean).join(" ");
|
||
return (
|
||
<th className={headerClassName} key={column.key}>
|
||
{isSortable ? (
|
||
<button
|
||
className={["report-sort-button", column.align === "left" ? "report-sort-button--left" : "", isActive ? "is-active" : ""].filter(Boolean).join(" ")}
|
||
type="button"
|
||
onClick={() => handleSort(column)}
|
||
>
|
||
<span>{column.label}</span>
|
||
<i>{isActive ? (sortState.direction === "asc" ? "↑" : "↓") : "↕"}</i>
|
||
</button>
|
||
) : (
|
||
column.label
|
||
)}
|
||
</th>
|
||
);
|
||
})}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{loading ? <ReportSkeletonRows colSpan={columns.length + 1} /> : null}
|
||
{!loading && !rows.length ? <ReportEmptyRow colSpan={columns.length + 1} /> : null}
|
||
{!loading && sortedRows.map((row, index) => {
|
||
const key = reportRowKey(row, index);
|
||
const rowProps = reportTableRowProps(row);
|
||
const { className: rowClassName, ...interactiveRowProps } = rowProps;
|
||
return (
|
||
<Fragment key={key}>
|
||
<tr
|
||
{...interactiveRowProps}
|
||
className={[reportRowClass(row), rowClassName].filter(Boolean).join(" ")}
|
||
>
|
||
<td>{reportRowIndex(row, index)}</td>
|
||
{columns.map((column) => (
|
||
<td
|
||
className={[
|
||
column.align === "left" ? "is-left" : "",
|
||
column.stickyLeft ? "is-sticky-left" : ""
|
||
].filter(Boolean).join(" ")}
|
||
key={column.key}
|
||
>
|
||
{column.render ? column.render(row, context) : row[column.key]}
|
||
</td>
|
||
))}
|
||
</tr>
|
||
{row.expanded && renderExpandedRow ? (
|
||
<tr className="report-row-expanded-detail">
|
||
<td colSpan={columns.length + 1}>{renderExpandedRow(row)}</td>
|
||
</tr>
|
||
) : null}
|
||
</Fragment>
|
||
);
|
||
})}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CountryDetailTable({ context, dateLabel, rows }) {
|
||
if (!rows.length) {
|
||
return <div className="report-subtable-empty">当前无数据</div>;
|
||
}
|
||
return (
|
||
<div className="report-subtable-wrap">
|
||
<table aria-label={`${dateLabel || "日期"} 国家明细`} className="report-table report-subtable">
|
||
<thead>
|
||
<tr>
|
||
<th>#</th>
|
||
{countryDetailColumns.map((column) => (
|
||
<th key={column.key}>{column.label}</th>
|
||
))}
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{rows.map((row, index) => (
|
||
<tr key={reportRowKey(row, index)}>
|
||
<td>{index + 1}</td>
|
||
{countryDetailColumns.map((column) => (
|
||
<td className={column.align === "left" ? "is-left" : ""} key={column.key}>{column.render ? column.render(row, context) : row[column.key]}</td>
|
||
))}
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PlatformGrantCoinCell({ context, row }) {
|
||
const value = Number(row.platform_grant_coin || 0);
|
||
const clickable = value > 0 && (row.row_type === "country" ? context?.onOpenPlatformGrantUsers : context?.onOpenPlatformGrantCountries);
|
||
if (!clickable) {
|
||
return formatOptionalCoin(row.platform_grant_coin);
|
||
}
|
||
const label = row.row_type === "country"
|
||
? `查看 ${row.country || "国家"} 平台发放金币用户明细`
|
||
: `查看 ${row.date_label || "全部"} 平台发放金币国家明细`;
|
||
return (
|
||
<button
|
||
aria-label={label}
|
||
className="report-link-button"
|
||
type="button"
|
||
onClick={(event) => {
|
||
event.stopPropagation();
|
||
if (row.row_type === "country") {
|
||
context.onOpenPlatformGrantUsers(row);
|
||
return;
|
||
}
|
||
context.onOpenPlatformGrantCountries(row);
|
||
}}
|
||
>
|
||
{formatCoin(value)}
|
||
</button>
|
||
);
|
||
}
|
||
|
||
function PlatformGrantCountryModal({ modal, onClose, onOpenUsers }) {
|
||
return (
|
||
<ReportDialog title={modal.title} onClose={onClose}>
|
||
<div className="report-modal-table-wrap">
|
||
<table className="report-modal-table">
|
||
<thead>
|
||
<tr>
|
||
<th>国家</th>
|
||
<th>平台发放金币</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{modal.rows.length ? modal.rows.map((row) => (
|
||
<tr key={reportRowKey(row, row.country_id || row.country)}>
|
||
<td>
|
||
<button
|
||
aria-label={`查看 ${row.country || "国家"} 平台发放金币用户明细`}
|
||
className="report-link-button report-link-button--left"
|
||
type="button"
|
||
onClick={() => onOpenUsers(row)}
|
||
>
|
||
<CountryCell row={row} />
|
||
</button>
|
||
</td>
|
||
<td>{formatOptionalCoin(row.platform_grant_coin)}</td>
|
||
</tr>
|
||
)) : (
|
||
<tr className="report-table-empty-row"><td colSpan={2}>当前无数据</td></tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</ReportDialog>
|
||
);
|
||
}
|
||
|
||
function PlatformGrantUsersModal({ modal, onClose, onOpenRecords }) {
|
||
const pageSize = 20;
|
||
const [page, setPage] = useState(1);
|
||
const [data, setData] = useState(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState("");
|
||
useEffect(() => {
|
||
let active = true;
|
||
setLoading(true);
|
||
setError("");
|
||
fetchPlatformGrantUsers({ ...modal.query, page, pageSize })
|
||
.then((nextData) => {
|
||
if (active) {
|
||
setData(nextData || {});
|
||
}
|
||
})
|
||
.catch((err) => {
|
||
if (active) {
|
||
setError(err.message || "请求失败");
|
||
setData(null);
|
||
}
|
||
})
|
||
.finally(() => {
|
||
if (active) {
|
||
setLoading(false);
|
||
}
|
||
});
|
||
return () => {
|
||
active = false;
|
||
};
|
||
}, [modal.query, page]);
|
||
const items = Array.isArray(data?.items) ? data.items : [];
|
||
const total = Number(data?.total || 0);
|
||
|
||
return (
|
||
<ReportDialog title={`${modal.countryLabel} 平台发放金币用户`} onClose={onClose}>
|
||
<div className="report-modal-table-wrap">
|
||
<table className="report-modal-table">
|
||
<thead>
|
||
<tr>
|
||
<th>用户</th>
|
||
<th>平台发放金币</th>
|
||
<th>记录数</th>
|
||
<th>最近发放时间</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{loading ? <tr className="report-table-skeleton-row"><td colSpan={4}><span className="skeleton-block report-skeleton-line" /></td></tr> : null}
|
||
{!loading && error ? <tr className="report-table-empty-row"><td colSpan={4}>{error}</td></tr> : null}
|
||
{!loading && !error && !items.length ? <tr className="report-table-empty-row"><td colSpan={4}>当前无数据</td></tr> : null}
|
||
{!loading && !error ? items.map((item) => {
|
||
const openRecords = () => onOpenRecords(item);
|
||
return (
|
||
<tr
|
||
aria-label={`查看 ${platformGrantUserLabel(item)} 平台发放金币来源明细`}
|
||
className="report-clickable-row"
|
||
key={item.user_id}
|
||
role="button"
|
||
tabIndex={0}
|
||
onClick={openRecords}
|
||
onKeyDown={(event) => {
|
||
if (event.key === "Enter" || event.key === " ") {
|
||
event.preventDefault();
|
||
openRecords();
|
||
}
|
||
}}
|
||
>
|
||
<td>
|
||
<span className="report-user-cell">
|
||
{item.avatar ? <img alt="" src={item.avatar} /> : null}
|
||
<span>
|
||
<b>{platformGrantUserLabel(item)}</b>
|
||
<small>{platformGrantUserMeta(item)}</small>
|
||
</span>
|
||
</span>
|
||
</td>
|
||
<td>{formatOptionalCoin(item.total_coin)}</td>
|
||
<td>{formatOptionalCoin(item.record_count)}</td>
|
||
<td>{formatDateTime(item.last_granted_at_ms)}</td>
|
||
</tr>
|
||
);
|
||
}) : null}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<ReportPagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} />
|
||
</ReportDialog>
|
||
);
|
||
}
|
||
|
||
function PlatformGrantRecordsModal({ modal, onClose }) {
|
||
const pageSize = 20;
|
||
const [page, setPage] = useState(1);
|
||
const [source, setSource] = useState("all");
|
||
const [data, setData] = useState(null);
|
||
const [loading, setLoading] = useState(false);
|
||
const [error, setError] = useState("");
|
||
useEffect(() => {
|
||
let active = true;
|
||
setLoading(true);
|
||
setError("");
|
||
fetchPlatformGrantRecords({ ...modal.query, page, pageSize, source })
|
||
.then((nextData) => {
|
||
if (active) {
|
||
setData(nextData || {});
|
||
}
|
||
})
|
||
.catch((err) => {
|
||
if (active) {
|
||
setError(err.message || "请求失败");
|
||
setData(null);
|
||
}
|
||
})
|
||
.finally(() => {
|
||
if (active) {
|
||
setLoading(false);
|
||
}
|
||
});
|
||
return () => {
|
||
active = false;
|
||
};
|
||
}, [modal.query, page, source]);
|
||
const items = Array.isArray(data?.items) ? data.items : [];
|
||
const total = Number(data?.total || 0);
|
||
const selectSource = (event) => {
|
||
setSource(event.target.value);
|
||
setPage(1);
|
||
};
|
||
|
||
return (
|
||
<ReportDialog title={`${platformGrantUserLabel(modal.user)} 平台发放金币来源`} onClose={onClose}>
|
||
<div className="report-modal-toolbar">
|
||
<label className="report-source-filter">
|
||
<span>来源</span>
|
||
<select aria-label="来源筛选" value={source} onChange={selectSource}>
|
||
{platformGrantRecordSourceOptions.map((option) => (
|
||
<option key={option.value} value={option.value}>{option.label}</option>
|
||
))}
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<div className="report-modal-table-wrap">
|
||
<table className="report-modal-table">
|
||
<thead>
|
||
<tr>
|
||
<th>来源</th>
|
||
<th>金币</th>
|
||
<th>发放时间</th>
|
||
<th>事件 ID</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{loading ? <tr className="report-table-skeleton-row"><td colSpan={4}><span className="skeleton-block report-skeleton-line" /></td></tr> : null}
|
||
{!loading && error ? <tr className="report-table-empty-row"><td colSpan={4}>{error}</td></tr> : null}
|
||
{!loading && !error && !items.length ? <tr className="report-table-empty-row"><td colSpan={4}>当前无数据</td></tr> : null}
|
||
{!loading && !error ? items.map((item) => (
|
||
<tr key={item.event_id}>
|
||
<td>{platformGrantSourceLabel(item.event_type)}</td>
|
||
<td>{formatOptionalCoin(item.amount)}</td>
|
||
<td>{formatDateTime(item.occurred_at_ms)}</td>
|
||
<td><span className="report-event-id">{item.event_id || "--"}</span></td>
|
||
</tr>
|
||
)) : null}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
<ReportPagination page={page} pageSize={pageSize} total={total} onPageChange={setPage} />
|
||
</ReportDialog>
|
||
);
|
||
}
|
||
|
||
function ReportDialog({ children, onClose, title }) {
|
||
return (
|
||
<div className="report-modal-backdrop">
|
||
<section aria-label={title} aria-modal="true" className="report-modal" role="dialog">
|
||
<header className="report-modal-head">
|
||
<h2>{title}</h2>
|
||
<button className="report-modal-close" type="button" onClick={onClose}>关闭</button>
|
||
</header>
|
||
<div className="report-modal-body">{children}</div>
|
||
</section>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ReportPagination({ onPageChange, page, pageSize, total }) {
|
||
const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
||
return (
|
||
<div className="report-pagination">
|
||
<span>共 {formatCoin(total)} 条</span>
|
||
<div>
|
||
<button disabled={page <= 1} type="button" onClick={() => onPageChange(page - 1)}>上一页</button>
|
||
<b>{page} / {totalPages}</b>
|
||
<button disabled={page >= totalPages} type="button" onClick={() => onPageChange(page + 1)}>下一页</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function CountryCell({ row }) {
|
||
return (
|
||
<span className="report-country-cell">
|
||
{row.flag ? <i>{row.flag}</i> : null}
|
||
<span>{row.country}</span>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function DateCell({ row }) {
|
||
const childCount = Array.isArray(row.children) ? row.children.length : 0;
|
||
return (
|
||
<span className={["report-date-cell", childCount ? "report-date-cell--expandable" : ""].filter(Boolean).join(" ")}>
|
||
{childCount ? (
|
||
<span className={["report-expand-caret", row.expanded ? "is-open" : ""].filter(Boolean).join(" ")} aria-hidden="true">
|
||
›
|
||
</span>
|
||
) : null}
|
||
<span>{row.date_label || "--"}</span>
|
||
{childCount ? <small>{childCount} 国</small> : null}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function FlowRateCell({ rate, value }) {
|
||
if (isMissing(value) && isMissing(rate)) {
|
||
return <span className="report-placeholder">--</span>;
|
||
}
|
||
return (
|
||
<span className="report-pair-cell">
|
||
<b>{formatOptionalCoin(value)}</b>
|
||
<small>{formatOptionalPercent(rate)}</small>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function ConsumeOutputCell({ row }) {
|
||
if (isMissing(row.consume_output_ratio) && isMissing(row.consume_output_delta)) {
|
||
return <span className="report-placeholder">--</span>;
|
||
}
|
||
return (
|
||
<span className="report-pair-cell">
|
||
<b>{formatOptionalPercent(row.consume_output_ratio)}</b>
|
||
<small>差额 {formatOptionalCoin(row.consume_output_delta)}</small>
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function TrendValue({ value }) {
|
||
return <span className={Number(value) >= 0 ? "report-trend-up" : "report-trend-down"}>{formatSignedPercent(value)}</span>;
|
||
}
|
||
|
||
function DeltaText({ value }) {
|
||
const text = String(value || "").trim();
|
||
if (!text) {
|
||
return <span className="report-delta report-delta--empty">--</span>;
|
||
}
|
||
return <span className={text.startsWith("-") ? "report-delta report-delta--down" : "report-delta"}>{text.startsWith("-") ? "↓" : "↑"} {text.replace(/^[-+]/, "")}</span>;
|
||
}
|
||
|
||
function ReportSkeletonRows({ colSpan }) {
|
||
return Array.from({ length: 6 }).map((_, index) => (
|
||
<tr className="report-table-skeleton-row" key={index}>
|
||
<td colSpan={colSpan}><span className="skeleton-block report-skeleton-line" /></td>
|
||
</tr>
|
||
));
|
||
}
|
||
|
||
function ReportEmptyRow({ colSpan }) {
|
||
return (
|
||
<tr className="report-table-empty-row">
|
||
<td colSpan={colSpan}>当前无数据</td>
|
||
</tr>
|
||
);
|
||
}
|
||
|
||
function createSummaryCards(model) {
|
||
const totalRecharge = model.kpis[0] || {};
|
||
const rechargeSplit = model.kpis[1]?.subMetrics || [];
|
||
const newUsers = model.kpis[2] || {};
|
||
const activeUsers = model.kpis[3] || {};
|
||
const paidSplit = model.kpis[4]?.subMetrics || [];
|
||
const arpu = model.kpis[5] || {};
|
||
const arppu = model.kpis[6] || {};
|
||
const payerRate = model.sideMetrics?.find((item) => item.label === "付费转化率") || {};
|
||
|
||
return [
|
||
{
|
||
delta: totalRecharge.delta,
|
||
details: [
|
||
{ label: "用户充值", value: rechargeSplit[0]?.value || "--" },
|
||
{ label: "新用户充值", value: rechargeSplit[1]?.value || "--" }
|
||
],
|
||
title: "总充值",
|
||
trendMetric: totalRecharge,
|
||
value: totalRecharge.value || "--",
|
||
valueLabel: "总充值"
|
||
},
|
||
{
|
||
delta: activeUsers.delta,
|
||
details: [
|
||
{ label: "新增用户", value: newUsers.value || "--" },
|
||
{ label: "活跃用户", value: activeUsers.value || "--" }
|
||
],
|
||
title: "活跃用户",
|
||
trendMetric: activeUsers,
|
||
value: activeUsers.value || "--",
|
||
valueLabel: "活跃用户"
|
||
},
|
||
{
|
||
delta: paidSplit[0]?.delta,
|
||
details: [
|
||
{ label: "付费用户", value: paidSplit[0]?.value || "--" },
|
||
{ label: "新增付费", value: paidSplit[1]?.value || "--" }
|
||
],
|
||
title: "付费转化率",
|
||
trendMetric: model.kpis[4],
|
||
value: payerRate.value || "--",
|
||
valueLabel: "付费转化率"
|
||
},
|
||
{
|
||
delta: arpu.delta,
|
||
details: [
|
||
{ label: "ARPU", value: arpu.value || "--" },
|
||
{ label: "ARPPU", value: arppu.value || "--" }
|
||
],
|
||
title: "ARPPU",
|
||
trendMetric: arpu,
|
||
value: arppu.value || "--",
|
||
valueLabel: "ARPPU"
|
||
}
|
||
];
|
||
}
|
||
|
||
function firstSortableColumn(columns) {
|
||
const column = columns.find((item) => item.sortable !== false);
|
||
return column ? { direction: column.type === "text" ? "asc" : "desc", key: column.key, type: column.type || "number" } : null;
|
||
}
|
||
|
||
function sortRows(rows, sortState, columns) {
|
||
if (!sortState?.key) {
|
||
return rows;
|
||
}
|
||
const column = columns.find((item) => item.key === sortState.key);
|
||
if (!column) {
|
||
return rows;
|
||
}
|
||
const multiplier = sortState.direction === "asc" ? 1 : -1;
|
||
return rows
|
||
.map((row, index) => ({ index, row }))
|
||
.sort((left, right) => {
|
||
const result = compareValues(readSortValue(left.row, column), readSortValue(right.row, column), sortState.type);
|
||
return result === 0 ? left.index - right.index : result * multiplier;
|
||
})
|
||
.map(({ row }) => row);
|
||
}
|
||
|
||
function readSortValue(row, column) {
|
||
return column.sortValue ? column.sortValue(row) : row[column.key];
|
||
}
|
||
|
||
function compareValues(leftValue, rightValue, type) {
|
||
if (type === "text") {
|
||
return textCollator.compare(String(leftValue || ""), String(rightValue || ""));
|
||
}
|
||
const leftNumber = Number(leftValue);
|
||
const rightNumber = Number(rightValue);
|
||
if (!Number.isFinite(leftNumber) && !Number.isFinite(rightNumber)) {
|
||
return 0;
|
||
}
|
||
if (!Number.isFinite(leftNumber)) {
|
||
return -1;
|
||
}
|
||
if (!Number.isFinite(rightNumber)) {
|
||
return 1;
|
||
}
|
||
return leftNumber - rightNumber;
|
||
}
|
||
|
||
function buildPlatformGrantQuery(baseQuery = {}, row = {}) {
|
||
const countryId = Number(row.country_id ?? row.countryId ?? baseQuery.countryId ?? 0);
|
||
const regionId = row.region_id ?? row.regionId ?? baseQuery.regionId;
|
||
return {
|
||
appCode: baseQuery.appCode,
|
||
countryId,
|
||
endMs: baseQuery.endMs,
|
||
regionId,
|
||
startMs: baseQuery.startMs,
|
||
statDay: row.stat_day || row.statDay || "",
|
||
statTz: baseQuery.statTz
|
||
};
|
||
}
|
||
|
||
function platformGrantCountryRows(row, rows) {
|
||
const sourceRows = row.row_type === "date_total" && Array.isArray(row.children) && row.children.length
|
||
? row.children
|
||
: collectPlatformGrantCountryRows(rows);
|
||
return aggregatePlatformGrantCountryRows(sourceRows)
|
||
.filter((item) => Number(item.platform_grant_coin || 0) > 0)
|
||
.sort((left, right) => Number(right.platform_grant_coin || 0) - Number(left.platform_grant_coin || 0));
|
||
}
|
||
|
||
function collectPlatformGrantCountryRows(rows) {
|
||
const out = [];
|
||
rows.forEach((row) => {
|
||
if (row.row_type === "country") {
|
||
out.push(row);
|
||
}
|
||
if (Array.isArray(row.children)) {
|
||
out.push(...collectPlatformGrantCountryRows(row.children));
|
||
}
|
||
});
|
||
return out;
|
||
}
|
||
|
||
function aggregatePlatformGrantCountryRows(rows) {
|
||
const grouped = new Map();
|
||
rows.forEach((row) => {
|
||
const key = [row.country_id ?? row.country, row.region_id ?? ""].join(":");
|
||
const current = grouped.get(key);
|
||
if (!current) {
|
||
grouped.set(key, { ...row, platform_grant_coin: Number(row.platform_grant_coin || 0) });
|
||
return;
|
||
}
|
||
const nextStatDay = current.stat_day && row.stat_day && current.stat_day === row.stat_day ? current.stat_day : "";
|
||
grouped.set(key, {
|
||
...current,
|
||
platform_grant_coin: Number(current.platform_grant_coin || 0) + Number(row.platform_grant_coin || 0),
|
||
stat_day: nextStatDay
|
||
});
|
||
});
|
||
return Array.from(grouped.values());
|
||
}
|
||
|
||
function platformGrantUserLabel(item = {}) {
|
||
return item.user?.display_user_id || item.display_user_id || item.user?.username || item.username || item.nickname || item.user_id || "用户";
|
||
}
|
||
|
||
function platformGrantUserMeta(item = {}) {
|
||
return [item.user?.username || item.username || item.nickname, item.user_id]
|
||
.filter((value, index, values) => value && values.indexOf(value) === index)
|
||
.join(" · ") || "--";
|
||
}
|
||
|
||
function platformGrantSourceLabel(value) {
|
||
switch (String(value || "").trim()) {
|
||
case "WalletLuckyGiftRewardCredited":
|
||
return "幸运礼物返奖";
|
||
case "WalletTaskRewardCredited":
|
||
return "任务奖励";
|
||
case "WalletWheelRewardCredited":
|
||
return "转盘奖励";
|
||
case "WalletRoomTurnoverRewardCredited":
|
||
return "房间流水奖励";
|
||
case "WalletInviteActivityRewardCredited":
|
||
return "邀请活动奖励";
|
||
case "WalletAgencyOpeningRewardCredited":
|
||
return "代理开通奖励";
|
||
case "WalletBalanceChanged":
|
||
return "资源发放";
|
||
case "WalletPlatformGrantCoinCredited":
|
||
return "平台发放";
|
||
default:
|
||
return value || "--";
|
||
}
|
||
}
|
||
|
||
function formatDateTime(value) {
|
||
const ms = Number(value || 0);
|
||
if (!ms) {
|
||
return "--";
|
||
}
|
||
return new Date(ms).toLocaleString("zh-CN", {
|
||
day: "2-digit",
|
||
hour: "2-digit",
|
||
hour12: false,
|
||
minute: "2-digit",
|
||
month: "2-digit",
|
||
second: "2-digit",
|
||
year: "numeric"
|
||
});
|
||
}
|
||
|
||
function formatSignedPercent(value) {
|
||
if (value === undefined || value === null || Number.isNaN(Number(value))) {
|
||
return "--";
|
||
}
|
||
return `${Number(value) >= 0 ? "+" : ""}${formatPercent(value)}`;
|
||
}
|
||
|
||
function reportRowKey(row, index) {
|
||
return [row.row_id, row.country, row.name, row.label, row.pool_id, row.game_id, row.platform_code, row.value, index]
|
||
.filter((item) => item !== undefined && item !== null && item !== "")
|
||
.join(":");
|
||
}
|
||
|
||
function reportRowClass(row) {
|
||
if (row.row_type === "total") {
|
||
return "report-row-total";
|
||
}
|
||
if (row.row_type === "date_total") {
|
||
return "report-row-date-total";
|
||
}
|
||
return "";
|
||
}
|
||
|
||
function reportTableRowProps(row) {
|
||
if (!row.onToggle) {
|
||
return {};
|
||
}
|
||
const action = row.expanded ? "收起" : "展开";
|
||
const label = `${action} ${row.date_label || "日期"} 国家明细`;
|
||
return {
|
||
"aria-expanded": row.expanded,
|
||
"aria-label": label,
|
||
className: "report-row-expandable",
|
||
role: "button",
|
||
tabIndex: 0,
|
||
onClick: row.onToggle,
|
||
onKeyDown: (event) => {
|
||
if (event.key === "Enter" || event.key === " ") {
|
||
event.preventDefault();
|
||
row.onToggle();
|
||
}
|
||
}
|
||
};
|
||
}
|
||
|
||
function reportRowIndex(row, index) {
|
||
if (row.row_type === "total") {
|
||
return "总";
|
||
}
|
||
if (row.row_type === "date_total") {
|
||
return "日";
|
||
}
|
||
return index + 1;
|
||
}
|
||
|
||
function formatOptionalMoney(value) {
|
||
return isMissing(value) ? <span className="report-placeholder">--</span> : formatMoneyFull(value);
|
||
}
|
||
|
||
function formatOptionalCoin(value) {
|
||
return isMissing(value) ? <span className="report-placeholder">--</span> : formatCoin(value);
|
||
}
|
||
|
||
function formatOptionalPercent(value) {
|
||
return isMissing(value) ? <span className="report-placeholder">--</span> : formatPercent(value);
|
||
}
|
||
|
||
function formatDuration(value) {
|
||
if (isMissing(value)) {
|
||
return <span className="report-placeholder">--</span>;
|
||
}
|
||
const totalSeconds = Math.max(0, Math.round(Number(value) / 1000));
|
||
const hours = Math.floor(totalSeconds / 3600);
|
||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||
if (hours > 0) {
|
||
return `${hours}h ${minutes}m`;
|
||
}
|
||
return `${minutes}m`;
|
||
}
|
||
|
||
function isMissing(value) {
|
||
return value === undefined || value === null || Number.isNaN(Number(value));
|
||
}
|