R57 A1-A6:报销模板与差旅申请关联

这个提交包含在:
2026-04-08 21:16:30 +08:00
父节点 14c2cbd6da
当前提交 88c987ae8f
共修改 17 个文件,包含 2886 行新增和 76 行删除
+86
查看文件
@@ -0,0 +1,86 @@
import { apiRequestData } from "./http";
export type ReimbursementTemplateItem = {
expenseTypeId: string;
description: string;
defaultAmount?: string;
};
export type ReimbursementTemplate = {
id: string;
name: string;
description: string | null;
items: ReimbursementTemplateItem[];
sortOrder: number;
enabled: boolean;
createdAt: string | null;
updatedAt: string | null;
};
export type ReimbursementTemplateListResponse = {
enabled: boolean | null;
items: ReimbursementTemplate[];
};
export type ReimbursementTemplateListQuery = {
enabled?: boolean;
};
export type CreateReimbursementTemplatePayload = {
name: string;
description?: string | null;
items?: ReimbursementTemplateItem[];
sortOrder?: number;
enabled?: boolean;
};
export type UpdateReimbursementTemplatePayload = {
name?: string;
description?: string | null;
items?: ReimbursementTemplateItem[];
sortOrder?: number;
enabled?: boolean;
};
function toQueryString(query: Record<string, string | number | boolean | undefined>) {
const params = new URLSearchParams();
for (const [key, value] of Object.entries(query)) {
if (value == null || value === "") {
continue;
}
params.set(key, String(value));
}
const encoded = params.toString();
return encoded ? `?${encoded}` : "";
}
export async function fetchReimbursementTemplates(query: ReimbursementTemplateListQuery = {}) {
return apiRequestData<ReimbursementTemplateListResponse>(
`/api/reimbursement-templates${toQueryString(query as Record<string, string | number | boolean | undefined>)}`,
);
}
export async function createReimbursementTemplate(payload: CreateReimbursementTemplatePayload) {
return apiRequestData<ReimbursementTemplate>("/api/reimbursement-templates", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function updateReimbursementTemplate(
templateId: string,
payload: UpdateReimbursementTemplatePayload,
) {
return apiRequestData<ReimbursementTemplate>(`/api/reimbursement-templates/${templateId}`, {
method: "PATCH",
body: JSON.stringify(payload),
});
}
export async function deleteReimbursementTemplate(templateId: string) {
return apiRequestData<ReimbursementTemplate>(`/api/reimbursement-templates/${templateId}`, {
method: "DELETE",
});
}
+61 -6
查看文件
@@ -24,6 +24,15 @@ export type ReimbursementItem = {
} | null;
};
export type LinkedTravelRequestSummary = {
id: string;
requestNo: string | null;
purpose: string | null;
startDate: string | null;
endDate: string | null;
estimatedAmount: string;
};
export type ReimbursementListItem = {
id: string;
formNumber: string | null;
@@ -113,6 +122,8 @@ export type ReimbursementDetail = {
totalAmount: string | null;
description: string | null;
projectId: string | null;
linkedTravelRequestId: string | null;
linkedTravelRequest: LinkedTravelRequestSummary | null;
project: {
id: string;
projectNo: string;
@@ -207,6 +218,48 @@ export type ReimbursementPayPayload = {
paymentChannel?: "bank" | "alipay" | "wechat" | "cash" | "other";
};
export type ReimbursementCreatePayload = {
ownerEmployeeId?: string;
reimbursementDate?: string;
description?: string;
templateId?: string;
linkedTravelRequestId?: string;
};
export type ReimbursementCreateResponse = {
id: string;
formNumber: string | null;
submitterId: string;
ownerId: string;
reimbursementDate: string | null;
status: ReimbursementStatus;
description: string | null;
projectId: string | null;
project: {
id: string;
projectNo: string;
name: string;
} | null;
totalAmount: string;
linkedTravelRequestId: string | null;
linkedTravelRequest: LinkedTravelRequestSummary | null;
items: ReimbursementItem[];
};
export type LinkableTravelRequestSummary = {
id: string;
requestNo: string | null;
purpose: string;
startDate: string | null;
endDate: string | null;
estimatedAmount: string;
};
export type LinkableTravelRequestListResponse = {
ownerEmployeeId: string;
items: LinkableTravelRequestSummary[];
};
export type ReimbursementItemPayload = {
expenseTypeId: string;
expenseDate: string;
@@ -301,17 +354,19 @@ export async function fetchReimbursementDetail(reimbursementId: string) {
return apiRequestData<ReimbursementDetail>(`/api/reimbursements/${reimbursementId}`);
}
export async function createReimbursement(payload: {
ownerEmployeeId?: string;
reimbursementDate?: string;
description?: string;
} = {}) {
return apiRequestData<{ id: string }>(`/api/reimbursements`, {
export async function createReimbursement(payload: ReimbursementCreatePayload = {}) {
return apiRequestData<ReimbursementCreateResponse>(`/api/reimbursements`, {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function fetchLinkableTravelRequests(query: { ownerEmployeeId?: string } = {}) {
return apiRequestData<LinkableTravelRequestListResponse>(
`/api/reimbursements/linkable-travel-requests${toQueryString(query as Record<string, string | number | undefined>)}`,
);
}
export async function precheckReimbursement(reimbursementId: string) {
return apiRequestData<PrecheckResponse>(`/api/reimbursements/${reimbursementId}/precheck`, {
method: "POST",
@@ -503,6 +503,43 @@ export function ReimbursementDetailPage() {
<div>说明:{detail.description || "-"}</div>
</div>
{detail.linkedTravelRequest ? (
<div className="rounded-lg border border-sky-200 bg-sky-50/70 p-4 text-sm">
<div className="flex items-start justify-between gap-3">
<div>
<h4 className="text-base font-semibold text-zinc-900">
关联差旅申请:{detail.linkedTravelRequest.requestNo ?? detail.linkedTravelRequest.id}
</h4>
<p className="mt-1 text-zinc-600">{detail.linkedTravelRequest.purpose || "未填写出差目的"}</p>
</div>
<Link
to={`/travel-requests?tab=requests&id=${detail.linkedTravelRequest.id}`}
className="text-sm text-primary hover:underline"
>
查看申请
</Link>
</div>
<div className="mt-3 grid gap-3 md:grid-cols-3">
<div className="rounded-lg border border-white/80 bg-white/80 px-3 py-2">
<div className="text-xs text-zinc-500">日期范围</div>
<div className="mt-1 font-medium text-zinc-900">
{detail.linkedTravelRequest.startDate ?? "-"} 至 {detail.linkedTravelRequest.endDate ?? "-"}
</div>
</div>
<div className="rounded-lg border border-white/80 bg-white/80 px-3 py-2">
<div className="text-xs text-zinc-500">预估金额</div>
<div className="mt-1 font-medium text-zinc-900">¥{detail.linkedTravelRequest.estimatedAmount}</div>
</div>
<div className="rounded-lg border border-white/80 bg-white/80 px-3 py-2">
<div className="text-xs text-zinc-500">申请编号</div>
<div className="mt-1 font-medium text-zinc-900">
{detail.linkedTravelRequest.requestNo ?? detail.linkedTravelRequest.id}
</div>
</div>
</div>
</div>
) : null}
<div className="rounded-lg border border-zinc-200 p-4 text-sm">
<div className="flex items-center justify-between gap-3">
<div>
@@ -0,0 +1,513 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchExpenseTypes } from "../api/expenseTypes";
import {
createReimbursementTemplate,
deleteReimbursementTemplate,
fetchReimbursementTemplates,
updateReimbursementTemplate,
type ReimbursementTemplate,
type ReimbursementTemplateItem,
} from "../api/reimbursementTemplates";
import { isApiError } from "../api/types";
import { ConfirmDialog } from "../components/common/ConfirmDialog";
import { PageHeader } from "../components/common/PageHeader";
import { Button } from "../components/ui/button";
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "../components/ui/dialog";
import { Input } from "../components/ui/input";
import { Label } from "../components/ui/label";
import { Textarea } from "../components/ui/textarea";
type FilterEnabled = "all" | "enabled" | "disabled";
type FormState = {
name: string;
description: string;
sortOrder: string;
enabled: boolean;
items: Array<{
expenseTypeId: string;
description: string;
defaultAmount: string;
}>;
};
function resolveErrorMessage(error: unknown) {
if (isApiError(error)) {
return `${error.code}: ${mapApiErrorMessage(error.code, error.details, error.message)}`;
}
if (error instanceof Error) {
return error.message;
}
return "请求失败,请稍后重试";
}
function emptyForm(expenseTypeId = ""): FormState {
return {
name: "",
description: "",
sortOrder: "0",
enabled: true,
items: [
{
expenseTypeId,
description: "",
defaultAmount: "",
},
],
};
}
function toForm(item: ReimbursementTemplate): FormState {
return {
name: item.name,
description: item.description ?? "",
sortOrder: String(item.sortOrder),
enabled: item.enabled,
items: item.items.length
? item.items.map((entry) => ({
expenseTypeId: entry.expenseTypeId,
description: entry.description,
defaultAmount: entry.defaultAmount ?? "",
}))
: [
{
expenseTypeId: "",
description: "",
defaultAmount: "",
},
],
};
}
export function ReimbursementTemplatesPage() {
const queryClient = useQueryClient();
const [enabledFilter, setEnabledFilter] = useState<FilterEnabled>("all");
const [dialogOpen, setDialogOpen] = useState(false);
const [editingItem, setEditingItem] = useState<ReimbursementTemplate | null>(null);
const [form, setForm] = useState<FormState>(emptyForm());
const [confirmDelete, setConfirmDelete] = useState<ReimbursementTemplate | null>(null);
const [error, setError] = useState("");
const templatesQuery = useQuery({
queryKey: ["reimbursement-templates", enabledFilter],
queryFn: () =>
fetchReimbursementTemplates({
enabled:
enabledFilter === "all"
? undefined
: enabledFilter === "enabled",
}),
});
const expenseTypesQuery = useQuery({
queryKey: ["expense-types", "reimbursement-templates"],
queryFn: () => fetchExpenseTypes({ enabled: true }),
});
useEffect(() => {
if (!dialogOpen) {
return;
}
if (editingItem) {
setForm(toForm(editingItem));
return;
}
const fallbackExpenseTypeId = expenseTypesQuery.data?.items?.[0]?.id ?? "";
setForm(emptyForm(fallbackExpenseTypeId));
}, [dialogOpen, editingItem, expenseTypesQuery.data?.items]);
const expenseTypeMap = useMemo(() => {
return new Map((expenseTypesQuery.data?.items ?? []).map((item) => [item.id, item]));
}, [expenseTypesQuery.data?.items]);
const saveMutation = useMutation({
mutationFn: async () => {
const items: ReimbursementTemplateItem[] = form.items
.filter((item) => item.expenseTypeId.trim() || item.description.trim() || item.defaultAmount.trim())
.map((item) => ({
expenseTypeId: item.expenseTypeId,
description: item.description.trim(),
...(item.defaultAmount.trim() ? { defaultAmount: item.defaultAmount.trim() } : {}),
}));
const payload = {
name: form.name.trim(),
description: form.description.trim() || null,
sortOrder: Number(form.sortOrder || 0),
enabled: form.enabled,
items,
};
if (editingItem) {
return updateReimbursementTemplate(editingItem.id, payload);
}
return createReimbursementTemplate(payload);
},
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["reimbursement-templates"] });
setDialogOpen(false);
setEditingItem(null);
setError("");
toast.success(editingItem ? "模板已更新" : "模板已创建");
},
onError: (mutationError) => {
const message = resolveErrorMessage(mutationError);
setError(message);
toast.error(message);
},
});
const deleteMutation = useMutation({
mutationFn: (templateId: string) => deleteReimbursementTemplate(templateId),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["reimbursement-templates"] });
setConfirmDelete(null);
setError("");
toast.success("模板已停用");
},
onError: (mutationError) => {
const message = resolveErrorMessage(mutationError);
setError(message);
toast.error(message);
},
});
const templates = templatesQuery.data?.items ?? [];
const canSubmit =
form.name.trim().length > 0 &&
form.items.some((item) => item.expenseTypeId.trim() && item.description.trim());
return (
<div className="space-y-6">
<PageHeader
title="报销模板管理"
subtitle="维护常见报销场景的预设明细,缩短员工建单时间。"
actions={(
<Button
type="button"
onClick={() => {
setEditingItem(null);
setDialogOpen(true);
}}
>
新增模板
</Button>
)}
/>
{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 space-y-4 p-6">
<div className="grid gap-3 md:max-w-xs">
<div className="space-y-1">
<Label htmlFor="reimbursement-template-enabled-filter">启用状态</Label>
<select
id="reimbursement-template-enabled-filter"
className="select-std"
value={enabledFilter}
onChange={(event) => setEnabledFilter(event.target.value as FilterEnabled)}
>
<option value="all">全部</option>
<option value="enabled">启用</option>
<option value="disabled">停用</option>
</select>
</div>
</div>
</div>
{templatesQuery.isLoading ? (
<div className="grid gap-4 md:grid-cols-2">
{Array.from({ length: 4 }).map((_, index) => (
<div key={index} className="card-std h-56 animate-pulse bg-zinc-100" />
))}
</div>
) : null}
{templatesQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(templatesQuery.error)}
</p>
) : null}
{!templatesQuery.isLoading && !templatesQuery.isError ? (
templates.length ? (
<div className="grid gap-4 md:grid-cols-2">
{templates.map((item) => (
<article key={item.id} className="card-std flex h-full flex-col gap-4 p-6">
<div className="flex items-start justify-between gap-3">
<div>
<h3 className="text-lg font-semibold text-zinc-900">{item.name}</h3>
<p className="mt-1 text-sm text-zinc-600">{item.description || "未填写模板说明"}</p>
</div>
<span
className={`rounded-full px-3 py-1 text-xs font-medium ${
item.enabled
? "bg-emerald-50 text-emerald-700"
: "bg-zinc-100 text-zinc-600"
}`}
>
{item.enabled ? "启用" : "停用"}
</span>
</div>
<div className="space-y-2">
<div className="text-xs uppercase tracking-[0.2em] text-zinc-400">预设明细</div>
<div className="space-y-2">
{item.items.length ? (
item.items.map((entry, index) => (
<div key={`${item.id}-${index}`} className="rounded-xl border border-zinc-200 px-4 py-3 text-sm">
<div className="font-medium text-zinc-900">
{expenseTypeMap.get(entry.expenseTypeId)?.name ?? entry.expenseTypeId}
</div>
<div className="mt-1 text-zinc-600">{entry.description}</div>
<div className="mt-2 text-xs text-zinc-500">
默认金额:{entry.defaultAmount ? `¥${entry.defaultAmount}` : "未预填"}
</div>
</div>
))
) : (
<div className="rounded-xl border border-dashed border-zinc-200 px-4 py-6 text-sm text-zinc-500">
该模板暂未配置预设明细。
</div>
)}
</div>
</div>
<div className="mt-auto flex items-center justify-between gap-3 text-sm text-zinc-500">
<span>排序:{item.sortOrder}</span>
<div className="flex gap-2">
<Button
type="button"
variant="secondary"
onClick={() => {
setEditingItem(item);
setDialogOpen(true);
}}
>
编辑
</Button>
<Button type="button" variant="secondary" onClick={() => setConfirmDelete(item)}>
删除
</Button>
</div>
</div>
</article>
))}
</div>
) : (
<div className="card-std px-6 py-10 text-sm text-zinc-500">当前筛选下暂无模板。</div>
)
) : null}
<Dialog
open={dialogOpen}
onOpenChange={(open) => {
setDialogOpen(open);
if (!open) {
setEditingItem(null);
setError("");
}
}}
>
<DialogContent className="max-w-4xl">
<DialogHeader>
<DialogTitle>{editingItem ? "编辑报销模板" : "新增报销模板"}</DialogTitle>
<DialogDescription>模板明细会在创建报销时自动生成对应报销项。</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-1">
<Label htmlFor="reimbursement-template-name">模板名称</Label>
<Input
id="reimbursement-template-name"
value={form.name}
onChange={(event) => setForm((current) => ({ ...current, name: event.target.value }))}
placeholder="例如:国内差旅报销"
/>
</div>
<div className="space-y-1">
<Label htmlFor="reimbursement-template-sort-order">排序</Label>
<Input
id="reimbursement-template-sort-order"
type="number"
value={form.sortOrder}
onChange={(event) => setForm((current) => ({ ...current, sortOrder: event.target.value }))}
/>
</div>
</div>
<div className="space-y-1">
<Label htmlFor="reimbursement-template-description">模板说明</Label>
<Textarea
id="reimbursement-template-description"
rows={2}
value={form.description}
onChange={(event) => setForm((current) => ({ ...current, description: event.target.value }))}
placeholder="描述适用场景或填单注意事项"
/>
</div>
<label className="flex items-center gap-2 text-sm text-zinc-700">
<input
type="checkbox"
checked={form.enabled}
onChange={(event) => setForm((current) => ({ ...current, enabled: event.target.checked }))}
/>
启用该模板
</label>
<div className="space-y-3 rounded-2xl border border-zinc-200 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<h4 className="text-base font-semibold text-zinc-900">预设明细</h4>
<p className="text-xs text-zinc-500">费用类型与说明必填,默认金额可选并按字符串原样提交。</p>
</div>
<Button
type="button"
variant="secondary"
onClick={() =>
setForm((current) => ({
...current,
items: [
...current.items,
{
expenseTypeId: expenseTypesQuery.data?.items?.[0]?.id ?? "",
description: "",
defaultAmount: "",
},
],
}))
}
>
添加明细
</Button>
</div>
<div className="space-y-3">
{form.items.map((item, index) => (
<div key={index} className="grid gap-3 rounded-xl border border-zinc-200 p-4 md:grid-cols-[1.4fr_1.8fr_1fr_auto]">
<div className="space-y-1">
<Label>费用类型</Label>
<select
className="select-std"
value={item.expenseTypeId}
onChange={(event) =>
setForm((current) => ({
...current,
items: current.items.map((entry, entryIndex) =>
entryIndex === index
? { ...entry, expenseTypeId: event.target.value }
: entry,
),
}))
}
>
<option value="">请选择费用类型</option>
{(expenseTypesQuery.data?.items ?? []).map((expenseType) => (
<option key={expenseType.id} value={expenseType.id}>
{expenseType.name}
</option>
))}
</select>
</div>
<div className="space-y-1">
<Label>说明</Label>
<Input
value={item.description}
onChange={(event) =>
setForm((current) => ({
...current,
items: current.items.map((entry, entryIndex) =>
entryIndex === index
? { ...entry, description: event.target.value }
: entry,
),
}))
}
placeholder="例如:出差往返交通"
/>
</div>
<div className="space-y-1">
<Label>默认金额</Label>
<Input
value={item.defaultAmount}
onChange={(event) =>
setForm((current) => ({
...current,
items: current.items.map((entry, entryIndex) =>
entryIndex === index
? { ...entry, defaultAmount: event.target.value }
: entry,
),
}))
}
placeholder="可选,例 100.00"
/>
</div>
<div className="flex items-end">
<Button
type="button"
variant="secondary"
disabled={form.items.length <= 1}
onClick={() =>
setForm((current) => ({
...current,
items: current.items.filter((_, entryIndex) => entryIndex !== index),
}))
}
>
删除
</Button>
</div>
</div>
))}
</div>
</div>
</div>
<DialogFooter>
<Button type="button" variant="secondary" onClick={() => setDialogOpen(false)}>
取消
</Button>
<Button type="button" disabled={!canSubmit || saveMutation.isPending} onClick={() => saveMutation.mutate()}>
{saveMutation.isPending ? "保存中..." : editingItem ? "保存修改" : "创建模板"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<ConfirmDialog
open={Boolean(confirmDelete)}
onOpenChange={(open) => {
if (!open) {
setConfirmDelete(null);
}
}}
title="确认删除该报销模板?"
description={confirmDelete ? `删除后会将“${confirmDelete.name}”停用,历史记录仍然保留。` : undefined}
confirmLabel="删除"
variant="destructive"
onConfirm={async () => {
if (confirmDelete) {
await deleteMutation.mutateAsync(confirmDelete.id);
}
}}
/>
</div>
);
}
export default ReimbursementTemplatesPage;
+121 -17
查看文件
@@ -1,11 +1,18 @@
import { useEffect, useMemo, useState } from "react";
import { Link, useSearchParams } from "react-router-dom";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { isApiError } from "../api/types";
import { mapApiErrorMessage } from "../api/errorMessages";
import { fetchMyDelegations } from "../api/delegations";
import { exportReimbursements } from "../api/exports";
import { createReimbursement, fetchReimbursements, type ReimbursementStatus } from "../api/reimbursements";
import { fetchReimbursementTemplates } from "../api/reimbursementTemplates";
import {
createReimbursement,
fetchLinkableTravelRequests,
fetchReimbursements,
type ReimbursementCreatePayload,
type ReimbursementStatus,
} from "../api/reimbursements";
import {
batchApproveWorkflowTasks,
batchRejectWorkflowTasks,
@@ -30,7 +37,13 @@ import {
DialogHeader,
DialogTitle,
} from "../components/ui/dialog";
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "../components/ui/dropdown-menu";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "../components/ui/dropdown-menu";
import { Tabs, TabsList, TabsTrigger } from "../components/ui/tabs";
import { Textarea } from "../components/ui/textarea";
@@ -78,6 +91,7 @@ function formatBatchOutcome(action: BatchAction, outcome: string) {
export function ReimbursementsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate();
const { permissions, me } = useAuth();
const queryClient = useQueryClient();
const [exportError, setExportError] = useState("");
@@ -92,6 +106,7 @@ export function ReimbursementsPage() {
const status = (searchParams.get("status") as ReimbursementStatus | null) ?? "draft";
const page = Number(searchParams.get("page") ?? "1") || 1;
const scope = (searchParams.get("scope") as "own" | "all" | null) ?? "own";
const canCreateExpense = hasPermission(permissions, "expense.create");
const canViewAll = hasPermission(permissions, "expense.view_all");
const canApproveExpense = hasPermission(permissions, "expense.approve");
const canProxySubmit = hasPermission(permissions, "expense.proxy_submit");
@@ -109,6 +124,21 @@ export function ReimbursementsPage() {
queryFn: () => fetchMyDelegations("active"),
});
const templateOptionsQuery = useQuery({
enabled: canCreateExpense,
queryKey: ["reimbursement-templates", "enabled", "create-menu"],
queryFn: () => fetchReimbursementTemplates({ enabled: true }),
});
const linkableTravelRequestsQuery = useQuery({
enabled: canCreateExpense,
queryKey: ["reimbursements", "linkable-travel-requests", proxyOwnerEmployeeId || me?.id || "self"],
queryFn: () =>
fetchLinkableTravelRequests({
ownerEmployeeId: proxyOwnerEmployeeId || undefined,
}),
});
const items = listQuery.data?.items ?? [];
const stats = useMemo(() => {
return items.reduce(
@@ -123,6 +153,9 @@ export function ReimbursementsPage() {
);
}, [items]);
const templateOptions = templateOptionsQuery.data?.items ?? [];
const linkableTravelRequests = linkableTravelRequestsQuery.data?.items ?? [];
const actionableItems = useMemo(
() =>
items.filter(
@@ -151,13 +184,17 @@ export function ReimbursementsPage() {
}, [actionableIdSet, canShowBatchEntry]);
const createMutation = useMutation({
mutationFn: () =>
createReimbursement({
ownerEmployeeId: proxyOwnerEmployeeId || undefined,
}),
onSuccess: async () => {
mutationFn: (payload: ReimbursementCreatePayload) => createReimbursement(payload),
onSuccess: async (response, payload) => {
await queryClient.invalidateQueries({ queryKey: ["reimbursements"] });
toast.success("报销单已创建");
if (payload.linkedTravelRequestId) {
toast.success("已按差旅申请创建报销单");
} else if (payload.templateId) {
toast.success("已按模板创建报销单");
} else {
toast.success("报销单已创建");
}
navigate(`/reimbursements/${response.id}`);
},
onError: (error) => {
toast.error(resolveErrorMessage(error, "创建报销单失败"));
@@ -293,6 +330,13 @@ export function ReimbursementsPage() {
});
}
function startCreateReimbursement(payload: Omit<ReimbursementCreatePayload, "ownerEmployeeId"> = {}) {
createMutation.mutate({
ownerEmployeeId: proxyOwnerEmployeeId || undefined,
...payload,
});
}
return (
<ListWorkspace>
<PageHeader
@@ -324,14 +368,62 @@ export function ReimbursementsPage() {
</Button>
) : null}
<Button
data-testid="reimbursements-create"
disabled={createMutation.isPending}
onClick={() => createMutation.mutate()}
type="button"
>
{createMutation.isPending ? "创建中..." : "新建报销"}
</Button>
{canCreateExpense ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
data-testid="reimbursements-create"
disabled={createMutation.isPending}
type="button"
>
{createMutation.isPending ? "创建中..." : "新建报销"}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80">
<DropdownMenuItem onSelect={() => startCreateReimbursement()}>
空白报销
</DropdownMenuItem>
<DropdownMenuSeparator />
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400">
从模板创建
</div>
{templateOptionsQuery.isLoading ? (
<DropdownMenuItem disabled>正在加载模板...</DropdownMenuItem>
) : templateOptions.length ? (
templateOptions.map((template) => (
<DropdownMenuItem
key={template.id}
onSelect={() => startCreateReimbursement({ templateId: template.id })}
>
{template.name}
</DropdownMenuItem>
))
) : (
<DropdownMenuItem disabled>暂无可用模板</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-[0.18em] text-zinc-400">
关联差旅申请
</div>
{linkableTravelRequestsQuery.isLoading ? (
<DropdownMenuItem disabled>正在加载差旅申请...</DropdownMenuItem>
) : linkableTravelRequests.length ? (
linkableTravelRequests.map((travelRequest) => (
<DropdownMenuItem
key={travelRequest.id}
onSelect={() => startCreateReimbursement({ linkedTravelRequestId: travelRequest.id })}
>
{travelRequest.requestNo ?? travelRequest.id} · {travelRequest.purpose}
</DropdownMenuItem>
))
) : (
<DropdownMenuItem disabled>暂无可关联的差旅申请</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
) : null}
<DropdownMenu>
<DropdownMenuTrigger asChild>
@@ -374,6 +466,18 @@ export function ReimbursementsPage() {
</p>
) : null}
{templateOptionsQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(templateOptionsQuery.error, "加载报销模板失败")}
</p>
) : null}
{linkableTravelRequestsQuery.isError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{resolveErrorMessage(linkableTravelRequestsQuery.error, "加载可关联差旅申请失败")}
</p>
) : null}
{exportError ? (
<p className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">
{exportError}
+1
查看文件
@@ -6,6 +6,7 @@ const ITEMS = [
{ to: "/people", title: "人员管理", description: "处理入职、转正、调岗、离职与档案。" },
{ to: "/system/approval-rules", title: "审批规则", description: "维护仍在使用的简单审批链与规则。" },
{ to: "/system/expense-standards", title: "费用标准", description: "维护报销费用类型上限与 warn/block 管控模式。" },
{ to: "/system/reimbursement-templates", title: "报销模板", description: "维护差旅费、招待费、办公用品等常见报销模板。" },
{ to: "/system/master-data", title: "主数据", description: "维护费用、法体、成本中心等主数据。" },
{ to: "/system/notification-channels", title: "通知渠道", description: "继续保留 IM/邮件等补充通知渠道。" },
{ to: "/system/integrations/erp", title: "外部系统桥接", description: "查看财务与外部系统桥接能力及出入口。" },
+10
查看文件
@@ -51,6 +51,7 @@ import {
PurchaseRequestsPage,
ReimbursementDetailPage,
ReimbursementDashboardPage,
ReimbursementTemplatesPage,
ReimbursementsPage,
TravelRequestsPage,
UserManagementPage,
@@ -158,6 +159,15 @@ export function AppRouter() {
}
/>
<Route
path="system/reimbursement-templates"
element={
<RequirePermission anyOf={["system.settings"]}>
{withRouteFallback(<ReimbursementTemplatesPage />)}
</RequirePermission>
}
/>
<Route
path="reimbursements/:reimbursementId"
element={
@@ -0,0 +1 @@
export { ReimbursementTemplatesPage as default } from "../../pages/ReimbursementTemplatesPage";
+1
查看文件
@@ -43,6 +43,7 @@ export const PurchaseRequestsPage = lazy(() => import("./PurchaseRequestsPage"))
export const ReimbursementDetailPage = lazy(() => import("./ReimbursementDetailPage"));
export const ReimbursementDashboardPage = lazy(() => import("./ReimbursementDashboardPage"));
export const ReimbursementsPage = lazy(() => import("./ReimbursementsPage"));
export const ReimbursementTemplatesPage = lazy(() => import("./ReimbursementTemplatesPage"));
export const TravelRequestsPage = lazy(() => import("./TravelRequestsPage"));
export const UserManagementPage = lazy(() => import("./UserManagementPage"));
export const WorkflowDefinitionsPage = lazy(() => import("./WorkflowDefinitionsPage"));