|
|
"use client";
|
|
|
|
|
|
import { Suspense, useCallback, useEffect, useState } from "react";
|
|
|
import { useSearchParams } from "next/navigation";
|
|
|
import { AdminLayout } from "@/components/layout/admin-layout";
|
|
|
import { PageHeader } from "@/components/layout/page-header";
|
|
|
import { Skeleton } from "@/components/ui/skeleton";
|
|
|
import { ErrorBanner } from "@/components/ui/error-banner";
|
|
|
import { SecondaryButton } from "@/components/ui/primary-button";
|
|
|
import { PrimaryButton } from "@/components/ui/primary-button";
|
|
|
import { InputField } from "@/components/ui/input-field";
|
|
|
import { SelectField } from "@/components/ui/select-field";
|
|
|
import { Card } from "@/components/ui/card";
|
|
|
import {
|
|
|
adminGetMarkupConfigs,
|
|
|
adminUpdateMarkupConfig,
|
|
|
} from "@/lib/frontend/api-client";
|
|
|
import { formatDateTime, formatPercent, formatUSD } from "@/lib/frontend/format";
|
|
|
import { useAuth } from "@/hooks/use-auth";
|
|
|
import type { DataPageStatus, MarkupConfig, MarkupType } from "@/lib/frontend/types";
|
|
|
|
|
|
const PAGE_SIZE = 10;
|
|
|
|
|
|
function formatMarkupSummary(row: MarkupConfig): string {
|
|
|
if (row.markup_type === "fixed") {
|
|
|
const amount = row.markup_fixed_amount ?? 0;
|
|
|
return amount > 0 ? `固定 ${formatUSD(amount)}` : "未配置";
|
|
|
}
|
|
|
return row.markup_percent > 0 ? formatPercent(row.markup_percent) : "0%";
|
|
|
}
|
|
|
|
|
|
function AdminMarkupPageContent() {
|
|
|
const { token, user } = useAuth();
|
|
|
const searchParams = useSearchParams();
|
|
|
const [status, setStatus] = useState<DataPageStatus>("loading");
|
|
|
const [rows, setRows] = useState<MarkupConfig[]>([]);
|
|
|
const [total, setTotal] = useState(0);
|
|
|
const [page, setPage] = useState(1);
|
|
|
const [keyword, setKeyword] = useState(
|
|
|
() => searchParams.get("keyword") ?? "",
|
|
|
);
|
|
|
|
|
|
const [editing, setEditing] = useState<MarkupConfig | null>(null);
|
|
|
const [markupType, setMarkupType] = useState<MarkupType>("percent");
|
|
|
const [percent, setPercent] = useState("");
|
|
|
const [fixedAmount, setFixedAmount] = useState("");
|
|
|
const [remark, setRemark] = useState("");
|
|
|
const [saving, setSaving] = useState(false);
|
|
|
const [fieldError, setFieldError] = useState<string | undefined>();
|
|
|
|
|
|
const load = useCallback(
|
|
|
async (p: number, kw: string) => {
|
|
|
if (!token) return;
|
|
|
setStatus("loading");
|
|
|
const res = await adminGetMarkupConfigs("", token, p, PAGE_SIZE, kw);
|
|
|
if (res.code !== 0) {
|
|
|
setStatus("error");
|
|
|
return;
|
|
|
}
|
|
|
setRows(res.data.list);
|
|
|
setTotal(res.data.total);
|
|
|
setStatus(res.data.total === 0 ? "empty" : "success");
|
|
|
},
|
|
|
[token],
|
|
|
);
|
|
|
|
|
|
useEffect(() => {
|
|
|
void load(page, keyword);
|
|
|
}, [load, page, keyword]);
|
|
|
|
|
|
const openEdit = (row: MarkupConfig) => {
|
|
|
setEditing(row);
|
|
|
setMarkupType(row.markup_type);
|
|
|
setPercent(String(row.markup_percent));
|
|
|
setFixedAmount(
|
|
|
row.markup_fixed_amount !== null ? String(row.markup_fixed_amount) : "",
|
|
|
);
|
|
|
setRemark(row.remark ?? "");
|
|
|
setFieldError(undefined);
|
|
|
};
|
|
|
|
|
|
const handleSave = async () => {
|
|
|
if (!editing || !token || !user) return;
|
|
|
|
|
|
if (markupType === "percent") {
|
|
|
const n = Number(percent);
|
|
|
if (Number.isNaN(n) || n < 0 || n > 30) {
|
|
|
setFieldError("加价比例须在 0~30% 之间");
|
|
|
return;
|
|
|
}
|
|
|
} else {
|
|
|
const n = Number(fixedAmount);
|
|
|
if (Number.isNaN(n) || n < 0) {
|
|
|
setFieldError("固定加价金额须 ≥ 0");
|
|
|
return;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
setSaving(true);
|
|
|
const res = await adminUpdateMarkupConfig("", token, editing.customer_id, {
|
|
|
markup_type: markupType,
|
|
|
markup_percent:
|
|
|
markupType === "percent" ? Number(percent) : undefined,
|
|
|
markup_fixed_amount:
|
|
|
markupType === "fixed" ? Number(fixedAmount) : undefined,
|
|
|
remark,
|
|
|
});
|
|
|
setSaving(false);
|
|
|
|
|
|
if (res.code !== 0) {
|
|
|
setFieldError(res.message);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
setRows((list) =>
|
|
|
list.map((r) => (r.customer_id === res.data.customer_id ? res.data : r)),
|
|
|
);
|
|
|
setEditing(null);
|
|
|
};
|
|
|
|
|
|
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
|
|
|
|
|
return (
|
|
|
<AdminLayout>
|
|
|
<PageHeader
|
|
|
title="加价配置"
|
|
|
subtitle="按客户对卡派报价设置运费百分比加价或固定金额加价"
|
|
|
action={
|
|
|
<div className="w-64">
|
|
|
<InputField
|
|
|
label="搜索客户"
|
|
|
placeholder="客户 ID"
|
|
|
value={keyword}
|
|
|
onChange={(e) => {
|
|
|
setPage(1);
|
|
|
setKeyword(e.target.value);
|
|
|
}}
|
|
|
/>
|
|
|
</div>
|
|
|
}
|
|
|
/>
|
|
|
|
|
|
{status === "loading" && (
|
|
|
<div className="space-y-2">
|
|
|
{Array.from({ length: 5 }).map((_, i) => (
|
|
|
<Skeleton key={i} className="h-14 w-full" />
|
|
|
))}
|
|
|
</div>
|
|
|
)}
|
|
|
|
|
|
{status === "error" && (
|
|
|
<ErrorBanner
|
|
|
action={
|
|
|
<SecondaryButton onClick={() => void load(page, keyword)}>
|
|
|
重试
|
|
|
</SecondaryButton>
|
|
|
}
|
|
|
>
|
|
|
加载加价配置失败
|
|
|
</ErrorBanner>
|
|
|
)}
|
|
|
|
|
|
{status === "empty" && (
|
|
|
<Card className="p-8 text-center text-text-secondary">
|
|
|
未找到匹配的客户
|
|
|
</Card>
|
|
|
)}
|
|
|
|
|
|
{status === "success" && (
|
|
|
<div className="space-y-4">
|
|
|
<div className="overflow-x-auto rounded-md border border-border">
|
|
|
<table className="min-w-full text-sm">
|
|
|
<thead className="bg-bg text-left text-text-secondary">
|
|
|
<tr>
|
|
|
<th className="px-4 py-3 font-medium">客户 ID</th>
|
|
|
<th className="px-4 py-3 font-medium">加价方式</th>
|
|
|
<th className="px-4 py-3 font-medium">加价规则</th>
|
|
|
<th className="px-4 py-3 font-medium">操作人</th>
|
|
|
<th className="px-4 py-3 font-medium">备注</th>
|
|
|
<th className="px-4 py-3 font-medium">更新时间</th>
|
|
|
<th className="px-4 py-3 font-medium">操作</th>
|
|
|
</tr>
|
|
|
</thead>
|
|
|
<tbody>
|
|
|
{rows.map((row) => (
|
|
|
<tr key={row.customer_id} className="border-t border-border">
|
|
|
<td className="px-4 py-3 font-mono">{row.customer_id}</td>
|
|
|
<td className="px-4 py-3">
|
|
|
{row.markup_type === "fixed" ? "固定金额" : "百分比"}
|
|
|
</td>
|
|
|
<td className="px-4 py-3 font-mono text-primary">
|
|
|
{formatMarkupSummary(row)}
|
|
|
</td>
|
|
|
<td className="px-4 py-3 text-text-secondary">
|
|
|
{row.operator_id || "—"}
|
|
|
</td>
|
|
|
<td className="px-4 py-3 text-text-secondary">
|
|
|
{row.remark || "—"}
|
|
|
</td>
|
|
|
<td className="px-4 py-3 text-text-secondary">
|
|
|
{row.operator_id
|
|
|
? formatDateTime(row.updated_at)
|
|
|
: "—"}
|
|
|
</td>
|
|
|
<td className="px-4 py-3">
|
|
|
<SecondaryButton onClick={() => openEdit(row)}>
|
|
|
编辑
|
|
|
</SecondaryButton>
|
|
|
</td>
|
|
|
</tr>
|
|
|
))}
|
|
|
</tbody>
|
|
|
</table>
|
|
|
</div>
|
|
|
|
|
|
<div className="flex items-center justify-between text-sm text-text-secondary">
|
|
|
<span>
|
|
|
共 {total} 条,第 {page} / {totalPages} 页
|
|
|
</span>
|
|
|
<div className="flex gap-2">
|
|
|
<SecondaryButton
|
|
|
disabled={page <= 1}
|
|
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
|
|
>
|
|
|
上一页
|
|
|
</SecondaryButton>
|
|
|
<SecondaryButton
|
|
|
disabled={page >= totalPages}
|
|
|
onClick={() => setPage((p) => p + 1)}
|
|
|
>
|
|
|
下一页
|
|
|
</SecondaryButton>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
)}
|
|
|
|
|
|
{editing && (
|
|
|
<div
|
|
|
className="fixed inset-0 z-50 flex justify-end bg-black/30"
|
|
|
onClick={() => !saving && setEditing(null)}
|
|
|
>
|
|
|
<div
|
|
|
className="h-full w-full max-w-md bg-surface p-6 shadow-lg"
|
|
|
onClick={(e) => e.stopPropagation()}
|
|
|
>
|
|
|
<h3 className="text-lg font-semibold">
|
|
|
编辑加价 · {editing.customer_id}
|
|
|
</h3>
|
|
|
<p className="mt-1 text-sm text-text-secondary">
|
|
|
百分比仅对运费加价(上限 30%,步长 0.01%);固定金额为每档报价叠加固定金额。
|
|
|
</p>
|
|
|
<div className="mt-6 space-y-4">
|
|
|
<SelectField
|
|
|
label="加价方式"
|
|
|
value={markupType}
|
|
|
disabled={saving}
|
|
|
options={[
|
|
|
{ value: "percent", label: "按运费百分比" },
|
|
|
{ value: "fixed", label: "固定金额(USD)" },
|
|
|
]}
|
|
|
onChange={(e) =>
|
|
|
setMarkupType(e.target.value as MarkupType)
|
|
|
}
|
|
|
/>
|
|
|
{markupType === "percent" ? (
|
|
|
<InputField
|
|
|
label="加价比例 (%)"
|
|
|
type="number"
|
|
|
step="0.01"
|
|
|
min={0}
|
|
|
max={30}
|
|
|
value={percent}
|
|
|
disabled={saving}
|
|
|
error={fieldError}
|
|
|
onChange={(e) => setPercent(e.target.value)}
|
|
|
/>
|
|
|
) : (
|
|
|
<InputField
|
|
|
label="固定加价金额 (USD)"
|
|
|
type="number"
|
|
|
step="0.01"
|
|
|
min={0}
|
|
|
value={fixedAmount}
|
|
|
disabled={saving}
|
|
|
error={fieldError}
|
|
|
onChange={(e) => setFixedAmount(e.target.value)}
|
|
|
/>
|
|
|
)}
|
|
|
<InputField
|
|
|
label="备注"
|
|
|
value={remark}
|
|
|
disabled={saving}
|
|
|
onChange={(e) => setRemark(e.target.value)}
|
|
|
/>
|
|
|
</div>
|
|
|
<div className="mt-8 flex justify-end gap-3">
|
|
|
<SecondaryButton disabled={saving} onClick={() => setEditing(null)}>
|
|
|
取消
|
|
|
</SecondaryButton>
|
|
|
<PrimaryButton loading={saving} onClick={() => void handleSave()}>
|
|
|
保存
|
|
|
</PrimaryButton>
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
)}
|
|
|
</AdminLayout>
|
|
|
);
|
|
|
}
|
|
|
|
|
|
export default function AdminMarkupPage() {
|
|
|
return (
|
|
|
<Suspense
|
|
|
fallback={
|
|
|
<AdminLayout>
|
|
|
<PageHeader title="加价配置" subtitle="按客户设置报价加价" />
|
|
|
<Skeleton className="h-64 w-full" />
|
|
|
</AdminLayout>
|
|
|
}
|
|
|
>
|
|
|
<AdminMarkupPageContent />
|
|
|
</Suspense>
|
|
|
);
|
|
|
}
|