R57 A1-A6:报销模板与差旅申请关联
这个提交包含在:
@@ -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",
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
|
||||
@@ -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: "查看财务与外部系统桥接能力及出入口。" },
|
||||
|
||||
@@ -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";
|
||||
@@ -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"));
|
||||
|
||||
在新工单中引用
屏蔽一个用户