422 lines
14 KiB
JavaScript
422 lines
14 KiB
JavaScript
import React, { useEffect, useState } from "../../../recovery-preview/node_modules/react/index.js";
|
|
import {
|
|
compactText,
|
|
formatCurrencyFen,
|
|
formatUnixSeconds,
|
|
requestJson,
|
|
} from "../../runtime/client.js";
|
|
|
|
const ORDER_PERIODS = [
|
|
"month_price",
|
|
"quarter_price",
|
|
"half_year_price",
|
|
"year_price",
|
|
"two_year_price",
|
|
"three_year_price",
|
|
"onetime_price",
|
|
"reset_price",
|
|
];
|
|
|
|
function FinanceOrderPage() {
|
|
const [loading, setLoading] = useState(true);
|
|
const [refreshTick, setRefreshTick] = useState(0);
|
|
const [searchInput, setSearchInput] = useState("");
|
|
const [keyword, setKeyword] = useState("");
|
|
const [status, setStatus] = useState("");
|
|
const [rows, setRows] = useState([]);
|
|
const [pagination, setPagination] = useState({ current: 1, last_page: 1, per_page: 20, total: 0 });
|
|
const [plans, setPlans] = useState([]);
|
|
const [modal, setModal] = useState(null);
|
|
const [assignForm, setAssignForm] = useState({ email: "", plan_id: "", period: "", total_amount: "" });
|
|
const [detail, setDetail] = useState(null);
|
|
const [notice, setNotice] = useState("");
|
|
const [error, setError] = useState("");
|
|
|
|
useEffect(() => {
|
|
let active = true;
|
|
(async () => {
|
|
try {
|
|
const payload = await requestJson("/plan/fetch");
|
|
if (!active) {
|
|
return;
|
|
}
|
|
setPlans(Array.isArray(payload?.data) ? payload.data : payload?.list || payload || []);
|
|
} catch (err) {
|
|
if (active) {
|
|
setNotice(err.message || "Plan list unavailable");
|
|
}
|
|
}
|
|
})();
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
let active = true;
|
|
(async () => {
|
|
setLoading(true);
|
|
setError("");
|
|
try {
|
|
const query = new URLSearchParams({
|
|
page: String(pagination.current || 1),
|
|
per_page: String(pagination.per_page || 20),
|
|
});
|
|
if (keyword) {
|
|
query.set("keyword", keyword);
|
|
}
|
|
if (status !== "") {
|
|
query.set("status", status);
|
|
}
|
|
const payload = await requestJson(`/order/fetch?${query.toString()}`);
|
|
if (!active) {
|
|
return;
|
|
}
|
|
setRows(Array.isArray(payload?.list) ? payload.list : []);
|
|
setPagination(payload?.pagination || pagination);
|
|
} catch (err) {
|
|
if (active) {
|
|
setError(err.message || "Failed to load orders");
|
|
setRows([]);
|
|
}
|
|
} finally {
|
|
if (active) {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
})();
|
|
return () => {
|
|
active = false;
|
|
};
|
|
}, [keyword, pagination.current, pagination.per_page, refreshTick, status]);
|
|
|
|
const reload = () => setRefreshTick((value) => value + 1);
|
|
|
|
const openDetail = async (order) => {
|
|
setModal({ type: "detail", title: order.trade_no });
|
|
setDetail(null);
|
|
try {
|
|
const payload = await requestJson("/order/detail", {
|
|
method: "POST",
|
|
body: { trade_no: order.trade_no },
|
|
});
|
|
setDetail(payload?.data || payload);
|
|
} catch (err) {
|
|
setDetail({ error: err.message || "Failed to load detail" });
|
|
}
|
|
};
|
|
|
|
const submitSearch = (event) => {
|
|
event.preventDefault();
|
|
setPagination((current) => ({ ...current, current: 1 }));
|
|
setKeyword(compactText(searchInput));
|
|
};
|
|
|
|
const markPaid = async (tradeNo) => {
|
|
if (!window.confirm(`Mark ${tradeNo} as paid?`)) {
|
|
return;
|
|
}
|
|
try {
|
|
await requestJson("/order/paid", { method: "POST", body: { trade_no: tradeNo } });
|
|
reload();
|
|
} catch (err) {
|
|
setError(err.message || "Failed to mark order paid");
|
|
}
|
|
};
|
|
|
|
const cancelOrder = async (tradeNo) => {
|
|
if (!window.confirm(`Cancel ${tradeNo}?`)) {
|
|
return;
|
|
}
|
|
try {
|
|
await requestJson("/order/cancel", { method: "POST", body: { trade_no: tradeNo } });
|
|
reload();
|
|
} catch (err) {
|
|
setError(err.message || "Failed to cancel order");
|
|
}
|
|
};
|
|
|
|
const saveAssign = async (event) => {
|
|
event.preventDefault();
|
|
try {
|
|
await requestJson("/order/assign", {
|
|
method: "POST",
|
|
body: {
|
|
email: compactText(assignForm.email),
|
|
plan_id: toNullableNumber(assignForm.plan_id),
|
|
period: compactText(assignForm.period),
|
|
total_amount: toNullableNumber(assignForm.total_amount),
|
|
},
|
|
});
|
|
setModal(null);
|
|
reload();
|
|
} catch (err) {
|
|
setError(err.message || "Failed to assign order");
|
|
}
|
|
};
|
|
|
|
const saveCommissionStatus = async (tradeNo, commissionStatus) => {
|
|
try {
|
|
await requestJson("/order/update", {
|
|
method: "POST",
|
|
body: { trade_no: tradeNo, commission_status: commissionStatus },
|
|
});
|
|
reload();
|
|
} catch (err) {
|
|
setError(err.message || "Failed to update commission state");
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="recovery-live-page">
|
|
<header className="recovery-live-hero">
|
|
<div>
|
|
<p className="eyebrow">Recovered React Page</p>
|
|
<h2>Finance Order</h2>
|
|
<p>Live order table with detail drill-down, manual payment actions, and commission state updates.</p>
|
|
</div>
|
|
<div className="hero-stats">
|
|
<div><strong>{pagination.total}</strong><span>orders</span></div>
|
|
<div><strong>{pagination.current}</strong><span>page</span></div>
|
|
<div><strong>{plans.length}</strong><span>plans</span></div>
|
|
</div>
|
|
</header>
|
|
|
|
<section className="recovery-toolbar">
|
|
<form className="recovery-search" onSubmit={submitSearch}>
|
|
<input
|
|
value={searchInput}
|
|
onChange={(event) => setSearchInput(event.target.value)}
|
|
placeholder="Search by trade no or user ID"
|
|
/>
|
|
<button type="submit">Search</button>
|
|
</form>
|
|
<select value={status} onChange={(event) => { setPagination((current) => ({ ...current, current: 1 })); setStatus(event.target.value); }}>
|
|
<option value="">All statuses</option>
|
|
<option value="0">Pending</option>
|
|
<option value="2">Cancelled</option>
|
|
<option value="3">Paid</option>
|
|
</select>
|
|
<button
|
|
type="button"
|
|
className="secondary"
|
|
onClick={() => {
|
|
setSearchInput("");
|
|
setKeyword("");
|
|
setStatus("");
|
|
setPagination((current) => ({ ...current, current: 1 }));
|
|
}}
|
|
>
|
|
Clear
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setAssignForm({ email: "", plan_id: "", period: "", total_amount: "" });
|
|
setModal({ type: "assign", title: "Assign order" });
|
|
}}
|
|
>
|
|
Assign
|
|
</button>
|
|
</section>
|
|
|
|
{notice ? <div className="toast toast-info">{notice}</div> : null}
|
|
{error ? <div className="toast toast-error">{error}</div> : null}
|
|
|
|
<section className="recovery-table-card">
|
|
<div className="table-scroll">
|
|
<table className="recovery-table">
|
|
<thead>
|
|
<tr>
|
|
<th>Trade No</th>
|
|
<th>User</th>
|
|
<th>Plan</th>
|
|
<th>Period</th>
|
|
<th>Amount</th>
|
|
<th>Status</th>
|
|
<th>Commission</th>
|
|
<th>Created</th>
|
|
<th>Actions</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{loading ? (
|
|
<tr><td colSpan={9} className="empty-cell">Loading...</td></tr>
|
|
) : rows.length === 0 ? (
|
|
<tr><td colSpan={9} className="empty-cell">No orders found</td></tr>
|
|
) : rows.map((order) => (
|
|
<tr key={order.trade_no}>
|
|
<td>
|
|
<button type="button" className="link-button" onClick={() => openDetail(order)}>
|
|
{order.trade_no}
|
|
</button>
|
|
</td>
|
|
<td>
|
|
<div className="cell-stack">
|
|
<strong>{order.user_email || `User ${order.user_id}`}</strong>
|
|
<span className="muted">UID: {order.user_id}</span>
|
|
</div>
|
|
</td>
|
|
<td>{order.plan?.name || order.plan_name || `Plan ${order.plan_id || "-"}`}</td>
|
|
<td>{order.period || "-"}</td>
|
|
<td>{formatCurrencyFen(order.total_amount)}</td>
|
|
<td><span className={`chip ${statusClass(order.status)}`}>{statusLabel(order.status)}</span></td>
|
|
<td>
|
|
<div className="cell-stack">
|
|
<span>{commissionStatusLabel(order.commission_status)}</span>
|
|
<span className="muted">{formatCurrencyFen(order.commission_balance)}</span>
|
|
</div>
|
|
</td>
|
|
<td>{formatUnixSeconds(order.created_at)}</td>
|
|
<td>
|
|
<div className="action-row">
|
|
{Number(order.status) === 0 ? (
|
|
<>
|
|
<button type="button" onClick={() => markPaid(order.trade_no)}>Paid</button>
|
|
<button type="button" className="danger" onClick={() => cancelOrder(order.trade_no)}>Cancel</button>
|
|
</>
|
|
) : null}
|
|
<button type="button" onClick={() => saveCommissionStatus(order.trade_no, 2)}>Valid</button>
|
|
<button type="button" onClick={() => saveCommissionStatus(order.trade_no, 3)}>Invalid</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
|
|
<div className="pagination-bar">
|
|
<button
|
|
type="button"
|
|
disabled={pagination.current <= 1}
|
|
onClick={() => setPagination((current) => ({ ...current, current: Math.max(1, current.current - 1) }))}
|
|
>
|
|
Prev
|
|
</button>
|
|
<span>
|
|
Page {pagination.current} / {pagination.last_page}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
disabled={pagination.current >= pagination.last_page}
|
|
onClick={() => setPagination((current) => ({ ...current, current: Math.min(current.last_page, current.current + 1) }))}
|
|
>
|
|
Next
|
|
</button>
|
|
</div>
|
|
</section>
|
|
|
|
{modal?.type === "assign" ? (
|
|
<Modal title="Assign order" onClose={() => setModal(null)}>
|
|
<form className="modal-grid" onSubmit={saveAssign}>
|
|
<label className="span-2">
|
|
<span>Email</span>
|
|
<input value={assignForm.email} onChange={(event) => setAssignForm((current) => ({ ...current, email: event.target.value }))} />
|
|
</label>
|
|
<label>
|
|
<span>Plan</span>
|
|
<select value={assignForm.plan_id} onChange={(event) => setAssignForm((current) => ({ ...current, plan_id: event.target.value }))}>
|
|
<option value="">-</option>
|
|
{plans.map((plan) => (
|
|
<option key={plan.id} value={plan.id}>{plan.name || `Plan ${plan.id}`}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label>
|
|
<span>Period</span>
|
|
<select value={assignForm.period} onChange={(event) => setAssignForm((current) => ({ ...current, period: event.target.value }))}>
|
|
<option value="">-</option>
|
|
{ORDER_PERIODS.map((period) => (
|
|
<option key={period} value={period}>{period}</option>
|
|
))}
|
|
</select>
|
|
</label>
|
|
<label className="span-2">
|
|
<span>Total Amount</span>
|
|
<input type="number" value={assignForm.total_amount} onChange={(event) => setAssignForm((current) => ({ ...current, total_amount: event.target.value }))} />
|
|
</label>
|
|
<div className="modal-actions span-2">
|
|
<button type="button" className="secondary" onClick={() => setModal(null)}>Cancel</button>
|
|
<button type="submit">Save</button>
|
|
</div>
|
|
</form>
|
|
</Modal>
|
|
) : null}
|
|
|
|
{modal?.type === "detail" ? (
|
|
<Modal title={`Order detail ${modal.title}`} onClose={() => setModal(null)}>
|
|
{detail?.error ? <div className="toast toast-error">{detail.error}</div> : null}
|
|
<pre className="detail-json">{JSON.stringify(detail, null, 2)}</pre>
|
|
</Modal>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Modal({ title, children, onClose }) {
|
|
return (
|
|
<div className="modal-backdrop" role="presentation" onClick={onClose}>
|
|
<div className="modal-panel modal-panel-wide" role="dialog" aria-modal="true" onClick={(event) => event.stopPropagation()}>
|
|
<header className="modal-header">
|
|
<h3>{title}</h3>
|
|
<button type="button" className="secondary" onClick={onClose}>Close</button>
|
|
</header>
|
|
{children}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function statusLabel(status) {
|
|
switch (Number(status)) {
|
|
case 0:
|
|
return "Pending";
|
|
case 2:
|
|
return "Cancelled";
|
|
case 3:
|
|
return "Paid";
|
|
default:
|
|
return String(status ?? "-");
|
|
}
|
|
}
|
|
|
|
function statusClass(status) {
|
|
switch (Number(status)) {
|
|
case 0:
|
|
return "chip-warn";
|
|
case 2:
|
|
return "chip-danger";
|
|
case 3:
|
|
return "chip-ok";
|
|
default:
|
|
return "chip-soft";
|
|
}
|
|
}
|
|
|
|
function commissionStatusLabel(status) {
|
|
switch (Number(status)) {
|
|
case 0:
|
|
return "Pending";
|
|
case 1:
|
|
return "Processing";
|
|
case 2:
|
|
return "Valid";
|
|
case 3:
|
|
return "Invalid";
|
|
default:
|
|
return String(status ?? "-");
|
|
}
|
|
}
|
|
|
|
function toNullableNumber(value) {
|
|
const trimmed = compactText(value);
|
|
if (!trimmed) {
|
|
return undefined;
|
|
}
|
|
const number = Number(trimmed);
|
|
return Number.isNaN(number) ? undefined : number;
|
|
}
|
|
|
|
export default FinanceOrderPage;
|