Files
sciagent/fe0/src/components/admin/review/AdminStaffReadonlyReviewDialog.tsx
T
Thinh Lam 688fac73e9
CI/CD / backend (push) Failing after 2m8s
CI/CD / frontend (push) Failing after 1m40s
CI/CD / deploy (push) Has been skipped
sciagent code + Gitea Actions CI/CD
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-30 09:38:30 +07:00

211 lines
7.7 KiB
TypeScript

import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { toast } from "sonner";
import { detailFromApiError } from "@/shared/api/client";
import { upsertAdminApplicationResult } from "@/lib/applicationAdminResultApi";
import { invalidateAfterAdminApplicationResultChange } from "@/components/admin/result/invalidateAdminApplicationResultQueries";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import {
type ApplicationMeritCategoryHint,
formatApplicationSubgroupLabel,
} from "@/components/admin/review/applicationMeritCategoryHint";
function MeritCategorySection({ hint }: { hint: ApplicationMeritCategoryHint }) {
const subgroupLabel = formatApplicationSubgroupLabel(hint.subgroupCode);
return (
<div className="rounded-md border border-border bg-muted/20 px-3 py-2 text-sm space-y-2">
<p className="font-medium text-foreground">Gợi ý theo nhóm Đơn</p>
{subgroupLabel ? (
<p>
<span className="text-muted-foreground">Mục Đơn: </span>
<span
className={
hint.subgroupCode === "2.2.1" ? "font-medium text-foreground" : "font-mono font-medium text-foreground"
}
>
{subgroupLabel}
</span>
</p>
) : null}
{hint.meritLevel ? (
<p className="font-medium text-foreground">Gợi ý mức xét: {hint.meritLevel}</p>
) : null}
<p className="text-muted-foreground">{hint.detail}</p>
</div>
);
}
/** Full DOCX/kho completeness — chỉ hiển thị sau khi admin đã ✓/✗ ít nhất một minh chứng trên kho (từ nút mở kho minh chứng). */
function DocxTemplateCompletenessSection({ gaps }: { gaps: string[] }) {
const complete = gaps.length === 0;
if (complete) {
return (
<Alert className="border-green-700/40 bg-green-50/80 dark:bg-green-950/30 dark:border-green-700/50">
<AlertTitle>Mẫu DOCX / kho minh chứng</AlertTitle>
<AlertDescription className="text-foreground/90">
Không phát hiện thiếu sót bản theo Đơn kho minh chứng các trường cần cho mẫu đã được đối chiếu đủ.
</AlertDescription>
</Alert>
);
}
return (
<Alert variant="destructive">
<AlertTitle>Các mục chưa đủ hoặc cần kiểm tra</AlertTitle>
<AlertDescription>
<ul className="list-disc pl-4 mt-2 space-y-1 text-sm">
{gaps.map((g) => (
<li key={g}>{g}</li>
))}
</ul>
</AlertDescription>
</Alert>
);
}
function PendingEvidenceStaffReviewPrompt() {
return (
<Alert>
<AlertTitle>Chưa mở khóa đối chiếu DOCX / kho minh chứng</AlertTitle>
<AlertDescription className="text-sm leading-relaxed">
Đóng hộp thoại này, rồi mở kho minh chứng bằng nút chân khu xem mẫu nút đó hiển thị nhãn tổng hợp{" "}
<span className="font-medium">Đạt</span>, <span className="font-medium">Không đạt</span> hoặc{" "}
<span className="font-medium">Chưa xem</span> (cùng quy tắc với tooltip « Mở kho minh chứng »). Trên trang
kho, nhấn <span className="font-medium"></span> phê duyệt hoặc <span className="font-medium"></span> từ chối đối với
ít nhất một dòng tệp. Sau đó mở lại thao tác từ chối / duyệt để xem tóm tắt đầy đủ.
</AlertDescription>
</Alert>
);
}
export type StaffReadonlyDialogVariant = "reject" | "approve";
export type AdminStaffReadonlyReviewDialogProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
/** Public application id — persisted with decision and feedback on confirm. */
applicationId: string;
variant: StaffReadonlyDialogVariant;
meritHint: ApplicationMeritCategoryHint;
/** From {@link collectDocxTemplateCompletenessGaps} — Đơn + kho minh chứng. */
docxCompletenessGaps: string[];
/** Sau khi admin đã ✓/✗ minh chứng trên kho (mở qua nút kho minh chứng; cùng mã case). */
staffEvidenceReviewAcknowledged: boolean;
};
const titles: Record<StaffReadonlyDialogVariant, string> = {
reject: "Từ chối (xem trước)",
approve: "Duyệt (xem trước)",
};
/**
* Hộp thoại giữa màn hình cho quản trị viên chỉ đọc: trạng thái mẫu DOCX, gợi ý mức xét, phản hồi.
*/
export function AdminStaffReadonlyReviewDialog({
open,
onOpenChange,
applicationId,
variant,
meritHint,
docxCompletenessGaps,
staffEvidenceReviewAcknowledged,
}: AdminStaffReadonlyReviewDialogProps) {
const queryClient = useQueryClient();
const [feedback, setFeedback] = useState("");
const [saving, setSaving] = useState(false);
const handleOpenChange = (next: boolean) => {
if (!next) setFeedback("");
onOpenChange(next);
};
const confirm = async () => {
const id = applicationId.trim();
if (!id) {
toast.error("Thiếu mã hồ sơ — không thể lưu kết quả.");
return;
}
const trimmed = feedback.trim();
const decision = variant === "approve" ? "approved" : "rejected";
setSaving(true);
try {
await upsertAdminApplicationResult(id, {
decision,
feedback: trimmed,
rationale: null,
});
await invalidateAfterAdminApplicationResultChange(queryClient, id);
handleOpenChange(false);
toast.success(trimmed ? "Đã lưu kết quả và phản hồi." : "Đã lưu kết quả.", {
...(trimmed ? { description: trimmed.length > 160 ? `${trimmed.slice(0, 157)}…` : trimmed } : {}),
});
} catch (e) {
toast.error(detailFromApiError(e, "Không lưu được kết quả."));
} finally {
setSaving(false);
}
};
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
<DialogContent className="max-w-lg max-h-[min(90vh,720px)] overflow-y-auto">
<DialogHeader>
<DialogTitle>{titles[variant]}</DialogTitle>
</DialogHeader>
<div className="space-y-4 text-sm">
{staffEvidenceReviewAcknowledged ? (
<DocxTemplateCompletenessSection gaps={docxCompletenessGaps} />
) : (
<PendingEvidenceStaffReviewPrompt />
)}
<MeritCategorySection hint={meritHint} />
<div className="space-y-1.5">
<Label htmlFor="staff-readonly-review-feedback">Phản hồi / nhận xét</Label>
<Textarea
id="staff-readonly-review-feedback"
className="min-h-[100px] resize-y"
placeholder="Nhập phản hồi hoặc ghi chú…"
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
/>
</div>
</div>
<DialogFooter className="gap-2 sm:gap-0">
<Button
type="button"
variant="outline"
disabled={saving}
onClick={() => handleOpenChange(false)}
>
Đóng
</Button>
<Button type="button" disabled={saving} onClick={() => void confirm()}>
{saving ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />
Đang lưu
</>
) : (
"Xác nhận"
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}