You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

798 lines
29 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useParams } from "next/navigation";
import { AdminLayout } from "@/components/layout/admin-layout";
import { PageHeader } from "@/components/layout/page-header";
import { Card } from "@/components/ui/card";
import { ErrorBanner } from "@/components/ui/error-banner";
import { InputField } from "@/components/ui/input-field";
import { SelectField } from "@/components/ui/select-field";
import { PrimaryButton, SecondaryButton } from "@/components/ui/primary-button";
import { Skeleton } from "@/components/ui/skeleton";
import { useAuth } from "@/hooks/use-auth";
import {
adminCreateBusinessCustomer,
adminGetBusinessCustomerUsers,
adminGetBusinessCustomers,
adminGetMarkupConfigs,
adminListTenantUsers,
adminUpdateBusinessCustomer,
adminUpdateMarkupConfig,
} from "@/lib/frontend/api-client";
import { formatDateTime, formatPercent, formatUSD } from "@/lib/frontend/format";
import type {
BusinessCustomerRecord,
BusinessCustomerUserRecord,
DataPageStatus,
MarkupConfig,
MarkupType,
} from "@/lib/frontend/types";
function formatMarkupRule(
row: Pick<
MarkupConfig,
"markup_type" | "markup_percent" | "markup_fixed_amount"
>,
): 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%";
}
export default function TenantBusinessCustomersPage() {
const params = useParams<{ customer_id: string }>();
const tenantId = decodeURIComponent(params.customer_id);
const { token } = useAuth();
const [status, setStatus] = useState<DataPageStatus>("loading");
const [rows, setRows] = useState<BusinessCustomerRecord[]>([]);
const [error, setError] = useState<string | null>(null);
/** 业务客户 → 当前加价摘要(打开/保存后刷新) */
const [markupByBcId, setMarkupByBcId] = useState<Record<string, string>>({});
const [createOpen, setCreateOpen] = useState(false);
const [creating, setCreating] = useState(false);
const [createError, setCreateError] = useState<string | null>(null);
const [createForm, setCreateForm] = useState({
business_customer_id: "",
name: "",
external_code: "",
remark: "",
});
const [editing, setEditing] = useState<BusinessCustomerRecord | null>(null);
const [savingEdit, setSavingEdit] = useState(false);
const [editError, setEditError] = useState<string | null>(null);
const [editForm, setEditForm] = useState({
name: "",
external_code: "",
remark: "",
status: "active" as "active" | "disabled",
});
const [markupTarget, setMarkupTarget] = useState<BusinessCustomerRecord | null>(
null,
);
const [markupType, setMarkupType] = useState<MarkupType>("percent");
const [markupPercent, setMarkupPercent] = useState("");
const [markupFixedAmount, setMarkupFixedAmount] = useState("");
const [markupRemark, setMarkupRemark] = useState("");
const [savingMarkup, setSavingMarkup] = useState(false);
const [loadingMarkup, setLoadingMarkup] = useState(false);
const [markupError, setMarkupError] = useState<string | null>(null);
const [markupSuccess, setMarkupSuccess] = useState<string | null>(null);
const [usersTarget, setUsersTarget] = useState<BusinessCustomerRecord | null>(
null,
);
const [usersStatus, setUsersStatus] = useState<DataPageStatus>("empty");
const [users, setUsers] = useState<BusinessCustomerUserRecord[]>([]);
const [usersError, setUsersError] = useState<string | null>(null);
const [onlyWithUsers, setOnlyWithUsers] = useState(true);
const [allUsersOpen, setAllUsersOpen] = useState(false);
const [allUsersKeyword, setAllUsersKeyword] = useState("");
const [allUsersStatus, setAllUsersStatus] = useState<DataPageStatus>("empty");
const [allUsers, setAllUsers] = useState<BusinessCustomerUserRecord[]>([]);
const [allUsersError, setAllUsersError] = useState<string | null>(null);
const load = useCallback(async () => {
if (!token) return;
setStatus("loading");
setError(null);
const res = await adminGetBusinessCustomers("", token, tenantId);
if (res.code !== 0) {
setStatus("error");
setError(res.message);
return;
}
setRows(res.data.list);
setStatus(res.data.list.length ? "success" : "empty");
}, [tenantId, token]);
useEffect(() => {
void load();
}, [load]);
const sortedRows = useMemo(() => {
const filtered = onlyWithUsers
? rows.filter((r) => (r.user_count ?? 0) > 0)
: rows;
return [...filtered].sort((a, b) => {
const uc = (b.user_count ?? 0) - (a.user_count ?? 0);
if (uc !== 0) return uc;
const ac = (
a.external_code ||
a.name ||
a.business_customer_id
).toLowerCase();
const bc = (
b.external_code ||
b.name ||
b.business_customer_id
).toLowerCase();
return ac.localeCompare(bc);
});
}, [rows, onlyWithUsers]);
const loadAllUsers = useCallback(
async (keyword?: string) => {
if (!token) return;
setAllUsersStatus("loading");
setAllUsersError(null);
const res = await adminListTenantUsers("", token, tenantId, keyword);
if (res.code !== 0) {
setAllUsersStatus("error");
setAllUsersError(res.message);
return;
}
setAllUsers(res.data.list);
setAllUsersStatus(res.data.list.length ? "success" : "empty");
},
[tenantId, token],
);
const openEdit = (row: BusinessCustomerRecord) => {
setEditing(row);
setEditError(null);
setEditForm({
name: row.name,
external_code: row.external_code ?? "",
remark: row.remark ?? "",
status: row.status,
});
};
const handleCreate = async () => {
if (!token) return;
setCreating(true);
setCreateError(null);
const res = await adminCreateBusinessCustomer("", token, tenantId, createForm);
setCreating(false);
if (res.code !== 0) {
setCreateError(res.message);
return;
}
setCreateOpen(false);
setCreateForm({
business_customer_id: "",
name: "",
external_code: "",
remark: "",
});
await load();
};
const handleSaveEdit = async () => {
if (!token || !editing) return;
setSavingEdit(true);
setEditError(null);
const res = await adminUpdateBusinessCustomer(
"",
token,
tenantId,
editing.business_customer_id,
editForm,
);
setSavingEdit(false);
if (res.code !== 0) {
setEditError(res.message);
return;
}
setRows((list) =>
list.map((item) =>
item.business_customer_id === res.data.business_customer_id
? res.data
: item,
),
);
setEditing(null);
};
const openMarkup = async (row: BusinessCustomerRecord) => {
setMarkupTarget(row);
setMarkupType("percent");
setMarkupPercent("");
setMarkupFixedAmount("");
setMarkupRemark("");
setMarkupError(null);
setMarkupSuccess(null);
if (!token) {
setMarkupError("未登录或登录已失效,请重新登录后再保存");
return;
}
setLoadingMarkup(true);
const kw =
row.external_code?.trim() ||
row.business_customer_id ||
row.name ||
"";
const res = await adminGetMarkupConfigs("", token, 1, 50, kw);
setLoadingMarkup(false);
if (res.code !== 0) {
setMarkupError(`读取已有加价失败:${res.message}`);
return;
}
const found = res.data.list.find(
(c) =>
c.customer_id === tenantId &&
(c.business_customer_id ?? "") === row.business_customer_id,
);
if (!found) return;
setMarkupType(found.markup_type);
setMarkupPercent(String(found.markup_percent ?? ""));
setMarkupFixedAmount(
found.markup_fixed_amount !== null && found.markup_fixed_amount !== undefined
? String(found.markup_fixed_amount)
: "",
);
setMarkupRemark(found.remark ?? "");
setMarkupByBcId((m) => ({
...m,
[row.business_customer_id]: formatMarkupRule(found),
}));
};
const handleSaveMarkup = async () => {
if (!markupTarget) return;
if (!token) {
setMarkupError("未登录或登录已失效,请重新登录后再保存");
return;
}
setMarkupError(null);
setMarkupSuccess(null);
let percentValue = 0;
let fixedValue: number | undefined;
if (markupType === "percent") {
const raw = markupPercent.trim();
if (!raw) {
setMarkupError("请填写加价比例(0~30),空值不会保存");
return;
}
const n = Number(raw);
if (Number.isNaN(n) || n < 0 || n > 30) {
setMarkupError("加价比例须在 0~30% 之间");
return;
}
percentValue = n;
} else {
const raw = markupFixedAmount.trim();
if (!raw) {
setMarkupError("请填写固定加价金额(USD),空值不会保存");
return;
}
const n = Number(raw);
if (Number.isNaN(n) || n < 0) {
setMarkupError("固定加价金额须 ≥ 0");
return;
}
fixedValue = n;
}
setSavingMarkup(true);
const res = await adminUpdateMarkupConfig("", token, tenantId, {
business_customer_id: markupTarget.business_customer_id,
markup_type: markupType,
markup_percent: markupType === "percent" ? percentValue : 0,
markup_fixed_amount: markupType === "fixed" ? fixedValue : undefined,
remark: markupRemark,
});
setSavingMarkup(false);
if (res.code !== 0) {
setMarkupError(res.message || "保存失败,请稍后重试");
return;
}
const summary = formatMarkupRule(res.data);
setMarkupByBcId((m) => ({
...m,
[markupTarget.business_customer_id]: summary,
}));
setMarkupSuccess(`已保存:${summary}`);
};
return (
<AdminLayout>
<PageHeader
title="业务客户"
subtitle={`租户 ${tenantId} · 客户组织与登录账号(加价按客户代码,识别靠账号)`}
action={
<div className="flex flex-wrap gap-2">
<SecondaryButton
onClick={() => {
setAllUsersOpen(true);
void loadAllUsers(allUsersKeyword);
}}
>
全部登录账号
</SecondaryButton>
<PrimaryButton onClick={() => setCreateOpen(true)}>
新增业务客户
</PrimaryButton>
</div>
}
/>
{status === "loading" && (
<div className="space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
)}
{status === "error" && (
<ErrorBanner action={<SecondaryButton onClick={() => void load()}>重试</SecondaryButton>}>
{error ?? "加载业务客户失败"}
</ErrorBanner>
)}
{status === "empty" && (
<Card className="p-8 text-center text-text-secondary">
当前租户还没有业务客户
</Card>
)}
{status === "success" && (
<Card className="overflow-x-auto p-0">
<div className="flex flex-wrap items-center gap-3 border-b border-border px-4 py-3 text-sm">
<label className="inline-flex items-center gap-2 text-text-secondary">
<input
type="checkbox"
checked={onlyWithUsers}
onChange={(e) => setOnlyWithUsers(e.target.checked)}
/>
仅显示有登录账号的客户({rows.filter((r) => (r.user_count ?? 0) > 0).length}/
{rows.length})
</label>
</div>
<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">客户代码</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>
<th className="px-4 py-3 font-medium">操作</th>
</tr>
</thead>
<tbody>
{sortedRows.map((row) => (
<tr key={row.business_customer_id} className="border-t border-border">
<td className="px-4 py-3 font-mono">
{row.external_code || "—"}
</td>
<td className="px-4 py-3">{row.name}</td>
<td className="px-4 py-3 font-mono text-primary">
{markupByBcId[row.business_customer_id] ?? "—"}
</td>
<td className="px-4 py-3 font-mono">{row.user_count ?? 0}</td>
<td className="px-4 py-3">{row.status === "active" ? "启用" : "停用"}</td>
<td className="px-4 py-3 text-text-secondary">{row.remark || "—"}</td>
<td className="px-4 py-3 text-text-secondary">
{formatDateTime(row.updated_at)}
</td>
<td className="px-4 py-3">
<div className="flex flex-wrap gap-2">
<SecondaryButton onClick={() => openEdit(row)}>
编辑
</SecondaryButton>
<SecondaryButton
onClick={() => void openMarkup(row)}
>
客户加价
</SecondaryButton>
<SecondaryButton
onClick={async () => {
setUsersTarget(row);
setUsersError(null);
setUsers([]);
setUsersStatus("loading");
if (!token) return;
const res = await adminGetBusinessCustomerUsers(
"",
token,
tenantId,
row.business_customer_id,
);
if (res.code !== 0) {
setUsersStatus("error");
setUsersError(res.message);
return;
}
setUsers(res.data.list);
setUsersStatus(
res.data.list.length ? "success" : "empty",
);
}}
>
登录账号
</SecondaryButton>
</div>
</td>
</tr>
))}
</tbody>
</table>
</Card>
)}
{createOpen && (
<div
className="fixed inset-0 z-50 flex justify-end bg-black/30"
onClick={() => !creating && setCreateOpen(false)}
>
<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">新增业务客户</h3>
<div className="mt-6 space-y-4">
<InputField
label="客户 ID"
value={createForm.business_customer_id}
onChange={(e) =>
setCreateForm((prev) => ({
...prev,
business_customer_id: e.target.value,
}))
}
/>
<InputField
label="名称"
value={createForm.name}
onChange={(e) =>
setCreateForm((prev) => ({ ...prev, name: e.target.value }))
}
/>
<InputField
label="外部编码"
value={createForm.external_code}
onChange={(e) =>
setCreateForm((prev) => ({
...prev,
external_code: e.target.value,
}))
}
/>
<InputField
label="备注"
value={createForm.remark}
onChange={(e) =>
setCreateForm((prev) => ({ ...prev, remark: e.target.value }))
}
/>
{createError ? <ErrorBanner>{createError}</ErrorBanner> : null}
<div className="flex justify-end gap-2">
<SecondaryButton onClick={() => setCreateOpen(false)}>取消</SecondaryButton>
<PrimaryButton loading={creating} onClick={() => void handleCreate()}>
保存
</PrimaryButton>
</div>
</div>
</div>
</div>
)}
{editing && (
<div
className="fixed inset-0 z-50 flex justify-end bg-black/30"
onClick={() => !savingEdit && 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">编辑业务客户</h3>
<div className="mt-6 space-y-4">
<InputField
label="名称"
value={editForm.name}
onChange={(e) =>
setEditForm((prev) => ({ ...prev, name: e.target.value }))
}
/>
<InputField
label="外部编码"
value={editForm.external_code}
onChange={(e) =>
setEditForm((prev) => ({
...prev,
external_code: e.target.value,
}))
}
/>
<SelectField
label="状态"
value={editForm.status}
options={[
{ value: "active", label: "启用" },
{ value: "disabled", label: "停用" },
]}
onChange={(e) =>
setEditForm((prev) => ({
...prev,
status: e.target.value as "active" | "disabled",
}))
}
/>
<InputField
label="备注"
value={editForm.remark}
onChange={(e) =>
setEditForm((prev) => ({ ...prev, remark: e.target.value }))
}
/>
{editError ? <ErrorBanner>{editError}</ErrorBanner> : null}
<div className="flex justify-end gap-2">
<SecondaryButton onClick={() => setEditing(null)}>取消</SecondaryButton>
<PrimaryButton loading={savingEdit} onClick={() => void handleSaveEdit()}>
保存
</PrimaryButton>
</div>
</div>
</div>
</div>
)}
{markupTarget && (
<div
className="fixed inset-0 z-50 flex justify-end bg-black/30"
onClick={() => !savingMarkup && setMarkupTarget(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">
客户加价 ·{" "}
{[markupTarget.external_code, markupTarget.name]
.filter(Boolean)
.join(" · ") || markupTarget.business_customer_id}
</h3>
<p className="mt-1 text-sm text-text-secondary">
仅对本业务客户生效;未配置时询价按 0% 处理。
</p>
<div className="mt-6 space-y-4">
{loadingMarkup ? (
<Skeleton className="h-10 w-full" />
) : null}
<SelectField
label="加价方式"
value={markupType}
disabled={savingMarkup || loadingMarkup}
options={[
{ value: "percent", label: "按运费百分比" },
{ value: "fixed", label: "固定金额(USD)" },
]}
onChange={(e) => {
setMarkupType(e.target.value as MarkupType);
setMarkupError(null);
setMarkupSuccess(null);
}}
/>
{markupType === "percent" ? (
<InputField
label="加价比例(%)"
type="number"
step="0.01"
min={0}
max={30}
value={markupPercent}
disabled={savingMarkup || loadingMarkup}
onChange={(e) => {
setMarkupPercent(e.target.value);
setMarkupError(null);
setMarkupSuccess(null);
}}
/>
) : (
<InputField
label="固定金额(USD)"
type="number"
step="0.01"
min={0}
value={markupFixedAmount}
disabled={savingMarkup || loadingMarkup}
onChange={(e) => {
setMarkupFixedAmount(e.target.value);
setMarkupError(null);
setMarkupSuccess(null);
}}
/>
)}
<InputField
label="备注"
value={markupRemark}
disabled={savingMarkup || loadingMarkup}
onChange={(e) => setMarkupRemark(e.target.value)}
/>
<Card className="bg-bg text-sm text-text-secondary">
保存后按客户组织 ID 匹配加价。百分比上限 30%;固定金额为每档报价叠加。空值不会保存,也不会覆盖已有规则。
</Card>
{markupError ? <ErrorBanner>{markupError}</ErrorBanner> : null}
{markupSuccess ? (
<Card className="border border-emerald-200 bg-emerald-50 text-sm text-emerald-900">
{markupSuccess}
</Card>
) : null}
<div className="flex justify-end gap-2">
<SecondaryButton
type="button"
disabled={savingMarkup}
onClick={() => setMarkupTarget(null)}
>
{markupSuccess ? "关闭" : "取消"}
</SecondaryButton>
<PrimaryButton
type="button"
loading={savingMarkup}
disabled={loadingMarkup}
onClick={() => void handleSaveMarkup()}
>
保存
</PrimaryButton>
</div>
</div>
</div>
</div>
)}
{usersTarget && (
<div
className="fixed inset-0 z-50 flex justify-end bg-black/30"
onClick={() => setUsersTarget(null)}
>
<div
className="flex h-full w-full max-w-lg flex-col bg-surface p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold">
登录账号 ·{" "}
{[usersTarget.external_code, usersTarget.name]
.filter(Boolean)
.join(" · ") || usersTarget.business_customer_id}
</h3>
<p className="mt-1 text-sm text-text-secondary">
来自 ccnew PC/Web 登录账号(LR_Base_User),用于询价识别加价客户。
</p>
<div className="mt-4 flex-1 overflow-auto">
{usersStatus === "loading" ? <Skeleton className="h-24 w-full" /> : null}
{usersError ? <ErrorBanner>{usersError}</ErrorBanner> : null}
{usersStatus === "empty" ? (
<p className="text-sm text-text-secondary">暂无同步账号</p>
) : null}
{usersStatus === "success" ? (
<table className="min-w-full text-sm">
<thead className="bg-bg text-left text-text-secondary">
<tr>
<th className="px-3 py-2 font-medium">账号</th>
<th className="px-3 py-2 font-medium">姓名</th>
<th className="px-3 py-2 font-medium">状态</th>
</tr>
</thead>
<tbody>
{users.map((u) => (
<tr
key={u.external_user_id}
className="border-t border-border"
>
<td className="px-3 py-2 font-mono">{u.account}</td>
<td className="px-3 py-2">{u.display_name || "—"}</td>
<td className="px-3 py-2">
{u.status === "active" ? "启用" : "停用"}
</td>
</tr>
))}
</tbody>
</table>
) : null}
</div>
<div className="mt-4 flex justify-end">
<SecondaryButton onClick={() => setUsersTarget(null)}>关闭</SecondaryButton>
</div>
</div>
</div>
)}
{allUsersOpen && (
<div
className="fixed inset-0 z-50 flex justify-end bg-black/30"
onClick={() => setAllUsersOpen(false)}
>
<div
className="flex h-full w-full max-w-2xl flex-col bg-surface p-6 shadow-lg"
onClick={(e) => e.stopPropagation()}
>
<h3 className="text-lg font-semibold">全部登录账号</h3>
<p className="mt-1 text-sm text-text-secondary">
对应 ccnew「用户管理」中的客户账号;询价靠账号识别所属客户代码再套加价。
</p>
<div className="mt-4 flex gap-2">
<InputField
label="搜索账号 / 客户代码 / 名称"
value={allUsersKeyword}
onChange={(e) => setAllUsersKeyword(e.target.value)}
/>
<div className="flex items-end">
<SecondaryButton onClick={() => void loadAllUsers(allUsersKeyword)}>
查询
</SecondaryButton>
</div>
</div>
<div className="mt-4 flex-1 overflow-auto">
{allUsersStatus === "loading" ? (
<Skeleton className="h-24 w-full" />
) : null}
{allUsersError ? <ErrorBanner>{allUsersError}</ErrorBanner> : null}
{allUsersStatus === "empty" ? (
<p className="text-sm text-text-secondary">暂无账号</p>
) : null}
{allUsersStatus === "success" ? (
<table className="min-w-full text-sm">
<thead className="bg-bg text-left text-text-secondary">
<tr>
<th className="px-3 py-2 font-medium">账号</th>
<th className="px-3 py-2 font-medium">姓名</th>
<th className="px-3 py-2 font-medium">客户代码</th>
<th className="px-3 py-2 font-medium">客户名称</th>
<th className="px-3 py-2 font-medium">状态</th>
</tr>
</thead>
<tbody>
{allUsers.map((u) => (
<tr
key={u.external_user_id}
className="border-t border-border"
>
<td className="px-3 py-2 font-mono">{u.account}</td>
<td className="px-3 py-2">{u.display_name || "—"}</td>
<td className="px-3 py-2 font-mono">
{u.business_customer_code || "—"}
</td>
<td className="px-3 py-2">
{u.business_customer_name || "—"}
</td>
<td className="px-3 py-2">
{u.status === "active" ? "启用" : "停用"}
</td>
</tr>
))}
</tbody>
</table>
) : null}
</div>
<div className="mt-4 flex justify-end">
<SecondaryButton onClick={() => setAllUsersOpen(false)}>关闭</SecondaryButton>
</div>
</div>
</div>
)}
</AdminLayout>
);
}