@@ -432,6 +436,7 @@ function AttachmentsFormSection({
{
void handleEssentialFileUpload(
file.esntlAtchFileId,
diff --git a/apps/as/src/widgets/pbadms-sprt/ui/checklist-form-section.tsx b/apps/as/src/widgets/pbadms-sprt/ui/checklist-form-section.tsx
index 82386ab06..a77663e58 100644
--- a/apps/as/src/widgets/pbadms-sprt/ui/checklist-form-section.tsx
+++ b/apps/as/src/widgets/pbadms-sprt/ui/checklist-form-section.tsx
@@ -5,7 +5,7 @@ import { Controller, useFormContext, useWatch } from "react-hook-form";
import { useQuery } from "@tanstack/react-query";
-import { Field, FieldLabel, FieldSet } from "@/shared/ui/field";
+import { Field, FieldLabel } from "@/shared/ui/field";
import { cklstMngService, type CklstTreeNode } from "@/entities/cklst";
import { useCodeList } from "@/entities/code";
import {
@@ -196,31 +196,33 @@ function ChecklistFormSection({ docSeComCd, fieldPrefix }: ChecklistFormSectionP
검토항목
주요 검토내용
-
검토
+
검토 결과
-
-
-
-
-
+
+
+
+
+
@@ -236,64 +238,72 @@ function ChecklistFormSection({ docSeComCd, fieldPrefix }: ChecklistFormSectionP
style={{
paddingLeft: `${Math.max(0, item.treeLevel - 1) * 16 + 8}px`,
}}
- colSpan={showRadios ? 0 : 2}
+ colSpan={showRadios ? 0 : 3}
>
{item.treeLevel > 1 ? "└ " : ""}
{item.content}
{showRadios && resultIdx >= 0 ? (
-
- (
-
- )}
- />
-
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+ />
) : null}
);
diff --git a/apps/as/src/widgets/pbadms-sprt/ui/contact-info-form-section.tsx b/apps/as/src/widgets/pbadms-sprt/ui/contact-info-form-section.tsx
index 7787f7a40..1d6e31a16 100644
--- a/apps/as/src/widgets/pbadms-sprt/ui/contact-info-form-section.tsx
+++ b/apps/as/src/widgets/pbadms-sprt/ui/contact-info-form-section.tsx
@@ -70,6 +70,8 @@ function ContactInfoFormSection({ fieldPrefix }: ContactInfoFormSectionProps) {
id={field.name}
placeholder="설계자 전화번호"
aria-invalid={fieldState.invalid}
+ maxLength={13}
+ onChange={(e) => field.onChange(e.target.value.replace(/[^0-9-]/g, ""))}
/>
diff --git a/apps/as/src/widgets/pbadms-sprt/ui/splmnt-dmnd-dialog.tsx b/apps/as/src/widgets/pbadms-sprt/ui/splmnt-dmnd-dialog.tsx
new file mode 100644
index 000000000..9373285c2
--- /dev/null
+++ b/apps/as/src/widgets/pbadms-sprt/ui/splmnt-dmnd-dialog.tsx
@@ -0,0 +1,329 @@
+"use client";
+
+import { useEffect, useId, useState } from "react";
+
+import { getDefaultFileKey, useTus } from "@/shared/lib";
+import { attachService } from "@/entities/attach";
+import { CodeSelect } from "@/entities/code";
+import {
+ Button,
+ Dialog,
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+ Textarea,
+} from "@common/components";
+
+const SPLMNT_ARTCL_UP_CD = "SPLMNTARTCL";
+const SPLMNT_DMND_FILE_KEY_PREFIX = "splmnt-dmnd::";
+
+interface UploadedSplmntFile {
+ fileName: string;
+ fileKey: string;
+ atflId?: string;
+ atflCd?: string;
+}
+
+interface SplmntDmndRow {
+ rowKey: string;
+ splmntArtclComCd: string;
+ splmntDmndCn: string;
+ splmntDmndAtchFileGroupId: string;
+ uploadedFile: UploadedSplmntFile | null;
+}
+
+interface SplmntDmndDialogConfirmItem {
+ splmntArtclComCd: string;
+ splmntDmndCn: string;
+ splmntDmndAtchFileGroupId?: string;
+}
+
+interface SplmntDmndDialogConfirmPayload {
+ items: SplmntDmndDialogConfirmItem[];
+}
+
+interface SplmntDmndDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ isSubmitting?: boolean;
+ onConfirm: (payload: SplmntDmndDialogConfirmPayload) => void;
+}
+
+function createRowKey() {
+ return `splmnt-row-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
+}
+
+function createEmptyRow(): SplmntDmndRow {
+ return {
+ rowKey: createRowKey(),
+ splmntArtclComCd: "",
+ splmntDmndCn: "",
+ splmntDmndAtchFileGroupId: "",
+ uploadedFile: null,
+ };
+}
+
+function createUploadKey(rowKey: string, file: File) {
+ return `${SPLMNT_DMND_FILE_KEY_PREFIX}${rowKey}::${getDefaultFileKey(file)}`;
+}
+
+function getRowKeyFromFileKey(fileKey: string): string | null {
+ if (!fileKey.startsWith(SPLMNT_DMND_FILE_KEY_PREFIX)) return null;
+ const rest = fileKey.slice(SPLMNT_DMND_FILE_KEY_PREFIX.length);
+ const separatorIndex = rest.indexOf("::");
+ if (separatorIndex < 0) return null;
+ return rest.slice(0, separatorIndex);
+}
+
+function isRowsValid(rows: SplmntDmndRow[]) {
+ return rows.every((row) => row.splmntArtclComCd.trim() && row.splmntDmndCn.trim());
+}
+
+function SplmntDmndDialog({ open, onOpenChange, isSubmitting, onConfirm }: SplmntDmndDialogProps) {
+ const formId = useId();
+ const [rows, setRows] = useState
([createEmptyRow()]);
+
+ const { uploadFileAsync, isUploading, uploads } = useTus({
+ onSuccess: (atflCd, atflId, _file, fileKey) => {
+ if (!atflCd || !atflId) return;
+ const rowKey = getRowKeyFromFileKey(fileKey);
+ if (!rowKey) return;
+
+ setRows((prev) =>
+ prev.map((row) => {
+ if (row.rowKey !== rowKey || row.uploadedFile?.fileKey !== fileKey) return row;
+ return {
+ ...row,
+ splmntDmndAtchFileGroupId: atflCd,
+ uploadedFile: { ...row.uploadedFile, atflCd, atflId },
+ };
+ }),
+ );
+ },
+ onError: (_error, _file, fileKey) => {
+ const rowKey = getRowKeyFromFileKey(fileKey);
+ if (!rowKey) return;
+
+ setRows((prev) =>
+ prev.map((row) => {
+ if (row.rowKey !== rowKey || row.uploadedFile?.fileKey !== fileKey) return row;
+ return { ...row, uploadedFile: null };
+ }),
+ );
+ },
+ });
+
+ useEffect(() => {
+ if (!open) return;
+ setRows([createEmptyRow()]);
+ }, [open]);
+
+ const isAnyUploading = rows.some(
+ (row) => row.uploadedFile != null && (isUploading[row.uploadedFile.fileKey] ?? false),
+ );
+ const canSubmit = isRowsValid(rows) && !isAnyUploading && !isSubmitting;
+
+ function updateRow(rowKey: string, patch: Partial) {
+ setRows((prev) => prev.map((row) => (row.rowKey === rowKey ? { ...row, ...patch } : row)));
+ }
+
+ function handleAddRow() {
+ setRows((prev) => [...prev, createEmptyRow()]);
+ }
+
+ function handleRemoveRow(rowKey: string) {
+ setRows((prev) => (prev.length <= 1 ? prev : prev.filter((row) => row.rowKey !== rowKey)));
+ }
+
+ async function handleFileUpload(rowKey: string, files: FileList | null) {
+ if (!files || files.length === 0) return;
+
+ const file = files[0];
+ const fileKey = createUploadKey(rowKey, file);
+ const targetRow = rows.find((row) => row.rowKey === rowKey);
+ const existingAtflCd = targetRow?.splmntDmndAtchFileGroupId;
+
+ updateRow(rowKey, {
+ uploadedFile: { fileName: file.name, fileKey },
+ });
+
+ try {
+ await uploadFileAsync(file, {
+ fileKey,
+ metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
+ });
+ } catch {
+ // onError callback handles cleanup
+ }
+ }
+
+ async function handleFileRemove(rowKey: string) {
+ const targetRow = rows.find((row) => row.rowKey === rowKey);
+ if (!targetRow?.uploadedFile) return;
+
+ const { fileKey, atflId } = targetRow.uploadedFile;
+ uploads[fileKey]?.abort();
+
+ if (atflId) {
+ try {
+ await attachService.deleteAttachmentByAtflId(atflId);
+ } catch (error) {
+ console.error("보완요청 첨부파일 삭제 실패:", error);
+ }
+ }
+
+ updateRow(rowKey, {
+ uploadedFile: null,
+ splmntDmndAtchFileGroupId: "",
+ });
+ }
+
+ function handleConfirm() {
+ if (!canSubmit) return;
+
+ onConfirm({
+ items: rows.map((row) => ({
+ splmntArtclComCd: row.splmntArtclComCd.trim(),
+ splmntDmndCn: row.splmntDmndCn.trim(),
+ splmntDmndAtchFileGroupId: row.splmntDmndAtchFileGroupId || undefined,
+ })),
+ });
+ }
+
+ return (
+
+ );
+}
+
+export { SplmntDmndDialog };
+export type { SplmntDmndDialogConfirmItem, SplmntDmndDialogConfirmPayload };
diff --git a/apps/as/src/widgets/pbadms-sprt/ui/splmnt-dmnd-form-section.tsx b/apps/as/src/widgets/pbadms-sprt/ui/splmnt-dmnd-form-section.tsx
new file mode 100644
index 000000000..28cb40497
--- /dev/null
+++ b/apps/as/src/widgets/pbadms-sprt/ui/splmnt-dmnd-form-section.tsx
@@ -0,0 +1,324 @@
+"use client";
+
+import { useEffect, useId, useRef, useState } from "react";
+import { Controller, useFormContext, useWatch } from "react-hook-form";
+
+import { Paperclip } from "lucide-react";
+
+import { dayjs, getDefaultFileKey, useTus } from "@/shared/lib";
+import { attachService, useDownloadAttachmentByAtflCd } from "@/entities/attach";
+import {
+ Badge,
+ Button,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+ Textarea,
+} from "@common/components";
+
+import { SectionCollapsible } from "./section-collapsible";
+
+const SPLMNT_CMPTN_FILE_KEY_PREFIX = "splmnt-cmptn::";
+
+interface UploadedSplmntFile {
+ fileName: string;
+ fileKey: string;
+ atflId?: string;
+ atflCd?: string;
+}
+
+interface SplmntDmndFormItem {
+ splmntDmndSn?: number;
+ splmntArtclComCd?: string;
+ splmntArtclCdNm?: string | null;
+ splmntDmndCn?: string;
+ splmntDmndDt?: string;
+ splmntDmndAtchFileGroupId?: string;
+ splmntDmndAtchFileNm?: string;
+ splmntCn?: string;
+ splmntPicNm?: string;
+ splmntCmptnDt?: string;
+ splmntAtchFileGroupId?: string;
+ splmntAtchFileNm?: string;
+ splmntAtchFileAtflId?: string;
+ cmptnYn?: string;
+}
+
+interface SplmntDmndFormData {
+ splmntDmndList: SplmntDmndFormItem[];
+}
+
+interface SplmntDmndFormSectionProps {
+ readOnly?: boolean;
+}
+
+function createUploadKey(rowKey: string, file: File) {
+ return `${SPLMNT_CMPTN_FILE_KEY_PREFIX}${rowKey}::${getDefaultFileKey(file)}`;
+}
+
+function getRowKeyFromFileKey(fileKey: string): string | null {
+ if (!fileKey.startsWith(SPLMNT_CMPTN_FILE_KEY_PREFIX)) return null;
+ const rest = fileKey.slice(SPLMNT_CMPTN_FILE_KEY_PREFIX.length);
+ const separatorIndex = rest.indexOf("::");
+ if (separatorIndex < 0) return null;
+ return rest.slice(0, separatorIndex);
+}
+
+function getRowKey(item: SplmntDmndFormItem, index: number) {
+ return item.splmntDmndSn != null ? String(item.splmntDmndSn) : `idx-${index}`;
+}
+
+function SplmntDmndFormSection({ readOnly = false }: SplmntDmndFormSectionProps) {
+ const formId = useId();
+ const { control, setValue, getValues } = useFormContext();
+ const splmntDmndList = useWatch({ control, name: "splmntDmndList" }) ?? [];
+ const [uploadedFiles, setUploadedFiles] = useState>({});
+ const initialFilesMappedRef = useRef(false);
+ const { mutate: downloadAttachment } = useDownloadAttachmentByAtflCd();
+
+ const { uploadFileAsync, isUploading, uploads } = useTus({
+ onSuccess: (atflCd, atflId, _file, fileKey) => {
+ if (!atflCd || !atflId) return;
+ const rowKey = getRowKeyFromFileKey(fileKey);
+ if (!rowKey) return;
+
+ setUploadedFiles((prev) => {
+ const current = prev[rowKey];
+ if (!current || current.fileKey !== fileKey) return prev;
+ return {
+ ...prev,
+ [rowKey]: { ...current, atflCd, atflId },
+ };
+ });
+
+ const list = getValues("splmntDmndList") ?? [];
+ const index = list.findIndex((item, idx) => getRowKey(item, idx) === rowKey);
+ if (index < 0) return;
+
+ setValue(`splmntDmndList.${index}.splmntAtchFileGroupId`, atflCd, { shouldDirty: true });
+ setValue(`splmntDmndList.${index}.splmntAtchFileAtflId`, atflId, { shouldDirty: true });
+ },
+ onError: (_error, _file, fileKey) => {
+ const rowKey = getRowKeyFromFileKey(fileKey);
+ if (!rowKey) return;
+
+ setUploadedFiles((prev) => {
+ const current = prev[rowKey];
+ if (!current || current.fileKey !== fileKey) return prev;
+ return { ...prev, [rowKey]: null };
+ });
+ },
+ });
+
+ useEffect(() => {
+ if (initialFilesMappedRef.current) return;
+ if (splmntDmndList.length === 0) return;
+
+ const initial: Record = {};
+ for (let index = 0; index < splmntDmndList.length; index += 1) {
+ const item = splmntDmndList[index];
+ const rowKey = getRowKey(item, index);
+ if (item.splmntAtchFileGroupId && item.splmntAtchFileNm) {
+ initial[rowKey] = {
+ fileName: item.splmntAtchFileNm,
+ fileKey: `${SPLMNT_CMPTN_FILE_KEY_PREFIX}${rowKey}::initial`,
+ atflCd: item.splmntAtchFileGroupId,
+ atflId: item.splmntAtchFileAtflId,
+ };
+ } else {
+ initial[rowKey] = null;
+ }
+ }
+
+ setUploadedFiles(initial);
+ initialFilesMappedRef.current = true;
+ }, [splmntDmndList]);
+
+ if (splmntDmndList.length === 0) return null;
+
+ async function handleFileUpload(index: number, files: FileList | null) {
+ if (!files || files.length === 0) return;
+
+ const item = splmntDmndList[index];
+ const rowKey = getRowKey(item, index);
+ const file = files[0];
+ const fileKey = createUploadKey(rowKey, file);
+ const existingAtflCd = item.splmntAtchFileGroupId;
+
+ setUploadedFiles((prev) => ({
+ ...prev,
+ [rowKey]: { fileName: file.name, fileKey },
+ }));
+ setValue(`splmntDmndList.${index}.splmntAtchFileNm`, file.name, { shouldDirty: true });
+
+ try {
+ await uploadFileAsync(file, {
+ fileKey,
+ metadata: existingAtflCd ? { atflCd: existingAtflCd } : undefined,
+ });
+ } catch {
+ // onError callback handles cleanup
+ }
+ }
+
+ async function handleFileRemove(index: number) {
+ const item = splmntDmndList[index];
+ const rowKey = getRowKey(item, index);
+ const uploaded = uploadedFiles[rowKey];
+ if (!uploaded) return;
+
+ const { fileKey, atflId } = uploaded;
+ uploads[fileKey]?.abort();
+
+ if (atflId) {
+ try {
+ await attachService.deleteAttachmentByAtflId(atflId);
+ } catch (error) {
+ console.error("보완완료 첨부파일 삭제 실패:", error);
+ }
+ }
+
+ setUploadedFiles((prev) => ({ ...prev, [rowKey]: null }));
+ setValue(`splmntDmndList.${index}.splmntAtchFileGroupId`, "", { shouldDirty: true });
+ setValue(`splmntDmndList.${index}.splmntAtchFileNm`, "", { shouldDirty: true });
+ setValue(`splmntDmndList.${index}.splmntAtchFileAtflId`, "", { shouldDirty: true });
+ }
+
+ return (
+
+
+
+ );
+}
+
+export { SplmntDmndFormSection };
+export type { SplmntDmndFormItem };
diff --git a/apps/as/src/widgets/pbadms-sprt/ui/supplement-demand-section.tsx b/apps/as/src/widgets/pbadms-sprt/ui/supplement-demand-section.tsx
new file mode 100644
index 000000000..c4637fd4e
--- /dev/null
+++ b/apps/as/src/widgets/pbadms-sprt/ui/supplement-demand-section.tsx
@@ -0,0 +1,114 @@
+"use client";
+
+import { Paperclip } from "lucide-react";
+
+import { dayjs } from "@/shared/lib";
+import type { SplmntDmndResponseExtension, SplmntDmndSaveBase } from "@/shared/model";
+import { useDownloadAttachmentByAtflCd } from "@/entities/attach";
+import {
+ Badge,
+ Table,
+ TableBody,
+ TableCell,
+ TableHead,
+ TableHeader,
+ TableRow,
+} from "@common/components";
+
+import { SectionCollapsible } from "./section-collapsible";
+
+type SplmntDmndItem = SplmntDmndSaveBase & SplmntDmndResponseExtension;
+
+interface SupplementDemandSectionProps {
+ splmntDmndList: SplmntDmndItem[];
+}
+
+function SupplementDemandSection({ splmntDmndList }: SupplementDemandSectionProps) {
+ const { mutate: downloadAttachment } = useDownloadAttachmentByAtflCd();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+ 보완항목
+ 보완요청일시
+ 보완요청내용
+ 보완완료일시
+ 보완완료내용
+ 완료여부
+
+
+
+ {splmntDmndList.map((item) => {
+ const sn = item.splmntDmndSn;
+ const cmptnYn = item.cmptnYn ?? "N";
+ const dmndAtchFileNm = item.splmntDmndAtchFileInfo?.orgnlFileNm;
+ const cmptnAtchFileNm = item.splmntAtchFileInfo?.orgnlFileNm;
+
+ return (
+
+
+ {item.splmntArtclCdNm || "-"}
+
+
+ {item.splmntDmndDt ? dayjs(item.splmntDmndDt).format("YYYY-MM-DD HH:mm") : "-"}
+
+
+
+
+
+ {item.splmntCmptnDt
+ ? dayjs(item.splmntCmptnDt).format("YYYY-MM-DD HH:mm")
+ : "-"}
+
+
+
+
+
+
+ {cmptnYn === "Y" ? "완료" : "미완료"}
+
+
+
+ );
+ })}
+
+
+
+
+ );
+}
+
+export { SupplementDemandSection };
diff --git a/apps/as/src/widgets/pbadms-sprt/ui/text-input-dialog.tsx b/apps/as/src/widgets/pbadms-sprt/ui/text-input-dialog.tsx
deleted file mode 100644
index 168012416..000000000
--- a/apps/as/src/widgets/pbadms-sprt/ui/text-input-dialog.tsx
+++ /dev/null
@@ -1,77 +0,0 @@
-"use client";
-
-import { useEffect, useState } from "react";
-
-import {
- Button,
- Dialog,
- DialogContent,
- DialogFooter,
- DialogHeader,
- DialogTitle,
- Textarea,
-} from "@common/components";
-
-interface TextInputDialogProps {
- open: boolean;
- onOpenChange: (open: boolean) => void;
- title: string;
- placeholder?: string;
- defaultValue?: string;
- isSubmitting?: boolean;
- onConfirm: (text: string) => void;
-}
-
-function TextInputDialog({
- open,
- onOpenChange,
- title,
- placeholder,
- defaultValue,
- isSubmitting,
- onConfirm,
-}: TextInputDialogProps) {
- const [value, setValue] = useState("");
-
- useEffect(() => {
- if (!open) return;
- setValue(defaultValue ?? "");
- }, [defaultValue, open]);
-
- function handleConfirm() {
- onConfirm(value.trim());
- }
-
- return (
-
- );
-}
-
-export { TextInputDialog };
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index ea942780c..a93fa55bb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -195,6 +195,9 @@ importers:
tus-js-client:
specifier: ^4.3.1
version: 4.3.1
+ uuid:
+ specifier: ^14.0.1
+ version: 14.0.1
zod:
specifier: ^3.24.2
version: 3.25.76
@@ -7417,6 +7420,10 @@ packages:
utrie@1.0.2:
resolution: {integrity: sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==}
+ uuid@14.0.1:
+ resolution: {integrity: sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew==}
+ hasBin: true
+
uuid@9.0.1:
resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==}
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
@@ -13550,6 +13557,8 @@ snapshots:
dependencies:
base64-arraybuffer: 1.0.2
+ uuid@14.0.1: {}
+
uuid@9.0.1: {}
victory-vendor@36.9.2: