211 lines
7.7 KiB
TypeScript
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 cơ bản theo Đơn và 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 có 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>
|
|
);
|
|
}
|