feat(P18A-3,P18A-4): 实现资产台账接口与页面入口
这个提交包含在:
@@ -0,0 +1,196 @@
|
||||
import { apiRequestData } from "./http";
|
||||
|
||||
export type AssetStatus = "in_stock" | "in_use" | "under_repair" | "retired" | "archived";
|
||||
|
||||
export type AssetCategoryItem = {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
documentCategoryId: string | null;
|
||||
enabled: boolean;
|
||||
archivedAt: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
documentCategory: {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
entityType: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type AssetAssigneeOption = {
|
||||
id: string;
|
||||
employeeNo: string;
|
||||
name: string;
|
||||
department: {
|
||||
id: string;
|
||||
name: string;
|
||||
} | null;
|
||||
};
|
||||
|
||||
export type AssetItem = {
|
||||
id: string;
|
||||
assetNo: string;
|
||||
name: string;
|
||||
categoryId: string;
|
||||
legalEntityId: string;
|
||||
costCenterId: string;
|
||||
purchaseRequestId: string | null;
|
||||
serialNo: string | null;
|
||||
location: string | null;
|
||||
status: AssetStatus;
|
||||
assigneeEmployeeId: string | null;
|
||||
activatedAt: string | null;
|
||||
retiredAt: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
category: {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
documentCategoryId: string | null;
|
||||
documentCategory: {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
} | null;
|
||||
} | null;
|
||||
legalEntity: {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
} | null;
|
||||
costCenter: {
|
||||
id: string;
|
||||
code: string;
|
||||
name: string;
|
||||
legalEntityId: string;
|
||||
} | null;
|
||||
purchaseRequest: {
|
||||
id: string;
|
||||
requestNo: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
} | null;
|
||||
assignee: {
|
||||
id: string;
|
||||
employeeNo: string;
|
||||
name: string;
|
||||
department: {
|
||||
id: string;
|
||||
name: string;
|
||||
} | null;
|
||||
} | null;
|
||||
permissions: {
|
||||
canEdit: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
export type AssetCategoryListResponse = {
|
||||
includeArchived: boolean;
|
||||
items: AssetCategoryItem[];
|
||||
};
|
||||
|
||||
export type AssetListResponse = {
|
||||
mine: boolean;
|
||||
items: AssetItem[];
|
||||
};
|
||||
|
||||
export type AssetListQuery = {
|
||||
status?: AssetStatus;
|
||||
categoryId?: string;
|
||||
assigneeId?: string;
|
||||
legalEntityId?: string;
|
||||
costCenterId?: string;
|
||||
mine?: boolean;
|
||||
};
|
||||
|
||||
export type AssetPayload = {
|
||||
name: string;
|
||||
categoryId: string;
|
||||
legalEntityId: string;
|
||||
costCenterId: string;
|
||||
purchaseRequestId?: string | null;
|
||||
serialNo?: string | null;
|
||||
location?: string | null;
|
||||
status?: AssetStatus;
|
||||
assigneeEmployeeId?: string | null;
|
||||
activatedAt?: string | null;
|
||||
retiredAt?: string | null;
|
||||
};
|
||||
|
||||
function toQueryString(query: Record<string, string | boolean | undefined>) {
|
||||
const params = new URLSearchParams();
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
if (value == null || value === "") {
|
||||
continue;
|
||||
}
|
||||
params.set(key, typeof value === "boolean" ? (value ? "1" : "0") : value);
|
||||
}
|
||||
const encoded = params.toString();
|
||||
return encoded ? `?${encoded}` : "";
|
||||
}
|
||||
|
||||
export async function fetchAssetCategories(params: { includeArchived?: boolean; enabled?: boolean } = {}) {
|
||||
return apiRequestData<AssetCategoryListResponse>(
|
||||
`/api/asset-categories${toQueryString(params as Record<string, string | boolean | undefined>)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function createAssetCategory(payload: {
|
||||
code: string;
|
||||
name: string;
|
||||
documentCategoryId?: string | null;
|
||||
enabled?: boolean;
|
||||
}) {
|
||||
return apiRequestData<{ assetCategory: AssetCategoryItem }>("/api/asset-categories", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function patchAssetCategory(
|
||||
assetCategoryId: string,
|
||||
payload: Partial<{
|
||||
code: string;
|
||||
name: string;
|
||||
documentCategoryId: string | null;
|
||||
enabled: boolean;
|
||||
}>,
|
||||
) {
|
||||
return apiRequestData<{ assetCategory: AssetCategoryItem }>(`/api/asset-categories/${assetCategoryId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchAssetAssignees(params: { q?: string } = {}) {
|
||||
return apiRequestData<{ items: AssetAssigneeOption[] }>(
|
||||
`/api/assets/assignees${toQueryString(params as Record<string, string | boolean | undefined>)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchAssets(params: AssetListQuery = {}) {
|
||||
return apiRequestData<AssetListResponse>(
|
||||
`/api/assets${toQueryString(params as Record<string, string | boolean | undefined>)}`,
|
||||
);
|
||||
}
|
||||
|
||||
export async function fetchAssetDetail(assetId: string) {
|
||||
return apiRequestData<{ asset: AssetItem }>(`/api/assets/${assetId}`);
|
||||
}
|
||||
|
||||
export async function createAsset(payload: AssetPayload) {
|
||||
return apiRequestData<{ asset: AssetItem }>("/api/assets", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
|
||||
export async function patchAsset(assetId: string, payload: Partial<Omit<AssetPayload, "purchaseRequestId">>) {
|
||||
return apiRequestData<{ asset: AssetItem }>(`/api/assets/${assetId}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
}
|
||||
@@ -11,6 +11,10 @@ const STATUS_MAP: Record<string, { label: string; className: string }> = {
|
||||
completed: { label: "已完成", className: "badge-success" },
|
||||
available: { label: "可用", className: "badge-success" },
|
||||
active: { label: "生效中", className: "badge-success" },
|
||||
in_stock: { label: "库存中", className: "badge-primary" },
|
||||
in_use: { label: "使用中", className: "badge-success" },
|
||||
under_repair: { label: "维修中", className: "badge-warning" },
|
||||
retired: { label: "已退役", className: "badge-neutral" },
|
||||
paid: { label: "已支付", className: "badge-success" },
|
||||
bound: { label: "已绑定", className: "badge-success" },
|
||||
rejected: { label: "已驳回", className: "badge-error" },
|
||||
|
||||
@@ -36,6 +36,12 @@ export const NAV_ITEMS: NavItem[] = [
|
||||
icon: "fact_check",
|
||||
anyOf: ["expense.approve", "leave.approve", "procurement.approve"],
|
||||
},
|
||||
{
|
||||
to: "/assets",
|
||||
label: "资产台账",
|
||||
icon: "inventory_2",
|
||||
anyOf: ["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"],
|
||||
},
|
||||
{
|
||||
to: "/invoice-pool",
|
||||
label: "票据中心",
|
||||
|
||||
@@ -0,0 +1,680 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { mapApiErrorMessage } from "../api/errorMessages";
|
||||
import {
|
||||
createAsset,
|
||||
createAssetCategory,
|
||||
fetchAssetAssignees,
|
||||
fetchAssetCategories,
|
||||
fetchAssetDetail,
|
||||
fetchAssets,
|
||||
patchAsset,
|
||||
patchAssetCategory,
|
||||
type AssetCategoryItem,
|
||||
type AssetStatus,
|
||||
} from "../api/assets";
|
||||
import { fetchPurchaseRequests } from "../api/purchaseRequests";
|
||||
import { fetchCostCenters, fetchDocumentCategories, fetchLegalEntities } from "../api/masterData";
|
||||
import { isApiError } from "../api/types";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasAnyPermission, hasPermission } from "../auth/permissions";
|
||||
import { PageHeader } from "../components/common/PageHeader";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { TableSkeleton } from "../components/common/TableSkeleton";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../components/ui/table";
|
||||
|
||||
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 initialCategoryForm() {
|
||||
return {
|
||||
code: "",
|
||||
name: "",
|
||||
documentCategoryId: "",
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
|
||||
function initialAssetForm() {
|
||||
return {
|
||||
name: "",
|
||||
categoryId: "",
|
||||
legalEntityId: "",
|
||||
costCenterId: "",
|
||||
purchaseRequestId: "",
|
||||
serialNo: "",
|
||||
location: "",
|
||||
status: "in_stock" as AssetStatus,
|
||||
assigneeEmployeeId: "",
|
||||
activatedAt: "",
|
||||
retiredAt: "",
|
||||
};
|
||||
}
|
||||
|
||||
function assetStatusOptions(): Array<{ value: AssetStatus | ""; label: string }> {
|
||||
return [
|
||||
{ value: "", label: "全部状态" },
|
||||
{ value: "in_stock", label: "库存中" },
|
||||
{ value: "in_use", label: "使用中" },
|
||||
{ value: "under_repair", label: "维修中" },
|
||||
{ value: "retired", label: "已退役" },
|
||||
{ value: "archived", label: "已归档" },
|
||||
];
|
||||
}
|
||||
|
||||
export function AssetRegistryPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const { permissions } = useAuth();
|
||||
|
||||
const canManage = hasPermission(permissions, "asset.manage");
|
||||
const canViewAll = hasAnyPermission(permissions, ["asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]);
|
||||
const canReadProcurementSources = canManage && hasAnyPermission(permissions, ["procurement.view_all", "procurement.manage"]);
|
||||
|
||||
const [categoryForm, setCategoryForm] = useState(initialCategoryForm);
|
||||
const [editingCategoryId, setEditingCategoryId] = useState("");
|
||||
|
||||
const [statusFilter, setStatusFilter] = useState<AssetStatus | "">("");
|
||||
const [categoryFilter, setCategoryFilter] = useState("");
|
||||
const [legalEntityFilter, setLegalEntityFilter] = useState("");
|
||||
const [costCenterFilter, setCostCenterFilter] = useState("");
|
||||
const [assigneeFilter, setAssigneeFilter] = useState("");
|
||||
const [mine, setMine] = useState(!canViewAll);
|
||||
|
||||
const [selectedAssetId, setSelectedAssetId] = useState("");
|
||||
const [isCreatingAsset, setIsCreatingAsset] = useState(false);
|
||||
const [assetForm, setAssetForm] = useState(initialAssetForm);
|
||||
const [assetFormError, setAssetFormError] = useState("");
|
||||
|
||||
const categoriesQuery = useQuery({
|
||||
queryKey: ["assets", "categories", canManage],
|
||||
queryFn: () => fetchAssetCategories({ includeArchived: canManage, enabled: canManage ? undefined : true }),
|
||||
});
|
||||
|
||||
const assetsQuery = useQuery({
|
||||
queryKey: ["assets", "list", statusFilter, categoryFilter, legalEntityFilter, costCenterFilter, assigneeFilter, mine, canViewAll],
|
||||
queryFn: () =>
|
||||
fetchAssets({
|
||||
status: statusFilter || undefined,
|
||||
categoryId: categoryFilter || undefined,
|
||||
legalEntityId: legalEntityFilter || undefined,
|
||||
costCenterId: costCenterFilter || undefined,
|
||||
assigneeId: assigneeFilter || undefined,
|
||||
mine: canViewAll ? mine : true,
|
||||
}),
|
||||
});
|
||||
|
||||
const assetDetailQuery = useQuery({
|
||||
enabled: Boolean(selectedAssetId) && !isCreatingAsset,
|
||||
queryKey: ["assets", "detail", selectedAssetId],
|
||||
queryFn: () => fetchAssetDetail(selectedAssetId),
|
||||
});
|
||||
|
||||
const legalEntitiesQuery = useQuery({
|
||||
queryKey: ["assets", "legal-entities"],
|
||||
queryFn: () => fetchLegalEntities({ enabled: true }),
|
||||
});
|
||||
|
||||
const costCentersQuery = useQuery({
|
||||
queryKey: ["assets", "cost-centers"],
|
||||
queryFn: () => fetchCostCenters({ enabled: true }),
|
||||
});
|
||||
|
||||
const documentCategoriesQuery = useQuery({
|
||||
queryKey: ["assets", "document-categories"],
|
||||
queryFn: () => fetchDocumentCategories({ entityType: "asset_card", enabled: true }),
|
||||
});
|
||||
|
||||
const assigneesQuery = useQuery({
|
||||
enabled: canManage,
|
||||
queryKey: ["assets", "assignees"],
|
||||
queryFn: () => fetchAssetAssignees(),
|
||||
});
|
||||
|
||||
const approvedPurchasesQuery = useQuery({
|
||||
enabled: canReadProcurementSources,
|
||||
queryKey: ["assets", "approved-purchases"],
|
||||
queryFn: () => fetchPurchaseRequests({ status: "approved", mine: false, page: 1, pageSize: 100 }),
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (isCreatingAsset) {
|
||||
return;
|
||||
}
|
||||
if (selectedAssetId) {
|
||||
if (assetDetailQuery.isLoading) {
|
||||
return;
|
||||
}
|
||||
if (!assetDetailQuery.isError) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
const nextId = assetsQuery.data?.items?.[0]?.id ?? "";
|
||||
if (nextId !== selectedAssetId) {
|
||||
setSelectedAssetId(nextId);
|
||||
}
|
||||
}, [assetDetailQuery.isError, assetDetailQuery.isLoading, assetsQuery.data?.items, isCreatingAsset, selectedAssetId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!assetDetailQuery.data?.asset) {
|
||||
return;
|
||||
}
|
||||
const asset = assetDetailQuery.data.asset;
|
||||
setAssetForm({
|
||||
name: asset.name,
|
||||
categoryId: asset.categoryId,
|
||||
legalEntityId: asset.legalEntityId,
|
||||
costCenterId: asset.costCenterId,
|
||||
purchaseRequestId: asset.purchaseRequestId ?? "",
|
||||
serialNo: asset.serialNo ?? "",
|
||||
location: asset.location ?? "",
|
||||
status: asset.status,
|
||||
assigneeEmployeeId: asset.assigneeEmployeeId ?? "",
|
||||
activatedAt: asset.activatedAt ?? "",
|
||||
retiredAt: asset.retiredAt ?? "",
|
||||
});
|
||||
setAssetFormError("");
|
||||
}, [assetDetailQuery.data]);
|
||||
|
||||
const filteredAssetCostCenters = useMemo(() => {
|
||||
if (!assetForm.legalEntityId) {
|
||||
return [];
|
||||
}
|
||||
return (costCentersQuery.data?.items ?? []).filter((item) => item.legalEntityId === assetForm.legalEntityId);
|
||||
}, [assetForm.legalEntityId, costCentersQuery.data?.items]);
|
||||
|
||||
const filteredFilterCostCenters = useMemo(() => {
|
||||
if (!legalEntityFilter) {
|
||||
return costCentersQuery.data?.items ?? [];
|
||||
}
|
||||
return (costCentersQuery.data?.items ?? []).filter((item) => item.legalEntityId === legalEntityFilter);
|
||||
}, [costCentersQuery.data?.items, legalEntityFilter]);
|
||||
|
||||
const currentAsset = assetDetailQuery.data?.asset ?? null;
|
||||
const canEditCurrent = canManage && (isCreatingAsset || currentAsset?.permissions.canEdit);
|
||||
|
||||
function resetCategoryEditor(category?: AssetCategoryItem) {
|
||||
if (!category) {
|
||||
setEditingCategoryId("");
|
||||
setCategoryForm(initialCategoryForm());
|
||||
return;
|
||||
}
|
||||
setEditingCategoryId(category.id);
|
||||
setCategoryForm({
|
||||
code: category.code,
|
||||
name: category.name,
|
||||
documentCategoryId: category.documentCategoryId ?? "",
|
||||
enabled: category.enabled,
|
||||
});
|
||||
}
|
||||
|
||||
function resetAssetEditor() {
|
||||
setIsCreatingAsset(true);
|
||||
setSelectedAssetId("");
|
||||
setAssetForm(initialAssetForm());
|
||||
setAssetFormError("");
|
||||
}
|
||||
|
||||
function buildAssetPayload() {
|
||||
return {
|
||||
name: assetForm.name.trim(),
|
||||
categoryId: assetForm.categoryId,
|
||||
legalEntityId: assetForm.legalEntityId,
|
||||
costCenterId: assetForm.costCenterId,
|
||||
purchaseRequestId: assetForm.purchaseRequestId || null,
|
||||
serialNo: assetForm.serialNo.trim() || null,
|
||||
location: assetForm.location.trim() || null,
|
||||
status: assetForm.status,
|
||||
assigneeEmployeeId: assetForm.assigneeEmployeeId || null,
|
||||
activatedAt: assetForm.activatedAt || null,
|
||||
retiredAt: assetForm.retiredAt || null,
|
||||
};
|
||||
}
|
||||
|
||||
function validateAssetForm() {
|
||||
if (!assetForm.name.trim()) {
|
||||
throw new Error("请填写资产名称");
|
||||
}
|
||||
if (!assetForm.categoryId) {
|
||||
throw new Error("请选择资产分类");
|
||||
}
|
||||
if (!assetForm.legalEntityId) {
|
||||
throw new Error("请选择法体");
|
||||
}
|
||||
if (!assetForm.costCenterId) {
|
||||
throw new Error("请选择成本中心");
|
||||
}
|
||||
}
|
||||
|
||||
const saveCategoryMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
if (!categoryForm.code.trim()) {
|
||||
throw new Error("请填写分类编码");
|
||||
}
|
||||
if (!categoryForm.name.trim()) {
|
||||
throw new Error("请填写分类名称");
|
||||
}
|
||||
const payload = {
|
||||
code: categoryForm.code.trim(),
|
||||
name: categoryForm.name.trim(),
|
||||
documentCategoryId: categoryForm.documentCategoryId || null,
|
||||
enabled: categoryForm.enabled,
|
||||
};
|
||||
return editingCategoryId ? patchAssetCategory(editingCategoryId, payload) : createAssetCategory(payload);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["assets", "categories"] });
|
||||
toast.success(editingCategoryId ? "资产分类已更新" : "资产分类已创建");
|
||||
resetCategoryEditor();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(resolveError(error));
|
||||
},
|
||||
});
|
||||
|
||||
const saveAssetMutation = useMutation({
|
||||
mutationFn: async () => {
|
||||
validateAssetForm();
|
||||
const payload = buildAssetPayload();
|
||||
if (isCreatingAsset) {
|
||||
return createAsset(payload);
|
||||
}
|
||||
if (!selectedAssetId) {
|
||||
throw new Error("请先选择资产卡片");
|
||||
}
|
||||
return patchAsset(selectedAssetId, payload);
|
||||
},
|
||||
onSuccess: async (data) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "list"] }),
|
||||
queryClient.invalidateQueries({ queryKey: ["assets", "detail"] }),
|
||||
]);
|
||||
setAssetFormError("");
|
||||
setSelectedAssetId(data.asset.id);
|
||||
setIsCreatingAsset(false);
|
||||
toast.success(isCreatingAsset ? "资产卡片已创建" : "资产卡片已更新");
|
||||
},
|
||||
onError: (error) => {
|
||||
const message = resolveError(error);
|
||||
setAssetFormError(message);
|
||||
toast.error(message);
|
||||
},
|
||||
});
|
||||
|
||||
const purchaseOptions = useMemo(() => {
|
||||
const items = approvedPurchasesQuery.data?.items ?? [];
|
||||
const currentPurchase = currentAsset?.purchaseRequest;
|
||||
if (currentPurchase && !items.some((item) => item.id === currentPurchase.id)) {
|
||||
return [
|
||||
...items,
|
||||
{
|
||||
id: currentPurchase.id,
|
||||
requestNo: currentPurchase.requestNo,
|
||||
title: currentPurchase.title,
|
||||
status: currentPurchase.status,
|
||||
applicantId: "",
|
||||
submitterId: null,
|
||||
departmentId: "",
|
||||
legalEntityId: null,
|
||||
costCenterId: null,
|
||||
supplierId: null,
|
||||
documentCategoryId: null,
|
||||
budgetPoolId: null,
|
||||
reason: null,
|
||||
currencyCode: "CNY",
|
||||
totalAmount: "0.00",
|
||||
expectedDeliveryDate: null,
|
||||
createdAt: null,
|
||||
updatedAt: null,
|
||||
applicant: null,
|
||||
submitter: null,
|
||||
department: null,
|
||||
legalEntity: null,
|
||||
costCenter: null,
|
||||
supplier: null,
|
||||
documentCategory: null,
|
||||
budgetPool: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
return items;
|
||||
}, [approvedPurchasesQuery.data?.items, currentAsset?.purchaseRequest]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<PageHeader
|
||||
title="资产台账"
|
||||
subtitle="维护资产分类与资产卡片,支持法体、成本中心和采购来源引用。"
|
||||
actions={
|
||||
canManage ? (
|
||||
<Button type="button" onClick={resetAssetEditor}>
|
||||
新建资产卡片
|
||||
</Button>
|
||||
) : null
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-6 xl:grid-cols-[380px_minmax(0,1fr)]">
|
||||
<div className="card-std space-y-4 p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-zinc-900">资产分类</h3>
|
||||
{canManage ? (
|
||||
<Button type="button" variant="secondary" onClick={() => resetCategoryEditor()}>
|
||||
新建分类
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700">分类编码</label>
|
||||
<Input value={categoryForm.code} disabled={!canManage} onChange={(event) => setCategoryForm((current) => ({ ...current, code: event.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700">分类名称</label>
|
||||
<Input value={categoryForm.name} disabled={!canManage} onChange={(event) => setCategoryForm((current) => ({ ...current, name: event.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700">单据分类</label>
|
||||
<select className="select-std" disabled={!canManage} value={categoryForm.documentCategoryId} onChange={(event) => setCategoryForm((current) => ({ ...current, documentCategoryId: event.target.value }))}>
|
||||
<option value="">使用系统默认</option>
|
||||
{(documentCategoriesQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name} ({item.code})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<label className="flex items-center gap-2 text-sm text-zinc-700">
|
||||
<input type="checkbox" className="h-4 w-4 rounded border-zinc-300" checked={categoryForm.enabled} disabled={!canManage} onChange={(event) => setCategoryForm((current) => ({ ...current, enabled: event.target.checked }))} />
|
||||
启用分类
|
||||
</label>
|
||||
{canManage ? (
|
||||
<Button type="button" onClick={() => saveCategoryMutation.mutate()} disabled={saveCategoryMutation.isPending}>
|
||||
{saveCategoryMutation.isPending ? "保存中..." : editingCategoryId ? "保存分类" : "创建分类"}
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{categoriesQuery.isLoading ? <TableSkeleton columns={3} rows={5} /> : null}
|
||||
{categoriesQuery.isError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{resolveError(categoriesQuery.error)}</p>
|
||||
) : null}
|
||||
{!categoriesQuery.isLoading && !categoriesQuery.isError ? (
|
||||
<div className="rounded-lg border border-zinc-200">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<tr>
|
||||
<TableHead>编码</TableHead>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
</tr>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(categoriesQuery.data?.items ?? []).map((item) => (
|
||||
<TableRow key={item.id} onClick={() => resetCategoryEditor(item)} className="cursor-pointer">
|
||||
<TableCell className="font-medium text-zinc-900">{item.code}</TableCell>
|
||||
<TableCell>
|
||||
<div>{item.name}</div>
|
||||
<div className="text-xs text-zinc-500">{item.documentCategory?.name ?? "系统默认"}</div>
|
||||
</TableCell>
|
||||
<TableCell>{item.enabled ? "启用" : "停用"}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="card-std space-y-4 p-6">
|
||||
<div className="grid gap-3 md:grid-cols-3 xl:grid-cols-6">
|
||||
<select className="select-std" value={statusFilter} onChange={(event) => setStatusFilter(event.target.value as AssetStatus | "") }>
|
||||
{assetStatusOptions().map((item) => (
|
||||
<option key={item.value || "all"} value={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select className="select-std" value={categoryFilter} onChange={(event) => setCategoryFilter(event.target.value)}>
|
||||
<option value="">全部分类</option>
|
||||
{(categoriesQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select className="select-std" value={legalEntityFilter} onChange={(event) => {
|
||||
setLegalEntityFilter(event.target.value);
|
||||
setCostCenterFilter("");
|
||||
}}>
|
||||
<option value="">全部法体</option>
|
||||
{(legalEntitiesQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select className="select-std" value={costCenterFilter} onChange={(event) => setCostCenterFilter(event.target.value)}>
|
||||
<option value="">全部成本中心</option>
|
||||
{filteredFilterCostCenters.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select className="select-std" value={assigneeFilter} onChange={(event) => setAssigneeFilter(event.target.value)} disabled={!canViewAll}>
|
||||
<option value="">全部归属人</option>
|
||||
{(assigneesQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{canViewAll ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button type="button" variant={mine ? "default" : "secondary"} onClick={() => setMine(true)}>
|
||||
仅自己
|
||||
</Button>
|
||||
<Button type="button" variant={!mine ? "default" : "secondary"} onClick={() => setMine(false)}>
|
||||
全范围
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{assetsQuery.isLoading ? <TableSkeleton columns={4} rows={6} /> : null}
|
||||
{assetsQuery.isError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{resolveError(assetsQuery.error)}</p>
|
||||
) : null}
|
||||
{!assetsQuery.isLoading && !assetsQuery.isError ? (
|
||||
<div className="rounded-lg border border-zinc-200">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<tr>
|
||||
<TableHead>资产</TableHead>
|
||||
<TableHead>分类</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>归属人</TableHead>
|
||||
</tr>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{(assetsQuery.data?.items ?? []).length ? (
|
||||
assetsQuery.data?.items.map((item) => (
|
||||
<TableRow key={item.id} onClick={() => {
|
||||
setIsCreatingAsset(false);
|
||||
setSelectedAssetId(item.id);
|
||||
}} className={item.id === selectedAssetId && !isCreatingAsset ? "bg-zinc-50" : "cursor-pointer"}>
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">{item.assetNo}</div>
|
||||
<div className="text-xs text-zinc-500">{item.name}</div>
|
||||
</TableCell>
|
||||
<TableCell>{item.category?.name ?? "-"}</TableCell>
|
||||
<TableCell><StatusBadge status={item.status} /></TableCell>
|
||||
<TableCell>{item.assignee?.name ?? "-"}</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell colSpan={4} className="py-4 text-zinc-500">
|
||||
暂无资产卡片
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="card-std space-y-4 p-6">
|
||||
{!selectedAssetId && !isCreatingAsset ? (
|
||||
<div className="rounded-lg border border-dashed border-zinc-300 p-8 text-center text-sm text-zinc-500">
|
||||
请先从列表选择资产卡片,或点击“新建资产卡片”。
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{selectedAssetId && !isCreatingAsset && assetDetailQuery.isLoading ? <TableSkeleton columns={2} rows={8} /> : null}
|
||||
{selectedAssetId && !isCreatingAsset && assetDetailQuery.isError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{resolveError(assetDetailQuery.error)}</p>
|
||||
) : null}
|
||||
|
||||
{(isCreatingAsset || currentAsset) && !assetDetailQuery.isLoading ? (
|
||||
<>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-zinc-900">{isCreatingAsset ? "新建资产卡片" : currentAsset?.assetNo ?? "资产卡片"}</h3>
|
||||
<p className="text-sm text-zinc-500">{isCreatingAsset ? "创建新的资产台账卡片。" : currentAsset?.name}</p>
|
||||
</div>
|
||||
{!isCreatingAsset && currentAsset ? <StatusBadge status={currentAsset.status} /> : null}
|
||||
</div>
|
||||
|
||||
{assetFormError ? (
|
||||
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{assetFormError}</p>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700">资产名称</label>
|
||||
<Input value={assetForm.name} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, name: event.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700">资产状态</label>
|
||||
<select className="select-std" value={assetForm.status} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, status: event.target.value as AssetStatus }))}>
|
||||
{assetStatusOptions().filter((item) => item.value).map((item) => (
|
||||
<option key={item.value} value={item.value}>
|
||||
{item.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700">资产分类</label>
|
||||
<select className="select-std" value={assetForm.categoryId} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, categoryId: event.target.value }))}>
|
||||
<option value="">请选择资产分类</option>
|
||||
{(categoriesQuery.data?.items ?? []).filter((item) => item.enabled).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700">法体</label>
|
||||
<select className="select-std" value={assetForm.legalEntityId} disabled={!canEditCurrent} onChange={(event) => {
|
||||
const legalEntityId = event.target.value;
|
||||
setAssetForm((current) => ({
|
||||
...current,
|
||||
legalEntityId,
|
||||
costCenterId: current.costCenterId && filteredAssetCostCenters.some((item) => item.id === current.costCenterId && item.legalEntityId === legalEntityId)
|
||||
? current.costCenterId
|
||||
: "",
|
||||
}));
|
||||
}}>
|
||||
<option value="">请选择法体</option>
|
||||
{(legalEntitiesQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700">成本中心</label>
|
||||
<select className="select-std" value={assetForm.costCenterId} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, costCenterId: event.target.value }))}>
|
||||
<option value="">请选择成本中心</option>
|
||||
{filteredAssetCostCenters.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700">归属人</label>
|
||||
<select className="select-std" value={assetForm.assigneeEmployeeId} disabled={!canEditCurrent || !canManage} onChange={(event) => setAssetForm((current) => ({ ...current, assigneeEmployeeId: event.target.value }))}>
|
||||
<option value="">暂不分配</option>
|
||||
{(assigneesQuery.data?.items ?? []).map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{item.name} ({item.employeeNo})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700">采购来源</label>
|
||||
<select className="select-std" value={assetForm.purchaseRequestId} disabled={!canEditCurrent || !canReadProcurementSources || !isCreatingAsset} onChange={(event) => setAssetForm((current) => ({ ...current, purchaseRequestId: event.target.value }))}>
|
||||
<option value="">无采购来源</option>
|
||||
{purchaseOptions.map((item) => (
|
||||
<option key={item.id} value={item.id}>
|
||||
{(item.requestNo ?? item.id)} · {item.title}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700">序列号</label>
|
||||
<Input value={assetForm.serialNo} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, serialNo: event.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700">存放位置</label>
|
||||
<Input value={assetForm.location} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, location: event.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700">启用日期</label>
|
||||
<Input type="date" value={assetForm.activatedAt} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, activatedAt: event.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium text-zinc-700">退役日期</label>
|
||||
<Input type="date" value={assetForm.retiredAt} disabled={!canEditCurrent} onChange={(event) => setAssetForm((current) => ({ ...current, retiredAt: event.target.value }))} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{canManage ? (
|
||||
<Button type="button" disabled={saveAssetMutation.isPending || !canEditCurrent} onClick={() => saveAssetMutation.mutate()}>
|
||||
{saveAssetMutation.isPending ? "保存中..." : isCreatingAsset ? "创建资产卡片" : "保存资产卡片"}
|
||||
</Button>
|
||||
) : null}
|
||||
{currentAsset?.purchaseRequest ? (
|
||||
<span className="text-sm text-zinc-500">采购来源:{currentAsset.purchaseRequest.requestNo ?? currentAsset.purchaseRequest.id}</span>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -18,6 +18,7 @@ import { LoginPage } from "../pages/LoginPage";
|
||||
import { LeaveRequestPage } from "../pages/LeaveRequestPage";
|
||||
import { NotificationsPage } from "../pages/NotificationsPage";
|
||||
import { PurchaseRequestsPage } from "../pages/PurchaseRequestsPage";
|
||||
import { AssetRegistryPage } from "../pages/AssetRegistryPage";
|
||||
import { NotFoundPage } from "../pages/NotFoundPage";
|
||||
import { ProfilePage } from "../pages/ProfilePage";
|
||||
import { ReimbursementDetailPage } from "../pages/ReimbursementDetailPage";
|
||||
@@ -108,6 +109,15 @@ export function AppRouter() {
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="assets"
|
||||
element={
|
||||
<RequirePermission anyOf={["asset.view_own", "asset.view_all", "asset.manage", "asset.transfer", "asset.inventory"]}>
|
||||
<AssetRegistryPage />
|
||||
</RequirePermission>
|
||||
}
|
||||
/>
|
||||
|
||||
<Route
|
||||
path="workflow/tasks"
|
||||
element={
|
||||
|
||||
在新工单中引用
屏蔽一个用户