summaryrefslogtreecommitdiff
path: root/components/form-data
diff options
context:
space:
mode:
Diffstat (limited to 'components/form-data')
-rw-r--r--components/form-data/form-data-table-columns.tsx138
-rw-r--r--components/form-data/form-data-table.tsx545
-rw-r--r--components/form-data/update-form-sheet.tsx239
3 files changed, 922 insertions, 0 deletions
diff --git a/components/form-data/form-data-table-columns.tsx b/components/form-data/form-data-table-columns.tsx
new file mode 100644
index 00000000..d44616f8
--- /dev/null
+++ b/components/form-data/form-data-table-columns.tsx
@@ -0,0 +1,138 @@
+import type { ColumnDef, Row } from "@tanstack/react-table"
+import { ClientDataTableColumnHeaderSimple } from "../client-data-table/data-table-column-simple-header"
+import { Button } from "@/components/ui/button"
+import { Ellipsis } from "lucide-react"
+import { formatDate } from "@/lib/utils"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+/** row 액션 관련 타입 */
+export interface DataTableRowAction<TData> {
+ row: Row<TData>
+ type: "open" | "edit" | "update"
+}
+
+/** 컬럼 타입 (필요에 따라 확장) */
+export type ColumnType = "STRING" | "NUMBER" | "LIST"
+
+
+export interface DataTableColumnJSON {
+ key: string
+ /** 실제 Excel 등에서 구분용으로 쓰이는 label (고정) */
+ label: string
+
+ /** UI 표시용 label (예: 단위를 함께 표시) */
+ displayLabel?: string
+
+ type: ColumnType
+ options?: string[]
+ uom?: string
+}
+/**
+ * getColumns 함수에 필요한 props
+ * - TData: 테이블에 표시할 행(Row)의 타입
+ */
+interface GetColumnsProps<TData> {
+ columnsJSON: DataTableColumnJSON[]
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TData> | null>>
+}
+
+/**
+ * getColumns 함수
+ * 1) columnsJSON 배열을 순회하면서 accessorKey / header / cell 등을 설정
+ * 2) 마지막에 "Action" 칼럼(예: update 버튼) 추가
+ */
+export function getColumns<TData extends object>({
+ columnsJSON,
+ setRowAction,
+}: GetColumnsProps<TData>): ColumnDef<TData>[] {
+
+ // (1) 기본 컬럼들
+ const baseColumns: ColumnDef<TData>[] = columnsJSON.map((col) => ({
+ accessorKey: col.key,
+ header: ({ column }) => (
+ <ClientDataTableColumnHeaderSimple
+ column={column}
+ title={col.displayLabel || col.label}
+ />
+ ),
+
+ meta: {
+ excelHeader: col.label,
+ minWidth: 80,
+ paddingFactor: 1.2,
+ maxWidth: col.key ==="tagNumber"?120:150,
+ },
+ // (2) 실제 셀(cell) 렌더링: type에 따라 분기 가능
+ cell: ({ row }) => {
+ const cellValue = row.getValue(col.key)
+
+ // 데이터 타입별 처리
+ switch (col.type) {
+ case "NUMBER":
+ // 예: number인 경우 콤마 등 표시
+ return <div>{cellValue ? Number(cellValue).toLocaleString() : ""}</div>
+
+ // case "date":
+ // // 예: 날짜 포맷팅
+ // // 실제론 dayjs / date-fns 등으로 포맷
+ // if (!cellValue) return <div></div>
+ // const dateString = cellValue as string
+ // if (!dateString) return null
+ // return formatDate(new Date(dateString))
+
+ case "LIST":
+ // 예: select인 경우 label만 표시
+ return <div>{String(cellValue ?? "")}</div>
+
+ case "STRING":
+ default:
+ return <div>{String(cellValue ?? "")}</div>
+ }
+ },
+ }))
+
+ // (3) 액션 칼럼 - update 버튼 예시
+ const actionColumn: ColumnDef<TData> = {
+ id: "update",
+ header: "",
+ cell: ({ row }) => (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button
+ aria-label="Open menu"
+ variant="ghost"
+ className="flex size-8 p-0 data-[state=open]:bg-muted"
+ >
+ <Ellipsis className="size-4" aria-hidden="true" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end" className="w-40">
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "update" })}
+ >
+ Edit
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ ),
+ size:40,
+ meta:{
+ maxWidth:40
+ },
+ enablePinning: true,
+ }
+
+ // (4) 최종 반환
+ return [...baseColumns, actionColumn]
+} \ No newline at end of file
diff --git a/components/form-data/form-data-table.tsx b/components/form-data/form-data-table.tsx
new file mode 100644
index 00000000..14fff12e
--- /dev/null
+++ b/components/form-data/form-data-table.tsx
@@ -0,0 +1,545 @@
+"use client"
+
+import * as React from "react"
+import { useParams } from "next/navigation"
+import { useTranslation } from "@/i18n/client"
+
+import { ClientDataTable } from "../client-data-table/data-table"
+import {
+ getColumns,
+ DataTableRowAction,
+ DataTableColumnJSON,
+ ColumnType,
+} from "./form-data-table-columns"
+
+import type { DataTableAdvancedFilterField } from "@/types/table"
+import { Button } from "../ui/button"
+import { Download, Loader, Save, Upload } from "lucide-react"
+import { toast } from "sonner"
+import { syncMissingTags, updateFormDataInDB } from "@/lib/forms/services"
+import { UpdateTagSheet } from "./update-form-sheet"
+
+import ExcelJS from "exceljs"
+import { saveAs } from "file-saver"
+
+interface GenericData {
+ [key: string]: any
+}
+
+export interface DynamicTableProps {
+ dataJSON: GenericData[]
+ columnsJSON: DataTableColumnJSON[]
+ contractItemId: number
+ formCode: string
+}
+
+export default function DynamicTable({
+ dataJSON,
+ columnsJSON,
+ contractItemId,
+ formCode,
+}: DynamicTableProps) {
+ const params = useParams()
+ const lng = (params?.lng as string) || "ko"
+ const { t } = useTranslation(lng, "translation")
+
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<GenericData> | null>(null)
+ const [tableData, setTableData] = React.useState<GenericData[]>(() => dataJSON)
+ const [isPending, setIsPending] = React.useState(false)
+ const [isSaving, setIsSaving] = React.useState(false)
+
+ // Reference to the table instance
+ const tableRef = React.useRef(null)
+
+ const columns = React.useMemo(
+ () => getColumns<GenericData>({ columnsJSON, setRowAction }),
+ [columnsJSON, setRowAction]
+ )
+
+ function mapColumnTypeToAdvancedFilterType(
+ columnType: ColumnType
+ ): DataTableAdvancedFilterField<GenericData>["type"] {
+ switch (columnType) {
+ case "STRING":
+ return "text"
+ case "NUMBER":
+ return "number"
+ case "LIST":
+ // "select"로 하셔도 되고 "multi-select"로 하셔도 됩니다.
+ return "select"
+ // 그 외 다른 타입들도 적절히 추가 매핑
+ default:
+ // 예: 못 매핑한 경우 기본적으로 "text" 적용
+ return "text"
+ }
+ }
+
+ const advancedFilterFields = React.useMemo<DataTableAdvancedFilterField<GenericData>[]>(
+ () => {
+ return columnsJSON.map((col) => ({
+ id: col.key,
+ label: col.label,
+ type: mapColumnTypeToAdvancedFilterType(col.type),
+ options:
+ col.type === "LIST"
+ ? col.options?.map((v) => ({ label: v, value: v }))
+ : undefined,
+ }))
+ },
+ [columnsJSON]
+ )
+
+ // 1) 태그 불러오기 (기존)
+ async function handleSyncTags() {
+ try {
+ setIsPending(true)
+ const result = await syncMissingTags(contractItemId, formCode)
+
+ // Prepare the toast messages based on what changed
+ const changes = []
+ if (result.createdCount > 0) changes.push(`${result.createdCount}건 태그 생성`)
+ if (result.updatedCount > 0) changes.push(`${result.updatedCount}건 태그 업데이트`)
+ if (result.deletedCount > 0) changes.push(`${result.deletedCount}건 태그 삭제`)
+
+ if (changes.length > 0) {
+ // If any changes were made, show success message and reload
+ toast.success(`동기화 완료: ${changes.join(', ')}`)
+ location.reload()
+ } else {
+ // If no changes were made, show an info message
+ toast.info("변경사항이 없습니다. 모든 태그가 최신 상태입니다.")
+ }
+ } catch (err) {
+ console.error(err)
+ toast.error("태그 동기화 중 에러가 발생했습니다.")
+ } finally {
+ setIsPending(false)
+ }
+ }
+ // 2) Excel Import (새로운 기능)
+ async function handleImportExcel(e: React.ChangeEvent<HTMLInputElement>) {
+ const file = e.target.files?.[0]
+ if (!file) return
+
+ try {
+ setIsPending(true)
+
+ // 기존 테이블 데이터의 tagNumber 목록 (이미 있는 태그)
+ const existingTagNumbers = new Set(tableData.map((d) => d.tagNumber))
+
+ const workbook = new ExcelJS.Workbook()
+ const arrayBuffer = await file.arrayBuffer()
+ await workbook.xlsx.load(arrayBuffer)
+
+ const worksheet = workbook.worksheets[0]
+
+ // (A) 헤더 파싱 (Error 열 추가 전에 먼저 체크)
+ const headerRow = worksheet.getRow(1)
+ const headerRowValues = headerRow.values as ExcelJS.CellValue[]
+
+ // 디버깅용 로그
+ console.log("원본 헤더 값:", headerRowValues)
+
+ // Excel의 헤더와 columnsJSON의 label 매핑 생성
+ // Excel의 행은 1부터 시작하므로 headerRowValues[0]은 undefined
+ const headerToIndexMap = new Map<string, number>()
+ for (let i = 1; i < headerRowValues.length; i++) {
+ const headerValue = String(headerRowValues[i] || "").trim()
+ if (headerValue) {
+ headerToIndexMap.set(headerValue, i)
+ }
+ }
+
+ // (B) 헤더 검사
+ let headerErrorMessage = ""
+
+ // (1) "columnsJSON에 있는데 엑셀에 없는" 라벨
+ columnsJSON.forEach((col) => {
+ const label = col.label
+ if (!headerToIndexMap.has(label)) {
+ headerErrorMessage += `Column "${label}" is missing. `
+ }
+ })
+
+ // (2) "엑셀에는 있는데 columnsJSON에 없는" 라벨 검사
+ headerToIndexMap.forEach((index, headerLabel) => {
+ const found = columnsJSON.some((col) => col.label === headerLabel)
+ if (!found) {
+ headerErrorMessage += `Unexpected column "${headerLabel}" found in Excel. `
+ }
+ })
+
+ // (C) 이제 Error 열 추가
+ const lastColIndex = worksheet.columnCount + 1
+ worksheet.getRow(1).getCell(lastColIndex).value = "Error"
+
+ // 헤더 에러가 있으면 기록 후 다운로드하고 중단
+ if (headerErrorMessage) {
+ headerRow.getCell(lastColIndex).value = headerErrorMessage.trim()
+
+ const outBuffer = await workbook.xlsx.writeBuffer()
+ saveAs(new Blob([outBuffer]), `import-check-result_${Date.now()}.xlsx`)
+
+ toast.error(`Header mismatch found. Please check downloaded file.`)
+ return
+ }
+
+ // -- 여기까지 왔다면, 헤더는 문제 없음 --
+
+ // 컬럼 키-인덱스 매핑 생성 (실제 데이터 행 파싱용)
+ // columnsJSON의 key와 Excel 열 인덱스 간의 매핑
+ const keyToIndexMap = new Map<string, number>()
+ columnsJSON.forEach((col) => {
+ const index = headerToIndexMap.get(col.label)
+ if (index !== undefined) {
+ keyToIndexMap.set(col.key, index)
+ }
+ })
+
+ // 데이터 파싱
+ const importedData: GenericData[] = []
+ const lastRowNumber = worksheet.lastRow?.number || 1
+ let errorCount = 0
+
+ // 실제 데이터 행 파싱
+ for (let rowNum = 2; rowNum <= lastRowNumber; rowNum++) {
+ const row = worksheet.getRow(rowNum)
+ const rowValues = row.values as ExcelJS.CellValue[]
+ if (!rowValues || rowValues.length <= 1) continue // 빈 행 스킵
+
+ let errorMessage = ""
+ const rowObj: Record<string, any> = {}
+
+ // 각 열에 대해 처리
+ columnsJSON.forEach((col) => {
+ const colIndex = keyToIndexMap.get(col.key)
+ if (colIndex === undefined) return
+
+ const cellValue = rowValues[colIndex] ?? ""
+ let stringVal = String(cellValue).trim()
+
+ // 타입별 검사
+ switch (col.type) {
+ case "STRING":
+ if (!stringVal && col.key === "tagNumber") {
+ errorMessage += `[${col.label}] is empty. `
+ }
+ rowObj[col.key] = stringVal
+ break
+
+ case "NUMBER":
+ if (stringVal) {
+ const num = parseFloat(stringVal)
+ if (isNaN(num)) {
+ errorMessage += `[${col.label}] '${stringVal}' is not a valid number. `
+ } else {
+ rowObj[col.key] = num
+ }
+ } else {
+ rowObj[col.key] = null
+ }
+ break
+
+ case "LIST":
+ if (stringVal && col.options && !col.options.includes(stringVal)) {
+ errorMessage += `[${col.label}] '${stringVal}' not in ${col.options.join(", ")}. `
+ }
+ rowObj[col.key] = stringVal
+ break
+
+ default:
+ rowObj[col.key] = stringVal
+ break
+ }
+ })
+
+ // tagNumber 검사
+ const tagNum = rowObj["tagNumber"]
+ if (!tagNum) {
+ errorMessage += `No tagNumber found. `
+ } else if (!existingTagNumbers.has(tagNum)) {
+ errorMessage += `TagNumber '${tagNum}' is not in current data. `
+ }
+
+ if (errorMessage) {
+ row.getCell(lastColIndex).value = errorMessage.trim()
+ errorCount++
+ } else {
+ importedData.push(rowObj)
+ }
+ }
+
+ // 에러가 있으면 재다운로드 후 import 중단
+ if (errorCount > 0) {
+ const outBuffer = await workbook.xlsx.writeBuffer()
+ saveAs(new Blob([outBuffer]), `import-check-result_${Date.now()}.xlsx`)
+ toast.error(`There are ${errorCount} error row(s). Please check downloaded file.`)
+ return
+ }
+
+ // 에러 없으니 tableData 병합
+ setTableData((prev) => {
+ const newDataMap = new Map<string, GenericData>()
+
+ // 기존 데이터를 맵에 추가
+ prev.forEach((item) => {
+ if (item.tagNumber) {
+ newDataMap.set(item.tagNumber, { ...item })
+ }
+ })
+
+ // 임포트 데이터로 기존 데이터 업데이트
+ importedData.forEach((item) => {
+ const tag = item.tagNumber
+ if (!tag) return
+ const oldItem = newDataMap.get(tag) || {}
+ newDataMap.set(tag, { ...oldItem, ...item })
+ })
+
+ return Array.from(newDataMap.values())
+ })
+
+ toast.success(`Imported ${importedData.length} rows successfully.`)
+ } catch (err) {
+ console.error("Excel import error:", err)
+ toast.error("Excel import failed.")
+ } finally {
+ setIsPending(false)
+ e.target.value = ""
+ }
+ }
+
+ // 3) Save -> 서버에 전체 tableData를 저장
+ async function handleSave() {
+ try {
+ setIsSaving(true)
+
+ // 유효성 검사
+ const invalidData = tableData.filter(item => !item.tagNumber?.trim())
+ if (invalidData.length > 0) {
+ toast.error(`태그 번호가 없는 항목이 ${invalidData.length}개 있습니다.`)
+ return
+ }
+
+ // 서버 액션 호출
+ const result = await updateFormDataInDB(formCode, contractItemId, tableData)
+
+ if (result.success) {
+ toast.success(result.message)
+ } else {
+ toast.error(result.message)
+ }
+ } catch (err) {
+ console.error("Save error:", err)
+ toast.error("데이터 저장 중 오류가 발생했습니다.")
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ // 4) NEW: Excel Export with data validation for select columns using a hidden validation sheet
+ async function handleExportExcel() {
+ try {
+ setIsPending(true)
+
+ // Create a new workbook
+ const workbook = new ExcelJS.Workbook()
+
+ // 데이터 시트 생성
+ const worksheet = workbook.addWorksheet("Data")
+
+ // 유효성 검사용 숨김 시트 생성
+ const validationSheet = workbook.addWorksheet("ValidationData")
+ validationSheet.state = 'hidden' // 시트 숨김 처리
+
+ // 1. 유효성 검사 시트에 select 옵션 추가
+ const selectColumns = columnsJSON.filter(col =>
+ col.type === "LIST" && col.options && col.options.length > 0
+ )
+
+ // 유효성 검사 범위 저장 맵 (컬럼 키 -> 유효성 검사 범위)
+ const validationRanges = new Map<string, string>()
+
+ selectColumns.forEach((col, idx) => {
+ const colIndex = idx + 1
+ const colLetter = validationSheet.getColumn(colIndex).letter
+
+ // 헤더 추가 (컬럼 레이블)
+ validationSheet.getCell(`${colLetter}1`).value = col.label
+
+ // 옵션 추가
+ if (col.options) {
+ col.options.forEach((option, optIdx) => {
+ validationSheet.getCell(`${colLetter}${optIdx + 2}`).value = option
+ })
+
+ // 유효성 검사 범위 저장 (ValidationData!$A$2:$A$4 형식)
+ validationRanges.set(
+ col.key,
+ `ValidationData!${colLetter}$2:${colLetter}${col.options.length + 1}`
+ )
+ }
+ })
+
+ // 2. 데이터 시트에 헤더 추가
+ const headers = columnsJSON.map(col => col.label)
+ worksheet.addRow(headers)
+
+ // 헤더 스타일 적용
+ const headerRow = worksheet.getRow(1)
+ headerRow.font = { bold: true }
+ headerRow.alignment = { horizontal: 'center' }
+ headerRow.eachCell((cell) => {
+ cell.fill = {
+ type: 'pattern',
+ pattern: 'solid',
+ fgColor: { argb: 'FFCCCCCC' }
+ }
+ })
+
+ // 3. 데이터 행 추가
+ tableData.forEach(row => {
+ const rowValues = columnsJSON.map(col => {
+ const value = row[col.key]
+ return value !== undefined && value !== null ? value : ''
+ })
+ worksheet.addRow(rowValues)
+ })
+
+ // 4. 데이터 유효성 검사 적용
+ const maxRows = 5000 // 데이터 유효성 검사를 적용할 최대 행 수
+
+ columnsJSON.forEach((col, idx) => {
+ if (col.type === "LIST" && validationRanges.has(col.key)) {
+ const colLetter = worksheet.getColumn(idx + 1).letter
+ const validationRange = validationRanges.get(col.key)!
+
+ // 유효성 검사 정의
+ const validation = {
+ type: 'list' as const,
+ allowBlank: true,
+ formulae: [validationRange],
+ showErrorMessage: true,
+ errorStyle: 'warning' as const,
+ errorTitle: '유효하지 않은 값',
+ error: '목록에서 값을 선택해주세요.'
+ }
+
+ // 모든 데이터 행에 유효성 검사 적용 (최대 maxRows까지)
+ for (let rowIdx = 2; rowIdx <= Math.min(tableData.length + 1, maxRows); rowIdx++) {
+ worksheet.getCell(`${colLetter}${rowIdx}`).dataValidation = validation
+ }
+
+ // 빈 행에도 적용 (최대 maxRows까지)
+ if (tableData.length + 1 < maxRows) {
+ for (let rowIdx = tableData.length + 2; rowIdx <= maxRows; rowIdx++) {
+ worksheet.getCell(`${colLetter}${rowIdx}`).dataValidation = validation
+ }
+ }
+ }
+ })
+
+ // 5. 컬럼 너비 자동 조정
+ columnsJSON.forEach((col, idx) => {
+ const column = worksheet.getColumn(idx + 1)
+
+ // 최적 너비 계산
+ let maxLength = col.label.length
+ tableData.forEach(row => {
+ const value = row[col.key]
+ if (value !== undefined && value !== null) {
+ const valueLength = String(value).length
+ if (valueLength > maxLength) {
+ maxLength = valueLength
+ }
+ }
+ })
+
+ // 너비 설정 (최소 10, 최대 50)
+ column.width = Math.min(Math.max(maxLength + 2, 10), 50)
+ })
+
+ // 6. 파일 다운로드
+ const buffer = await workbook.xlsx.writeBuffer()
+ saveAs(new Blob([buffer]), `${formCode}_data_${new Date().toISOString().slice(0, 10)}.xlsx`)
+
+ toast.success("Excel 내보내기 완료!")
+ } catch (err) {
+ console.error("Excel export error:", err)
+ toast.error("Excel 내보내기 실패.")
+ } finally {
+ setIsPending(false)
+ }
+ }
+
+ return (
+ <>
+ <ClientDataTable
+ data={tableData}
+ columns={columns}
+ advancedFilterFields={advancedFilterFields}
+ // tableRef={tableRef}
+ >
+ {/* 버튼 그룹 */}
+ <div className="flex items-center gap-2">
+ {/* 태그 불러오기 버튼 */}
+ <Button variant="default" size="sm" onClick={handleSyncTags} disabled={isPending}>
+ {isPending && <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />}
+ Sync Tags
+ </Button>
+
+ {/* IMPORT 버튼 (파일 선택) */}
+ <Button asChild variant="outline" size="sm" disabled={isPending}>
+ <label>
+ <Upload className="size-4" />
+ Import
+ <input
+ type="file"
+ accept=".xlsx,.xls"
+ onChange={handleImportExcel}
+ style={{ display: "none" }}
+ />
+ </label>
+ </Button>
+
+ {/* EXPORT 버튼 (새로 추가) */}
+ <Button variant="outline" size="sm" onClick={handleExportExcel} disabled={isPending}>
+ <Download className="mr-2 size-4" />
+ Export Template
+ </Button>
+
+ {/* SAVE 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleSave}
+ disabled={isPending || isSaving}
+ >
+ {isSaving ? (
+ <>
+ <Loader className="mr-2 size-4 animate-spin" />
+ 저장 중...
+ </>
+ ) : (
+ <>
+ <Save className="mr-2 size-4" />
+ Save
+ </>
+ )}
+ </Button>
+ </div>
+ </ClientDataTable>
+
+ <UpdateTagSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={(open) => {
+ if (!open) setRowAction(null)
+ }}
+ columns={columnsJSON}
+ rowData={rowAction?.row.original ?? null}
+ formCode={formCode}
+ contractItemId={contractItemId}
+ />
+ </>
+ )
+} \ No newline at end of file
diff --git a/components/form-data/update-form-sheet.tsx b/components/form-data/update-form-sheet.tsx
new file mode 100644
index 00000000..d5f7d21b
--- /dev/null
+++ b/components/form-data/update-form-sheet.tsx
@@ -0,0 +1,239 @@
+"use client"
+
+import * as React from "react"
+import { z } from "zod"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useForm } from "react-hook-form"
+import { Loader } from "lucide-react"
+import { toast } from "sonner"
+
+import {
+ Sheet,
+ SheetClose,
+ SheetContent,
+ SheetDescription,
+ SheetFooter,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import {
+ Form,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormControl,
+ FormMessage,
+} from "@/components/ui/form"
+import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select"
+
+import { DataTableColumnJSON } from "./form-data-table-columns"
+import { updateFormDataInDB } from "@/lib/forms/services"
+
+interface UpdateTagSheetProps extends React.ComponentPropsWithoutRef<typeof Sheet> {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ columns: DataTableColumnJSON[]
+ rowData: Record<string, any> | null
+ formCode: string
+ contractItemId: number
+ /** 업데이트 성공 시 호출될 콜백 */
+ onUpdateSuccess?: (updatedValues: Record<string, any>) => void
+}
+
+export function UpdateTagSheet({
+ open,
+ onOpenChange,
+ columns,
+ rowData,
+ formCode,
+ contractItemId,
+ onUpdateSuccess,
+ ...props
+}: UpdateTagSheetProps) {
+ const [isPending, startTransition] = React.useTransition()
+
+ // 1) zod 스키마
+ const dynamicSchema = React.useMemo(() => {
+ const shape: Record<string, z.ZodType<any>> = {}
+ for (const col of columns) {
+ if (col.type === "NUMBER") {
+ shape[col.key] = z
+ .union([z.coerce.number(), z.nan()])
+ .transform((val) => (isNaN(val) ? undefined : val))
+ .optional()
+ } else {
+ shape[col.key] = z.string().optional()
+ }
+ }
+ return z.object(shape)
+ }, [columns])
+
+ // 2) form init
+ const form = useForm({
+ resolver: zodResolver(dynamicSchema),
+ defaultValues: React.useMemo(() => {
+ if (!rowData) return {}
+ const defaults: Record<string, any> = {}
+ for (const col of columns) {
+ defaults[col.key] = rowData[col.key] ?? ""
+ }
+ return defaults
+ }, [rowData, columns]),
+ })
+
+ React.useEffect(() => {
+ if (!rowData) {
+ form.reset({})
+ return
+ }
+ const defaults: Record<string, any> = {}
+ for (const col of columns) {
+ defaults[col.key] = rowData[col.key] ?? ""
+ }
+ form.reset(defaults)
+ }, [rowData, columns, form])
+
+ async function onSubmit(values: Record<string, any>) {
+ startTransition(async () => {
+ const { success, message } = await updateFormDataInDB(formCode, contractItemId, values)
+ if (!success) {
+ toast.error(message)
+ return
+ }
+ toast.success("Updated successfully!")
+
+ // (A) 수정된 값(폼 데이터)을 부모 콜백에 전달
+ onUpdateSuccess?.({
+ // rowData(원본)와 values를 합쳐서 최종 "수정된 row"를 만든다.
+ // tagNumber는 기존 그대로
+ ...rowData,
+ ...values,
+ tagNumber: rowData?.tagNumber,
+ })
+
+ onOpenChange(false)
+ })
+ }
+
+ return (
+ <Sheet open={open} onOpenChange={onOpenChange} {...props}>
+ <SheetContent className="sm:max-w-xl md:max-w-3xl lg:max-w-4xl xl:max-w-5xl flex flex-col">
+ <SheetHeader className="text-left">
+ <SheetTitle>Update Row</SheetTitle>
+ <SheetDescription>
+ Modify the fields below and save changes
+ </SheetDescription>
+ </SheetHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
+ <div className="overflow-y-auto max-h-[80vh] flex-1 pr-4 -mr-4">
+ <div className="flex flex-col gap-4 pt-2">
+ {columns.map((col) => {
+ const isTagNumberField = col.key === "tagNumber" || col.key === "tagDescription"
+ return (
+ <FormField
+ key={col.key}
+ control={form.control}
+ name={col.key}
+ render={({ field }) => {
+ switch (col.type) {
+ case "NUMBER":
+ return (
+ <FormItem>
+ <FormLabel>{col.displayLabel}</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ readOnly={isTagNumberField}
+ onChange={(e) => {
+ const num = parseFloat(e.target.value)
+ field.onChange(isNaN(num) ? "" : num)
+ }}
+ value={field.value ?? ""}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )
+
+ case "LIST":
+ return (
+ <FormItem>
+ <FormLabel>{col.label}</FormLabel>
+ <Select
+ disabled={isTagNumberField}
+ value={field.value ?? ""}
+ onValueChange={(val) => field.onChange(val)}
+ >
+ <SelectTrigger>
+ <SelectValue placeholder="Select an option" />
+ </SelectTrigger>
+ <SelectContent>
+ {col.options?.map((opt) => (
+ <SelectItem key={opt} value={opt}>
+ {opt}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )
+
+ // case "date":
+ // return (
+ // <FormItem>
+ // <FormLabel>{col.label}</FormLabel>
+ // <FormControl>
+ // <Input
+ // type="date"
+ // readOnly={isTagNumberField}
+ // onChange={field.onChange}
+ // value={field.value ?? ""}
+ // />
+ // </FormControl>
+ // <FormMessage />
+ // </FormItem>
+ // )
+
+ case "STRING":
+ default:
+ return (
+ <FormItem>
+ <FormLabel>{col.label}</FormLabel>
+ <FormControl>
+ <Input readOnly={isTagNumberField} {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )
+ }
+ }}
+ />
+ )
+ })}
+
+ </div>
+ </div>
+
+ <SheetFooter className="gap-2 pt-2">
+ <SheetClose asChild>
+ <Button type="button" variant="outline">
+ Cancel
+ </Button>
+ </SheetClose>
+
+ <Button type="submit" disabled={isPending}>
+ {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />}
+ Save
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file