feat(P16C-4,P16C-6): 完成供应商主数据前端与矩阵收口
这个提交包含在:
@@ -94,6 +94,27 @@ export type HolidayCalendarDayItem = {
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type SupplierItem = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
shortName: string | null;
|
||||
taxNo: string | null;
|
||||
contactName: string | null;
|
||||
contactPhone: string | null;
|
||||
paymentTerms: string | null;
|
||||
legalEntityId: string | null;
|
||||
enabled: boolean;
|
||||
archivedAt: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
legalEntity: {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type MasterDataListResponse<T> = {
|
||||
includeArchived: boolean;
|
||||
items: T[];
|
||||
@@ -345,3 +366,61 @@ export async function replaceHolidayCalendarDays(
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchSuppliers(params: {
|
||||
includeArchived?: boolean;
|
||||
enabled?: boolean;
|
||||
legalEntityId?: string;
|
||||
q?: string;
|
||||
} = {}) {
|
||||
return apiRequestData<MasterDataListResponse<SupplierItem>>(
|
||||
`/api/master-data/suppliers${toQueryString(params as Record<string, string | boolean | undefined>)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createSupplier(payload: {
|
||||
code: string;
|
||||
name: string;
|
||||
shortName?: string | null;
|
||||
taxNo?: string | null;
|
||||
contactName?: string | null;
|
||||
contactPhone?: string | null;
|
||||
paymentTerms?: string | null;
|
||||
legalEntityId?: string | null;
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
return apiRequestData<{ supplier: SupplierItem }>("/api/master-data/suppliers", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function patchSupplier(
|
||||
supplierId: string,
|
||||
payload: Partial<{
|
||||
code: string;
|
||||
name: string;
|
||||
shortName: string | null;
|
||||
taxNo: string | null;
|
||||
contactName: string | null;
|
||||
contactPhone: string | null;
|
||||
paymentTerms: string | null;
|
||||
legalEntityId: string | null;
|
||||
enabled: boolean;
|
||||
}>,
|
||||
) {
|
||||
return apiRequestData<{ supplier: SupplierItem }>(`/api/master-data/suppliers/${supplierId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function archiveSupplier(supplierId: string) {
|
||||
return apiRequestData<{ supplier: SupplierItem; outcome: string }>(
|
||||
`/api/master-data/suppliers/${supplierId}/archive`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,360 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { mapApiErrorMessage } from "../../api/errorMessages";
|
||||
import {
|
||||
archiveSupplier,
|
||||
createSupplier,
|
||||
fetchSuppliers,
|
||||
type LegalEntityItem,
|
||||
type SupplierItem,
|
||||
} from "../../api/masterData";
|
||||
import { isApiError } from "../../api/types";
|
||||
import { ConfirmDialog } from "../common/ConfirmDialog";
|
||||
import { StatusBadge } from "../common/StatusBadge";
|
||||
import { TableSkeleton } from "../common/TableSkeleton";
|
||||
import { Button } from "../ui/button";
|
||||
import { Input } from "../ui/input";
|
||||
import { Label } from "../ui/label";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../ui/table";
|
||||
import { Textarea } from "../ui/textarea";
|
||||
|
||||
type SupplierTabProps = {
|
||||
canManage: boolean;
|
||||
includeArchived: boolean;
|
||||
legalEntities: LegalEntityItem[];
|
||||
};
|
||||
|
||||
function resolveError(error: unknown) {
|
||||
if (isApiError(error)) {
|
||||
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
return "操作失败,请稍后重试。";
|
||||
}
|
||||
|
||||
function toEnabledFilter(value: "" | "true" | "false") {
|
||||
if (value === "") {
|
||||
return undefined;
|
||||
}
|
||||
return value === "true";
|
||||
}
|
||||
|
||||
function renderEnabledBadge(item: { archivedAt: string | null; enabled: boolean }) {
|
||||
if (item.archivedAt) {
|
||||
return <StatusBadge status="archived" />;
|
||||
}
|
||||
if (item.enabled) {
|
||||
return <StatusBadge status="active" />;
|
||||
}
|
||||
return <StatusBadge status="archived" label="已停用" className="badge-neutral" />;
|
||||
}
|
||||
|
||||
function renderLegalEntityLabel(item: SupplierItem) {
|
||||
if (!item.legalEntity) {
|
||||
return <span className="text-zinc-400">全局</span>;
|
||||
}
|
||||
return `${item.legalEntity.code} / ${item.legalEntity.name}`;
|
||||
}
|
||||
|
||||
export function SupplierTab({ canManage, includeArchived, legalEntities }: SupplierTabProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [error, setError] = useState("");
|
||||
const [enabledFilter, setEnabledFilter] = useState<"" | "true" | "false">("");
|
||||
const [legalEntityFilter, setLegalEntityFilter] = useState("");
|
||||
const [keyword, setKeyword] = useState("");
|
||||
|
||||
const [supplierCode, setSupplierCode] = useState("");
|
||||
const [supplierName, setSupplierName] = useState("");
|
||||
const [supplierShortName, setSupplierShortName] = useState("");
|
||||
const [supplierTaxNo, setSupplierTaxNo] = useState("");
|
||||
const [supplierContactName, setSupplierContactName] = useState("");
|
||||
const [supplierContactPhone, setSupplierContactPhone] = useState("");
|
||||
const [supplierPaymentTerms, setSupplierPaymentTerms] = useState("");
|
||||
const [supplierLegalEntityId, setSupplierLegalEntityId] = useState("");
|
||||
const [confirmArchiveId, setConfirmArchiveId] = useState<string | null>(null);
|
||||
|
||||
const suppliersQuery = useQuery({
|
||||
queryKey: ["master-data", "suppliers", includeArchived, enabledFilter, legalEntityFilter, keyword],
|
||||
queryFn: () =>
|
||||
fetchSuppliers({
|
||||
includeArchived,
|
||||
enabled: toEnabledFilter(enabledFilter),
|
||||
legalEntityId: legalEntityFilter || undefined,
|
||||
q: keyword.trim() || undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const suppliers = suppliersQuery.data?.items ?? [];
|
||||
const activeLegalEntities = useMemo(
|
||||
() => legalEntities.filter((item) => !item.archivedAt),
|
||||
[legalEntities],
|
||||
);
|
||||
|
||||
const invalidateSuppliers = async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["master-data", "suppliers"] });
|
||||
};
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: async () =>
|
||||
createSupplier({
|
||||
code: supplierCode,
|
||||
name: supplierName,
|
||||
shortName: supplierShortName || null,
|
||||
taxNo: supplierTaxNo || null,
|
||||
contactName: supplierContactName || null,
|
||||
contactPhone: supplierContactPhone || null,
|
||||
paymentTerms: supplierPaymentTerms || null,
|
||||
legalEntityId: supplierLegalEntityId || null,
|
||||
}),
|
||||
onSuccess: async () => {
|
||||
await invalidateSuppliers();
|
||||
setSupplierCode("");
|
||||
setSupplierName("");
|
||||
setSupplierShortName("");
|
||||
setSupplierTaxNo("");
|
||||
setSupplierContactName("");
|
||||
setSupplierContactPhone("");
|
||||
setSupplierPaymentTerms("");
|
||||
setSupplierLegalEntityId("");
|
||||
setError("");
|
||||
toast.success("供应商已创建");
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
const message = resolveError(mutationError);
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const archiveMutation = useMutation({
|
||||
mutationFn: async (supplierId: string) => archiveSupplier(supplierId),
|
||||
onSuccess: async () => {
|
||||
await invalidateSuppliers();
|
||||
setConfirmArchiveId(null);
|
||||
setError("");
|
||||
toast.success("供应商已归档");
|
||||
},
|
||||
onError: (mutationError) => {
|
||||
const message = resolveError(mutationError);
|
||||
setConfirmArchiveId(null);
|
||||
setError(message);
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{error ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</p>
|
||||
) : null}
|
||||
|
||||
<div className="card-std p-6 space-y-4">
|
||||
<div className="grid gap-3 md:grid-cols-3">
|
||||
<select
|
||||
className="select-std"
|
||||
value={enabledFilter}
|
||||
onChange={(event) => setEnabledFilter(event.target.value as "" | "true" | "false")}
|
||||
>
|
||||
<option value="">全部状态</option>
|
||||
<option value="true">仅启用</option>
|
||||
<option value="false">仅停用</option>
|
||||
</select>
|
||||
<select
|
||||
className="select-std"
|
||||
value={legalEntityFilter}
|
||||
onChange={(event) => setLegalEntityFilter(event.target.value)}
|
||||
>
|
||||
<option value="">全部法体</option>
|
||||
{activeLegalEntities.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.code} / {item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<Input
|
||||
value={keyword}
|
||||
onChange={(event) => setKeyword(event.target.value)}
|
||||
placeholder="搜索编码 / 名称 / 税号 / 联系人"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{canManage ? (
|
||||
<div className="card-std p-6 space-y-4">
|
||||
<h3 className="text-base font-semibold text-zinc-900">新建供应商</h3>
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="supplierCode">编码</Label>
|
||||
<Input
|
||||
id="supplierCode"
|
||||
value={supplierCode}
|
||||
onChange={(event) => setSupplierCode(event.target.value)}
|
||||
placeholder="VENDOR_001"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<Label htmlFor="supplierName">名称</Label>
|
||||
<Input
|
||||
id="supplierName"
|
||||
value={supplierName}
|
||||
onChange={(event) => setSupplierName(event.target.value)}
|
||||
placeholder="海音达演示供应商"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="supplierShortName">简称</Label>
|
||||
<Input
|
||||
id="supplierShortName"
|
||||
value={supplierShortName}
|
||||
onChange={(event) => setSupplierShortName(event.target.value)}
|
||||
placeholder="演示供应商"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="supplierTaxNo">税号</Label>
|
||||
<Input
|
||||
id="supplierTaxNo"
|
||||
value={supplierTaxNo}
|
||||
onChange={(event) => setSupplierTaxNo(event.target.value)}
|
||||
placeholder="9144..."
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="supplierContactName">联系人</Label>
|
||||
<Input
|
||||
id="supplierContactName"
|
||||
value={supplierContactName}
|
||||
onChange={(event) => setSupplierContactName(event.target.value)}
|
||||
placeholder="张采购"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="supplierContactPhone">联系电话</Label>
|
||||
<Input
|
||||
id="supplierContactPhone"
|
||||
value={supplierContactPhone}
|
||||
onChange={(event) => setSupplierContactPhone(event.target.value)}
|
||||
placeholder="0755-12345678"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 md:col-span-4">
|
||||
<Label htmlFor="supplierPaymentTerms">账期说明</Label>
|
||||
<Textarea
|
||||
id="supplierPaymentTerms"
|
||||
value={supplierPaymentTerms}
|
||||
onChange={(event) => setSupplierPaymentTerms(event.target.value)}
|
||||
placeholder="例如:月结30天 / 预付50% + 到货结50%"
|
||||
rows={2}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1 md:col-span-2">
|
||||
<Label htmlFor="supplierLegalEntity">所属法体(可选)</Label>
|
||||
<select
|
||||
id="supplierLegalEntity"
|
||||
className="select-std"
|
||||
value={supplierLegalEntityId}
|
||||
onChange={(event) => setSupplierLegalEntityId(event.target.value)}
|
||||
>
|
||||
<option value="">全局供应商</option>
|
||||
{activeLegalEntities.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.code} / {item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => createMutation.mutate()}
|
||||
disabled={
|
||||
createMutation.isPending || !supplierCode.trim() || !supplierName.trim()
|
||||
}
|
||||
>
|
||||
{createMutation.isPending ? "创建中..." : "创建供应商"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="card-std p-6 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-zinc-900">供应商列表</h3>
|
||||
<span className="text-sm text-zinc-500">共 {suppliers.length} 条</span>
|
||||
</div>
|
||||
|
||||
{suppliersQuery.isLoading ? (
|
||||
<TableSkeleton rows={6} columns={6} />
|
||||
) : suppliers.length ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>编码 / 名称</TableHead>
|
||||
<TableHead>法体</TableHead>
|
||||
<TableHead>联系人</TableHead>
|
||||
<TableHead>税号 / 账期</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{suppliers.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">{item.code}</div>
|
||||
<div className="text-sm text-zinc-500">{item.name}</div>
|
||||
{item.shortName ? <div className="text-xs text-zinc-400">简称:{item.shortName}</div> : null}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-zinc-600">{renderLegalEntityLabel(item)}</TableCell>
|
||||
<TableCell className="text-sm text-zinc-600">
|
||||
<div>{item.contactName || "-"}</div>
|
||||
<div className="text-xs text-zinc-400">{item.contactPhone || "-"}</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-zinc-600">
|
||||
<div>{item.taxNo || "-"}</div>
|
||||
<div className="text-xs text-zinc-400 line-clamp-2">{item.paymentTerms || "-"}</div>
|
||||
</TableCell>
|
||||
<TableCell>{renderEnabledBadge(item)}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{canManage && !item.archivedAt ? (
|
||||
<Button type="button" variant="outline" onClick={() => setConfirmArchiveId(item.id)}>
|
||||
归档
|
||||
</Button>
|
||||
) : (
|
||||
<span className="text-xs text-zinc-400">-</span>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="rounded-xl border border-dashed border-zinc-200 bg-zinc-50 px-4 py-8 text-center text-sm text-zinc-500">
|
||||
当前筛选条件下暂无供应商。
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(confirmArchiveId)}
|
||||
title="归档供应商"
|
||||
description="归档后该供应商将不能被后续业务单据选择。是否继续?"
|
||||
confirmLabel={archiveMutation.isPending ? "归档中..." : "确认归档"}
|
||||
onConfirm={() => {
|
||||
if (confirmArchiveId) {
|
||||
archiveMutation.mutate(confirmArchiveId);
|
||||
}
|
||||
}}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !archiveMutation.isPending) {
|
||||
setConfirmArchiveId(null);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import { useAuth } from "../auth/AuthContext";
|
||||
import { hasAnyPermission, hasPermission } from "../auth/permissions";
|
||||
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
||||
import { HolidayCalendarTab } from "../components/master-data/HolidayCalendarTab";
|
||||
import { SupplierTab } from "../components/master-data/SupplierTab";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||
@@ -78,7 +79,7 @@ export function MasterDataPage() {
|
||||
const canView = hasAnyPermission(permissions, ["masterdata.view", "masterdata.manage"]);
|
||||
const canManage = hasPermission(permissions, "masterdata.manage");
|
||||
|
||||
const [tab, setTab] = useState<"legal-entities" | "cost-centers" | "document-categories" | "holiday-calendars">(
|
||||
const [tab, setTab] = useState<"legal-entities" | "cost-centers" | "document-categories" | "holiday-calendars" | "suppliers">(
|
||||
"legal-entities",
|
||||
);
|
||||
const [includeArchived, setIncludeArchived] = useState(false);
|
||||
@@ -302,7 +303,7 @@ export function MasterDataPage() {
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="主数据"
|
||||
subtitle="集中维护法体、成本中心、单据分类与日历,为预算、采购、请假与资产扩展提供统一骨架。"
|
||||
subtitle="集中维护法体、成本中心、单据分类、日历与供应商,为预算、采购、请假与资产扩展提供统一骨架。"
|
||||
actions={
|
||||
<Button variant="secondary" type="button" onClick={() => setIncludeArchived((value) => !value)}>
|
||||
{includeArchived ? "隐藏归档" : "显示归档"}
|
||||
@@ -320,6 +321,7 @@ export function MasterDataPage() {
|
||||
<TabsTrigger value="cost-centers">成本中心</TabsTrigger>
|
||||
<TabsTrigger value="document-categories">单据分类</TabsTrigger>
|
||||
<TabsTrigger value="holiday-calendars">日历</TabsTrigger>
|
||||
<TabsTrigger value="suppliers">供应商</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="legal-entities" className="space-y-6">
|
||||
@@ -762,6 +764,13 @@ export function MasterDataPage() {
|
||||
legalEntities={legalEntities}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="suppliers" className="space-y-6">
|
||||
<SupplierTab
|
||||
canManage={canManage}
|
||||
includeArchived={includeArchived}
|
||||
legalEntities={legalEntities}
|
||||
/>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<ConfirmDialog
|
||||
|
||||
在新工单中引用
屏蔽一个用户