sciagent code + Gitea Actions CI/CD
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,210 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user