实现业务附件受控入库 V1(05-28-business-attachment-governance-v1)
这个提交包含在:
@@ -1,4 +1,5 @@
|
||||
import { apiRequestBlob, apiRequestData, type ApiBlobResponse } from "./http";
|
||||
import { buildAttachmentFileUrl, type AttachmentGovernanceFields } from "../lib/attachmentGovernance";
|
||||
import type { RiskFlag } from "./feeControl";
|
||||
|
||||
export type PaymentBatchEntityType = "reimbursement" | "travel_order";
|
||||
@@ -64,6 +65,7 @@ export type PaymentBatchReceiptAttachment = {
|
||||
mimeType: string;
|
||||
fileSize: number;
|
||||
fileSha256: string;
|
||||
} & AttachmentGovernanceFields & {
|
||||
uploadedBy: {
|
||||
id: string;
|
||||
employeeNo: string;
|
||||
@@ -162,8 +164,9 @@ export function paymentBatchReceiptAttachmentFileUrl(batchId: string, attachment
|
||||
export async function downloadPaymentBatchReceiptAttachment(
|
||||
batchId: string,
|
||||
attachmentId: string,
|
||||
reason?: string,
|
||||
): Promise<ApiBlobResponse> {
|
||||
return apiRequestBlob(paymentBatchReceiptAttachmentFileUrl(batchId, attachmentId));
|
||||
return apiRequestBlob(buildAttachmentFileUrl(paymentBatchReceiptAttachmentFileUrl(batchId, attachmentId), reason));
|
||||
}
|
||||
|
||||
export async function deletePaymentBatchReceiptAttachment(batchId: string, attachmentId: string) {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { apiRequestData } from "./http";
|
||||
import type { AttachmentGovernanceFields } from "../lib/attachmentGovernance";
|
||||
import type { PrecheckResponse, RiskFlag } from "./feeControl";
|
||||
|
||||
export type PurchaseRequestStatus = "draft" | "reviewing" | "approved" | "rejected" | "cancelled";
|
||||
@@ -89,6 +90,8 @@ export type PurchaseAttachment = {
|
||||
originalName: string;
|
||||
mimeType: string;
|
||||
fileSize: number;
|
||||
fileSha256: string | null;
|
||||
} & AttachmentGovernanceFields & {
|
||||
uploadedBy: string;
|
||||
createdAt: string | null;
|
||||
uploader: {
|
||||
@@ -383,12 +386,12 @@ export async function deletePurchaseAttachment(purchaseRequestId: string, attach
|
||||
);
|
||||
}
|
||||
|
||||
export async function issuePurchaseAttachmentSignedUrl(purchaseRequestId: string, attachmentId: string) {
|
||||
export async function issuePurchaseAttachmentSignedUrl(purchaseRequestId: string, attachmentId: string, reason?: string) {
|
||||
return apiRequestData<{ signedUrl: string; expiresAt: string }>(
|
||||
`/api/purchase-requests/${purchaseRequestId}/attachments/${attachmentId}/signed-url`,
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({}),
|
||||
body: JSON.stringify(reason ? { reason } : {}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { apiRequestBlob, apiRequestData } from "./http";
|
||||
import type { ExpenseStandardCheck, PrecheckResponse, RiskFlag } from "./feeControl";
|
||||
import { buildAttachmentFileUrl, type AttachmentGovernanceFields } from "../lib/attachmentGovernance";
|
||||
|
||||
export type ReimbursementStatus =
|
||||
| "draft"
|
||||
@@ -182,6 +183,7 @@ export type ReimbursementDetail = {
|
||||
mimeType: string;
|
||||
fileSize: number;
|
||||
fileSha256: string;
|
||||
} & AttachmentGovernanceFields & {
|
||||
uploadedBy: {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -516,8 +518,8 @@ export function reimbursementAttachmentFileUrl(reimbursementId: string, attachme
|
||||
return `/api/reimbursements/${reimbursementId}/attachments/${attachmentId}/file`;
|
||||
}
|
||||
|
||||
export async function downloadReimbursementAttachment(reimbursementId: string, attachmentId: string) {
|
||||
return apiRequestBlob(reimbursementAttachmentFileUrl(reimbursementId, attachmentId));
|
||||
export async function downloadReimbursementAttachment(reimbursementId: string, attachmentId: string, reason?: string) {
|
||||
return apiRequestBlob(buildAttachmentFileUrl(reimbursementAttachmentFileUrl(reimbursementId, attachmentId), reason));
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { ComponentPropsWithoutRef } from "react";
|
||||
import {
|
||||
ATTACHMENT_TYPE_LABEL,
|
||||
GOVERNANCE_STATUS_LABEL,
|
||||
SENSITIVITY_LEVEL_LABEL,
|
||||
attachmentRequiresDownloadReason,
|
||||
type AttachmentGovernanceFields,
|
||||
} from "../../lib/attachmentGovernance";
|
||||
import { cn } from "../../lib/utils";
|
||||
|
||||
function governanceStatusClass(value: string) {
|
||||
if (value === "approved" || value === "archived") return "badge-success";
|
||||
if (value === "pending_review" || value === "under_approval" || value === "returned") return "badge-warning";
|
||||
if (value === "rejected" || value === "quarantined") return "badge-error";
|
||||
return "badge-neutral";
|
||||
}
|
||||
|
||||
function sensitivityClass(value: string) {
|
||||
if (value === "restricted") return "badge-error";
|
||||
if (value === "confidential") return "badge-warning";
|
||||
if (value === "index_only") return "badge-neutral";
|
||||
return "badge-neutral";
|
||||
}
|
||||
|
||||
export function AttachmentGovernanceBadges({
|
||||
attachment,
|
||||
className,
|
||||
...props
|
||||
}: ComponentPropsWithoutRef<"div"> & {
|
||||
attachment: Pick<AttachmentGovernanceFields, "governanceStatus" | "sensitivityLevel" | "attachmentType" | "downloadPolicy">;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex flex-wrap items-center gap-1.5", className)} {...props}>
|
||||
<span className={governanceStatusClass(attachment.governanceStatus)}>
|
||||
{GOVERNANCE_STATUS_LABEL[attachment.governanceStatus] ?? attachment.governanceStatus}
|
||||
</span>
|
||||
<span className="badge-neutral">
|
||||
{ATTACHMENT_TYPE_LABEL[attachment.attachmentType] ?? attachment.attachmentType}
|
||||
</span>
|
||||
<span className={sensitivityClass(attachment.sensitivityLevel)}>
|
||||
{SENSITIVITY_LEVEL_LABEL[attachment.sensitivityLevel] ?? attachment.sensitivityLevel}
|
||||
</span>
|
||||
{attachmentRequiresDownloadReason(attachment) ? <span className="badge-warning">需填用途</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import {
|
||||
attachmentRequiresDownloadReason,
|
||||
type AttachmentGovernanceFields,
|
||||
} from "../../lib/attachmentGovernance";
|
||||
import { Button } from "../ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "../ui/dialog";
|
||||
import { Textarea } from "../ui/textarea";
|
||||
|
||||
type DownloadReasonTarget = Pick<AttachmentGovernanceFields, "downloadPolicy" | "sensitivityLevel"> & {
|
||||
originalName?: string;
|
||||
};
|
||||
|
||||
export function useAttachmentDownloadReasonDialog() {
|
||||
const [target, setTarget] = useState<DownloadReasonTarget | null>(null);
|
||||
const [reason, setReason] = useState("业务复核");
|
||||
const [error, setError] = useState("");
|
||||
const resolverRef = useRef<((reason: string | null | undefined) => void) | null>(null);
|
||||
|
||||
const closeWith = useCallback((value: string | null | undefined) => {
|
||||
resolverRef.current?.(value);
|
||||
resolverRef.current = null;
|
||||
setTarget(null);
|
||||
setReason("业务复核");
|
||||
setError("");
|
||||
}, []);
|
||||
|
||||
const requestDownloadReason = useCallback((attachment: DownloadReasonTarget) => {
|
||||
if (!attachmentRequiresDownloadReason(attachment)) {
|
||||
return Promise.resolve(undefined);
|
||||
}
|
||||
|
||||
setTarget(attachment);
|
||||
setReason("业务复核");
|
||||
setError("");
|
||||
return new Promise<string | null | undefined>((resolve) => {
|
||||
resolverRef.current = resolve;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const confirm = useCallback(() => {
|
||||
const trimmed = reason.trim();
|
||||
if (!trimmed) {
|
||||
setError("请填写下载用途");
|
||||
return;
|
||||
}
|
||||
closeWith(trimmed);
|
||||
}, [closeWith, reason]);
|
||||
|
||||
const downloadReasonDialog = (
|
||||
<Dialog open={Boolean(target)} onOpenChange={(open) => { if (!open) closeWith(null); }}>
|
||||
<DialogContent className="max-w-md p-5">
|
||||
<DialogHeader>
|
||||
<DialogTitle>填写下载用途</DialogTitle>
|
||||
<DialogDescription>
|
||||
{target?.originalName ? `附件:${target.originalName}` : "该附件需要用途留痕后才能下载。"}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="mt-4 space-y-2">
|
||||
<Textarea
|
||||
value={reason}
|
||||
onChange={(event) => {
|
||||
setReason(event.target.value);
|
||||
if (error) setError("");
|
||||
}}
|
||||
aria-label="下载用途"
|
||||
rows={3}
|
||||
maxLength={200}
|
||||
placeholder="例如:付款复核、审批查验、归档核对"
|
||||
/>
|
||||
<div className="flex items-center justify-between text-xs">
|
||||
<span className={error ? "text-red-600" : "text-zinc-500"}>{error || "用途会写入下载审计日志。"}</span>
|
||||
<span className="text-zinc-400">{reason.trim().length}/200</span>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="secondary" onClick={() => closeWith(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="button" onClick={confirm}>
|
||||
继续下载
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
return { requestDownloadReason, downloadReasonDialog };
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
export type AttachmentGovernanceStatus =
|
||||
| "draft"
|
||||
| "under_approval"
|
||||
| "pending_review"
|
||||
| "approved"
|
||||
| "archived"
|
||||
| "returned"
|
||||
| "rejected"
|
||||
| "quarantined";
|
||||
|
||||
export type AttachmentSensitivityLevel =
|
||||
| "public"
|
||||
| "internal"
|
||||
| "confidential"
|
||||
| "restricted"
|
||||
| "index_only";
|
||||
|
||||
export type BusinessAttachmentType =
|
||||
| "contract_draft"
|
||||
| "contract_stamped"
|
||||
| "invoice"
|
||||
| "receipt"
|
||||
| "quote"
|
||||
| "acceptance"
|
||||
| "delivery"
|
||||
| "policy_document"
|
||||
| "supporting_material"
|
||||
| "other";
|
||||
|
||||
export type AttachmentDownloadPolicy = "standard" | "reason_required" | "restricted";
|
||||
|
||||
export type AttachmentGovernanceFields = {
|
||||
governanceStatus: AttachmentGovernanceStatus | string;
|
||||
sensitivityLevel: AttachmentSensitivityLevel | string;
|
||||
attachmentType: BusinessAttachmentType | string;
|
||||
downloadPolicy: AttachmentDownloadPolicy | string;
|
||||
reviewedBy: string | null;
|
||||
reviewedAt: string | null;
|
||||
reviewNote: string | null;
|
||||
archivedBy: string | null;
|
||||
archivedAt: string | null;
|
||||
archiveNote: string | null;
|
||||
};
|
||||
|
||||
export const GOVERNANCE_STATUS_LABEL: Record<string, string> = {
|
||||
draft: "草稿",
|
||||
under_approval: "审批中",
|
||||
pending_review: "待复核",
|
||||
approved: "已通过",
|
||||
archived: "已归档",
|
||||
returned: "已退回",
|
||||
rejected: "已拒绝",
|
||||
quarantined: "已隔离",
|
||||
};
|
||||
|
||||
export const SENSITIVITY_LEVEL_LABEL: Record<string, string> = {
|
||||
public: "公开",
|
||||
internal: "内部",
|
||||
confidential: "保密",
|
||||
restricted: "受限",
|
||||
index_only: "仅索引",
|
||||
};
|
||||
|
||||
export const ATTACHMENT_TYPE_LABEL: Record<string, string> = {
|
||||
contract_draft: "合同稿",
|
||||
contract_stamped: "盖章件",
|
||||
invoice: "发票",
|
||||
receipt: "回单",
|
||||
quote: "报价",
|
||||
acceptance: "验收",
|
||||
delivery: "交付物",
|
||||
policy_document: "制度资料",
|
||||
supporting_material: "补充材料",
|
||||
other: "其他",
|
||||
};
|
||||
|
||||
export function attachmentRequiresDownloadReason(attachment: Pick<AttachmentGovernanceFields, "downloadPolicy" | "sensitivityLevel">) {
|
||||
return (
|
||||
attachment.downloadPolicy === "reason_required" ||
|
||||
attachment.downloadPolicy === "restricted" ||
|
||||
attachment.sensitivityLevel === "confidential" ||
|
||||
attachment.sensitivityLevel === "restricted"
|
||||
);
|
||||
}
|
||||
|
||||
export function buildAttachmentFileUrl(path: string, reason?: string) {
|
||||
const trimmed = reason?.trim();
|
||||
if (!trimmed) return path;
|
||||
const params = new URLSearchParams({ reason: trimmed });
|
||||
return `${path}${path.includes("?") ? "&" : "?"}${params.toString()}`;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { fetchPurchaseRequestDetail } from "../api/purchaseRequests";
|
||||
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
|
||||
import { AttachmentGovernanceBadges } from "../components/common/AttachmentGovernanceBadges";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { PageScaffold } from "../components/patterns/PageScaffold";
|
||||
import {
|
||||
@@ -161,6 +162,7 @@ export function MobilePurchaseRequestDetailPage() {
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
{joinBusinessMeta([formatBytes(attachment.fileSize), attachment.uploader?.name, formatDate(attachment.createdAt)])}
|
||||
</div>
|
||||
<AttachmentGovernanceBadges attachment={attachment} className="mt-2" />
|
||||
</article>
|
||||
)) : <div className="mobile-empty-state">暂无附件</div>}
|
||||
</div>
|
||||
|
||||
@@ -31,6 +31,8 @@ import { remindWorkflowTask } from "../api/workflow";
|
||||
import type { PrecheckResponse } from "../api/feeControl";
|
||||
import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { AttachmentGovernanceBadges } from "../components/common/AttachmentGovernanceBadges";
|
||||
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
|
||||
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
||||
import { MobileIcon } from "../components/mobile/MobileIcon";
|
||||
import { MobileDetailHeader } from "../components/mobile/MobileDetailHeader";
|
||||
@@ -55,6 +57,7 @@ import {
|
||||
} from "../lib/invoiceStatus";
|
||||
import { resolveErrorMessage } from "../lib/resolveErrorMessage";
|
||||
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
|
||||
import type { AttachmentGovernanceFields } from "../lib/attachmentGovernance";
|
||||
import { TEST_IDS } from "../lib/testIds";
|
||||
import { withUploadTimeout } from "../lib/uploadTimeout";
|
||||
import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
|
||||
@@ -671,8 +674,11 @@ export function MobileReimbursementDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const { requestDownloadReason, downloadReasonDialog } = useAttachmentDownloadReasonDialog();
|
||||
|
||||
const openAttachmentMutation = useMutation({
|
||||
mutationFn: (attachmentId: string) => downloadReimbursementAttachment(reimbursementId, attachmentId),
|
||||
mutationFn: ({ attachmentId, reason }: { attachmentId: string; reason?: string }) =>
|
||||
downloadReimbursementAttachment(reimbursementId, attachmentId, reason),
|
||||
onSuccess: (result) => {
|
||||
const url = URL.createObjectURL(result.blob);
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
@@ -683,6 +689,14 @@ export function MobileReimbursementDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
async function handleOpenAttachment(attachment: { id: string; originalName?: string } & AttachmentGovernanceFields) {
|
||||
const reason = await requestDownloadReason(attachment);
|
||||
if (reason === null) {
|
||||
return;
|
||||
}
|
||||
openAttachmentMutation.mutate({ attachmentId: attachment.id, reason });
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!detail) {
|
||||
return;
|
||||
@@ -811,6 +825,7 @@ export function MobileReimbursementDetailPage() {
|
||||
)}
|
||||
data-testid="mobile-reimbursement-detail"
|
||||
>
|
||||
{downloadReasonDialog}
|
||||
<MobileDetailHeader
|
||||
title={displayCode}
|
||||
subtitle="报销详情"
|
||||
@@ -1078,6 +1093,7 @@ export function MobileReimbursementDetailPage() {
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
{Math.ceil(attachment.fileSize / 1024)} KB · {formatDateTime(attachment.createdAt)}
|
||||
</div>
|
||||
<AttachmentGovernanceBadges attachment={attachment} className="mt-2" />
|
||||
</div>
|
||||
<div className="mobile-two-action-grid mt-3">
|
||||
<Button
|
||||
@@ -1085,7 +1101,7 @@ export function MobileReimbursementDetailPage() {
|
||||
variant="secondary"
|
||||
type="button"
|
||||
disabled={openAttachmentMutation.isPending}
|
||||
onClick={() => openAttachmentMutation.mutate(attachment.id)}
|
||||
onClick={() => void handleOpenAttachment(attachment)}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
|
||||
@@ -23,6 +23,8 @@ import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { ListWorkspace, WorkspaceHeader } from "../components/patterns/ListWorkspace";
|
||||
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
|
||||
import { AttachmentGovernanceBadges } from "../components/common/AttachmentGovernanceBadges";
|
||||
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
|
||||
import { Button } from "../components/ui/button";
|
||||
import { Input } from "../components/ui/input";
|
||||
import { Label } from "../components/ui/label";
|
||||
@@ -301,12 +303,14 @@ export function PaymentBatchesPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const { requestDownloadReason, downloadReasonDialog } = useAttachmentDownloadReasonDialog();
|
||||
|
||||
const downloadReceiptMutation = useMutation({
|
||||
mutationFn: async (attachment: PaymentBatchReceiptAttachment) => {
|
||||
mutationFn: async ({ attachment, reason }: { attachment: PaymentBatchReceiptAttachment; reason?: string }) => {
|
||||
if (!selectedBatch) {
|
||||
throw new Error("请先选择付款批次");
|
||||
}
|
||||
const response = await downloadPaymentBatchReceiptAttachment(selectedBatch.id, attachment.id);
|
||||
const response = await downloadPaymentBatchReceiptAttachment(selectedBatch.id, attachment.id, reason);
|
||||
return { response, attachment };
|
||||
},
|
||||
onSuccess: ({ response, attachment }) => {
|
||||
@@ -319,6 +323,14 @@ export function PaymentBatchesPage() {
|
||||
},
|
||||
});
|
||||
|
||||
async function handleDownloadReceipt(attachment: PaymentBatchReceiptAttachment) {
|
||||
const reason = await requestDownloadReason(attachment);
|
||||
if (reason === null) {
|
||||
return;
|
||||
}
|
||||
downloadReceiptMutation.mutate({ attachment, reason });
|
||||
}
|
||||
|
||||
const deleteReceiptMutation = useMutation({
|
||||
mutationFn: async (attachmentId: string) => {
|
||||
if (!selectedBatch) {
|
||||
@@ -340,6 +352,7 @@ export function PaymentBatchesPage() {
|
||||
|
||||
return (
|
||||
<ListWorkspace>
|
||||
{downloadReasonDialog}
|
||||
<WorkspaceHeader title="付款批次" subtitle="复核、回单和已付款标记集中处理。" />
|
||||
|
||||
{error ? <div className="rounded-lg border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-600">{error}</div> : null}
|
||||
@@ -607,6 +620,7 @@ export function PaymentBatchesPage() {
|
||||
{attachment.mimeType} · {formatFileSize(attachment.fileSize)} · {formatDisplayDateTime(attachment.createdAt)}
|
||||
{attachment.uploadedBy ? ` · ${attachment.uploadedBy.name}` : ""}
|
||||
</div>
|
||||
<AttachmentGovernanceBadges attachment={attachment} className="mt-2" />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
@@ -614,7 +628,7 @@ export function PaymentBatchesPage() {
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
disabled={downloadReceiptMutation.isPending}
|
||||
onClick={() => downloadReceiptMutation.mutate(attachment)}
|
||||
onClick={() => void handleDownloadReceipt(attachment)}
|
||||
>
|
||||
下载
|
||||
</Button>
|
||||
|
||||
@@ -39,6 +39,8 @@ import { hasPermission } from "../auth/permissions";
|
||||
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
|
||||
import { AiSuggestionCard } from "../components/ai/AiSuggestionCard";
|
||||
import { ActionConfirmDialog } from "../components/common/ActionConfirmDialog";
|
||||
import { AttachmentGovernanceBadges } from "../components/common/AttachmentGovernanceBadges";
|
||||
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
|
||||
import { DateTextInput } from "../components/common/DateTextInput";
|
||||
import { Pagination } from "../components/common/Pagination";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
@@ -49,6 +51,7 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from ".
|
||||
import { Textarea } from "../components/ui/textarea";
|
||||
import { FilterBar, ListWorkspace, PrimaryTable, WorkspaceHeader } from "../components/patterns/ListWorkspace";
|
||||
import { SectionPanel } from "../components/patterns/SectionPanel";
|
||||
import type { AttachmentGovernanceFields } from "../lib/attachmentGovernance";
|
||||
import { formatCompactIdentifier, formatDisplayDateTime, formatTextWithCompactLongNumbers, isCompactedIdentifier } from "../lib/displayFormat";
|
||||
import { buildPrecheckDialogMessage } from "../lib/precheck";
|
||||
import {
|
||||
@@ -744,12 +747,18 @@ export function PurchaseRequestsPage() {
|
||||
},
|
||||
});
|
||||
|
||||
async function handleDownloadAttachment(attachmentId: string) {
|
||||
const { requestDownloadReason, downloadReasonDialog } = useAttachmentDownloadReasonDialog();
|
||||
|
||||
async function handleDownloadAttachment(attachment: { id: string; originalName?: string } & AttachmentGovernanceFields) {
|
||||
if (!selectedId) {
|
||||
return;
|
||||
}
|
||||
const reason = await requestDownloadReason(attachment);
|
||||
if (reason === null) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const response = await issuePurchaseAttachmentSignedUrl(selectedId, attachmentId);
|
||||
const response = await issuePurchaseAttachmentSignedUrl(selectedId, attachment.id, reason);
|
||||
window.open(response.signedUrl, "_blank", "noopener,noreferrer");
|
||||
} catch (error) {
|
||||
const message = resolveError(error);
|
||||
@@ -814,6 +823,7 @@ export function PurchaseRequestsPage() {
|
||||
|
||||
return (
|
||||
<ListWorkspace>
|
||||
{downloadReasonDialog}
|
||||
<WorkspaceHeader
|
||||
title="采购申请"
|
||||
subtitle="草稿、附件、审批。"
|
||||
@@ -1576,6 +1586,7 @@ export function PurchaseRequestsPage() {
|
||||
<div key={attachment.id} className="rounded-lg border border-zinc-200 bg-white p-4">
|
||||
<div className="font-medium text-zinc-900">{attachment.originalName}</div>
|
||||
<div className="mt-1 text-xs text-zinc-500">{attachment.mimeType} / {attachment.fileSize} bytes</div>
|
||||
<AttachmentGovernanceBadges attachment={attachment} className="mt-2" />
|
||||
<div className="mt-3 grid grid-cols-2 gap-2 text-xs text-zinc-600">
|
||||
<div>
|
||||
<div className="text-zinc-400">上传人</div>
|
||||
@@ -1589,7 +1600,7 @@ export function PurchaseRequestsPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => void handleDownloadAttachment(attachment.id)}>
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => void handleDownloadAttachment(attachment)}>
|
||||
下载
|
||||
</Button>
|
||||
<Button
|
||||
@@ -1625,12 +1636,13 @@ export function PurchaseRequestsPage() {
|
||||
<TableCell>
|
||||
<div className="font-medium text-zinc-900">{attachment.originalName}</div>
|
||||
<div className="text-xs text-zinc-500">{attachment.mimeType} / {attachment.fileSize} bytes</div>
|
||||
<AttachmentGovernanceBadges attachment={attachment} className="mt-2" />
|
||||
</TableCell>
|
||||
<TableCell>{attachment.uploader?.name ?? "-"}</TableCell>
|
||||
<TableCell>{formatDisplayDateTime(attachment.createdAt)}</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => void handleDownloadAttachment(attachment.id)}>
|
||||
<Button type="button" size="sm" variant="secondary" onClick={() => void handleDownloadAttachment(attachment)}>
|
||||
下载
|
||||
</Button>
|
||||
<Button
|
||||
|
||||
@@ -35,6 +35,8 @@ import { useAuth } from "../auth/AuthContext";
|
||||
import { hasPermission } from "../auth/permissions";
|
||||
import { CardSkeleton } from "../components/common/CardSkeleton";
|
||||
import { ConfirmDialog } from "../components/common/ConfirmDialog";
|
||||
import { AttachmentGovernanceBadges } from "../components/common/AttachmentGovernanceBadges";
|
||||
import { useAttachmentDownloadReasonDialog } from "../components/common/useAttachmentDownloadReasonDialog";
|
||||
import { RiskFlagsCard } from "../components/common/RiskFlagsCard";
|
||||
import { StatusBadge } from "../components/common/StatusBadge";
|
||||
import { AiSuggestionCard } from "../components/ai/AiSuggestionCard";
|
||||
@@ -62,6 +64,7 @@ import {
|
||||
} from "../lib/invoiceStatus";
|
||||
import { useInvoiceOcrTracker } from "../hooks/useInvoiceOcrTracker";
|
||||
import { resolveReimbursementDisplayCode } from "../lib/reimbursementDisplay";
|
||||
import type { AttachmentGovernanceFields } from "../lib/attachmentGovernance";
|
||||
import { formatDisplayDate } from "../lib/displayFormat";
|
||||
import { TEST_IDS } from "../lib/testIds";
|
||||
import { PDF_FILE_ACCEPT, rejectedPdfMessage, splitPdfFiles } from "../lib/pdfFiles";
|
||||
@@ -726,8 +729,11 @@ export function ReimbursementDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
const { requestDownloadReason, downloadReasonDialog } = useAttachmentDownloadReasonDialog();
|
||||
|
||||
const openAttachmentMutation = useMutation({
|
||||
mutationFn: (attachmentId: string) => downloadReimbursementAttachment(reimbursementId, attachmentId),
|
||||
mutationFn: ({ attachmentId, reason }: { attachmentId: string; reason?: string }) =>
|
||||
downloadReimbursementAttachment(reimbursementId, attachmentId, reason),
|
||||
onSuccess: (result) => {
|
||||
const url = URL.createObjectURL(result.blob);
|
||||
window.open(url, "_blank", "noopener,noreferrer");
|
||||
@@ -738,6 +744,14 @@ export function ReimbursementDetailPage() {
|
||||
},
|
||||
});
|
||||
|
||||
async function handleOpenAttachment(attachment: { id: string; originalName?: string } & AttachmentGovernanceFields) {
|
||||
const reason = await requestDownloadReason(attachment);
|
||||
if (reason === null) {
|
||||
return;
|
||||
}
|
||||
openAttachmentMutation.mutate({ attachmentId: attachment.id, reason });
|
||||
}
|
||||
|
||||
if (!reimbursementId) {
|
||||
return (
|
||||
<div className="card-std p-5">
|
||||
@@ -959,6 +973,7 @@ export function ReimbursementDetailPage() {
|
||||
|
||||
return (
|
||||
<PageScaffold>
|
||||
{downloadReasonDialog}
|
||||
<EntityOverviewHero
|
||||
title={`报销详情 ${displayCode}`}
|
||||
subtitle="状态、票据、审批和付款集中处理。"
|
||||
@@ -1197,6 +1212,7 @@ export function ReimbursementDetailPage() {
|
||||
<div className="text-xs text-zinc-500">
|
||||
{Math.ceil(attachment.fileSize / 1024)} KB · {formatDateTime(attachment.createdAt)}
|
||||
</div>
|
||||
<AttachmentGovernanceBadges attachment={attachment} className="mt-2" />
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
@@ -1204,7 +1220,7 @@ export function ReimbursementDetailPage() {
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={openAttachmentMutation.isPending}
|
||||
onClick={() => openAttachmentMutation.mutate(attachment.id)}
|
||||
onClick={() => void handleOpenAttachment(attachment)}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
|
||||
在新工单中引用
屏蔽一个用户