summaryrefslogtreecommitdiff
path: root/components
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-07-17 10:50:28 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-07-17 10:50:28 +0000
commitfb276ed3db86fe4fc0c0fcd870fd3d085b034be0 (patch)
tree4a8ab1027d7fd14602a0f837d4e18b04e2169e58 /components
parent4eb7532f822c821fb6b69bf103bd075fefba769b (diff)
(대표님) 벤더데이터 S-EDP 변경사항 대응(seperator), 정기평가 점수오류, dim 준비
Diffstat (limited to 'components')
-rw-r--r--components/form-data/form-data-table.tsx1
-rw-r--r--components/form-data/spreadJS-dialog.tsx166
-rw-r--r--components/form-data/update-form-sheet.tsx10
-rw-r--r--components/signup/tech-vendor-join-form.tsx907
-rw-r--r--components/tech-vendors/tech-vendor-container.tsx2
5 files changed, 1035 insertions, 51 deletions
diff --git a/components/form-data/form-data-table.tsx b/components/form-data/form-data-table.tsx
index d964b17b..90c8490b 100644
--- a/components/form-data/form-data-table.tsx
+++ b/components/form-data/form-data-table.tsx
@@ -917,6 +917,7 @@ export default function DynamicTable({
formCode={formCode}
contractItemId={contractItemId}
editableFieldsMap={editableFieldsMap}
+ columnsJSON={columnsJSON}
onUpdateSuccess={(updatedValues) => {
// 업데이트 로직도 수정해야 함 - 단일 행 또는 복수 행 처리
if (Array.isArray(updatedValues)) {
diff --git a/components/form-data/spreadJS-dialog.tsx b/components/form-data/spreadJS-dialog.tsx
index c106f926..1cf23369 100644
--- a/components/form-data/spreadJS-dialog.tsx
+++ b/components/form-data/spreadJS-dialog.tsx
@@ -10,6 +10,7 @@ import { toast } from "sonner";
import { updateFormDataInDB } from "@/lib/forms/services";
import { Loader, Save } from "lucide-react";
import '@mescius/spread-sheets/styles/gc.spread.sheets.excel2016colorful.css';
+import { DataTableColumnJSON } from "./form-data-table-columns";
// SpreadSheets를 동적으로 import (SSR 비활성화)
const SpreadSheets = dynamic(
@@ -71,9 +72,10 @@ interface TemplateViewDialogProps {
isOpen: boolean;
onClose: () => void;
templateData: TemplateItem[] | any;
- selectedRow?: GenericData; // SPR_ITM_LST_SETUP용
- tableData?: GenericData[]; // SPR_LST_SETUP용
+ selectedRow?: GenericData; // SPREAD_ITEM용
+ tableData?: GenericData[]; // SPREAD_LIST용
formCode: string;
+ columnsJSON: DataTableColumnJSON[]
contractItemId: number;
editableFieldsMap?: Map<string, string[]>;
onUpdateSuccess?: (updatedValues: Record<string, any> | GenericData[]) => void;
@@ -87,6 +89,7 @@ export function TemplateViewDialog({
tableData = [],
formCode,
contractItemId,
+ columnsJSON,
editableFieldsMap = new Map(),
onUpdateSuccess
}: TemplateViewDialogProps) {
@@ -100,14 +103,14 @@ export function TemplateViewDialog({
const [currentSpread, setCurrentSpread] = React.useState<any>(null);
const [cellMappings, setCellMappings] = React.useState<Array<{attId: string, cellAddress: string, isEditable: boolean}>>([]);
const [isClient, setIsClient] = React.useState(false);
- const [templateType, setTemplateType] = React.useState<'SPR_LST_SETUP' | 'SPR_ITM_LST_SETUP' | null>(null);
+ const [templateType, setTemplateType] = React.useState<'SPREAD_LIST' | 'SPREAD_ITEM' | null>(null);
// 클라이언트 사이드에서만 렌더링되도록 보장
React.useEffect(() => {
setIsClient(true);
}, []);
- // 템플릿 데이터를 배열로 정규화하고 CONTENT가 있는 것 찾기
+ // 템플릿 데이터를 배열로 정규화하고 TMPL_TYPE에 따라 템플릿 타입 결정
const { normalizedTemplate, detectedTemplateType } = React.useMemo(() => {
if (!templateData) return { normalizedTemplate: null, detectedTemplateType: null };
@@ -118,13 +121,16 @@ export function TemplateViewDialog({
templates = [templateData as TemplateItem];
}
- // CONTENT가 있는 템플릿 찾기
+ // TMPL_TYPE이 SPREAD_LIST 또는 SPREAD_ITEM인 템플릿 찾기
for (const template of templates) {
- if (template.SPR_LST_SETUP?.CONTENT) {
- return { normalizedTemplate: template, detectedTemplateType: 'SPR_LST_SETUP' as const };
- }
- if (template.SPR_ITM_LST_SETUP?.CONTENT) {
- return { normalizedTemplate: template, detectedTemplateType: 'SPR_ITM_LST_SETUP' as const };
+ if (template.TMPL_TYPE === "SPREAD_LIST" || template.TMPL_TYPE === "SPREAD_ITEM") {
+ // SPR_LST_SETUP.CONTENT 또는 SPR_ITM_LST_SETUP.CONTENT 중 하나라도 있으면 해당 템플릿 사용
+ if (template.SPR_LST_SETUP?.CONTENT || template.SPR_ITM_LST_SETUP?.CONTENT) {
+ return {
+ normalizedTemplate: template,
+ detectedTemplateType: template.TMPL_TYPE as 'SPREAD_LIST' | 'SPREAD_ITEM'
+ };
+ }
}
}
@@ -136,21 +142,64 @@ export function TemplateViewDialog({
setTemplateType(detectedTemplateType);
}, [detectedTemplateType]);
+ const editableFields = React.useMemo(() => {
+ if (!selectedRow?.TAG_NO || !editableFieldsMap.has(selectedRow.TAG_NO)) {
+ return [];
+ }
+ return editableFieldsMap.get(selectedRow.TAG_NO) || [];
+ }, [selectedRow?.TAG_NO, editableFieldsMap]);
+
// 필드가 편집 가능한지 판별하는 함수
- const isFieldEditable = React.useCallback((attId: string) => {
- // TAG_NO와 TAG_DESC는 기본적으로 편집 가능
+ // const isFieldEditable = React.useCallback((attId: string) => {
+ // // columnsJSON에서 해당 attId의 shi 값 확인
+ // const columnConfig = columnsJSON.find(col => col.key === attId);
+ // if (columnConfig?.shi === true) {
+ // return false; // columnsJSON에서 shi가 true이면 편집 불가
+ // }
+
+ // // TAG_NO와 TAG_DESC는 기본적으로 편집 가능 (columnsJSON의 shi가 false인 경우)
+ // if (attId === "TAG_NO" || attId === "TAG_DESC") {
+ // return true;
+ // }
+
+ // if (selectedRow?.TAG_NO && editableFieldsMap.has(selectedRow.TAG_NO)) {
+ // return editableFields.includes(attId);
+ // }
+
+
+
+ // // SPREAD_LIST인 경우는 기본적으로 편집 가능 (개별 행의 shi 상태는 저장시 확인)
+ // return true;
+ // }, [templateType, selectedRow, columnsJSON, editableFields]);
+
+ const isFieldEditable = React.useCallback((attId: string, rowData?: GenericData) => {
+ // columnsJSON에서 해당 attId의 shi 값 확인
+ const columnConfig = columnsJSON.find(col => col.key === attId);
+ if (columnConfig?.shi === true) {
+ return false; // columnsJSON에서 shi가 true이면 편집 불가
+ }
+
+ // TAG_NO와 TAG_DESC는 기본적으로 편집 가능 (columnsJSON의 shi가 false인 경우)
if (attId === "TAG_NO" || attId === "TAG_DESC") {
return true;
}
+
+ // SPREAD_ITEM 모드일 때는 selectedRow 사용
+ // if (templateType === 'SPREAD_ITEM' && selectedRow?.TAG_NO && editableFieldsMap.has(selectedRow.TAG_NO)) {
+ // const editableFields = editableFieldsMap.get(selectedRow.TAG_NO) || [];
+ // return editableFields.includes(attId);
+ // }
- // SPR_ITM_LST_SETUP인 경우 selectedRow.shi 확인
- if (templateType === 'SPR_ITM_LST_SETUP' && selectedRow) {
- return selectedRow.shi !== true;
- }
+ // // SPREAD_LIST 모드일 때는 각 행의 데이터 사용
+ // if (templateType === 'SPREAD_LIST' && rowData?.TAG_NO && editableFieldsMap.has(rowData.TAG_NO)) {
+ // const editableFields = editableFieldsMap.get(rowData.TAG_NO) || [];
+ // return editableFields.includes(attId);
+ // }
- // SPR_LST_SETUP인 경우는 기본적으로 편집 가능 (개별 행의 shi 상태는 저장시 확인)
+ // SPREAD_LIST인 경우는 기본적으로 편집 가능 (개별 행의 shi 상태는 저장시 확인)
return true;
- }, [templateType, selectedRow]);
+ }, [templateType, selectedRow, columnsJSON, editableFieldsMap]);
+
// 편집 가능한 필드 개수 계산
const editableFieldsCount = React.useMemo(() => {
@@ -184,18 +233,21 @@ export function TemplateViewDialog({
setCurrentSpread(spread);
setHasChanges(false);
- // 템플릿 타입에 따라 CONTENT와 DATA_SHEETS 가져오기
+ // SPR_LST_SETUP.CONTENT와 SPR_ITM_LST_SETUP.CONTENT 중에서 값이 있는 것을 찾아서 사용
let contentJson = null;
let dataSheets = null;
- if (templateType === 'SPR_LST_SETUP') {
+ // SPR_LST_SETUP.CONTENT가 있으면 우선 사용
+ if (normalizedTemplate.SPR_LST_SETUP?.CONTENT) {
contentJson = normalizedTemplate.SPR_LST_SETUP.CONTENT;
dataSheets = normalizedTemplate.SPR_LST_SETUP.DATA_SHEETS;
- console.log('Using SPR_LST_SETUP.CONTENT for template:', normalizedTemplate.NAME);
- } else if (templateType === 'SPR_ITM_LST_SETUP') {
+ console.log('Using SPR_LST_SETUP.CONTENT for template:', normalizedTemplate.NAME, '(TMPL_TYPE:', normalizedTemplate.TMPL_TYPE, ')');
+ }
+ // SPR_ITM_LST_SETUP.CONTENT가 있으면 사용
+ else if (normalizedTemplate.SPR_ITM_LST_SETUP?.CONTENT) {
contentJson = normalizedTemplate.SPR_ITM_LST_SETUP.CONTENT;
dataSheets = normalizedTemplate.SPR_ITM_LST_SETUP.DATA_SHEETS;
- console.log('Using SPR_ITM_LST_SETUP.CONTENT for template:', normalizedTemplate.NAME);
+ console.log('Using SPR_ITM_LST_SETUP.CONTENT for template:', normalizedTemplate.NAME, '(TMPL_TYPE:', normalizedTemplate.TMPL_TYPE, ')');
}
if (!contentJson) {
@@ -203,7 +255,7 @@ export function TemplateViewDialog({
return;
}
- console.log(`Loading template content for: ${normalizedTemplate.NAME} (Type: ${templateType})`);
+ console.log(`Loading template content for: ${normalizedTemplate.NAME} (Type: ${normalizedTemplate.TMPL_TYPE})`);
const jsonData = typeof contentJson === 'string'
? JSON.parse(contentJson)
@@ -242,7 +294,7 @@ export function TemplateViewDialog({
});
// 템플릿 타입에 따라 다른 데이터 처리
- if (templateType === 'SPR_ITM_LST_SETUP' && selectedRow) {
+ if (templateType === 'SPREAD_ITEM' && selectedRow) {
// 단일 행 처리 (기존 로직)
const cell = activeSheet.getCell(cellPos.row, cellPos.col);
const value = selectedRow[ATT_ID];
@@ -250,6 +302,10 @@ export function TemplateViewDialog({
cell.value(value);
}
+ if (value === undefined || value === null) {
+ cell.value(null);
+ }
+
// 스타일 적용
cell.locked(!isEditable);
const existingStyle = activeSheet.getStyle(cellPos.row, cellPos.col);
@@ -264,7 +320,7 @@ export function TemplateViewDialog({
activeSheet.setStyle(cellPos.row, cellPos.col, newStyle);
- } else if (templateType === 'SPR_LST_SETUP' && tableData.length > 0) {
+ } else if (templateType === 'SPREAD_LIST' && tableData.length > 0) {
// 복수 행 처리 - 첫 번째 행부터 시작해서 아래로 채움
tableData.forEach((rowData, index) => {
const targetRow = cellPos.row + index;
@@ -274,16 +330,23 @@ export function TemplateViewDialog({
if (value !== undefined && value !== null) {
cell.value(value);
}
+
+ if (value === undefined || value === null) {
+ cell.value(null);
+ }
- // 개별 행의 편집 가능 여부 확인 (shi 필드 기준)
- const rowEditable = isEditable && (rowData.shi !== true);
- cell.locked(!rowEditable);
+ // 개별 행의 편집 가능 여부 확인 (행의 shi + columnsJSON의 shi 모두 확인)
+ // const columnConfig = columnsJSON.find(col => col.key === ATT_ID);
+ // const cellEditable = columnConfig?.shi !== true; // columnsJSON에서 shi가 true이면 편집 불가
+
+ const cellEditable = isFieldEditable(ATT_ID, rowData); // 각 행의 데이터를 전달
+ cell.locked(!cellEditable);
// 스타일 적용
const existingStyle = activeSheet.getStyle(targetRow, cellPos.col);
const newStyle = existingStyle ? Object.assign(new GC.Spread.Sheets.Style(), existingStyle) : new GC.Spread.Sheets.Style();
- if (rowEditable) {
+ if (cellEditable) {
newStyle.backColor = "#f0fdf4";
} else {
newStyle.backColor = "#f9fafb";
@@ -334,8 +397,16 @@ export function TemplateViewDialog({
});
if (mapping) {
- // SPR_LST_SETUP인 경우 해당 행의 데이터에서 shi 확인
- if (templateType === 'SPR_LST_SETUP') {
+ // columnsJSON에서 해당 필드의 shi 확인
+ const columnConfig = columnsJSON.find(col => col.key === mapping.attId);
+ if (columnConfig?.shi === true) {
+ toast.warning(`${mapping.attId} field is read-only (Column configuration)`);
+ info.cancel = true;
+ return;
+ }
+
+ // SPREAD_LIST인 경우 해당 행의 데이터에서 shi 확인
+ if (templateType === 'SPREAD_LIST') {
const dataRowIndex = info.row - parseCellAddress(mapping.cellAddress)!.row;
const rowData = tableData[dataRowIndex];
if (rowData && rowData.shi === true) {
@@ -363,7 +434,7 @@ export function TemplateViewDialog({
spread.resumePaint();
}
}
- }, [normalizedTemplate, templateType, selectedRow, tableData, isFieldEditable]);
+ }, [normalizedTemplate, templateType, selectedRow, tableData, isFieldEditable, columnsJSON]);
// 변경사항 저장 함수
const handleSaveChanges = React.useCallback(async () => {
@@ -377,7 +448,7 @@ export function TemplateViewDialog({
const activeSheet = currentSpread.getActiveSheet();
- if (templateType === 'SPR_ITM_LST_SETUP' && selectedRow) {
+ if (templateType === 'SPREAD_ITEM' && selectedRow) {
// 단일 행 저장 (기존 로직)
const dataToSave = { ...selectedRow };
@@ -407,7 +478,7 @@ export function TemplateViewDialog({
toast.success("Changes saved successfully!");
onUpdateSuccess?.(dataToSave);
- } else if (templateType === 'SPR_LST_SETUP' && tableData.length > 0) {
+ } else if (templateType === 'SPREAD_LIST' && tableData.length > 0) {
// 복수 행 저장
const updatedRows: GenericData[] = [];
let saveCount = 0;
@@ -419,7 +490,12 @@ export function TemplateViewDialog({
// 각 매핑에 대해 해당 행의 값 확인
cellMappings.forEach(mapping => {
- if (mapping.isEditable && originalRow.shi !== true) { // shi가 true인 행은 편집 불가
+ // columnsJSON에서 해당 필드의 shi 확인
+ const columnConfig = columnsJSON.find(col => col.key === mapping.attId);
+ const isColumnEditable = columnConfig?.shi !== true;
+ const isRowEditable = originalRow.shi !== true;
+
+ if (mapping.isEditable && isColumnEditable && isRowEditable) {
const cellPos = parseCellAddress(mapping.cellAddress);
if (cellPos) {
const targetRow = cellPos.row + i;
@@ -469,13 +545,13 @@ export function TemplateViewDialog({
} finally {
setIsPending(false);
}
- }, [currentSpread, hasChanges, templateType, selectedRow, tableData, formCode, contractItemId, onUpdateSuccess, cellMappings]);
+ }, [currentSpread, hasChanges, templateType, selectedRow, tableData, formCode, contractItemId, onUpdateSuccess, cellMappings, columnsJSON]);
if (!isOpen) return null;
// 데이터 유효성 검사
- const isDataValid = templateType === 'SPR_ITM_LST_SETUP' ? !!selectedRow : tableData.length > 0;
- const dataCount = templateType === 'SPR_ITM_LST_SETUP' ? 1 : tableData.length;
+ const isDataValid = templateType === 'SPREAD_ITEM' ? !!selectedRow : tableData.length > 0;
+ const dataCount = templateType === 'SPREAD_ITEM' ? 1 : tableData.length;
return (
<Dialog open={isOpen} onOpenChange={onClose}>
@@ -486,15 +562,15 @@ export function TemplateViewDialog({
<DialogHeader className="flex-shrink-0">
<DialogTitle>SEDP Template - {formCode}</DialogTitle>
<DialogDescription>
- {templateType && (
+ {normalizedTemplate && (
<span className="font-medium text-blue-600">
- Template Type: {templateType === 'SPR_LST_SETUP' ? 'List View' : 'Item View'}
+ Template Type: {normalizedTemplate.TMPL_TYPE === 'SPREAD_LIST' ? 'List View (SPREAD_LIST)' : 'Item View (SPREAD_ITEM)'}
</span>
)}
- {templateType === 'SPR_ITM_LST_SETUP' && selectedRow && (
+ {templateType === 'SPREAD_ITEM' && selectedRow && (
<span className="ml-2">• Selected TAG_NO: {selectedRow.TAG_NO || 'N/A'}</span>
)}
- {templateType === 'SPR_LST_SETUP' && (
+ {templateType === 'SPREAD_LIST' && (
<span className="ml-2">• {dataCount} rows</span>
)}
{hasChanges && (
@@ -525,7 +601,7 @@ export function TemplateViewDialog({
<div className="flex-1 overflow-hidden">
{normalizedTemplate && isClient && isDataValid ? (
<SpreadSheets
- key={`${templateType}-${normalizedTemplate.TMPL_ID}`}
+ key={`${normalizedTemplate.TMPL_TYPE}-${normalizedTemplate.TMPL_ID}`}
workbookInitialized={initSpread}
hostStyle={hostStyle}
/>
@@ -539,7 +615,7 @@ export function TemplateViewDialog({
) : !normalizedTemplate ? (
"No template available"
) : !isDataValid ? (
- `No ${templateType === 'SPR_ITM_LST_SETUP' ? 'selected row' : 'data'} available`
+ `No ${templateType === 'SPREAD_ITEM' ? 'selected row' : 'data'} available`
) : (
"Template not ready"
)}
diff --git a/components/form-data/update-form-sheet.tsx b/components/form-data/update-form-sheet.tsx
index c8772e2a..ecf42048 100644
--- a/components/form-data/update-form-sheet.tsx
+++ b/components/form-data/update-form-sheet.tsx
@@ -93,14 +93,14 @@ export function UpdateTagSheet({
return true;
}
- // 3. editableFieldsMap이 있으면 해당 리스트에 있는지 확인
- if (rowData?.TAG_NO && editableFieldsMap.has(rowData.TAG_NO)) {
- return editableFields.includes(column.key);
- }
+ //3. editableFieldsMap이 있으면 해당 리스트에 있는지 확인
+ // if (rowData?.TAG_NO && editableFieldsMap.has(rowData.TAG_NO)) {
+ // return editableFields.includes(column.key);
+ // }
// 4. editableFieldsMap 정보가 없으면 기본적으로 편집 불가 (안전한 기본값)
return false;
- }, [rowData?.TAG_NO, editableFieldsMap, editableFields]);
+ }, []);
// 읽기 전용 필드인지 판별하는 함수 (편집 가능의 반대)
const isFieldReadOnly = React.useCallback((column: DataTableColumnJSON) => {
diff --git a/components/signup/tech-vendor-join-form.tsx b/components/signup/tech-vendor-join-form.tsx
new file mode 100644
index 00000000..db81b88c
--- /dev/null
+++ b/components/signup/tech-vendor-join-form.tsx
@@ -0,0 +1,907 @@
+"use client"
+
+import * as React from "react"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { useForm, useFieldArray } from "react-hook-form"
+import { useRouter, useSearchParams, useParams } from "next/navigation"
+
+import i18nIsoCountries from "i18n-iso-countries"
+import enLocale from "i18n-iso-countries/langs/en.json"
+import koLocale from "i18n-iso-countries/langs/ko.json"
+
+import { Button } from "@/components/ui/button"
+import { Separator } from "@/components/ui/separator"
+import {
+ Form,
+ FormControl,
+ FormDescription,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import { Input } from "@/components/ui/input"
+import { toast } from "@/hooks/use-toast"
+import {
+ Popover,
+ PopoverTrigger,
+ PopoverContent,
+} from "@/components/ui/popover"
+import {
+ Command,
+ CommandInput,
+ CommandEmpty,
+ CommandGroup,
+ CommandItem,
+} from "@/components/ui/command"
+import { Check, ChevronsUpDown, Loader2, Plus, X } from "lucide-react"
+import { cn } from "@/lib/utils"
+
+import { createTechVendorFromSignup } from "@/lib/tech-vendors/service"
+import { createTechVendorSchema, CreateTechVendorSchema } from "@/lib/tech-vendors/validations"
+import { VENDOR_TYPES } from "@/db/schema/techVendors"
+import { verifyTechVendorInvitationToken } from "@/lib/tech-vendor-invitation-token"
+
+import {
+ Dropzone,
+ DropzoneZone,
+ DropzoneInput,
+ DropzoneUploadIcon,
+ DropzoneTitle,
+ DropzoneDescription,
+} from "@/components/ui/dropzone"
+import {
+ FileList,
+ FileListItem,
+ FileListHeader,
+ FileListIcon,
+ FileListInfo,
+ FileListName,
+ FileListDescription,
+ FileListAction,
+} from "@/components/ui/file-list"
+import { Badge } from "@/components/ui/badge"
+import { ScrollArea } from "@/components/ui/scroll-area"
+import prettyBytes from "pretty-bytes"
+
+i18nIsoCountries.registerLocale(enLocale)
+i18nIsoCountries.registerLocale(koLocale)
+
+const locale = "ko"
+const countryMap = i18nIsoCountries.getNames(locale, { select: "official" })
+const countryArray = Object.entries(countryMap).map(([code, label]) => ({
+ code,
+ label,
+}))
+
+// Sort countries to put Korea first, then alphabetically
+const sortedCountryArray = [...countryArray].sort((a, b) => {
+ if (a.code === "KR") return -1;
+ if (b.code === "KR") return 1;
+ return a.label.localeCompare(b.label);
+});
+
+// Add English names for Korean locale
+const enhancedCountryArray = sortedCountryArray.map(country => ({
+ ...country,
+ label: locale === "ko" && country.code === "KR"
+ ? "대한민국 (South Korea)"
+ : country.label
+}));
+
+// Comprehensive list of country dial codes
+const countryDialCodes: { [key: string]: string } = {
+ AF: "+93", AL: "+355", DZ: "+213", AS: "+1-684", AD: "+376", AO: "+244",
+ AI: "+1-264", AG: "+1-268", AR: "+54", AM: "+374", AW: "+297", AU: "+61",
+ AT: "+43", AZ: "+994", BS: "+1-242", BH: "+973", BD: "+880", BB: "+1-246",
+ BY: "+375", BE: "+32", BZ: "+501", BJ: "+229", BM: "+1-441", BT: "+975",
+ BO: "+591", BA: "+387", BW: "+267", BR: "+55", BN: "+673", BG: "+359",
+ BF: "+226", BI: "+257", KH: "+855", CM: "+237", CA: "+1", CV: "+238",
+ KY: "+1-345", CF: "+236", TD: "+235", CL: "+56", CN: "+86", CO: "+57",
+ KM: "+269", CG: "+242", CD: "+243", CR: "+506", CI: "+225", HR: "+385",
+ CU: "+53", CY: "+357", CZ: "+420", DK: "+45", DJ: "+253", DM: "+1-767",
+ DO: "+1-809", EC: "+593", EG: "+20", SV: "+503", GQ: "+240", ER: "+291",
+ EE: "+372", ET: "+251", FJ: "+679", FI: "+358", FR: "+33", GA: "+241",
+ GM: "+220", GE: "+995", DE: "+49", GH: "+233", GR: "+30", GD: "+1-473",
+ GT: "+502", GN: "+224", GW: "+245", GY: "+592", HT: "+509", HN: "+504",
+ HK: "+852", HU: "+36", IS: "+354", IN: "+91", ID: "+62", IR: "+98",
+ IQ: "+964", IE: "+353", IL: "+972", IT: "+39", JM: "+1-876", JP: "+81",
+ JO: "+962", KZ: "+7", KE: "+254", KI: "+686", KR: "+82", KW: "+965",
+ KG: "+996", LA: "+856", LV: "+371", LB: "+961", LS: "+266", LR: "+231",
+ LY: "+218", LI: "+423", LT: "+370", LU: "+352", MK: "+389", MG: "+261",
+ MW: "+265", MY: "+60", MV: "+960", ML: "+223", MT: "+356", MH: "+692",
+ MR: "+222", MU: "+230", MX: "+52", FM: "+691", MD: "+373", MC: "+377",
+ MN: "+976", ME: "+382", MA: "+212", MZ: "+258", MM: "+95", NA: "+264",
+ NR: "+674", NP: "+977", NL: "+31", NZ: "+64", NI: "+505", NE: "+227",
+ NG: "+234", NU: "+683", KP: "+850", NO: "+47", OM: "+968", PK: "+92",
+ PW: "+680", PS: "+970", PA: "+507", PG: "+675", PY: "+595", PE: "+51",
+ PH: "+63", PL: "+48", PT: "+351", PR: "+1-787", QA: "+974", RO: "+40",
+ RU: "+7", RW: "+250", KN: "+1-869", LC: "+1-758", VC: "+1-784", WS: "+685",
+ SM: "+378", ST: "+239", SA: "+966", SN: "+221", RS: "+381", SC: "+248",
+ SL: "+232", SG: "+65", SK: "+421", SI: "+386", SB: "+677", SO: "+252",
+ ZA: "+27", SS: "+211", ES: "+34", LK: "+94", SD: "+249", SR: "+597",
+ SZ: "+268", SE: "+46", CH: "+41", SY: "+963", TW: "+886", TJ: "+992",
+ TZ: "+255", TH: "+66", TL: "+670", TG: "+228", TK: "+690", TO: "+676",
+ TT: "+1-868", TN: "+216", TR: "+90", TM: "+993", TV: "+688", UG: "+256",
+ UA: "+380", AE: "+971", GB: "+44", US: "+1", UY: "+598", UZ: "+998",
+ VU: "+678", VA: "+39-06", VE: "+58", VN: "+84", YE: "+967", ZM: "+260",
+ ZW: "+263"
+};
+
+const MAX_FILE_SIZE = 3e9
+
+export function TechVendorJoinForm() {
+ const params = useParams() || {};
+ const lng = params.lng ? String(params.lng) : "ko";
+
+ const router = useRouter()
+ const searchParams = useSearchParams() || new URLSearchParams();
+ const defaultTaxId = searchParams.get("taxID") ?? ""
+ const invitationToken = searchParams.get("token") ?? ""
+
+ // States
+ const [selectedFiles, setSelectedFiles] = React.useState<File[]>([])
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [hasValidToken, setHasValidToken] = React.useState<boolean | null>(null)
+
+ // React Hook Form (항상 최상위에서 호출)
+ const form = useForm<CreateTechVendorSchema>({
+ resolver: zodResolver(createTechVendorSchema),
+ defaultValues: {
+ vendorName: "",
+ vendorCode: "",
+ items: "",
+ taxId: defaultTaxId,
+ address: "",
+ email: "",
+ phone: "",
+ country: "",
+ website: "",
+ techVendorType: ["조선"],
+ representativeName: "",
+ representativeBirth: "",
+ representativeEmail: "",
+ representativePhone: "",
+ files: undefined,
+ contacts: [
+ {
+ contactName: "",
+ contactPosition: "",
+ contactEmail: "",
+ contactPhone: "",
+ },
+ ],
+ },
+ mode: "onChange",
+ })
+
+ // Field array for contacts (항상 최상위에서 호출)
+ const { fields: contactFields, append: addContact, remove: removeContact } =
+ useFieldArray({
+ control: form.control,
+ name: "contacts",
+ })
+
+ // 토큰 검증 로직
+ React.useEffect(() => {
+ // 토큰이 없으면 유효하지 않음
+ if (!invitationToken) {
+ setHasValidToken(false);
+ return;
+ }
+
+ // 토큰이 있으면 검증 수행
+ const validateToken = async () => {
+ setIsLoading(true);
+
+ try {
+ const tokenPayload = await verifyTechVendorInvitationToken(invitationToken);
+
+ if (tokenPayload) {
+ setHasValidToken(true);
+ // 토큰에서 가져온 정보로 폼 미리 채우기
+ form.setValue("vendorName", tokenPayload.vendorName);
+ form.setValue("email", tokenPayload.email);
+
+ // 연락처 정보도 미리 채우기
+ form.setValue("contacts.0.contactName", tokenPayload.vendorName);
+ form.setValue("contacts.0.contactEmail", tokenPayload.email);
+
+ toast({
+ title: "초대 정보 로드 완료",
+ description: "기존 정보가 자동으로 입력되었습니다. 추가 정보를 입력해주세요.",
+ });
+ } else {
+ setHasValidToken(false);
+ toast({
+ variant: "destructive",
+ title: "유효하지 않은 초대 링크",
+ description: "초대 링크가 만료되었거나 유효하지 않습니다.",
+ });
+ }
+ } catch (error) {
+ console.error("Token verification error:", error);
+ setHasValidToken(false);
+ toast({
+ variant: "destructive",
+ title: "오류 발생",
+ description: "초대 정보를 불러오는 중 오류가 발생했습니다.",
+ });
+ } finally {
+ setIsLoading(false);
+ }
+ };
+
+ validateToken();
+ }, [invitationToken, form, router]);
+
+ // 토큰이 유효하지 않으면 에러 페이지 표시
+ if (hasValidToken === false) {
+ return (
+ <div className="container py-6">
+ <section className="overflow-hidden rounded-md border bg-background shadow-sm">
+ <div className="p-6 md:p-10 space-y-6">
+ <div className="flex flex-col items-center justify-center py-12 text-center">
+ <div className="rounded-lg border border-dashed border-destructive p-10 shadow-sm">
+ <h3 className="mb-2 text-xl font-semibold text-destructive">유효하지 않은 접근</h3>
+ <p className="mb-6 text-muted-foreground">
+ 기술영업 협력업체 등록은 초대를 통해서만 가능합니다.<br />
+ 올바른 초대 링크를 통해 접근해주세요.
+ </p>
+ <Button
+ variant="destructive"
+ onClick={() => router.push("/ko/partners")}
+ >
+ 돌아가기
+ </Button>
+ </div>
+ </div>
+ </div>
+ </section>
+ </div>
+ );
+ }
+
+ const isFormValid = form.formState.isValid
+ console.log("Form errors:", form.formState.errors);
+ console.log("Form values:", form.getValues());
+ console.log("Form valid:", form.formState.isValid);
+
+ // Dropzone handlers
+ const handleDropAccepted = (acceptedFiles: File[]) => {
+ const newFiles = [...selectedFiles, ...acceptedFiles]
+ setSelectedFiles(newFiles)
+ form.setValue("files", newFiles, { shouldValidate: true })
+ }
+
+ const handleDropRejected = (fileRejections: any[]) => {
+ fileRejections.forEach((rej) => {
+ toast({
+ variant: "destructive",
+ title: "File Error",
+ description: `${rej.file.name}: ${rej.errors[0]?.message || "Upload failed"}`,
+ })
+ })
+ }
+
+ const removeFile = (index: number) => {
+ const updated = [...selectedFiles]
+ updated.splice(index, 1)
+ setSelectedFiles(updated)
+ form.setValue("files", updated, { shouldValidate: true })
+ }
+
+ // Submit
+ async function onSubmit(values: CreateTechVendorSchema) {
+ setIsSubmitting(true)
+ try {
+ const mainFiles = values.files
+ ? Array.from(values.files as FileList)
+ : []
+
+ const techVendorData = {
+ vendorName: values.vendorName,
+ vendorCode: values.vendorCode,
+ items: values.items,
+ website: values.website,
+ taxId: values.taxId,
+ address: values.address,
+ email: values.email,
+ phone: values.phone,
+ country: values.country,
+ techVendorType: Array.isArray(values.techVendorType) ? values.techVendorType[0] : values.techVendorType,
+ representativeName: values.representativeName || "",
+ representativeBirth: values.representativeBirth || "",
+ representativeEmail: values.representativeEmail || "",
+ representativePhone: values.representativePhone || "",
+ contacts: values.contacts,
+ files: mainFiles,
+ }
+
+ const result = await createTechVendorFromSignup({
+ vendorData: techVendorData,
+ files: mainFiles,
+ contacts: values.contacts,
+ invitationToken: invitationToken || undefined,
+ })
+
+ if (!result.error) {
+ toast({
+ title: "등록 완료",
+ description: invitationToken
+ ? "기술영업 업체 정보가 성공적으로 업데이트되었습니다."
+ : "기술영업 업체 등록이 완료되었습니다.",
+ })
+ router.push("/ko/partners")
+ } else {
+ toast({
+ variant: "destructive",
+ title: "오류",
+ description: result.error || "등록에 실패했습니다.",
+ })
+ }
+ } catch (error: any) {
+ console.error(error)
+ toast({
+ variant: "destructive",
+ title: "서버 에러",
+ description: error.message || "에러가 발생했습니다.",
+ })
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ // Get country code for phone number placeholder
+ const getPhonePlaceholder = (countryCode: string) => {
+ if (!countryCode || !countryDialCodes[countryCode]) return "전화번호";
+ return `${countryDialCodes[countryCode]} 전화번호`;
+ };
+
+ if (isLoading) {
+ return (
+ <div className="container py-6">
+ <div className="flex items-center justify-center">
+ <Loader2 className="h-8 w-8 animate-spin" />
+ <span className="ml-2">기존 정보를 불러오는 중...</span>
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="container py-6">
+ <section className="overflow-hidden rounded-md border bg-background shadow-sm">
+ <div className="p-6 md:p-10 space-y-6">
+ <div className="space-y-2">
+ <h3 className="text-xl font-semibold">
+ {invitationToken ? "기술영업 업체 정보 업데이트" : "기술영업 업체 등록"}
+ </h3>
+ <p className="text-sm text-muted-foreground">
+ {invitationToken
+ ? "기술영업 업체 정보를 업데이트하고 필요한 서류를 첨부해주세요."
+ : "기술영업 업체 기본 정보를 입력하고 필요한 서류를 첨부해주세요. 검토 후 빠른 시일 내에 승인처리 해드리겠습니다."
+ }
+ </p>
+ </div>
+
+ <Separator />
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-8">
+ {/* ─────────────────────────────────────────
+ 기본 정보
+ ───────────────────────────────────────── */}
+ <div className="rounded-md border p-4 space-y-4">
+ <h4 className="text-md font-semibold">기본 정보</h4>
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ {/* 업체 유형 */}
+ <FormField
+ control={form.control}
+ name="techVendorType"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500">
+ 업체 유형
+ </FormLabel>
+ <div className="space-y-2">
+ {VENDOR_TYPES.map((type) => (
+ <div key={type} className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id={`techVendorType-${type}`}
+ checked={field.value?.includes(type) || false}
+ onChange={(e) => {
+ const currentValues = field.value || [];
+ if (e.target.checked) {
+ field.onChange([...currentValues, type]);
+ } else {
+ field.onChange(currentValues.filter((v: string) => v !== type));
+ }
+ }}
+ className="rounded border-input"
+ />
+ <label htmlFor={`techVendorType-${type}`} className="text-sm">
+ {type}
+ </label>
+ </div>
+ ))}
+ </div>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 업체명 */}
+ <FormField
+ control={form.control}
+ name="vendorName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500">
+ 업체명
+ </FormLabel>
+ <FormControl>
+ <Input placeholder="업체명을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 업체 코드 */}
+ <FormField
+ control={form.control}
+ name="vendorCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>업체 코드</FormLabel>
+ <FormControl>
+ <Input placeholder="업체 코드를 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 사업자등록번호 */}
+ <FormField
+ control={form.control}
+ name="taxId"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500">
+ 사업자등록번호
+ </FormLabel>
+ <FormControl>
+ <Input placeholder="000-00-00000" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 국가 */}
+ <FormField
+ control={form.control}
+ name="country"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500">
+ 국가
+ </FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "w-full justify-between",
+ !field.value && "text-muted-foreground"
+ )}
+ >
+ {field.value
+ ? i18nIsoCountries.getName(field.value, "ko")
+ : "국가를 선택하세요"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-full p-0">
+ <Command>
+ <CommandInput placeholder="국가를 검색하세요..." />
+ <CommandEmpty>국가를 찾을 수 없습니다.</CommandEmpty>
+ <CommandGroup>
+ <ScrollArea className="h-72">
+ {Object.entries(i18nIsoCountries.getNames("ko")).map(([code, name]) => (
+ <CommandItem
+ key={code}
+ value={`${code} ${name}`}
+ onSelect={() => {
+ form.setValue("country", code);
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ code === field.value ? "opacity-100" : "opacity-0"
+ )}
+ />
+ {name}
+ </CommandItem>
+ ))}
+ </ScrollArea>
+ </CommandGroup>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 주소 */}
+ <FormField
+ control={form.control}
+ name="address"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>주소</FormLabel>
+ <FormControl>
+ <Input placeholder="주소를 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 이메일 */}
+ <FormField
+ control={form.control}
+ name="email"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500">
+ 이메일
+ </FormLabel>
+ <FormControl>
+ <Input placeholder="example@company.com" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 전화번호 */}
+ <FormField
+ control={form.control}
+ name="phone"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>전화번호</FormLabel>
+ <FormControl>
+ <Input
+ placeholder={getPhonePlaceholder(form.watch("country"))}
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 웹사이트 */}
+ <FormField
+ control={form.control}
+ name="website"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>웹사이트</FormLabel>
+ <FormControl>
+ <Input placeholder="https://www.example.com" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 주요 품목 */}
+ <FormField
+ control={form.control}
+ name="items"
+ render={({ field }) => (
+ <FormItem className="md:col-span-2">
+ <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500">
+ 주요 품목
+ </FormLabel>
+ <FormControl>
+ <Input placeholder="주요 품목을 입력하세요" {...field} />
+ </FormControl>
+ <FormDescription>
+ 회사에서 주로 다루는 품목들을 쉼표로 구분하여 입력하세요.
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+
+ {/* ─────────────────────────────────────────
+ 대표자 정보
+ ───────────────────────────────────────── */}
+ <div className="rounded-md border p-4 space-y-4">
+ <h4 className="text-md font-semibold">대표자 정보</h4>
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ {/* 대표자명 */}
+ <FormField
+ control={form.control}
+ name="representativeName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>대표자명</FormLabel>
+ <FormControl>
+ <Input placeholder="대표자명을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 대표자 생년월일 */}
+ <FormField
+ control={form.control}
+ name="representativeBirth"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>대표자 생년월일</FormLabel>
+ <FormControl>
+ <Input placeholder="YYYY-MM-DD" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 대표자 이메일 */}
+ <FormField
+ control={form.control}
+ name="representativeEmail"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>대표자 이메일</FormLabel>
+ <FormControl>
+ <Input placeholder="representative@company.com" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 대표자 전화번호 */}
+ <FormField
+ control={form.control}
+ name="representativePhone"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>대표자 전화번호</FormLabel>
+ <FormControl>
+ <Input placeholder="대표자 전화번호를 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+
+ {/* ─────────────────────────────────────────
+ 연락처 정보
+ ───────────────────────────────────────── */}
+ <div className="rounded-md border p-4 space-y-4">
+ <div className="flex items-center justify-between">
+ <h4 className="text-md font-semibold">연락처 정보</h4>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ addContact({
+ contactName: "",
+ contactPosition: "",
+ contactEmail: "",
+ contactPhone: "",
+ })
+ }
+ >
+ <Plus className="mr-2 h-4 w-4" />
+ 연락처 추가
+ </Button>
+ </div>
+
+ {contactFields.map((field, index) => (
+ <div key={field.id} className="space-y-4 p-4 border rounded-lg">
+ <div className="flex items-center justify-between">
+ <h5 className="text-sm font-medium">연락처 {index + 1}</h5>
+ {contactFields.length > 1 && (
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={() => removeContact(index)}
+ >
+ <X className="mr-2 h-4 w-4" />
+ 삭제
+ </Button>
+ )}
+ </div>
+
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name={`contacts.${index}.contactName`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500">
+ 연락처명
+ </FormLabel>
+ <FormControl>
+ <Input placeholder="연락처명을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name={`contacts.${index}.contactPosition`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>직책</FormLabel>
+ <FormControl>
+ <Input placeholder="직책을 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name={`contacts.${index}.contactEmail`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="after:content-['*'] after:ml-0.5 after:text-red-500">
+ 이메일
+ </FormLabel>
+ <FormControl>
+ <Input placeholder="contact@company.com" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name={`contacts.${index}.contactPhone`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>전화번호</FormLabel>
+ <FormControl>
+ <Input placeholder="전화번호를 입력하세요" {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+ ))}
+ </div>
+
+ {/* ─────────────────────────────────────────
+ 첨부파일
+ ───────────────────────────────────────── */}
+ <div className="rounded-md border p-4 space-y-4">
+ <h4 className="text-md font-semibold">첨부파일</h4>
+ <FormField
+ control={form.control}
+ name="files"
+ render={({ field }) => (
+ <FormItem>
+ <FormControl>
+ <Dropzone
+ onDropAccepted={handleDropAccepted}
+ onDropRejected={handleDropRejected}
+ maxSize={MAX_FILE_SIZE}
+ accept={{
+ "application/pdf": [".pdf"],
+ "application/msword": [".doc"],
+ "application/vnd.openxmlformats-officedocument.wordprocessingml.document": [
+ ".docx",
+ ],
+ "application/vnd.ms-excel": [".xls"],
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet": [
+ ".xlsx",
+ ],
+ "image/*": [".png", ".jpg", ".jpeg"],
+ }}
+ >
+ <DropzoneZone>
+ <DropzoneUploadIcon className="mx-auto h-12 w-12 text-muted-foreground" />
+ <DropzoneTitle className="text-lg font-medium">
+ 파일을 드래그하거나 클릭하여 업로드
+ </DropzoneTitle>
+ <DropzoneDescription className="text-sm text-muted-foreground">
+ PDF, Word, Excel, 이미지 파일 (최대 3GB)
+ </DropzoneDescription>
+ </DropzoneZone>
+ <DropzoneInput />
+ </Dropzone>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {selectedFiles.length > 0 && (
+ <div className="space-y-2">
+ <FileListHeader>
+ <FileListIcon />
+ <FileListInfo>
+ <FileListName>업로드된 파일</FileListName>
+ <FileListDescription>
+ {selectedFiles.length}개 파일
+ </FileListDescription>
+ </FileListInfo>
+ </FileListHeader>
+ <ScrollArea className="h-32">
+ {selectedFiles.map((file, index) => (
+ <FileListItem key={index}>
+ <FileListIcon />
+ <FileListInfo>
+ <FileListName>{file.name}</FileListName>
+ <FileListDescription>
+ {prettyBytes(file.size)}
+ </FileListDescription>
+ </FileListInfo>
+ <FileListAction>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={() => removeFile(index)}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ </FileListAction>
+ </FileListItem>
+ ))}
+ </ScrollArea>
+ </div>
+ )}
+ </div>
+
+ <div className="flex justify-end">
+ <Button type="submit" disabled={!isFormValid || isSubmitting}>
+ {isSubmitting ? (
+ <>
+ <Loader2 className="mr-2 h-4 w-4 animate-spin" />
+ {invitationToken ? "업데이트 중..." : "등록 중..."}
+ </>
+ ) : (
+ invitationToken ? "업데이트하기" : "등록하기"
+ )}
+ </Button>
+ </div>
+ </form>
+ </Form>
+ </div>
+ </section>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/components/tech-vendors/tech-vendor-container.tsx b/components/tech-vendors/tech-vendor-container.tsx
index 0b5b436d..af5169b8 100644
--- a/components/tech-vendors/tech-vendor-container.tsx
+++ b/components/tech-vendors/tech-vendor-container.tsx
@@ -62,7 +62,7 @@ export function TechVendorContainer({
{/* 왼쪽: 타이틀 & 설명 */}
<div>
<div className="flex items-center gap-2">
- <h2 className="text-2xl font-bold tracking-tight">기술영업 벤더 리스트</h2>
+ <h2 className="text-2xl font-bold tracking-tight">기술영업 벤더 관리</h2>
<InformationButton pagePath="evcp/tech-vendors" />
</div>
{/* <p className="text-muted-foreground">