행정지원 검토의견 수정

This commit is contained in:
2026-10-20 10:04:42 +09:00
parent 3a5b48b608
commit 8a98cd7d19
15 changed files with 2012 additions and 1526 deletions
+1
View File
@@ -51,6 +51,7 @@ export {
} from "./ui/pbadms-task-apply-dialog";
export { PrivacyConsentSection } from "./ui/privacy-consent-section";
export { ProgressStatusSection } from "./ui/progress-status-section";
export { RvwOpnnListTable, type RvwOpnnListTableProps } from "./ui/rvw-opnn-list-table";
export {
SplmntDmndDialog,
type SplmntDmndDialogConfirmItem,
@@ -0,0 +1,406 @@
"use client";
import { useState } from "react";
import { useConfirm } from "@frontend-opensource/use-react-hooks";
import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib";
import type { AtflMngClientResponseDto, RvwOpnnUpdateRequestDto } from "@/shared/model";
import { AttachmentFileField } from "@/shared/ui";
import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
import {
AttachmentDownloadLink,
attachService,
useDownloadAttachmentByAtflId,
} from "@/entities/attach";
import {
Button,
RadioGroup,
RadioGroupItem,
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
Textarea,
} from "@common/components";
const RVW_OPNN_EDIT_FILE_KEY_PREFIX = "rvw-opnn-edit::";
/** 업무별 검토의견 응답 DTO 공통 필드 (업무마다 null 허용 여부가 달라 느슨하게 정의) */
interface RvwOpnnListItem {
opnrSeComCd?: string | null;
opnrNm?: string | null;
rvwRsltCn?: string | null;
rvwRsltAnmlyYn?: string | null;
atchFileGroupId?: string | null;
atchFileInfo?: AtflMngClientResponseDto | null;
regDt?: string | null;
rgtr?: string | null;
}
interface RvwOpnnListTableProps<T extends RvwOpnnListItem> {
items: T[];
getRowKey: (item: T) => string;
/** 로그인 사용자 UUID — 검토의견 등록자(rgtr)와 같을 때만 수정·삭제 버튼 노출 */
currentUserUuid?: string;
/** 현재 검토 단계가 로그인 사용자 차례인지 여부 — false면 등록자라도 수정·삭제 불가 */
isReviewTurn?: boolean;
onUpdate: (item: T, body: RvwOpnnUpdateRequestDto) => Promise<void>;
onDelete: (item: T) => Promise<void>;
}
interface EditDraft {
rvwRsltCn: string;
rvwRsltAnmlyYn: string;
atchFileGroupId: string;
}
interface EditFile {
fileName: string;
fileKey: string;
atflId?: string;
atflCd?: string;
/** 이번 수정 중 새로 업로드한 파일 여부 (취소 시 정리 대상) */
isNew: boolean;
}
function formatAnmlyYn(value?: string | null) {
if (value === "Y") return "이상";
if (value === "N") return "정상";
return "-";
}
function RvwOpnnListTable<T extends RvwOpnnListItem>({
items,
getRowKey,
currentUserUuid,
isReviewTurn = false,
onUpdate,
onDelete,
}: RvwOpnnListTableProps<T>) {
const { confirm } = useConfirm();
const { mutate: downloadByAtflId } = useDownloadAttachmentByAtflId();
const [editingKey, setEditingKey] = useState<string | null>(null);
const [draft, setDraft] = useState<EditDraft | null>(null);
const [editFile, setEditFile] = useState<EditFile | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
const { uploadFileAsync, isUploading, uploads } = useTus({
onSuccess: (atflCd, atflId, _file, fileKey) => {
if (!atflCd || !atflId) return;
setDraft((prev) => (prev ? { ...prev, atchFileGroupId: atflCd } : prev));
setEditFile((prev) => (prev?.fileKey === fileKey ? { ...prev, atflCd, atflId } : prev));
},
onError: (_error, _file, fileKey) => {
setEditFile((prev) => (prev?.fileKey === fileKey ? null : prev));
},
});
const editFileUploading = editFile ? (isUploading[editFile.fileKey] ?? false) : false;
function canModify(item: T) {
return isReviewTurn && Boolean(currentUserUuid) && item.rgtr === currentUserUuid;
}
async function discardNewFile(file: EditFile | null) {
if (!file?.isNew) return;
uploads[file.fileKey]?.abort();
if (file.atflId) {
try {
await attachService.deleteAttachmentByAtflId(file.atflId);
} catch (error) {
console.error("검토의견 첨부파일 삭제 실패:", error);
}
}
}
function resetEdit() {
setEditingKey(null);
setDraft(null);
setEditFile(null);
}
function startEdit(item: T) {
void discardNewFile(editFile);
setEditingKey(getRowKey(item));
setDraft({
rvwRsltCn: item.rvwRsltCn ?? "",
rvwRsltAnmlyYn: item.rvwRsltAnmlyYn ?? "",
atchFileGroupId: item.atchFileGroupId ?? "",
});
setEditFile(
item.atchFileInfo?.atflCd
? {
fileName: item.atchFileInfo.orgnlFileNm || "첨부파일",
fileKey: `${RVW_OPNN_EDIT_FILE_KEY_PREFIX}${item.atchFileInfo.atflCd}`,
atflId: item.atchFileInfo.atflId,
atflCd: item.atchFileInfo.atflCd,
isNew: false,
}
: null,
);
}
function cancelEdit() {
void discardNewFile(editFile);
resetEdit();
}
async function handleFileAdd(files: FileList | null) {
if (!files || files.length === 0) return;
const file = files[0];
const fileKey = `${RVW_OPNN_EDIT_FILE_KEY_PREFIX}${getDefaultFileKey(file)}`;
setEditFile({ fileName: file.name, fileKey, isNew: true });
try {
await uploadFileAsync(file, { fileKey });
} catch {
// onError callback handles cleanup
}
}
function handleFileRemove() {
// 기존 파일은 저장 시점에 연결만 해제하고, 새로 올린 파일은 즉시 정리
void discardNewFile(editFile);
setEditFile(null);
setDraft((prev) => (prev ? { ...prev, atchFileGroupId: "" } : prev));
}
async function handleSave(item: T) {
if (!draft) return;
if (!draft.rvwRsltCn.trim()) {
alert("의견내용을 입력하세요.");
return;
}
if (!draft.rvwRsltAnmlyYn) {
alert("검토결과를 선택하세요.");
return;
}
if (!(await confirm("검토의견을 수정하시겠습니까?"))) return;
setIsSubmitting(true);
try {
await onUpdate(item, {
opnrSeComCd: item.opnrSeComCd ?? undefined,
opnrNm: item.opnrNm ?? undefined,
rvwRsltCn: draft.rvwRsltCn,
rvwRsltAnmlyYn: draft.rvwRsltAnmlyYn,
atchFileGroupId: draft.atchFileGroupId,
});
alert("수정되었습니다.");
resetEdit();
} catch (error) {
console.error("검토의견 수정 실패:", error);
alert("검토의견 수정 중 오류가 발생했습니다.");
} finally {
setIsSubmitting(false);
}
}
async function handleDelete(item: T) {
if (!(await confirm("검토의견을 삭제하시겠습니까?"))) return;
setIsSubmitting(true);
try {
await onDelete(item);
alert("삭제되었습니다.");
if (editingKey === getRowKey(item)) resetEdit();
} catch (error) {
console.error("검토의견 삭제 실패:", error);
alert("검토의견 삭제 중 오류가 발생했습니다.");
} finally {
setIsSubmitting(false);
}
}
function renderViewRow(item: T) {
return (
<>
<TableCell align="left">{item.rvwRsltCn || "-"}</TableCell>
<TableCell align="center">{formatAnmlyYn(item.rvwRsltAnmlyYn)}</TableCell>
<TableCell align="center">
{item.atchFileInfo?.atflCd ? (
<AttachmentDownloadLink
atflCd={item.atchFileInfo.atflCd}
fileName={item.atchFileInfo.orgnlFileNm || "다운로드"}
/>
) : (
"-"
)}
</TableCell>
</>
);
}
function renderEditRow(rowKey: string, editDraft: EditDraft) {
return (
<>
<TableCell align="left">
<Textarea
value={editDraft.rvwRsltCn}
onChange={(event) =>
setDraft((prev) => (prev ? { ...prev, rvwRsltCn: event.target.value } : prev))
}
placeholder="의견내용을 입력하세요"
rows={4}
className="min-h-[8rem] !text-xl"
/>
</TableCell>
<TableCell align="center">
<FieldSet>
<RadioGroup
value={editDraft.rvwRsltAnmlyYn}
onValueChange={(value) =>
setDraft((prev) => (prev ? { ...prev, rvwRsltAnmlyYn: value } : prev))
}
>
<div className="flex flex-col gap-2">
<FieldLabel
htmlFor={`rvw-opnn-edit-normal-${rowKey}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="N" id={`rvw-opnn-edit-normal-${rowKey}`} />
<span className="font-normal">정상</span>
</Field>
</FieldLabel>
<FieldLabel
htmlFor={`rvw-opnn-edit-abnormal-${rowKey}`}
className="!w-auto border-none"
>
<Field orientation="horizontal" className="w-auto">
<RadioGroupItem value="Y" id={`rvw-opnn-edit-abnormal-${rowKey}`} />
<span className="font-normal">이상</span>
</Field>
</FieldLabel>
</div>
</RadioGroup>
</FieldSet>
</TableCell>
<TableCell align="center">
<AttachmentFileField
files={
editFile
? [
{
fileKey: editFile.fileKey,
fileName: editFile.fileName,
onDownload: editFile.atflId
? () => downloadByAtflId(editFile.atflId!)
: undefined,
},
]
: []
}
inputId={`rvw-opnn-edit-${rowKey}-file-input`}
isUploadingByKey={isUploading}
multiple={false}
showAddButton={!editFile}
addDisabled={editFileUploading}
onAdd={(fileList) => void handleFileAdd(fileList)}
onRemove={handleFileRemove}
/>
</TableCell>
</>
);
}
return (
<div className="overflow-x-auto">
<Table
caption="검토의견 목록으로 의견자명, 의견내용, 검토결과, 첨부파일, 등록일시, 관리 정보 제공"
variant="secondary"
>
<colgroup>
<col width="10%" />
<col width="47%" />
<col width="6%" />
<col width="17%" />
<col width="10%" />
<col width="10%" />
</colgroup>
<TableHeader>
<TableRow>
<TableHead>의견자명</TableHead>
<TableHead>의견내용</TableHead>
<TableHead>검토결과</TableHead>
<TableHead>첨부파일</TableHead>
<TableHead>등록일시</TableHead>
<TableHead>관리</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{items.map((item) => {
const rowKey = getRowKey(item);
const isEditing = editingKey === rowKey && draft !== null;
return (
<TableRow key={rowKey}>
<TableCell align="center">{item.opnrNm}</TableCell>
{isEditing ? renderEditRow(rowKey, draft) : renderViewRow(item)}
<TableCell align="center">
{item.regDt ? dayjs(item.regDt).format("YYYY-MM-DD HH:mm:ss") : "-"}
</TableCell>
<TableCell align="center">
{!canModify(item) ? (
"-"
) : isEditing ? (
<div className="flex flex-wrap justify-center gap-1">
<Button
type="button"
size="sm"
onClick={() => void handleSave(item)}
disabled={isSubmitting || editFileUploading}
>
저장
</Button>
<Button
type="button"
size="sm"
color="white"
onClick={cancelEdit}
disabled={isSubmitting}
>
취소
</Button>
</div>
) : (
<div className="flex flex-wrap justify-center gap-1">
<Button
type="button"
size="sm"
color="white"
onClick={() => startEdit(item)}
disabled={isSubmitting}
>
수정
</Button>
<Button
type="button"
size="sm"
color="gray"
onClick={() => void handleDelete(item)}
disabled={isSubmitting}
>
삭제
</Button>
</div>
)}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
);
}
export { RvwOpnnListTable, type RvwOpnnListTableProps };