diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-11 09:02:00 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-08-11 09:02:00 +0000 |
| commit | cbb4c7fe0b94459162ad5e998bc05cd293e0ff96 (patch) | |
| tree | 0a26712f7685e4f6511e637b9a81269d90a47c8f /components | |
| parent | eb654f88214095f71be142b989e620fd28db3f69 (diff) | |
(대표님) 입찰, EDP 변경사항 대응, spreadJS 오류 수정, 벤더실사 수정
Diffstat (limited to 'components')
17 files changed, 1449 insertions, 871 deletions
diff --git a/components/ProjectSelector.tsx b/components/ProjectSelector.tsx index 50d5b9d5..652bf77b 100644 --- a/components/ProjectSelector.tsx +++ b/components/ProjectSelector.tsx @@ -90,7 +90,11 @@ export function ProjectSelector({ placeholder="프로젝트 코드/이름 검색..." onValueChange={setSearchTerm} /> - <CommandList className="max-h-[300px]"> + <CommandList className="max-h-[300px]" onWheel={(e) => { + e.stopPropagation(); // 이벤트 전파 차단 + const target = e.currentTarget; + target.scrollTop += e.deltaY; // 직접 스크롤 처리 + }}> <CommandEmpty>검색 결과가 없습니다</CommandEmpty> {isLoading ? ( <div className="py-6 text-center text-sm">로딩 중...</div> diff --git a/components/form-data/add-formTag-dialog.tsx b/components/form-data/add-formTag-dialog.tsx index 2cd336a0..9d80de8c 100644 --- a/components/form-data/add-formTag-dialog.tsx +++ b/components/form-data/add-formTag-dialog.tsx @@ -1,7 +1,7 @@ "use client" import * as React from "react" -import { useRouter } from "next/navigation" +import { useParams, useRouter } from "next/navigation"; import { useForm, useFieldArray } from "react-hook-form" import { toast } from "sonner" import { Loader2, ChevronsUpDown, Check, Plus, Trash2, Copy } from "lucide-react" @@ -57,6 +57,7 @@ import { getTagTypeByDescription, getSubfieldsByTagTypeForForm } from "@/lib/forms/services" +import { useTranslation } from "@/i18n/client"; // Form-specific tag mapping interface interface FormTagMapping { @@ -107,6 +108,9 @@ export function AddFormTagDialog({ onOpenChange: externalOnOpenChange }: AddFormTagDialogProps) { const router = useRouter() + const params = useParams(); + const lng = (params?.lng as string) || "ko"; + const { t } = useTranslation(lng, "engineering"); // Use external control if provided, otherwise use internal state const [internalOpen, setInternalOpen] = React.useState(false); @@ -439,7 +443,7 @@ export function AddFormTagDialog({ // --------------- // Render Class field // --------------- - function renderClassField(field: any) { + function renderClassField(field: any) { const [popoverOpen, setPopoverOpen] = React.useState(false) const buttonId = React.useMemo( @@ -460,7 +464,7 @@ export function AddFormTagDialog({ return ( <FormItem className="w-1/2"> - <FormLabel>Class</FormLabel> + <FormLabel>{t("labels.class")}</FormLabel> <FormControl> <Popover open={popoverOpen} onOpenChange={setPopoverOpen}> <PopoverTrigger asChild> @@ -473,13 +477,13 @@ export function AddFormTagDialog({ > {isLoadingClasses ? ( <> - <span>클래스 로딩 중...</span> + <span>{t("messages.loadingClasses")}</span> <Loader2 className="ml-2 h-4 w-4 animate-spin" /> </> ) : ( <> <span className="truncate mr-1 flex-grow text-left"> - {field.value || "클래스 선택..."} + {field.value || t("placeholders.selectClass")} </span> <ChevronsUpDown className="h-4 w-4 opacity-50 flex-shrink-0" /> </> @@ -490,10 +494,10 @@ export function AddFormTagDialog({ <Command key={commandId}> <CommandInput key={`${commandId}-input`} - placeholder="클래스 검색..." + placeholder={t("placeholders.searchClass")} /> <CommandList key={`${commandId}-list`} className="max-h-[300px]"> - <CommandEmpty key={`${commandId}-empty`}>검색 결과가 없습니다.</CommandEmpty> + <CommandEmpty key={`${commandId}-empty`}>{t("messages.noSearchResults")}</CommandEmpty> <CommandGroup key={`${commandId}-group`}> {classOptions.map((className, optIndex) => { if (!classOptionIdsRef.current[className]) { @@ -553,7 +557,7 @@ export function AddFormTagDialog({ return ( <FormItem className="w-1/2"> - <FormLabel>Tag Type</FormLabel> + <FormLabel>{t("labels.tagType")}</FormLabel> <FormControl> {isReadOnly ? ( <div className="relative"> @@ -569,7 +573,7 @@ export function AddFormTagDialog({ key={`tag-type-placeholder-${inputId}`} {...field} readOnly - placeholder="클래스 선택시 자동으로 결정됩니다" + placeholder={t("placeholders.autoSetByClass")} className="h-9 bg-muted" /> )} @@ -587,7 +591,7 @@ export function AddFormTagDialog({ return ( <div className="flex justify-center items-center py-8"> <Loader2 className="h-8 w-8 animate-spin text-primary" /> - <div className="ml-3 text-muted-foreground">필드 로딩 중...</div> + <div className="ml-3 text-muted-foreground">{t("messages.loadingFields")}</div> </div> ) } @@ -595,7 +599,7 @@ export function AddFormTagDialog({ if (subFields.length === 0 && selectedTagTypeCode) { return ( <div className="py-4 text-center text-muted-foreground"> - 이 태그 유형에 대한 필드가 없습니다. + {t("messages.noFieldsForTagType")} </div> ) } @@ -603,7 +607,7 @@ export function AddFormTagDialog({ if (subFields.length === 0) { return ( <div className="py-4 text-center text-muted-foreground"> - 태그 데이터를 입력하려면 먼저 상단에서 클래스를 선택하세요. + {t("messages.selectClassFirst")} </div> ) } @@ -612,10 +616,10 @@ export function AddFormTagDialog({ <div className="space-y-4"> {/* 헤더 */} <div className="flex justify-between items-center"> - <h3 className="text-sm font-medium">태그 항목 ({fields.length}개)</h3> + <h3 className="text-sm font-medium">{t("sections.tagItems")} ({fields.length}개)</h3> {!areAllTagNosValid && ( <Badge variant="destructive" className="ml-2"> - 유효하지 않은 태그 존재 + {t("messages.invalidTagsExist")} </Badge> )} </div> @@ -628,10 +632,10 @@ export function AddFormTagDialog({ <TableRow> <TableHead className="w-10 text-center">#</TableHead> <TableHead className="w-[120px]"> - <div className="font-medium">Tag No</div> + <div className="font-medium">{t("labels.tagNo")}</div> </TableHead> <TableHead className="w-[180px]"> - <div className="font-medium">Description</div> + <div className="font-medium">{t("labels.description")}</div> </TableHead> {/* Subfields */} @@ -653,7 +657,7 @@ export function AddFormTagDialog({ </TableHead> ))} - <TableHead className="w-[100px] text-center sticky right-0 bg-muted">Actions</TableHead> + <TableHead className="w-[100px] text-center sticky right-0 bg-muted">{t("labels.actions")}</TableHead> </TableRow> </TableHeader> @@ -711,7 +715,7 @@ export function AddFormTagDialog({ <Input {...field} className="h-8 w-full" - placeholder="항목 이름 입력" + placeholder={t("placeholders.enterDescription")} title={field.value || ""} /> </FormControl> @@ -793,7 +797,7 @@ export function AddFormTagDialog({ </Button> </TooltipTrigger> <TooltipContent side="left"> - <p>행 복제</p> + <p>{t("tooltips.duplicateRow")}</p> </TooltipContent> </Tooltip> </TooltipProvider> @@ -816,7 +820,7 @@ export function AddFormTagDialog({ </Button> </TooltipTrigger> <TooltipContent side="left"> - <p>행 삭제</p> + <p>{t("tooltips.deleteRow")}</p> </TooltipContent> </Tooltip> </TooltipProvider> @@ -837,7 +841,7 @@ export function AddFormTagDialog({ disabled={!selectedTagTypeCode || isLoadingSubFields} > <Plus className="h-4 w-4 mr-2" /> - 새 행 추가 + {t("buttons.addRow")} </Button> </div> </div> @@ -877,16 +881,16 @@ export function AddFormTagDialog({ <DialogTrigger asChild> <Button variant="outline" size="sm"> <Plus className="mr-2 size-4" /> - 태그 추가 + {t("buttons.addTags")} </Button> </DialogTrigger> )} <DialogContent className="max-h-[90vh] max-w-[95vw]" style={{ width: 1500 }}> <DialogHeader> - <DialogTitle>폼 태그 추가 - {formName || formCode}</DialogTitle> + <DialogTitle>{t("dialogs.addFormTag")} - {formName || formCode}</DialogTitle> <DialogDescription> - 클래스를 선택하여 태그 유형과 하위 필드를 로드한 다음, 여러 행을 추가하여 여러 태그를 생성하세요. + {t("dialogs.selectClassToLoadFields")} </DialogDescription> </DialogHeader> @@ -934,7 +938,7 @@ export function AddFormTagDialog({ }} disabled={isSubmitting} > - 취소 + {t("buttons.cancel")} </Button> <Button type="submit" @@ -943,10 +947,10 @@ export function AddFormTagDialog({ {isSubmitting ? ( <> <Loader2 className="mr-2 h-4 w-4 animate-spin" /> - 처리 중... + {t("messages.processing")} </> ) : ( - `${fields.length}개 태그 생성` + `${fields.length} ${t("buttons.create")}` )} </Button> </div> diff --git a/components/form-data/delete-form-data-dialog.tsx b/components/form-data/delete-form-data-dialog.tsx index ca2f8729..9298b43b 100644 --- a/components/form-data/delete-form-data-dialog.tsx +++ b/components/form-data/delete-form-data-dialog.tsx @@ -3,6 +3,8 @@ import * as React from "react" import { Loader, Trash } from "lucide-react" import { toast } from "sonner" +import { useParams } from "next/navigation" +import { useTranslation } from "@/i18n/client" import { useMediaQuery } from "@/hooks/use-media-query" import { Button } from "@/components/ui/button" @@ -55,22 +57,26 @@ export function DeleteFormDataDialog({ }: DeleteFormDataDialogProps) { const [isDeletePending, startDeleteTransition] = React.useTransition() const isDesktop = useMediaQuery("(min-width: 640px)") + + const params = useParams(); + const lng = (params?.lng as string) || "ko"; + const { t } = useTranslation(lng, "engineering"); // TAG_NO가 있는 항목들만 필터링 - const validItems = formData.filter(item => item.TAG_NO?.trim()) - const tagNos = validItems.map(item => item.TAG_NO).filter(Boolean) as string[] + const validItems = formData.filter(item => item.TAG_IDX?.trim()) + const tagIdxs = validItems.map(item => item.TAG_IDX).filter(Boolean) as string[] function onDelete() { startDeleteTransition(async () => { - if (tagNos.length === 0) { - toast.error("No valid items to delete") + if (tagIdxs.length === 0) { + toast.error(t("delete.noValidItems")) return } const result = await deleteFormDataByTags({ formCode, contractItemId, - tagNos, + tagIdxs, }) if (result.error) { @@ -88,12 +94,15 @@ export function DeleteFormDataDialog({ // 데이터 불일치 경고 console.warn(`Data inconsistency: FormEntries deleted: ${deletedCount}, Tags deleted: ${deletedTagsCount}`) toast.error( - `Deleted ${deletedCount} form entries and ${deletedTagsCount} tags (data inconsistency detected)` + t("delete.dataInconsistency", { deletedCount, deletedTagsCount }) ) } else { // 정상적인 삭제 완료 toast.success( - `Successfully deleted ${deletedCount} item${deletedCount === 1 ? "" : "s"}` + t("delete.successMessage", { + count: deletedCount, + items: deletedCount === 1 ? t("delete.item") : t("delete.items") + }) ) } @@ -101,7 +110,7 @@ export function DeleteFormDataDialog({ }) } - const itemCount = tagNos.length + const itemCount = tagIdxs.length const hasValidItems = itemCount > 0 if (isDesktop) { @@ -115,24 +124,25 @@ export function DeleteFormDataDialog({ disabled={!hasValidItems} > <Trash className="mr-2 size-4" aria-hidden="true" /> - Delete ({itemCount}) + {t("buttons.delete")} ({itemCount}) </Button> </DialogTrigger> ) : null} <DialogContent> <DialogHeader> - <DialogTitle>Are you absolutely sure?</DialogTitle> + <DialogTitle>{t("delete.confirmTitle")}</DialogTitle> <DialogDescription> - This action cannot be undone. This will permanently delete{" "} - <span className="font-medium">{itemCount}</span> - {itemCount === 1 ? " item" : " items"} and related tag records from the database. + {t("delete.confirmDescription", { + count: itemCount, + items: itemCount === 1 ? t("delete.item") : t("delete.items") + })} {itemCount > 0 && ( <> <br /> <br /> <span className="text-sm text-muted-foreground"> - TAG_NO(s): {tagNos.slice(0, 3).join(", ")} - {tagNos.length > 3 && ` and ${tagNos.length - 3} more...`} + {t("delete.tagNumbers")}: {tagIdxs.slice(0, 3).join(", ")} + {tagIdxs.length > 3 && t("delete.andMore", { count: tagIdxs.length - 3 })} </span> </> )} @@ -140,10 +150,10 @@ export function DeleteFormDataDialog({ </DialogHeader> <DialogFooter className="gap-2 sm:space-x-0"> <DialogClose asChild> - <Button variant="outline">Cancel</Button> + <Button variant="outline">{t("buttons.cancel")}</Button> </DialogClose> <Button - aria-label="Delete selected entries" + aria-label={t("delete.deleteButtonLabel")} variant="destructive" onClick={onDelete} disabled={isDeletePending || !hasValidItems} @@ -154,7 +164,7 @@ export function DeleteFormDataDialog({ aria-hidden="true" /> )} - Delete + {t("buttons.delete")} </Button> </DialogFooter> </DialogContent> @@ -172,24 +182,25 @@ export function DeleteFormDataDialog({ disabled={!hasValidItems} > <Trash className="mr-2 size-4" aria-hidden="true" /> - Delete ({itemCount}) + {t("buttons.delete")} ({itemCount}) </Button> </DrawerTrigger> ) : null} <DrawerContent> <DrawerHeader> - <DrawerTitle>Are you absolutely sure?</DrawerTitle> + <DrawerTitle>{t("delete.confirmTitle")}</DrawerTitle> <DrawerDescription> - This action cannot be undone. This will permanently delete{" "} - <span className="font-medium">{itemCount}</span> - {itemCount === 1 ? " item" : " items"} and related tag records from the database. + {t("delete.confirmDescription", { + count: itemCount, + items: itemCount === 1 ? t("delete.item") : t("delete.items") + })} {itemCount > 0 && ( <> <br /> <br /> <span className="text-sm text-muted-foreground"> - TAG_NO(s): {tagNos.slice(0, 3).join(", ")} - {tagNos.length > 3 && ` and ${tagNos.length - 3} more...`} + {t("delete.tagNumbers")}: {tagIdxs.slice(0, 3).join(", ")} + {tagIdxs.length > 3 && t("delete.andMore", { count: tagIdxs.length - 3 })} </span> </> )} @@ -197,10 +208,10 @@ export function DeleteFormDataDialog({ </DrawerHeader> <DrawerFooter className="gap-2 sm:space-x-0"> <DrawerClose asChild> - <Button variant="outline">Cancel</Button> + <Button variant="outline">{t("buttons.cancel")}</Button> </DrawerClose> <Button - aria-label="Delete selected entries" + aria-label={t("delete.deleteButtonLabel")} variant="destructive" onClick={onDelete} disabled={isDeletePending || !hasValidItems} @@ -208,7 +219,7 @@ export function DeleteFormDataDialog({ {isDeletePending && ( <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> )} - Delete + {t("buttons.delete")} </Button> </DrawerFooter> </DrawerContent> diff --git a/components/form-data/form-data-report-batch-dialog.tsx b/components/form-data/form-data-report-batch-dialog.tsx index 53f8c489..fdd36c80 100644 --- a/components/form-data/form-data-report-batch-dialog.tsx +++ b/components/form-data/form-data-report-batch-dialog.tsx @@ -7,6 +7,8 @@ import React, { useState, useEffect, } from "react"; +import { useParams } from "next/navigation"; +import { useTranslation } from "@/i18n/client"; import { useToast } from "@/hooks/use-toast"; import { toast as toastMessage } from "sonner"; import prettyBytes from "pretty-bytes"; @@ -84,6 +86,10 @@ export const FormDataReportBatchDialog: FC<FormDataReportBatchDialogProps> = ({ formCode, }) => { const { toast } = useToast(); + const params = useParams(); + const lng = (params?.lng as string) || "ko"; + const { t } = useTranslation(lng, "engineering"); + const [tempList, setTempList] = useState<tempFile[]>([]); const [selectTemp, setSelectTemp] = useState<string>(""); const [selectedFiles, setSelectedFiles] = useState<File[]>([]); @@ -115,9 +121,9 @@ export const FormDataReportBatchDialog: FC<FormDataReportBatchDialogProps> = ({ fileRejections.forEach((rejection) => { toast({ variant: "destructive", - title: "File Error", + title: t("batchReport.fileError"), description: `${rejection.file.name}: ${ - rejection.errors[0]?.message || "Upload failed" + rejection.errors[0]?.message || t("batchReport.uploadFailed") }`, }); }); @@ -164,7 +170,7 @@ export const FormDataReportBatchDialog: FC<FormDataReportBatchDialogProps> = ({ if (requestCreateReport.ok) { const blob = await requestCreateReport.blob(); saveAs(blob, `${formCode}.pdf`); - toastMessage.success("Report 다운로드 완료!"); + toastMessage.success(t("batchReport.downloadComplete")); } else { const err = await requestCreateReport.json(); console.error("에러:", err); @@ -173,8 +179,8 @@ export const FormDataReportBatchDialog: FC<FormDataReportBatchDialogProps> = ({ } catch (err) { console.error(err); toast({ - title: "Error", - description: "Report 생성 중 오류가 발생했습니다.", + title: t("batchReport.error"), + description: t("batchReport.reportGenerationError"), variant: "destructive", }); } finally { @@ -219,7 +225,7 @@ export const FormDataReportBatchDialog: FC<FormDataReportBatchDialogProps> = ({ const blob = await requestCreateReport.blob(); setGeneratedFileBlob(blob); setPublishDialogOpen(true); - toastMessage.success("문서가 생성되었습니다. 발행 정보를 입력해주세요."); + toastMessage.success(t("batchReport.documentGenerated")); } else { const err = await requestCreateReport.json(); console.error("에러:", err); @@ -228,8 +234,8 @@ export const FormDataReportBatchDialog: FC<FormDataReportBatchDialogProps> = ({ } catch (err) { console.error(err); toast({ - title: "Error", - description: "문서 생성 중 오류가 발생했습니다.", + title: t("batchReport.error"), + description: t("batchReport.documentGenerationError"), variant: "destructive", }); } finally { @@ -242,17 +248,16 @@ export const FormDataReportBatchDialog: FC<FormDataReportBatchDialogProps> = ({ <Dialog open={open} onOpenChange={onClose}> <DialogContent className="w-[600px]" style={{ maxWidth: "none" }}> <DialogHeader> - <DialogTitle>Vendor Document Create</DialogTitle> + <DialogTitle>{t("batchReport.dialogTitle")}</DialogTitle> <DialogDescription> - Vendor Document Template을 선택하신 후 갑지를 업로드하여 주시기 - 바랍니다. + {t("batchReport.dialogDescription")} </DialogDescription> </DialogHeader> <div className="h-[60px]"> - <Label>Vendor Document Template Select</Label> + <Label>{t("batchReport.templateSelectLabel")}</Label> <Select value={selectTemp} onValueChange={setSelectTemp}> <SelectTrigger className="w-[100%]"> - <SelectValue placeholder="사용하시고자하는 Report Template를 선택하여 주시기 바랍니다." /> + <SelectValue placeholder={t("batchReport.templateSelectPlaceholder")} /> </SelectTrigger> <SelectContent> {tempList.map((c) => { @@ -268,7 +273,7 @@ export const FormDataReportBatchDialog: FC<FormDataReportBatchDialogProps> = ({ </Select> </div> <div> - <Label>Vendor Document Cover Page Upload(.docx)</Label> + <Label>{t("batchReport.coverPageUploadLabel")}</Label> <Dropzone maxSize={MAX_FILE_SIZE} multiple={false} @@ -284,16 +289,17 @@ export const FormDataReportBatchDialog: FC<FormDataReportBatchDialogProps> = ({ <div className="flex items-center gap-6"> <DropzoneUploadIcon /> <div className="grid gap-0.5"> - <DropzoneTitle>파일을 여기에 드롭하세요</DropzoneTitle> + <DropzoneTitle>{t("batchReport.dropFileHere")}</DropzoneTitle> <DropzoneDescription> - 또는 클릭하여 파일을 선택하세요. 최대 크기:{" "} - {maxSize ? prettyBytes(maxSize) : "무제한"} + {t("batchReport.orClickToSelect", { + maxSize: maxSize ? prettyBytes(maxSize) : t("batchReport.unlimited") + })} </DropzoneDescription> </div> </div> </DropzoneZone> <Label className="text-xs text-muted-foreground"> - 여러 파일을 선택할 수 있습니다. + {t("batchReport.multipleFilesAllowed")} </Label> </> )} @@ -304,15 +310,18 @@ export const FormDataReportBatchDialog: FC<FormDataReportBatchDialogProps> = ({ <div className="grid gap-2"> <div className="flex items-center justify-between"> <h6 className="text-sm font-semibold"> - 선택된 파일 ({selectedFiles.length}) + {t("batchReport.selectedFiles", { count: selectedFiles.length })} </h6> - <Badge variant="secondary">{selectedFiles.length}개 파일</Badge> + <Badge variant="secondary"> + {t("batchReport.fileCount", { count: selectedFiles.length })} + </Badge> </div> <ScrollArea> <UploadFileItem selectedFiles={selectedFiles} removeFile={removeFile} isUploading={isUploading} + t={t} /> </ScrollArea> </div> @@ -331,7 +340,7 @@ export const FormDataReportBatchDialog: FC<FormDataReportBatchDialogProps> = ({ className="mr-2" > {isUploading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - Publish + {t("batchReport.publish")} </Button> <Button disabled={ @@ -342,7 +351,7 @@ export const FormDataReportBatchDialog: FC<FormDataReportBatchDialogProps> = ({ onClick={submitData} > {isUploading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} - Create Vendor Document + {t("batchReport.createDocument")} </Button> </DialogFooter> </DialogContent> @@ -364,12 +373,14 @@ interface UploadFileItemProps { selectedFiles: File[]; removeFile: (index: number) => void; isUploading: boolean; + t: (key: string, options?: any) => string; } const UploadFileItem: FC<UploadFileItemProps> = ({ selectedFiles, removeFile, isUploading, + t, }) => { return ( <FileList className="max-h-[200px] gap-3"> @@ -388,7 +399,7 @@ const UploadFileItem: FC<UploadFileItemProps> = ({ disabled={isUploading} > <X className="h-4 w-4" /> - <span className="sr-only">Remove</span> + <span className="sr-only">{t("batchReport.remove")}</span> </FileListAction> </FileListHeader> </FileListItem> diff --git a/components/form-data/form-data-report-dialog.tsx b/components/form-data/form-data-report-dialog.tsx index 3cfbbeb3..c990f019 100644 --- a/components/form-data/form-data-report-dialog.tsx +++ b/components/form-data/form-data-report-dialog.tsx @@ -8,6 +8,8 @@ import React, { useEffect, useRef, } from "react"; +import { useParams } from "next/navigation"; +import { useTranslation } from "@/i18n/client"; import { WebViewerInstance } from "@pdftron/webviewer"; import { Loader2 } from "lucide-react"; import { saveAs } from "file-saver"; @@ -60,6 +62,9 @@ export const FormDataReportDialog: FC<FormDataReportDialogProps> = ({ formId, formCode, }) => { + const params = useParams(); + const lng = (params?.lng as string) || "ko"; + const { t } = useTranslation(lng, "engineering"); const [tempList, setTempList] = useState<tempFile[]>([]); const [selectTemp, setSelectTemp] = useState<string>(""); @@ -100,7 +105,7 @@ export const FormDataReportDialog: FC<FormDataReportDialogProps> = ({ saveAs(new Blob([fileData]), fileName); - toast.success("Report 다운로드 완료!"); + toast.success(t("singleReport.downloadComplete")); } }; @@ -120,7 +125,7 @@ export const FormDataReportDialog: FC<FormDataReportDialogProps> = ({ setPublishDialogOpen(true); } catch (error) { console.error("Error preparing file for publishing:", error); - toast.error("Failed to prepare document for publishing"); + toast.error(t("singleReport.publishPreparationFailed")); } } }; @@ -130,20 +135,20 @@ export const FormDataReportDialog: FC<FormDataReportDialogProps> = ({ <Dialog open={reportData.length > 0} onOpenChange={onClose}> <DialogContent className="w-[70vw]" style={{ maxWidth: "none" }}> <DialogHeader> - <DialogTitle>Create Vendor Document</DialogTitle> + <DialogTitle>{t("singleReport.dialogTitle")}</DialogTitle> <DialogDescription> - 사용하시고자 하는 Vendor Document Template를 선택하여 주시기 바랍니다. + {t("singleReport.dialogDescription")} </DialogDescription> </DialogHeader> <div className="h-[60px]"> - <Label>Vendor Document Template Select</Label> + <Label>{t("singleReport.templateSelectLabel")}</Label> <Select value={selectTemp} onValueChange={setSelectTemp} disabled={instance === null} > <SelectTrigger className="w-[100%]"> - <SelectValue placeholder="사용하시고자하는 Vendor Document Template을 선택하여 주시기 바랍니다." /> + <SelectValue placeholder={t("singleReport.templateSelectPlaceholder")} /> </SelectTrigger> <SelectContent> {tempList.map((c) => { @@ -167,6 +172,7 @@ export const FormDataReportDialog: FC<FormDataReportDialogProps> = ({ setInstance={setInstance} setFileLoading={setFileLoading} formCode={formCode} + t={t} /> </div> @@ -178,10 +184,10 @@ export const FormDataReportDialog: FC<FormDataReportDialogProps> = ({ variant="outline" className="mr-2" > - Publish + {t("singleReport.publish")} </Button> <Button onClick={downloadFileData} disabled={selectTemp.length === 0}> - Create Vendor Document + {t("singleReport.createDocument")} </Button> </DialogFooter> </DialogContent> @@ -208,6 +214,7 @@ interface ReportWebViewerProps { setInstance: Dispatch<SetStateAction<WebViewerInstance | null>>; setFileLoading: Dispatch<SetStateAction<boolean>>; formCode: string; + t: (key: string, options?: any) => string; } const ReportWebViewer: FC<ReportWebViewerProps> = ({ @@ -218,6 +225,7 @@ const ReportWebViewer: FC<ReportWebViewerProps> = ({ setInstance, setFileLoading, formCode, + t, }) => { const [viwerLoading, setViewerLoading] = useState<boolean>(true); const viewer = useRef<HTMLDivElement>(null); @@ -280,7 +288,7 @@ const ReportWebViewer: FC<ReportWebViewerProps> = ({ {viwerLoading && ( <div className="flex flex-col items-center justify-center py-12"> <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" /> - <p className="text-sm text-muted-foreground">문서 뷰어 로딩 중...</p> + <p className="text-sm text-muted-foreground">{t("singleReport.documentViewerLoading")}</p> </div> )} </div> diff --git a/components/form-data/form-data-report-temp-upload-dialog.tsx b/components/form-data/form-data-report-temp-upload-dialog.tsx index 78663d64..59ea6ade 100644 --- a/components/form-data/form-data-report-temp-upload-dialog.tsx +++ b/components/form-data/form-data-report-temp-upload-dialog.tsx @@ -1,6 +1,8 @@ "use client"; import React, { FC, Dispatch, SetStateAction, useState } from "react"; +import { useParams } from "next/navigation"; +import { useTranslation } from "@/i18n/client"; import { Dialog, DialogContent, @@ -38,60 +40,62 @@ export const FormDataReportTempUploadDialog: FC< formCode, uploaderType, }) => { - const [tabValue, setTabValue] = useState<"upload" | "uploaded">("upload"); + const params = useParams(); + const lng = (params?.lng as string) || "ko"; + const { t } = useTranslation(lng, "engineering"); - return ( - <Dialog open={open} onOpenChange={setOpen}> - <DialogContent className="w-[600px]" style={{ maxWidth: "none" }}> - <DialogHeader className="gap-2"> - <DialogTitle>Vendor Document Template</DialogTitle> - <DialogDescription className="flex justify-around gap-[16px] "> - {/* 사용하시고자 하는 Vendor Document Template(.docx)를 업로드 - 하여주시기 바랍니다. */} - <FileActionsDropdown - filePath={"/vendorFormReportSample/sample_template_file.docx"} - fileName={"sample_template_file.docx"} - variant="ghost" - size="icon" - description="Sample File" - /> - <VarListDownloadBtn columnsJSON={columnsJSON} formCode={formCode} /> - </DialogDescription> - </DialogHeader> - <Tabs value={tabValue}> - <div className="flex justify-between items-center"> - <TabsList className="w-full"> - <TabsTrigger - value="upload" - onClick={() => setTabValue("upload")} - className="flex-1" - > - Upload Template File - </TabsTrigger> - <TabsTrigger - value="uploaded" - onClick={() => setTabValue("uploaded")} - className="flex-1" - > - Uploaded Template File List - </TabsTrigger> - </TabsList> - </div> - <TabsContent value="upload"> - <FormDataReportTempUploadTab - packageId={packageId} - formId={formId} - uploaderType={uploaderType} - /> - </TabsContent> - <TabsContent value="uploaded"> - <FormDataReportTempUploadedListTab - packageId={packageId} - formId={formId} - /> - </TabsContent> - </Tabs> - </DialogContent> - </Dialog> - ); - };
\ No newline at end of file + const [tabValue, setTabValue] = useState<"upload" | "uploaded">("upload"); + + return ( + <Dialog open={open} onOpenChange={setOpen}> + <DialogContent className="w-[600px]" style={{ maxWidth: "none" }}> + <DialogHeader className="gap-2"> + <DialogTitle>{t("templateUpload.dialogTitle")}</DialogTitle> + <DialogDescription className="flex justify-around gap-[16px] "> + <FileActionsDropdown + filePath={"/vendorFormReportSample/sample_template_file.docx"} + fileName={"sample_template_file.docx"} + variant="ghost" + size="icon" + description={t("templateUpload.sampleFile")} + /> + <VarListDownloadBtn columnsJSON={columnsJSON} formCode={formCode} /> + </DialogDescription> + </DialogHeader> + <Tabs value={tabValue}> + <div className="flex justify-between items-center"> + <TabsList className="w-full"> + <TabsTrigger + value="upload" + onClick={() => setTabValue("upload")} + className="flex-1" + > + {t("templateUpload.uploadTab")} + </TabsTrigger> + <TabsTrigger + value="uploaded" + onClick={() => setTabValue("uploaded")} + className="flex-1" + > + {t("templateUpload.uploadedListTab")} + </TabsTrigger> + </TabsList> + </div> + <TabsContent value="upload"> + <FormDataReportTempUploadTab + packageId={packageId} + formId={formId} + uploaderType={uploaderType} + /> + </TabsContent> + <TabsContent value="uploaded"> + <FormDataReportTempUploadedListTab + packageId={packageId} + formId={formId} + /> + </TabsContent> + </Tabs> + </DialogContent> + </Dialog> + ); +};
\ No newline at end of file diff --git a/components/form-data/form-data-report-temp-upload-tab.tsx b/components/form-data/form-data-report-temp-upload-tab.tsx index 32161e49..39d895a1 100644 --- a/components/form-data/form-data-report-temp-upload-tab.tsx +++ b/components/form-data/form-data-report-temp-upload-tab.tsx @@ -1,6 +1,8 @@ "use client"; import React, { FC, useState } from "react"; +import { useParams } from "next/navigation"; +import { useTranslation } from "@/i18n/client"; import { useToast } from "@/hooks/use-toast"; import { toast as toastMessage } from "sonner"; import prettyBytes from "pretty-bytes"; @@ -43,6 +45,10 @@ export const FormDataReportTempUploadTab: FC< FormDataReportTempUploadTabProps > = ({ packageId, formId, uploaderType }) => { const { toast } = useToast(); + const params = useParams(); + const lng = (params?.lng as string) || "ko"; + const { t } = useTranslation(lng, "engineering"); + const [selectedFiles, setSelectedFiles] = useState<File[]>([]); const [isUploading, setIsUploading] = useState(false); const [uploadProgress, setUploadProgress] = useState(0); @@ -58,9 +64,9 @@ export const FormDataReportTempUploadTab: FC< fileRejections.forEach((rejection) => { toast({ variant: "destructive", - title: "File Error", + title: t("templateUploadTab.fileError"), description: `${rejection.file.name}: ${ - rejection.errors[0]?.message || "Upload failed" + rejection.errors[0]?.message || t("templateUploadTab.uploadFailed") }`, }); }); @@ -93,12 +99,12 @@ export const FormDataReportTempUploadTab: FC< successCount++; setUploadProgress(Math.round((successCount / totalFiles) * 100)); } - toastMessage.success("Template File 업로드 완료!"); + toastMessage.success(t("templateUploadTab.uploadComplete")); } catch (err) { console.error(err); toast({ - title: "Error", - description: "파일 업로드 중 오류가 발생했습니다.", + title: t("templateUploadTab.error"), + description: t("templateUploadTab.uploadError"), variant: "destructive", }); } finally { @@ -111,7 +117,7 @@ export const FormDataReportTempUploadTab: FC< return ( <div className='flex flex-col gap-4'> <div> - <Label>Vendor Document Template File Upload(.docx)</Label> + <Label>{t("templateUploadTab.uploadLabel")}</Label> <Dropzone maxSize={MAX_FILE_SIZE} multiple={true} @@ -127,16 +133,17 @@ export const FormDataReportTempUploadTab: FC< <div className="flex items-center gap-6"> <DropzoneUploadIcon /> <div className="grid gap-0.5"> - <DropzoneTitle>파일을 여기에 드롭하세요</DropzoneTitle> + <DropzoneTitle>{t("templateUploadTab.dropFileHere")}</DropzoneTitle> <DropzoneDescription> - 또는 클릭하여 파일을 선택하세요. 최대 크기:{" "} - {maxSize ? prettyBytes(maxSize) : "무제한"} + {t("templateUploadTab.orClickToSelect", { + maxSize: maxSize ? prettyBytes(maxSize) : t("templateUploadTab.unlimited") + })} </DropzoneDescription> </div> </div> </DropzoneZone> <Label className="text-xs text-muted-foreground"> - 여러 파일을 선택할 수 있습니다. + {t("templateUploadTab.multipleFilesAllowed")} </Label> </> )} @@ -147,24 +154,27 @@ export const FormDataReportTempUploadTab: FC< <div className="grid gap-2"> <div className="flex items-center justify-between"> <h6 className="text-sm font-semibold"> - 선택된 파일 ({selectedFiles.length}) + {t("templateUploadTab.selectedFiles", { count: selectedFiles.length })} </h6> - <Badge variant="secondary">{selectedFiles.length}개 파일</Badge> + <Badge variant="secondary"> + {t("templateUploadTab.fileCount", { count: selectedFiles.length })} + </Badge> </div> <ScrollArea> <UploadFileItem selectedFiles={selectedFiles} removeFile={removeFile} isUploading={isUploading} + t={t} /> </ScrollArea> </div> )} - {isUploading && <UploadProgressBox uploadProgress={uploadProgress} />} + {isUploading && <UploadProgressBox uploadProgress={uploadProgress} t={t} />} <DialogFooter> <Button disabled={selectedFiles.length === 0} onClick={submitData}> - 업로드 + {t("templateUploadTab.upload")} </Button> </DialogFooter> </div> @@ -175,12 +185,14 @@ interface UploadFileItemProps { selectedFiles: File[]; removeFile: (index: number) => void; isUploading: boolean; + t: (key: string, options?: any) => string; } const UploadFileItem: FC<UploadFileItemProps> = ({ selectedFiles, removeFile, isUploading, + t, }) => { return ( <FileList className="max-h-[150px] gap-3"> @@ -199,7 +211,7 @@ const UploadFileItem: FC<UploadFileItemProps> = ({ disabled={isUploading} > <X className="h-4 w-4" /> - <span className="sr-only">Remove</span> + <span className="sr-only">{t("templateUploadTab.remove")}</span> </FileListAction> </FileListHeader> </FileListItem> @@ -208,14 +220,17 @@ const UploadFileItem: FC<UploadFileItemProps> = ({ ); }; -const UploadProgressBox: FC<{ uploadProgress: number }> = ({ - uploadProgress, -}) => { +const UploadProgressBox: FC<{ + uploadProgress: number; + t: (key: string, options?: any) => string; +}> = ({ uploadProgress, t }) => { return ( <div className="flex flex-col gap-1 mt-2"> <div className="flex items-center gap-2"> <Loader2 className="h-4 w-4 animate-spin" /> - <span className="text-sm">{uploadProgress}% 업로드 중...</span> + <span className="text-sm"> + {t("templateUploadTab.uploadingProgress", { progress: uploadProgress })} + </span> </div> <div className="h-2 w-full bg-muted rounded-full overflow-hidden"> <div diff --git a/components/form-data/form-data-report-temp-uploaded-list-tab.tsx b/components/form-data/form-data-report-temp-uploaded-list-tab.tsx index a5c3c7a5..7bd5eeef 100644 --- a/components/form-data/form-data-report-temp-uploaded-list-tab.tsx +++ b/components/form-data/form-data-report-temp-uploaded-list-tab.tsx @@ -7,6 +7,8 @@ import React, { useState, useEffect, } from "react"; +import { useParams } from "next/navigation"; +import { useTranslation } from "@/i18n/client"; import { useToast } from "@/hooks/use-toast"; import { toast as toastMessage } from "sonner"; import { Download, Trash2 } from "lucide-react"; @@ -44,6 +46,10 @@ interface FormDataReportTempUploadedListTabProps { export const FormDataReportTempUploadedListTab: FC< FormDataReportTempUploadedListTabProps > = ({ packageId, formId }) => { + const params = useParams(); + const lng = (params?.lng as string) || "ko"; + const { t } = useTranslation(lng, "engineering"); + const [prevReportTemp, setPrevReportTemp] = useState<VendorDataReportTemps[]>( [] ); @@ -60,13 +66,14 @@ export const FormDataReportTempUploadedListTab: FC< return ( <div> - <Label>Uploaded Template File List</Label> + <Label>{t("templateUploadedList.listLabel")}</Label> <UploadedTempFiles prevReportTemp={prevReportTemp} updateReportTempList={() => updateReportTempList(packageId, formId, setPrevReportTemp) } isLoading={isLoading} + t={t} /> </div> ); @@ -91,12 +98,14 @@ interface UploadedTempFiles { prevReportTemp: VendorDataReportTemps[]; updateReportTempList: () => void; isLoading: boolean; + t: (key: string, options?: any) => string; } const UploadedTempFiles: FC<UploadedTempFiles> = ({ prevReportTemp, updateReportTempList, isLoading, + t, }) => { const { toast } = useToast(); @@ -109,19 +118,17 @@ const UploadedTempFiles: FC<UploadedTempFiles> = ({ saveAs(blob, fileName); - toastMessage.success("Report 다운로드 완료!"); + toastMessage.success(t("templateUploadedList.downloadComplete")); } else { const err = await getTempFile.json(); console.error("에러:", err); throw new Error(err.message); } - - toastMessage.success("Template File 다운로드 완료!"); } catch (err) { console.error(err); toast({ - title: "Error", - description: "Template File 다운로드 중 오류가 발생했습니다.", + title: t("templateUploadedList.error"), + description: t("templateUploadedList.downloadError"), variant: "destructive", }); } @@ -133,14 +140,14 @@ const UploadedTempFiles: FC<UploadedTempFiles> = ({ if (result) { updateReportTempList(); - toastMessage.success("Template File 삭제 완료!"); + toastMessage.success(t("templateUploadedList.deleteComplete")); } else { throw new Error(error); } } catch (err) { toast({ - title: "Error", - description: "Template File 삭제 중 오류가 발생했습니다.", + title: t("templateUploadedList.error"), + description: t("templateUploadedList.deleteError"), variant: "destructive", }); } @@ -149,7 +156,7 @@ const UploadedTempFiles: FC<UploadedTempFiles> = ({ if (isLoading) { return ( <div className="min-h-[157px]"> - <Label>로딩 중...</Label> + <Label>{t("templateUploadedList.loading")}</Label> </div> ); } @@ -174,29 +181,29 @@ const UploadedTempFiles: FC<UploadedTempFiles> = ({ }} > <Download className="h-4 w-4" /> - <span className="sr-only">Download</span> + <span className="sr-only">{t("templateUploadedList.download")}</span> </FileListAction> <AlertDialogTrigger asChild> <FileListAction> <Trash2 className="h-4 w-4" /> - <span className="sr-only">Delete</span> + <span className="sr-only">{t("templateUploadedList.delete")}</span> </FileListAction> </AlertDialogTrigger> <AlertDialogContent> <AlertDialogHeader> <AlertDialogTitle> - Report Templete File({fileName})을 삭제하시겠습니까? + {t("templateUploadedList.deleteConfirmTitle", { fileName })} </AlertDialogTitle> <AlertDialogDescription /> </AlertDialogHeader> <AlertDialogFooter> - <AlertDialogCancel>취소</AlertDialogCancel> + <AlertDialogCancel>{t("templateUploadedList.cancel")}</AlertDialogCancel> <AlertDialogAction onClick={() => { deleteTempFile(id); }} > - 삭제 + {t("templateUploadedList.delete")} </AlertDialogAction> </AlertDialogFooter> </AlertDialogContent> diff --git a/components/form-data/form-data-table-columns.tsx b/components/form-data/form-data-table-columns.tsx index 2a065d1b..2f623bdb 100644 --- a/components/form-data/form-data-table-columns.tsx +++ b/components/form-data/form-data-table-columns.tsx @@ -63,7 +63,93 @@ interface GetColumnsProps<TData> { // 체크박스 선택 관련 props selectedRows?: Record<string, boolean>; onRowSelectionChange?: (updater: Record<string, boolean> | ((prev: Record<string, boolean>) => Record<string, boolean>)) => void; - // editableFieldsMap 제거됨 + // 새로 추가: templateData + templateData?: any; +} + +/** + * 셀 주소(예: "A1", "B1", "AA1")에서 컬럼 순서를 추출하는 함수 + * A=0, B=1, C=2, ..., Z=25, AA=26, AB=27, ... + */ +function getColumnOrderFromCellAddress(cellAddress: string): number { + if (!cellAddress || typeof cellAddress !== 'string') { + return 999999; // 유효하지 않은 경우 맨 뒤로 + } + + // 셀 주소에서 알파벳 부분만 추출 (예: "A1" -> "A", "AA1" -> "AA") + const match = cellAddress.match(/^([A-Z]+)/); + if (!match) { + return 999999; + } + + const columnLetters = match[1]; + let result = 0; + + // 알파벳을 숫자로 변환 (26진법과 유사하지만 0이 없는 체계) + for (let i = 0; i < columnLetters.length; i++) { + const charCode = columnLetters.charCodeAt(i) - 65 + 1; // A=1, B=2, ..., Z=26 + result = result * 26 + charCode; + } + + return result - 1; // 0부터 시작하도록 조정 +} + +/** + * templateData에서 SPREAD_LIST의 컬럼 순서 정보를 추출하여 seq를 업데이트하는 함수 + */ +function updateSeqFromTemplate(columnsJSON: DataTableColumnJSON[], templateData: any): DataTableColumnJSON[] { + if (!templateData) { + return columnsJSON; // templateData가 없으면 원본 그대로 반환 + } + + // templateData가 배열인지 단일 객체인지 확인 + let templates: any[]; + if (Array.isArray(templateData)) { + templates = templateData; + } else { + templates = [templateData]; + } + + // SPREAD_LIST 타입의 템플릿 찾기 + const spreadListTemplate = templates.find(template => + template.TMPL_TYPE === 'SPREAD_LIST' && + template.SPR_LST_SETUP?.DATA_SHEETS + ); + + if (!spreadListTemplate) { + return columnsJSON; // SPREAD_LIST 템플릿이 없으면 원본 그대로 반환 + } + + // MAP_CELL_ATT에서 ATT_ID와 IN 매핑 정보 추출 + const cellMappings = new Map<string, string>(); // key: ATT_ID, value: IN (셀 주소) + + spreadListTemplate.SPR_LST_SETUP.DATA_SHEETS.forEach((dataSheet: any) => { + if (dataSheet.MAP_CELL_ATT) { + dataSheet.MAP_CELL_ATT.forEach((mapping: any) => { + if (mapping.ATT_ID && mapping.IN) { + cellMappings.set(mapping.ATT_ID, mapping.IN); + } + }); + } + }); + + // columnsJSON을 복사하여 seq 값 업데이트 + const updatedColumns = columnsJSON.map(column => { + const cellAddress = cellMappings.get(column.key); + if (cellAddress) { + // 셀 주소에서 컬럼 순서 추출 + const newSeq = getColumnOrderFromCellAddress(cellAddress); + console.log(`🔄 Updating seq for ${column.key}: ${column.seq} -> ${newSeq} (from ${cellAddress})`); + + return { + ...column, + seq: newSeq + }; + } + return column; // 매핑이 없으면 원본 그대로 + }); + + return updatedColumns; } /** @@ -271,12 +357,15 @@ export function getColumns<TData extends object>({ tempCount, selectedRows = {}, onRowSelectionChange, - // editableFieldsMap 매개변수 제거됨 + templateData, // 새로 추가된 매개변수 }: GetColumnsProps<TData>): ColumnDef<TData>[] { const columns: ColumnDef<TData>[] = []; - // (0) 컬럼 필터링 및 정렬 - const visibleColumns = columnsJSON + // (0) templateData에서 SPREAD_LIST인 경우 seq 값 업데이트 + const processedColumnsJSON = updateSeqFromTemplate(columnsJSON, templateData); + + // (1) 컬럼 필터링 및 정렬 + const visibleColumns = processedColumnsJSON .filter(col => col.hidden !== true) // hidden이 true가 아닌 것들만 .sort((a, b) => { // seq가 없는 경우 999999로 처리하여 맨 뒤로 보냄 @@ -285,7 +374,10 @@ export function getColumns<TData extends object>({ return seqA - seqB; }); - // (1) 체크박스 컬럼 (항상 표시) + console.log('📊 Final column order after template processing:', + visibleColumns.map(c => `${c.key}(seq:${c.seq})`)); + + // (2) 체크박스 컬럼 (항상 표시) const selectColumn: ColumnDef<TData> = { id: "select", header: ({ table }) => ( @@ -335,11 +427,11 @@ export function getColumns<TData extends object>({ }; columns.push(selectColumn); - // (2) 기본 컬럼들 (seq 순서를 유지하면서 head에 따라 그룹핑 처리) + // (3) 기본 컬럼들 (seq 순서를 유지하면서 head에 따라 그룹핑 처리) const groupedColumns = groupColumnsByHead(visibleColumns); columns.push(...groupedColumns); - // (3) 액션 칼럼 - update 버튼 예시 + // (4) 액션 칼럼 - update 버튼 예시 const actionColumn: ColumnDef<TData> = { id: "update", header: "", @@ -392,6 +484,6 @@ export function getColumns<TData extends object>({ columns.push(actionColumn); - // (4) 최종 반환 + // (5) 최종 반환 return columns; }
\ No newline at end of file diff --git a/components/form-data/form-data-table.tsx b/components/form-data/form-data-table.tsx index 9936e870..be37de7a 100644 --- a/components/form-data/form-data-table.tsx +++ b/components/form-data/form-data-table.tsx @@ -172,7 +172,7 @@ export default function DynamicTable({ const params = useParams(); const router = useRouter(); const lng = (params?.lng as string) || "ko"; - const { t } = useTranslation(lng, "translation"); + const { t } = useTranslation(lng, "engineering"); const [rowAction, setRowAction] = React.useState<DataTableRowAction<GenericData> | null>(null); @@ -719,7 +719,7 @@ export default function DynamicTable({ onClick={handleBatchDelete} > <Trash2 className="mr-2 size-4" /> - Delete ({selectedRowCount}) + {t("buttons.delete")} ({selectedRowCount}) </Button> )} @@ -733,7 +733,7 @@ export default function DynamicTable({ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> )} <TagsIcon className="size-4" /> - Tag Operations + {t("buttons.tagOperations")} </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> @@ -741,12 +741,12 @@ export default function DynamicTable({ {mode === "IM" ? ( <DropdownMenuItem onClick={handleSyncTags} disabled={isAnyOperationPending}> <Tag className="mr-2 h-4 w-4" /> - Sync Tags + {t("buttons.syncTags")} </DropdownMenuItem> ) : ( <DropdownMenuItem onClick={handleGetTags} disabled={isAnyOperationPending}> <RefreshCcw className="mr-2 h-4 w-4" /> - Get Tags + {t("buttons.getTags")} </DropdownMenuItem> )} <DropdownMenuItem @@ -754,7 +754,7 @@ export default function DynamicTable({ disabled={isAnyOperationPending || isAddTagDisabled} > <Plus className="mr-2 h-4 w-4" /> - Add Tags + {t("buttons.addTags")} </DropdownMenuItem> </DropdownMenuContent> </DropdownMenu> @@ -764,17 +764,17 @@ export default function DynamicTable({ <DropdownMenuTrigger asChild> <Button variant="outline" size="sm" disabled={isAnyOperationPending}> <Clipboard className="size-4" /> - Report Operations + {t("buttons.reportOperations")} </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end"> <DropdownMenuItem onClick={() => setTempUpDialog(true)} disabled={isAnyOperationPending}> <Upload className="mr-2 h-4 w-4" /> - Upload Template + {t("buttons.uploadTemplate")} </DropdownMenuItem> <DropdownMenuItem onClick={handleBatchDocument} disabled={isAnyOperationPending}> <FileOutput className="mr-2 h-4 w-4" /> - Batch Document + {t("buttons.batchDocument")} {selectedRowCount > 0 && ( <span className="ml-2 text-xs bg-blue-100 text-blue-700 px-2 py-1 rounded"> {selectedRowCount} @@ -792,7 +792,7 @@ export default function DynamicTable({ ) : ( <Upload className="size-4" /> )} - Import + {t("buttons.import")} <input type="file" accept=".xlsx,.xls" @@ -815,10 +815,10 @@ export default function DynamicTable({ ) : ( <Download className="mr-2 size-4" /> )} - Export + {t("buttons.export")} </Button> - {/* 새로 추가된 Template 보기 버튼 */} + {/* Template 보기 버튼 */} <Button variant="outline" size="sm" @@ -830,10 +830,9 @@ export default function DynamicTable({ ) : ( <Eye className="mr-2 size-4" /> )} - View Template + {t("buttons.viewTemplate")} </Button> - {/* COMPARE WITH SEDP 버튼 */} <Button variant="outline" @@ -842,7 +841,7 @@ export default function DynamicTable({ disabled={isAnyOperationPending} > <GitCompareIcon className="mr-2 size-4" /> - Compare with SEDP + {t("buttons.compareWithSEDP")} </Button> {/* SEDP 전송 버튼 */} @@ -855,12 +854,12 @@ export default function DynamicTable({ {isSendingSEDP ? ( <> <Loader className="mr-2 size-4 animate-spin" /> - SEDP 전송 중... + {t("messages.sendingSEDP")} </> ) : ( <> <Send className="size-4" /> - Send to SHI + {t("buttons.sendToSHI")} </> )} </Button> diff --git a/components/form-data/sedp-compare-dialog.tsx b/components/form-data/sedp-compare-dialog.tsx index 647f2810..1a9938bd 100644 --- a/components/form-data/sedp-compare-dialog.tsx +++ b/components/form-data/sedp-compare-dialog.tsx @@ -11,6 +11,8 @@ import { DataTableColumnJSON } from "./form-data-table-columns"; import { ExcelDownload } from "./sedp-excel-download"; import { Switch } from "../ui/switch"; import { Card, CardContent } from "@/components/ui/card"; +import { useTranslation } from "@/i18n/client" +import { useParams } from "next/navigation" interface SEDPCompareDialogProps { isOpen: boolean; @@ -56,85 +58,6 @@ const DisplayValue = ({ value, uom, isSedp = false }: { value: any; uom?: string ); }; -// 범례 컴포넌트 -const ColorLegend = () => { - return ( - <div className="flex items-center gap-4 text-sm p-2 bg-muted/20 rounded"> - <div className="flex items-center gap-1.5"> - <Info className="h-4 w-4 text-muted-foreground" /> - <span className="font-medium">범례:</span> - </div> - <div className="flex items-center gap-3"> - <div className="flex items-center gap-1.5"> - <div className="h-3 w-3 rounded-full bg-red-500"></div> - <span className="line-through text-red-500">로컬 값</span> - </div> - <div className="flex items-center gap-1.5"> - <div className="h-3 w-3 rounded-full bg-green-500"></div> - <span className="text-green-500">SEDP 값</span> - </div> - </div> - </div> - ); -}; - -// 확장 가능한 차이점 표시 컴포넌트 -const DifferencesCard = ({ - attributes, - columnLabelMap, - showOnlyDifferences -}: { - attributes: ComparisonResult['attributes']; - columnLabelMap: Record<string, string>; - showOnlyDifferences: boolean; -}) => { - const attributesToShow = showOnlyDifferences - ? attributes.filter(attr => !attr.isMatching) - : attributes; - - if (attributesToShow.length === 0) { - return ( - <div className="text-center text-muted-foreground py-4"> - 모든 속성이 일치합니다 - </div> - ); - } - - return ( - <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 p-4"> - {attributesToShow.map((attr) => ( - <Card key={attr.key} className={`${!attr.isMatching ? 'border-red-200' : 'border-green-200'}`}> - <CardContent className="p-3"> - <div className="font-medium text-sm mb-2 truncate" title={attr.label}> - {attr.label} - {attr.uom && <span className="text-xs text-muted-foreground ml-1">({attr.uom})</span>} - </div> - {attr.isMatching ? ( - <div className="text-sm"> - <DisplayValue value={attr.localValue} uom={attr.uom} /> - </div> - ) : ( - <div className="space-y-2"> - <div className="flex items-center gap-2 text-sm"> - <span className="text-xs text-muted-foreground min-w-[35px]">로컬:</span> - <span className="line-through text-red-500 flex-1"> - <DisplayValue value={attr.localValue} uom={attr.uom} isSedp={false} /> - </span> - </div> - <div className="flex items-center gap-2 text-sm"> - <span className="text-xs text-muted-foreground min-w-[35px]">SEDP:</span> - <span className="text-green-500 flex-1"> - <DisplayValue value={attr.sedpValue} uom={attr.uom} isSedp={true} /> - </span> - </div> - </div> - )} - </CardContent> - </Card> - ))} - </div> - ); -}; export function SEDPCompareDialog({ isOpen, @@ -145,6 +68,92 @@ export function SEDPCompareDialog({ formCode, fetchTagDataFromSEDP, }: SEDPCompareDialogProps) { + + const params = useParams() || {} + const lng = params.lng ? String(params.lng) : "ko" + const { t } = useTranslation(lng, "engineering") + + // 범례 컴포넌트 + const ColorLegend = () => { + return ( + <div className="flex items-center gap-4 text-sm p-2 bg-muted/20 rounded"> + <div className="flex items-center gap-1.5"> + <Info className="h-4 w-4 text-muted-foreground" /> + <span className="font-medium">{t("labels.legend")}:</span> + </div> + <div className="flex items-center gap-3"> + <div className="flex items-center gap-1.5"> + <div className="h-3 w-3 rounded-full bg-red-500"></div> + <span className="line-through text-red-500">{t("labels.localValue")}</span> + </div> + <div className="flex items-center gap-1.5"> + <div className="h-3 w-3 rounded-full bg-green-500"></div> + <span className="text-green-500">{t("labels.sedpValue")}</span> + </div> + </div> + </div> + ); + }; + + // 확장 가능한 차이점 표시 컴포넌트 + const DifferencesCard = ({ + attributes, + columnLabelMap, + showOnlyDifferences + }: { + attributes: ComparisonResult['attributes']; + columnLabelMap: Record<string, string>; + showOnlyDifferences: boolean; + }) => { + const attributesToShow = showOnlyDifferences + ? attributes.filter(attr => !attr.isMatching) + : attributes; + + if (attributesToShow.length === 0) { + return ( + <div className="text-center text-muted-foreground py-4"> + {t("messages.allAttributesMatch")} + </div> + ); + } + + return ( + <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 p-4"> + {attributesToShow.map((attr) => ( + <Card key={attr.key} className={`${!attr.isMatching ? 'border-red-200' : 'border-green-200'}`}> + <CardContent className="p-3"> + <div className="font-medium text-sm mb-2 truncate" title={attr.label}> + {attr.label} + {attr.uom && <span className="text-xs text-muted-foreground ml-1">({attr.uom})</span>} + </div> + {attr.isMatching ? ( + <div className="text-sm"> + <DisplayValue value={attr.localValue} uom={attr.uom} /> + </div> + ) : ( + <div className="space-y-2"> + <div className="flex items-center gap-2 text-sm"> + <span className="text-xs text-muted-foreground min-w-[35px]">로컬:</span> + <span className="line-through text-red-500 flex-1"> + <DisplayValue value={attr.localValue} uom={attr.uom} isSedp={false} /> + </span> + </div> + <div className="flex items-center gap-2 text-sm"> + <span className="text-xs text-muted-foreground min-w-[35px]">SEDP:</span> + <span className="text-green-500 flex-1"> + <DisplayValue value={attr.sedpValue} uom={attr.uom} isSedp={true} /> + </span> + </div> + </div> + )} + </CardContent> + </Card> + ))} + </div> + ); + }; + + const [isLoading, setIsLoading] = React.useState(false); const [comparisonResults, setComparisonResults] = React.useState<ComparisonResult[]>([]); const [activeTab, setActiveTab] = React.useState("all"); @@ -181,7 +190,7 @@ export function SEDPCompareDialog({ // Filter and search results const filteredResults = React.useMemo(() => { let results = comparisonResults; - + // Filter by tab switch (activeTab) { case "matching": @@ -198,8 +207,8 @@ export function SEDPCompareDialog({ // Apply search filter if (searchTerm.trim()) { const search = searchTerm.toLowerCase(); - results = results.filter(r => - r.tagNo.toLowerCase().includes(search) || + results = results.filter(r => + r.tagNo.toLowerCase().includes(search) || r.tagDesc.toLowerCase().includes(search) ); } @@ -291,7 +300,7 @@ export function SEDPCompareDialog({ // Compare attributes const attributeComparisons = columnsJSON - .filter(col => col.key !== "TAG_NO" && col.key !== "TAG_DESC"&& col.key !== "status") + .filter(col => col.key !== "TAG_NO" && col.key !== "TAG_DESC" && col.key !== "status") .map(col => { const localValue = localItem[col.key]; const sedpValue = sedpItem.attributes.get(col.key); @@ -370,7 +379,7 @@ export function SEDPCompareDialog({ <Dialog open={isOpen} onOpenChange={onClose}> <DialogContent className="max-w-6xl max-h-[90vh] overflow-hidden flex flex-col"> <DialogHeader> - <DialogTitle className="mb-2">SEDP 데이터 비교</DialogTitle> + <DialogTitle className="mb-2">{t("dialogs.sedpDataComparison")}</DialogTitle> <div className="flex items-center justify-between gap-2 pr-8"> <div className="flex items-center gap-4"> <div className="flex items-center gap-2"> @@ -380,22 +389,22 @@ export function SEDPCompareDialog({ id="show-differences" /> <label htmlFor="show-differences" className="text-sm cursor-pointer"> - 차이가 있는 항목만 표시 + {t("switches.showOnlyDifferences")} </label> </div> - + {/* 검색 입력 */} <div className="relative"> <Search className="absolute left-2 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Input - placeholder="태그 번호 또는 설명 검색..." + placeholder={t("placeholders.searchTagOrDesc")} value={searchTerm} onChange={(e) => setSearchTerm(e.target.value)} className="pl-8 w-64" /> </div> </div> - + <div className="flex items-center gap-2"> <Badge variant={matchingTags === totalTags && totalMissingTags === 0 ? "default" : "destructive"}> {matchingTags} / {totalTags} 일치 {totalMissingTags > 0 ? `(${totalMissingTags} 누락)` : ''} @@ -411,7 +420,7 @@ export function SEDPCompareDialog({ ) : ( <RefreshCw className="h-4 w-4" /> )} - <span className="ml-2">새로고침</span> + <span className="ml-2">{t("buttons.refresh")}</span> </Button> </div> </div> @@ -424,11 +433,11 @@ export function SEDPCompareDialog({ <Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col overflow-hidden"> <TabsList> - <TabsTrigger value="all">전체 태그 ({totalTags})</TabsTrigger> - <TabsTrigger value="differences">차이 있음 ({nonMatchingTags})</TabsTrigger> - <TabsTrigger value="matching">일치함 ({matchingTags})</TabsTrigger> + <TabsTrigger value="all">{t("tabs.allTags")} ({totalTags})</TabsTrigger> + <TabsTrigger value="differences">{t("tabs.differences")} ({nonMatchingTags})</TabsTrigger> + <TabsTrigger value="matching">{t("tabs.matching")} ({matchingTags})</TabsTrigger> <TabsTrigger value="missing" className={totalMissingTags > 0 ? "text-red-500" : ""}> - 누락된 태그 ({totalMissingTags}) + {t("tabs.missingTags")} ({totalMissingTags}) </TabsTrigger> </TabsList> @@ -436,19 +445,19 @@ export function SEDPCompareDialog({ {isLoading ? ( <div className="flex items-center justify-center h-full"> <Loader className="h-8 w-8 animate-spin mr-2" /> - <span>데이터 비교 중...</span> + <span>{t("messages.dataComparing")}</span> </div> ) : activeTab === "missing" ? ( // Missing tags tab content <div className="space-y-6"> {missingTags.localOnly.length > 0 && ( <div> - <h3 className="text-sm font-medium mb-2">로컬에만 있는 태그 ({missingTags.localOnly.length})</h3> + <h3 className="text-sm font-medium mb-2">{t("sections.localOnlyTags")} ({missingTags.localOnly.length})</h3> <Table> <TableHeader className="sticky top-0 bg-background z-10"> <TableRow> - <TableHead className="w-[180px]">Tag Number</TableHead> - <TableHead>Tag Description</TableHead> + <TableHead className="w-[180px]">{t("labels.tagNo")}</TableHead> + <TableHead>{t("labels.description")}</TableHead> </TableRow> </TableHeader> <TableBody> @@ -465,12 +474,12 @@ export function SEDPCompareDialog({ {missingTags.sedpOnly.length > 0 && ( <div> - <h3 className="text-sm font-medium mb-2">SEDP에만 있는 태그 ({missingTags.sedpOnly.length})</h3> + <h3 className="text-sm font-medium mb-2">{t("sections.sedpOnlyTags")} ({missingTags.sedpOnly.length})</h3> <Table> <TableHeader className="sticky top-0 bg-background z-10"> <TableRow> - <TableHead className="w-[180px]">Tag Number</TableHead> - <TableHead>Tag Description</TableHead> + <TableHead className="w-[180px]">{t("labels.tagNo")}</TableHead> + <TableHead>{t("labels.description")}</TableHead> </TableRow> </TableHeader> <TableBody> @@ -487,7 +496,7 @@ export function SEDPCompareDialog({ {totalMissingTags === 0 && ( <div className="flex items-center justify-center h-full text-muted-foreground"> - 모든 태그가 양쪽 시스템에 존재합니다 + {t("messages.allTagsExistInBothSystems")} </div> )} </div> @@ -498,9 +507,9 @@ export function SEDPCompareDialog({ <TableHeader className="sticky top-0 bg-muted/50 z-10"> <TableRow> <TableHead className="w-12"></TableHead> - <TableHead className="w-[180px]">Tag Number</TableHead> - <TableHead className="w-[250px]">Tag Description</TableHead> - <TableHead className="w-[120px]">상태</TableHead> + <TableHead className="w-[180px]">{t("labels.tagNo")}</TableHead> + <TableHead className="w-[250px]">{t("labels.description")}</TableHead> + <TableHead className="w-[120px]">{t("labels.status")}</TableHead> <TableHead>차이점 개수</TableHead> </TableRow> </TableHeader> @@ -508,7 +517,7 @@ export function SEDPCompareDialog({ {filteredResults.map((result) => ( <React.Fragment key={result.tagNo}> {/* 메인 행 */} - <TableRow + <TableRow className={`hover:bg-muted/20 cursor-pointer ${!result.isMatching ? "bg-muted/10" : ""}`} onClick={() => toggleRowExpansion(result.tagNo)} > @@ -533,29 +542,29 @@ export function SEDPCompareDialog({ {result.isMatching ? ( <Badge variant="default" className="flex items-center gap-1"> <CheckCircle className="h-3 w-3" /> - <span>일치</span> + <span>{t("labels.matching")}</span> </Badge> ) : ( <Badge variant="destructive" className="flex items-center gap-1"> <AlertCircle className="h-3 w-3" /> - <span>차이</span> + <span>{t("labels.different")}</span> </Badge> )} </TableCell> <TableCell> {!result.isMatching && ( <span className="text-sm text-muted-foreground"> - {result.attributes.filter(attr => !attr.isMatching).length}개 속성이 다름 + {result.attributes.filter(attr => !attr.isMatching).length}{t("sections.differenceCount")} </span> )} </TableCell> </TableRow> - + {/* 확장된 차이점 표시 행 */} {expandedRows.has(result.tagNo) && ( <TableRow> <TableCell colSpan={5} className="p-0 bg-muted/5"> - <DifferencesCard + <DifferencesCard attributes={result.attributes} columnLabelMap={columnLabelMap} showOnlyDifferences={showOnlyDifferences} @@ -570,7 +579,7 @@ export function SEDPCompareDialog({ </div> ) : ( <div className="flex items-center justify-center h-full text-muted-foreground"> - {searchTerm ? "검색 결과가 없습니다" : "현재 필터에 맞는 태그가 없습니다"} + {searchTerm ? t("messages.noSearchResults") : t("messages.noMatchingTags")} </div> )} </TabsContent> @@ -583,7 +592,7 @@ export function SEDPCompareDialog({ formCode={formCode} disabled={isLoading || (nonMatchingTags === 0 && totalMissingTags === 0)} /> - <Button onClick={onClose}>닫기</Button> + <Button onClick={onClose}>{t("buttons.close")}</Button> </DialogFooter> </DialogContent> </Dialog> diff --git a/components/form-data/sedp-components.tsx b/components/form-data/sedp-components.tsx index 4865e23d..869f730c 100644 --- a/components/form-data/sedp-components.tsx +++ b/components/form-data/sedp-components.tsx @@ -1,6 +1,8 @@ "use client"; import * as React from "react"; +import { useParams } from "next/navigation"; +import { useTranslation } from "@/i18n/client"; import { Button } from "@/components/ui/button"; import { Dialog, @@ -30,36 +32,40 @@ export function SEDPConfirmationDialog({ tagCount: number; isLoading: boolean; }) { + const params = useParams(); + const lng = (params?.lng as string) || "ko"; + const { t } = useTranslation(lng, "engineering"); + return ( <Dialog open={isOpen} onOpenChange={onClose}> <DialogContent className="sm:max-w-md"> <DialogHeader> - <DialogTitle>Send Data to SEDP</DialogTitle> + <DialogTitle>{t("sedp.sendDataTitle")}</DialogTitle> <DialogDescription> - You are about to send form data to the Samsung Engineering Design Platform (SEDP). + {t("sedp.sendDataDescription")} </DialogDescription> </DialogHeader> <div className="py-4"> <div className="grid grid-cols-2 gap-4 mb-4"> - <div className="text-muted-foreground">Form Name:</div> + <div className="text-muted-foreground">{t("sedp.formName")}:</div> <div className="font-medium">{formName}</div> - <div className="text-muted-foreground">Total Tags:</div> + <div className="text-muted-foreground">{t("sedp.totalTags")}:</div> <div className="font-medium">{tagCount}</div> </div> <div className="bg-amber-50 p-3 rounded-md border border-amber-200 flex items-start gap-2"> <AlertTriangle className="h-5 w-5 text-amber-500 mt-0.5 flex-shrink-0" /> <div className="text-sm text-amber-800"> - Data sent to SEDP cannot be easily reverted. Please ensure all information is correct before proceeding. + {t("sedp.warningMessage")} </div> </div> </div> <DialogFooter className="gap-2 sm:gap-0"> <Button variant="outline" onClick={onClose} disabled={isLoading}> - Cancel + {t("buttons.cancel")} </Button> <Button variant="samsung" @@ -70,12 +76,12 @@ export function SEDPConfirmationDialog({ {isLoading ? ( <> <Loader className="h-4 w-4 animate-spin" /> - Sending... + {t("sedp.sending")} </> ) : ( <> <Send className="h-4 w-4" /> - Send to SEDP + {t("sedp.sendToSEDP")} </> )} </Button> @@ -103,17 +109,31 @@ export function SEDPStatusDialog({ errorCount: number; totalCount: number; }) { + const params = useParams(); + const lng = (params?.lng as string) || "ko"; + const { t } = useTranslation(lng, "engineering"); + // Calculate percentage for the progress bar const percentage = Math.round((successCount / totalCount) * 100); + const getStatusTitle = () => { + switch (status) { + case 'success': + return t("sedp.dataSentSuccessfully"); + case 'partial': + return t("sedp.partiallySuccessful"); + case 'error': + default: + return t("sedp.failedToSendData"); + } + }; + return ( <Dialog open={isOpen} onOpenChange={onClose}> <DialogContent className="sm:max-w-md"> <DialogHeader> <DialogTitle> - {status === 'success' ? 'Data Sent Successfully' : - status === 'partial' ? 'Partially Successful' : - 'Failed to Send Data'} + {getStatusTitle()} </DialogTitle> </DialogHeader> @@ -141,20 +161,20 @@ export function SEDPStatusDialog({ {/* Progress Stats */} <div className="space-y-2 mb-4"> <div className="flex justify-between text-sm"> - <span>Progress</span> + <span>{t("sedp.progress")}</span> <span>{percentage}%</span> </div> <Progress value={percentage} className="h-2" /> <div className="flex justify-between text-sm pt-1"> <div> <Badge variant="outline" className="bg-green-50 text-green-700 hover:bg-green-50"> - {successCount} Successful + {t("sedp.successfulCount", { count: successCount })} </Badge> </div> {errorCount > 0 && ( <div> <Badge variant="outline" className="bg-red-50 text-red-700 hover:bg-red-50"> - {errorCount} Failed + {t("sedp.failedCount", { count: errorCount })} </Badge> </div> )} @@ -164,7 +184,7 @@ export function SEDPStatusDialog({ <DialogFooter> <Button onClick={onClose}> - Close + {t("buttons.close")} </Button> </DialogFooter> </DialogContent> diff --git a/components/form-data/sedp-excel-download.tsx b/components/form-data/sedp-excel-download.tsx index 24e1003d..36be4847 100644 --- a/components/form-data/sedp-excel-download.tsx +++ b/components/form-data/sedp-excel-download.tsx @@ -1,4 +1,6 @@ import * as React from "react"; +import { useParams } from "next/navigation"; +import { useTranslation } from "@/i18n/client"; import { Button } from "@/components/ui/button"; import { FileDown, Loader } from "lucide-react"; import { toast } from "sonner"; @@ -28,6 +30,9 @@ interface ExcelDownloadProps { export function ExcelDownload({ comparisonResults, missingTags, formCode, disabled }: ExcelDownloadProps) { const [isExporting, setIsExporting] = React.useState(false); + const params = useParams(); + const lng = (params?.lng as string) || "ko"; + const { t } = useTranslation(lng, "engineering"); // Function to generate and download Excel file with differences const handleExportDifferences = async () => { @@ -39,7 +44,7 @@ export function ExcelDownload({ comparisonResults, missingTags, formCode, disabl const hasMissingTags = missingTags.localOnly.length > 0 || missingTags.sedpOnly.length > 0; if (itemsWithDifferences.length === 0 && !hasMissingTags) { - toast.info("차이가 없어 다운로드할 내용이 없습니다"); + toast.info(t("excelDownload.noDifferencesToDownload")); return; } @@ -50,15 +55,15 @@ export function ExcelDownload({ comparisonResults, missingTags, formCode, disabl // Add a worksheet for attribute differences if (itemsWithDifferences.length > 0) { - const worksheet = workbook.addWorksheet('속성 차이'); + const worksheet = workbook.addWorksheet(t("excelDownload.attributeDifferencesSheet")); // Add headers worksheet.columns = [ - { header: 'Tag Number', key: 'tagNo', width: 20 }, - { header: 'Tag Description', key: 'tagDesc', width: 30 }, - { header: 'Attribute', key: 'attribute', width: 25 }, - { header: 'Local Value', key: 'localValue', width: 20 }, - { header: 'SEDP Value', key: 'sedpValue', width: 20 } + { header: t("excelDownload.tagNumber"), key: 'tagNo', width: 20 }, + { header: t("excelDownload.tagDescription"), key: 'tagDesc', width: 30 }, + { header: t("excelDownload.attribute"), key: 'attribute', width: 25 }, + { header: t("excelDownload.localValue"), key: 'localValue', width: 20 }, + { header: t("excelDownload.sedpValue"), key: 'sedpValue', width: 20 } ]; // Style the header row @@ -90,12 +95,12 @@ export function ExcelDownload({ comparisonResults, missingTags, formCode, disabl // Format local value with UOM const localDisplay = diff.localValue === null || diff.localValue === undefined || diff.localValue === '' - ? "(empty)" + ? t("excelDownload.emptyValue") : diff.uom ? `${diff.localValue} ${diff.uom}` : diff.localValue; // SEDP value is displayed as-is const sedpDisplay = diff.sedpValue === null || diff.sedpValue === undefined || diff.sedpValue === '' - ? "(empty)" + ? t("excelDownload.emptyValue") : diff.sedpValue; // Set cell values @@ -127,13 +132,13 @@ export function ExcelDownload({ comparisonResults, missingTags, formCode, disabl // Add a worksheet for missing tags if there are any if (hasMissingTags) { - const missingWorksheet = workbook.addWorksheet('누락된 태그'); + const missingWorksheet = workbook.addWorksheet(t("excelDownload.missingTagsSheet")); // Add headers missingWorksheet.columns = [ - { header: 'Tag Number', key: 'tagNo', width: 20 }, - { header: 'Tag Description', key: 'tagDesc', width: 30 }, - { header: 'Status', key: 'status', width: 20 } + { header: t("excelDownload.tagNumber"), key: 'tagNo', width: 20 }, + { header: t("excelDownload.tagDescription"), key: 'tagDesc', width: 30 }, + { header: t("excelDownload.status"), key: 'status', width: 20 } ]; // Style the header row @@ -160,7 +165,7 @@ export function ExcelDownload({ comparisonResults, missingTags, formCode, disabl row.getCell('tagNo').value = tag.tagNo; row.getCell('tagDesc').value = tag.tagDesc; - row.getCell('status').value = '로컬에만 존재'; + row.getCell('status').value = t("excelDownload.localOnlyStatus"); // Style the status cell row.getCell('status').font = { color: { argb: 'FFFF8C00' } }; // Orange for local-only @@ -187,7 +192,7 @@ export function ExcelDownload({ comparisonResults, missingTags, formCode, disabl row.getCell('tagNo').value = tag.tagNo; row.getCell('tagDesc').value = tag.tagDesc; - row.getCell('status').value = 'SEDP에만 존재'; + row.getCell('status').value = t("excelDownload.sedpOnlyStatus"); // Style the status cell row.getCell('status').font = { color: { argb: 'FF0000FF' } }; // Blue for SEDP-only @@ -214,7 +219,7 @@ export function ExcelDownload({ comparisonResults, missingTags, formCode, disabl const url = window.URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; - a.download = `SEDP_차이점_${formCode}_${new Date().toISOString().slice(0, 10)}.xlsx`; + a.download = `${t("excelDownload.fileNamePrefix")}_${formCode}_${new Date().toISOString().slice(0, 10)}.xlsx`; document.body.appendChild(a); a.click(); @@ -222,10 +227,10 @@ export function ExcelDownload({ comparisonResults, missingTags, formCode, disabl window.URL.revokeObjectURL(url); document.body.removeChild(a); - toast.success("Excel 다운로드 완료"); + toast.success(t("excelDownload.downloadComplete")); } catch (error) { console.error("Error exporting to Excel:", error); - toast.error("Excel 다운로드 실패"); + toast.error(t("excelDownload.downloadFailed")); } finally { setIsExporting(false); } @@ -248,7 +253,7 @@ export function ExcelDownload({ comparisonResults, missingTags, formCode, disabl ) : ( <FileDown className="h-4 w-4" /> )} - 차이점 Excel로 다운로드 + {t("excelDownload.downloadButtonText")} </Button> ); }
\ No newline at end of file diff --git a/components/form-data/spreadJS-dialog.tsx b/components/form-data/spreadJS-dialog.tsx index 1d0796fe..11d37911 100644 --- a/components/form-data/spreadJS-dialog.tsx +++ b/components/form-data/spreadJS-dialog.tsx @@ -119,7 +119,7 @@ export function TemplateViewDialog({ const [currentSpread, setCurrentSpread] = React.useState<any>(null); const [cellMappings, setCellMappings] = React.useState<CellMapping[]>([]); const [isClient, setIsClient] = React.useState(false); - const [templateType, setTemplateType] = React.useState<'SPREAD_LIST' | 'SPREAD_ITEM' | null>(null); + const [templateType, setTemplateType] = React.useState<'SPREAD_LIST' | 'SPREAD_ITEM' | 'GRD_LIST' | null>(null); const [validationErrors, setValidationErrors] = React.useState<ValidationError[]>([]); const [selectedTemplateId, setSelectedTemplateId] = React.useState<string>(""); const [availableTemplates, setAvailableTemplates] = React.useState<TemplateItem[]>([]); @@ -140,21 +140,35 @@ export function TemplateViewDialog({ templates = [templateData as TemplateItem]; } - // CONTENT가 있는 템플릿들 필터링 + // 유효한 템플릿들 필터링 const validTemplates = templates.filter(template => { const hasSpreadListContent = template.SPR_LST_SETUP?.CONTENT; const hasSpreadItemContent = template.SPR_ITM_LST_SETUP?.CONTENT; - const isValidType = template.TMPL_TYPE === "SPREAD_LIST" || template.TMPL_TYPE === "SPREAD_ITEM"; + const hasGrdListSetup = template.GRD_LST_SETUP && columnsJSON.length > 0; // GRD_LIST 조건: GRD_LST_SETUP 존재 + columnsJSON 있음 + + const isValidType = template.TMPL_TYPE === "SPREAD_LIST" || + template.TMPL_TYPE === "SPREAD_ITEM" || + template.TMPL_TYPE === "GRD_LIST"; // GRD_LIST 타입 추가 - return isValidType && (hasSpreadListContent || hasSpreadItemContent); + return isValidType && (hasSpreadListContent || hasSpreadItemContent || hasGrdListSetup); }); setAvailableTemplates(validTemplates); // 첫 번째 유효한 템플릿을 기본으로 선택 if (validTemplates.length > 0 && !selectedTemplateId) { - setSelectedTemplateId(validTemplates[0].TMPL_ID); - setTemplateType(validTemplates[0].TMPL_TYPE as 'SPREAD_LIST' | 'SPREAD_ITEM'); + const firstTemplate = validTemplates[0]; + setSelectedTemplateId(firstTemplate.TMPL_ID); + + // 템플릿 타입 결정 + let templateTypeToSet: 'SPREAD_LIST' | 'SPREAD_ITEM' | 'GRD_LIST'; + if (firstTemplate.GRD_LST_SETUP && columnsJSON.length > 0) { + templateTypeToSet = 'GRD_LIST'; + } else { + templateTypeToSet = firstTemplate.TMPL_TYPE as 'SPREAD_LIST' | 'SPREAD_ITEM'; + } + + setTemplateType(templateTypeToSet); } }, [templateData, selectedTemplateId]); @@ -163,7 +177,16 @@ export function TemplateViewDialog({ const template = availableTemplates.find(t => t.TMPL_ID === templateId); if (template) { setSelectedTemplateId(templateId); - setTemplateType(template.TMPL_TYPE as 'SPREAD_LIST' | 'SPREAD_ITEM'); + + // 템플릿 타입 결정 + let templateTypeToSet: 'SPREAD_LIST' | 'SPREAD_ITEM' | 'GRD_LIST'; + if (template.GRD_LST_SETUP && columnsJSON.length > 0) { + templateTypeToSet = 'GRD_LIST'; + } else { + templateTypeToSet = template.TMPL_TYPE as 'SPREAD_LIST' | 'SPREAD_ITEM'; + } + + setTemplateType(templateTypeToSet); setHasChanges(false); setValidationErrors([]); @@ -192,8 +215,8 @@ export function TemplateViewDialog({ return editableFieldsMap.get(selectedRow.TAG_NO) || []; } - // SPREAD_LIST인 경우: 첫 번째 행의 TAG_NO를 기준으로 처리 - if (templateType === 'SPREAD_LIST' && tableData.length > 0) { + // SPREAD_LIST 또는 GRD_LIST인 경우: 첫 번째 행의 TAG_NO를 기준으로 처리 + if ((templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && tableData.length > 0) { const firstRowTagNo = tableData[0]?.TAG_NO; if (firstRowTagNo && editableFieldsMap.has(firstRowTagNo)) { return editableFieldsMap.get(firstRowTagNo) || []; @@ -221,8 +244,8 @@ export function TemplateViewDialog({ return editableFields.includes(attId); } - // SPREAD_LIST인 경우: 개별 행의 편집 가능성도 고려 - if (templateType === 'SPREAD_LIST') { + // SPREAD_LIST 또는 GRD_LIST인 경우: 개별 행의 편집 가능성도 고려 + if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') { // 기본적으로 editableFields에 포함되어야 함 if (!editableFields.includes(attId)) { return false; @@ -236,8 +259,6 @@ export function TemplateViewDialog({ return true; } - // 기본적으로는 editableFields 체크 - // return editableFields.includes(attId); return true; }, [templateType, columnsJSON, editableFields]); @@ -266,6 +287,17 @@ export function TemplateViewDialog({ return { row, col }; }; + // 행과 열을 셀 주소로 변환하는 함수 (GRD_LIST용) + const getCellAddress = (row: number, col: number): string => { + let colStr = ''; + let colNum = col; + while (colNum >= 0) { + colStr = String.fromCharCode((colNum % 26) + 65) + colStr; + colNum = Math.floor(colNum / 26) - 1; + } + return colStr + (row + 1); + }; + // 데이터 타입 검증 함수 const validateCellValue = (value: any, columnType: ColumnType, options?: string[]): string | null => { if (value === undefined || value === null || value === "") { @@ -322,7 +354,7 @@ export function TemplateViewDialog({ message: errorMessage }); } - } else if (templateType === 'SPREAD_LIST') { + } else if (templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') { // 복수 행 검증 - 각 매핑은 이미 개별 행을 가리킴 const cellValue = activeSheet.getValue(cellPos.row, cellPos.col); const errorMessage = validateCellValue(cellValue, columnConfig.type, columnConfig.options); @@ -359,164 +391,282 @@ export function TemplateViewDialog({ return style; }, []); - // 📋 최적화된 LIST 드롭다운 설정 -const setupOptimizedListValidation = React.useCallback((activeSheet: any, cellPos: { row: number, col: number }, options: string[], rowCount: number) => { - try { - console.log(`🎯 Setting up DataValidation dropdown for ${rowCount} rows with options:`, options); - - // 🚨 성능 임계점 확인 - if (rowCount > 100) { - console.warn(`⚡ Large dataset (${rowCount} rows): Using simple validation only`); - setupSimpleValidation(activeSheet, cellPos, options, rowCount); - return; - } - - // ✅ 1단계: options 철저하게 정규화 (이것이 에러 방지의 핵심!) - let safeOptions; + // 🎯 드롭다운 설정 + const setupOptimizedListValidation = React.useCallback((activeSheet: any, cellPos: { row: number, col: number }, options: string[], rowCount: number) => { try { - safeOptions = options - .filter(opt => opt !== null && opt !== undefined && opt !== '') // null, undefined, 빈값 제거 - .map(opt => { - // 모든 값을 안전한 문자열로 변환 - let str = String(opt); - // 특수 문자나 문제 있는 문자 처리 - str = str.replace(/[\r\n\t]/g, ' '); // 줄바꿈, 탭을 공백으로 - str = str.replace(/[^\x20-\x7E\u00A1-\uFFFF]/g, ''); // 제어 문자 제거 - return str.trim(); - }) - .filter(opt => opt.length > 0 && opt.length < 255) // 빈값과 너무 긴 값 제거 - .filter((opt, index, arr) => arr.indexOf(opt) === index) // 중복 제거 - .slice(0, 100); // 최대 100개로 제한 - - console.log(`📋 Original options:`, options); - console.log(`📋 Safe options:`, safeOptions); - } catch (filterError) { - console.error('❌ Options filtering failed:', filterError); - safeOptions = ['Option1', 'Option2']; // 안전한 폴백 옵션 - } + console.log(`🎯 Setting up dropdown for ${rowCount} rows with options:`, options); + + // ✅ options 정규화 + const safeOptions = options + .filter(opt => opt !== null && opt !== undefined && opt !== '') + .map(opt => String(opt).trim()) + .filter(opt => opt.length > 0) + .filter((opt, index, arr) => arr.indexOf(opt) === index) + .slice(0, 20); + + if (safeOptions.length === 0) { + console.warn(`⚠️ No valid options found, skipping`); + return; + } - if (safeOptions.length === 0) { - console.warn(`⚠️ No valid options found, using fallback`); - safeOptions = ['Please Select']; - } + console.log(`📋 Safe options:`, safeOptions); - // ✅ 2단계: DataValidation 생성 (엑셀 스타일 드롭다운) - let validator; - try { - validator = GC.Spread.Sheets.DataValidation.createListValidator(safeOptions); - console.log(`✅ DataValidation validator created successfully`); - } catch (validatorError) { - console.error('❌ Failed to create validator:', validatorError); - return; - } + // ✅ DataValidation용 문자열 준비 + const optionsString = safeOptions.join(','); - // ✅ 3단계: 셀/범위에 적용 - try { - if (rowCount > 1) { - // 범위에 적용 - const range = activeSheet.getRange(cellPos.row, cellPos.col, rowCount, 1); - range.dataValidator(validator); - console.log(`✅ DataValidation applied to range [${cellPos.row}, ${cellPos.col}, ${rowCount}, 1]`); - } else { - // 단일 셀에 적용 - activeSheet.setDataValidator(cellPos.row, cellPos.col, validator); - console.log(`✅ DataValidation applied to single cell [${cellPos.row}, ${cellPos.col}]`); - } - } catch (applicationError) { - console.error('❌ Failed to apply DataValidation:', applicationError); - - // 폴백: 개별 셀에 하나씩 적용 - console.log('🔄 Trying individual cell application...'); - for (let i = 0; i < Math.min(rowCount, 50); i++) { + // 🔑 핵심 수정: 각 셀마다 개별 ComboBox 인스턴스 생성! + for (let i = 0; i < rowCount; i++) { try { - const individualValidator = GC.Spread.Sheets.DataValidation.createListValidator([...safeOptions]); - activeSheet.setDataValidator(cellPos.row + i, cellPos.col, individualValidator); - console.log(`✅ Individual DataValidation set for row ${cellPos.row + i}`); - } catch (individualError) { - console.warn(`⚠️ Failed individual cell ${cellPos.row + i}:`, individualError); + const targetRow = cellPos.row + i; + + // ✅ 각 셀마다 새로운 ComboBox 인스턴스 생성 + const comboBoxCellType = new GC.Spread.Sheets.CellTypes.ComboBox(); + comboBoxCellType.items(safeOptions); // 배열로 전달 + comboBoxCellType.editorValueType(GC.Spread.Sheets.CellTypes.EditorValueType.text); + + // ✅ 각 셀마다 새로운 DataValidation 인스턴스 생성 + const cellValidator = GC.Spread.Sheets.DataValidation.createListValidator(optionsString); + + // ComboBox + DataValidation 둘 다 적용 + activeSheet.setCellType(targetRow, cellPos.col, comboBoxCellType); + activeSheet.setDataValidator(targetRow, cellPos.col, cellValidator); + + // 셀 잠금 해제 + const cell = activeSheet.getCell(targetRow, cellPos.col); + cell.locked(false); + + console.log(`✅ Individual dropdown applied to [${targetRow}, ${cellPos.col}]`); + + } catch (cellError) { + console.warn(`⚠️ Failed to apply to row ${cellPos.row + i}:`, cellError); } } - } - console.log(`✅ DataValidation dropdown setup completed`); + console.log(`✅ Safe dropdown setup completed for ${rowCount} cells`); - } catch (error) { - console.error('❌ DataValidation setup failed completely:', error); - console.error('Error stack:', error.stack); - console.log('🔄 Falling back to no validation'); - } -}, []); + } catch (error) { + console.error('❌ Dropdown setup failed:', error); + } + }, []); + + // 🚀 행 용량 확보 + const ensureRowCapacity = React.useCallback((activeSheet: any, requiredRowCount: number) => { + const currentRowCount = activeSheet.getRowCount(); + if (requiredRowCount > currentRowCount) { + activeSheet.setRowCount(requiredRowCount + 10); // 여유분 추가 + console.log(`📈 Expanded sheet to ${requiredRowCount + 10} rows`); + } + }, []); -// ⚡ 단순 검증 설정 (드롭다운 없이 검증만) -const setupSimpleValidation = React.useCallback((activeSheet: any, cellPos: { row: number, col: number }, options: string[], rowCount: number) => { - try { - console.log(`⚡ Setting up simple validation (no dropdown UI) for ${rowCount} rows`); + // 🆕 GRD_LIST용 동적 테이블 생성 함수 + const createGrdListTable = React.useCallback((activeSheet: any, template: TemplateItem) => { + console.log('🏗️ Creating GRD_LIST table'); - const safeOptions = options - .filter(opt => opt != null && opt !== '') - .map(opt => String(opt).trim()) - .filter(opt => opt.length > 0); + // columnsJSON의 visible 컬럼들을 seq 순서로 정렬하여 사용 + const visibleColumns = columnsJSON + .filter(col => col.hidden !== true) // hidden이 true가 아닌 것들만 + .sort((a, b) => { + // seq가 없는 경우 999999로 처리하여 맨 뒤로 보냄 + const seqA = a.seq !== undefined ? a.seq : 999999; + const seqB = b.seq !== undefined ? b.seq : 999999; + return seqA - seqB; + }); - if (safeOptions.length === 0) return; + console.log('📊 Using columns:', visibleColumns.map(c => `${c.key}(seq:${c.seq})`)); - const validator = GC.Spread.Sheets.DataValidation.createListValidator(safeOptions); + if (visibleColumns.length === 0) { + console.warn('❌ No visible columns found in columnsJSON'); + return []; + } + + // 테이블 생성 시작 + const mappings: CellMapping[] = []; + const startCol = 1; // A열 제외하고 B열부터 시작 - // 범위로 적용 시도 - try { - activeSheet.getRange(cellPos.row, cellPos.col, rowCount, 1).dataValidator(validator); - console.log(`✅ Simple validation applied to range`); - } catch (rangeError) { - console.warn('Range validation failed, trying individual cells'); - // 폴백: 개별 적용 - for (let i = 0; i < Math.min(rowCount, 100); i++) { - try { - activeSheet.setDataValidator(cellPos.row + i, cellPos.col, validator); - } catch (individualError) { - // 개별 실패해도 계속 + // 🔍 그룹 헤더 분석 + const groupInfo = analyzeColumnGroups(visibleColumns); + const hasGroups = groupInfo.groups.length > 0; + + // 헤더 행 계산: 그룹이 있으면 2행, 없으면 1행 + const groupHeaderRow = 0; + const columnHeaderRow = hasGroups ? 1 : 0; + const dataStartRow = hasGroups ? 2 : 1; + + // 🎨 헤더 스타일 생성 + const groupHeaderStyle = new GC.Spread.Sheets.Style(); + groupHeaderStyle.backColor = "#1e40af"; // 더 진한 파란색 + groupHeaderStyle.foreColor = "#ffffff"; + groupHeaderStyle.font = "bold 13px Arial"; + groupHeaderStyle.hAlign = GC.Spread.Sheets.HorizontalAlign.center; + groupHeaderStyle.vAlign = GC.Spread.Sheets.VerticalAlign.center; + + const columnHeaderStyle = new GC.Spread.Sheets.Style(); + columnHeaderStyle.backColor = "#3b82f6"; // 기본 파란색 + columnHeaderStyle.foreColor = "#ffffff"; + columnHeaderStyle.font = "bold 12px Arial"; + columnHeaderStyle.hAlign = GC.Spread.Sheets.HorizontalAlign.center; + columnHeaderStyle.vAlign = GC.Spread.Sheets.VerticalAlign.center; + + let currentCol = startCol; + + // 🏗️ 그룹 헤더 및 컬럼 헤더 생성 + if (hasGroups) { + // 그룹 헤더가 있는 경우 + groupInfo.groups.forEach(group => { + if (group.isGroup) { + // 그룹 헤더 생성 및 병합 + const groupStartCol = currentCol; + const groupEndCol = currentCol + group.columns.length - 1; + + // 그룹 헤더 셀 설정 + const groupHeaderCell = activeSheet.getCell(groupHeaderRow, groupStartCol); + groupHeaderCell.value(group.head); + + // 그룹 헤더 병합 + if (group.columns.length > 1) { + activeSheet.addSpan(groupHeaderRow, groupStartCol, 1, group.columns.length); + } + + // 그룹 헤더 스타일 적용 + for (let col = groupStartCol; col <= groupEndCol; col++) { + activeSheet.setStyle(groupHeaderRow, col, groupHeaderStyle); + activeSheet.getCell(groupHeaderRow, col).locked(true); + } + + console.log(`📝 Group Header [${groupHeaderRow}, ${groupStartCol}-${groupEndCol}]: ${group.head}`); + + // 그룹 내 개별 컬럼 헤더 생성 + group.columns.forEach((column, index) => { + const colIndex = groupStartCol + index; + const columnHeaderCell = activeSheet.getCell(columnHeaderRow, colIndex); + columnHeaderCell.value(column.label); + activeSheet.setStyle(columnHeaderRow, colIndex, columnHeaderStyle); + columnHeaderCell.locked(true); + + console.log(`📝 Column Header [${columnHeaderRow}, ${colIndex}]: ${column.label}`); + }); + + currentCol += group.columns.length; + } else { + // 그룹이 아닌 단일 컬럼 + const column = group.columns[0]; + + // 그룹 헤더 행에는 빈 셀 (개별 컬럼이므로) + const groupHeaderCell = activeSheet.getCell(groupHeaderRow, currentCol); + groupHeaderCell.value(""); + activeSheet.setStyle(groupHeaderRow, currentCol, groupHeaderStyle); + groupHeaderCell.locked(true); + + // 컬럼 헤더 생성 + const columnHeaderCell = activeSheet.getCell(columnHeaderRow, currentCol); + columnHeaderCell.value(column.label); + activeSheet.setStyle(columnHeaderRow, currentCol, columnHeaderStyle); + columnHeaderCell.locked(true); + + console.log(`📝 Single Column [${columnHeaderRow}, ${currentCol}]: ${column.label}`); + currentCol++; } - } + }); + } else { + // 그룹이 없는 경우 - 기존 로직 + visibleColumns.forEach((column, colIndex) => { + const headerCol = startCol + colIndex; + const headerCell = activeSheet.getCell(columnHeaderRow, headerCol); + headerCell.value(column.label); + activeSheet.setStyle(columnHeaderRow, headerCol, columnHeaderStyle); + headerCell.locked(true); + + console.log(`📝 Header [${columnHeaderRow}, ${headerCol}]: ${column.label}`); + }); } - - } catch (error) { - console.error('❌ Simple validation failed:', error); - } -}, []); - -// ═══════════════════════════════════════════════════════════════════════════════ -// 🔍 디버깅용: 에러 발생 원인 추적 -// ═══════════════════════════════════════════════════════════════════════════════ - -const debugDropdownError = (options: any[], attId: string) => { - console.group(`🔍 Debugging dropdown for ${attId}`); - - console.log('Original options type:', typeof options); - console.log('Is array:', Array.isArray(options)); - console.log('Length:', options?.length); - console.log('Raw options:', options); - - if (Array.isArray(options)) { - options.forEach((opt, index) => { - console.log(`[${index}] Type: ${typeof opt}, Value: "${opt}", String: "${String(opt)}"`); + + // 데이터 행 생성 + const dataRowCount = tableData.length; + ensureRowCapacity(activeSheet, dataStartRow + dataRowCount); + + tableData.forEach((rowData, rowIndex) => { + const targetRow = dataStartRow + rowIndex; - // 문제 있는 값 체크 - if (opt === null) console.warn(` ⚠️ NULL value at index ${index}`); - if (opt === undefined) console.warn(` ⚠️ UNDEFINED value at index ${index}`); - if (typeof opt === 'object') console.warn(` ⚠️ OBJECT value at index ${index}:`, opt); - if (typeof opt === 'string' && opt.includes('\n')) console.warn(` ⚠️ NEWLINE in string at index ${index}`); - if (typeof opt === 'string' && opt.length === 0) console.warn(` ⚠️ EMPTY STRING at index ${index}`); + visibleColumns.forEach((column, colIndex) => { + const targetCol = startCol + colIndex; + const cellAddress = getCellAddress(targetRow, targetCol); + const isEditable = isFieldEditable(column.key, rowData); + + // 매핑 정보 추가 + mappings.push({ + attId: column.key, + cellAddress: cellAddress, + isEditable: isEditable, + dataRowIndex: rowIndex + }); + + // 셀 값 설정 + const cell = activeSheet.getCell(targetRow, targetCol); + const value = rowData[column.key]; + cell.value(value ?? null); + + // 스타일 적용 + const style = createCellStyle(isEditable); + activeSheet.setStyle(targetRow, targetCol, style); + cell.locked(!isEditable); + + console.log(`📝 Data [${targetRow}, ${targetCol}]: ${column.key} = "${value}" (${isEditable ? 'Editable' : 'ReadOnly'})`); + + // LIST 타입 드롭다운 설정 + if (column.type === "LIST" && column.options && isEditable) { + setupOptimizedListValidation(activeSheet, { row: targetRow, col: targetCol }, column.options, 1); + } + }); }); - } - - console.groupEnd(); -}; - // 🚀 행 용량 확보 - const ensureRowCapacity = React.useCallback((activeSheet: any, requiredRowCount: number) => { - const currentRowCount = activeSheet.getRowCount(); - if (requiredRowCount > currentRowCount) { - activeSheet.setRowCount(requiredRowCount + 10); // 여유분 추가 - console.log(`📈 Expanded sheet to ${requiredRowCount + 10} rows`); + console.log(`🏗️ GRD_LIST table created with ${mappings.length} mappings, hasGroups: ${hasGroups}`); + return mappings; + }, [tableData, columnsJSON, isFieldEditable, createCellStyle, ensureRowCapacity, setupOptimizedListValidation]); + + // 🔍 컬럼 그룹 분석 함수 + const analyzeColumnGroups = React.useCallback((columns: DataTableColumnJSON[]) => { + const groups: Array<{ + head: string; + isGroup: boolean; + columns: DataTableColumnJSON[]; + }> = []; + + let i = 0; + while (i < columns.length) { + const currentCol = columns[i]; + + // head가 없거나 빈 문자열인 경우 단일 컬럼으로 처리 + if (!currentCol.head || !currentCol.head.trim()) { + groups.push({ + head: '', + isGroup: false, + columns: [currentCol] + }); + i++; + continue; + } + + // 같은 head를 가진 연속된 컬럼들을 찾기 + const groupHead = currentCol.head.trim(); + const groupColumns: DataTableColumnJSON[] = [currentCol]; + let j = i + 1; + + while (j < columns.length && columns[j].head && columns[j].head.trim() === groupHead) { + groupColumns.push(columns[j]); + j++; + } + + // 그룹 추가 + groups.push({ + head: groupHead, + isGroup: groupColumns.length > 1, + columns: groupColumns + }); + + i = j; // 다음 그룹으로 이동 } + + return { groups }; }, []); // 🛡️ 시트 보호 및 이벤트 설정 @@ -574,8 +724,8 @@ const debugDropdownError = (options: any[], attId: string) => { return; } - // SPREAD_LIST 개별 행 SHI 확인 - if (templateType === 'SPREAD_LIST' && exactMapping.dataRowIndex !== undefined) { + // SPREAD_LIST 또는 GRD_LIST 개별 행 SHI 확인 + if ((templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && exactMapping.dataRowIndex !== undefined) { const dataRowIndex = exactMapping.dataRowIndex; console.log(`🔍 Checking SHI for data row ${dataRowIndex}`); @@ -663,149 +813,167 @@ const debugDropdownError = (options: any[], attId: string) => { setHasChanges(false); setValidationErrors([]); - // 📋 템플릿 콘텐츠 및 데이터 시트 추출 - let contentJson = null; - let dataSheets = null; - - // SPR_LST_SETUP.CONTENT 우선 사용 - if (workingTemplate.SPR_LST_SETUP?.CONTENT) { - contentJson = workingTemplate.SPR_LST_SETUP.CONTENT; - dataSheets = workingTemplate.SPR_LST_SETUP.DATA_SHEETS; - console.log('✅ Using SPR_LST_SETUP.CONTENT for template:', workingTemplate.NAME); - } - // SPR_ITM_LST_SETUP.CONTENT 대안 사용 - else if (workingTemplate.SPR_ITM_LST_SETUP?.CONTENT) { - contentJson = workingTemplate.SPR_ITM_LST_SETUP.CONTENT; - dataSheets = workingTemplate.SPR_ITM_LST_SETUP.DATA_SHEETS; - console.log('✅ Using SPR_ITM_LST_SETUP.CONTENT for template:', workingTemplate.NAME); - } - - if (!contentJson) { - console.warn('❌ No CONTENT found in template:', workingTemplate.NAME); - return; - } - - // 🏗️ SpreadSheets 초기화 - const jsonData = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson; - // 성능을 위한 렌더링 일시 중단 spread.suspendPaint(); try { - // 템플릿 구조 로드 - spread.fromJSON(jsonData); const activeSheet = spread.getActiveSheet(); // 시트 보호 해제 (편집을 위해) activeSheet.options.isProtected = false; - // 📊 셀 매핑 및 데이터 처리 - if (dataSheets && dataSheets.length > 0) { - const mappings: CellMapping[] = []; + let mappings: CellMapping[] = []; + + // 🆕 GRD_LIST 처리 + if (templateType === 'GRD_LIST' && workingTemplate.GRD_LST_SETUP) { + console.log('🏗️ Processing GRD_LIST template'); - // 🔄 각 데이터 시트의 매핑 정보 처리 - dataSheets.forEach(dataSheet => { - if (dataSheet.MAP_CELL_ATT) { - dataSheet.MAP_CELL_ATT.forEach(mapping => { - const { ATT_ID, IN } = mapping; - - if (IN && IN.trim() !== "") { - const cellPos = parseCellAddress(IN); - if (cellPos) { - const columnConfig = columnsJSON.find(col => col.key === ATT_ID); - - // 🎯 템플릿 타입별 데이터 처리 - if (templateType === 'SPREAD_ITEM' && selectedRow) { - // 📝 단일 행 처리 (SPREAD_ITEM) - const isEditable = isFieldEditable(ATT_ID); - - // 매핑 정보 저장 - mappings.push({ - attId: ATT_ID, - cellAddress: IN, - isEditable: isEditable, - dataRowIndex: 0 - }); - - const cell = activeSheet.getCell(cellPos.row, cellPos.col); - const value = selectedRow[ATT_ID]; - - // 값 설정 - cell.value(value ?? null); - - // 🎨 스타일 및 편집 권한 설정 - cell.locked(!isEditable); - const style = createCellStyle(isEditable); - activeSheet.setStyle(cellPos.row, cellPos.col, style); - - // 📋 LIST 타입 드롭다운 설정 - if (columnConfig?.type === "LIST" && columnConfig.options && isEditable) { - setupOptimizedListValidation(activeSheet, cellPos, columnConfig.options, 1); - } + // 기본 워크북 설정 + spread.clearSheets(); + spread.addSheet(0); + const sheet = spread.getSheet(0); + sheet.name('Data'); + spread.setActiveSheet('Data'); + + // 동적 테이블 생성 + mappings = createGrdListTable(sheet, workingTemplate); + + } else { + // 기존 SPREAD_LIST 및 SPREAD_ITEM 처리 + let contentJson = null; + let dataSheets = null; + + // SPR_LST_SETUP.CONTENT 우선 사용 + if (workingTemplate.SPR_LST_SETUP?.CONTENT) { + contentJson = workingTemplate.SPR_LST_SETUP.CONTENT; + dataSheets = workingTemplate.SPR_LST_SETUP.DATA_SHEETS; + console.log('✅ Using SPR_LST_SETUP.CONTENT for template:', workingTemplate.NAME); + } + // SPR_ITM_LST_SETUP.CONTENT 대안 사용 + else if (workingTemplate.SPR_ITM_LST_SETUP?.CONTENT) { + contentJson = workingTemplate.SPR_ITM_LST_SETUP.CONTENT; + dataSheets = workingTemplate.SPR_ITM_LST_SETUP.DATA_SHEETS; + console.log('✅ Using SPR_ITM_LST_SETUP.CONTENT for template:', workingTemplate.NAME); + } - } else if (templateType === 'SPREAD_LIST' && tableData.length > 0) { - // 📊 복수 행 처리 (SPREAD_LIST) - 성능 최적화됨 - console.log(`🔄 Processing SPREAD_LIST for ${ATT_ID} with ${tableData.length} rows`); - - // 🚀 행 확장 (필요시) - ensureRowCapacity(activeSheet, cellPos.row + tableData.length); - - // 📋 각 행마다 개별 매핑 생성 - tableData.forEach((rowData, index) => { - const targetRow = cellPos.row + index; - const targetCellAddress = `${String.fromCharCode(65 + cellPos.col)}${targetRow + 1}`; - const cellEditable = isFieldEditable(ATT_ID, rowData); + if (!contentJson) { + console.warn('❌ No CONTENT found in template:', workingTemplate.NAME); + return; + } + + // 🏗️ SpreadSheets 초기화 + const jsonData = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson; + + // 템플릿 구조 로드 + spread.fromJSON(jsonData); + + // 📊 셀 매핑 및 데이터 처리 + if (dataSheets && dataSheets.length > 0) { + + // 🔄 각 데이터 시트의 매핑 정보 처리 + dataSheets.forEach(dataSheet => { + if (dataSheet.MAP_CELL_ATT) { + dataSheet.MAP_CELL_ATT.forEach(mapping => { + const { ATT_ID, IN } = mapping; + + if (IN && IN.trim() !== "") { + const cellPos = parseCellAddress(IN); + if (cellPos) { + const columnConfig = columnsJSON.find(col => col.key === ATT_ID); + + // 🎯 템플릿 타입별 데이터 처리 + if (templateType === 'SPREAD_ITEM' && selectedRow) { + // 📝 단일 행 처리 (SPREAD_ITEM) + const isEditable = isFieldEditable(ATT_ID); - // 개별 매핑 추가 + // 매핑 정보 저장 mappings.push({ attId: ATT_ID, - cellAddress: targetCellAddress, // 각 행마다 다른 주소 - isEditable: cellEditable, - dataRowIndex: index // 원본 데이터 인덱스 + cellAddress: IN, + isEditable: isEditable, + dataRowIndex: 0 }); - - console.log(`📝 Mapping ${ATT_ID} Row ${index}: ${targetCellAddress} (${cellEditable ? 'Editable' : 'ReadOnly'})`); - }); - - // 📋 LIST 타입 드롭다운 설정 (조건부) - if (columnConfig?.type === "LIST" && columnConfig.options) { - // 편집 가능한 행이 하나라도 있으면 드롭다운 설정 - const hasEditableRows = tableData.some((rowData) => isFieldEditable(ATT_ID, rowData)); - if (hasEditableRows) { - setupOptimizedListValidation(activeSheet, cellPos, columnConfig.options, tableData.length); - } - } - // 🎨 개별 셀 데이터 및 스타일 설정 - tableData.forEach((rowData, index) => { - const targetRow = cellPos.row + index; - const cell = activeSheet.getCell(targetRow, cellPos.col); - const value = rowData[ATT_ID]; + const cell = activeSheet.getCell(cellPos.row, cellPos.col); + const value = selectedRow[ATT_ID]; // 값 설정 cell.value(value ?? null); - console.log(`📝 Row ${targetRow}: ${ATT_ID} = "${value}"`); + + // 🎨 스타일 및 편집 권한 설정 + cell.locked(!isEditable); + const style = createCellStyle(isEditable); + activeSheet.setStyle(cellPos.row, cellPos.col, style); + + // 📋 LIST 타입 드롭다운 설정 + if (columnConfig?.type === "LIST" && columnConfig.options && isEditable) { + setupOptimizedListValidation(activeSheet, cellPos, columnConfig.options, 1); + } + + } else if (templateType === 'SPREAD_LIST' && tableData.length > 0) { + // 📊 복수 행 처리 (SPREAD_LIST) - 성능 최적화됨 + console.log(`🔄 Processing SPREAD_LIST for ${ATT_ID} with ${tableData.length} rows`); - // 편집 권한 및 스타일 설정 - const cellEditable = isFieldEditable(ATT_ID, rowData); - cell.locked(!cellEditable); - const style = createCellStyle(cellEditable); - activeSheet.setStyle(targetRow, cellPos.col, style); - }); - } + // 🚀 행 확장 (필요시) + ensureRowCapacity(activeSheet, cellPos.row + tableData.length); + + // 📋 각 행마다 개별 매핑 생성 + tableData.forEach((rowData, index) => { + const targetRow = cellPos.row + index; + const targetCellAddress = `${String.fromCharCode(65 + cellPos.col)}${targetRow + 1}`; + const cellEditable = isFieldEditable(ATT_ID, rowData); + + // 개별 매핑 추가 + mappings.push({ + attId: ATT_ID, + cellAddress: targetCellAddress, // 각 행마다 다른 주소 + isEditable: cellEditable, + dataRowIndex: index // 원본 데이터 인덱스 + }); + + console.log(`📝 Mapping ${ATT_ID} Row ${index}: ${targetCellAddress} (${cellEditable ? 'Editable' : 'ReadOnly'})`); + }); - console.log(`📌 Mapped ${ATT_ID} → ${IN} (${templateType})`); - } - } - }); - } - }); + // 📋 LIST 타입 드롭다운 설정 (조건부) + if (columnConfig?.type === "LIST" && columnConfig.options) { + // 편집 가능한 행이 하나라도 있으면 드롭다운 설정 + const hasEditableRows = tableData.some((rowData) => isFieldEditable(ATT_ID, rowData)); + if (hasEditableRows) { + setupOptimizedListValidation(activeSheet, cellPos, columnConfig.options, tableData.length); + } + } - // 💾 매핑 정보 저장 및 이벤트 설정 - setCellMappings(mappings); - setupSheetProtectionAndEvents(activeSheet, mappings); + // 🎨 개별 셀 데이터 및 스타일 설정 + tableData.forEach((rowData, index) => { + const targetRow = cellPos.row + index; + const cell = activeSheet.getCell(targetRow, cellPos.col); + const value = rowData[ATT_ID]; + + // 값 설정 + cell.value(value ?? null); + // console.log(`📝 Row ${targetRow}: ${ATT_ID} = "${value}"`); + + // 편집 권한 및 스타일 설정 + const cellEditable = isFieldEditable(ATT_ID, rowData); + cell.locked(!cellEditable); + const style = createCellStyle(cellEditable); + activeSheet.setStyle(targetRow, cellPos.col, style); + }); + } + + // console.log(`📌 Mapped ${ATT_ID} → ${IN} (${templateType})`); + } + } + }); + } + }); + } } + // 💾 매핑 정보 저장 및 이벤트 설정 + setCellMappings(mappings); + setupSheetProtectionAndEvents(activeSheet, mappings); + } finally { // 렌더링 재개 spread.resumePaint(); @@ -818,7 +986,7 @@ const debugDropdownError = (options: any[], attId: string) => { spread.resumePaint(); } } - }, [selectedTemplate, templateType, selectedRow, tableData, isFieldEditable, columnsJSON, createCellStyle, setupOptimizedListValidation, ensureRowCapacity, setupSheetProtectionAndEvents]); + }, [selectedTemplate, templateType, selectedRow, tableData, isFieldEditable, columnsJSON, createCellStyle, setupOptimizedListValidation, ensureRowCapacity, setupSheetProtectionAndEvents, createGrdListTable]); // 변경사항 저장 함수 const handleSaveChanges = React.useCallback(async () => { @@ -869,8 +1037,8 @@ const debugDropdownError = (options: any[], attId: string) => { toast.success("Changes saved successfully!"); onUpdateSuccess?.(dataToSave); - } else if (templateType === 'SPREAD_LIST' && tableData.length > 0) { - // 복수 행 저장 + } else if ((templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && tableData.length > 0) { + // 복수 행 저장 (SPREAD_LIST와 GRD_LIST 동일 처리) const updatedRows: GenericData[] = []; let saveCount = 0; @@ -966,7 +1134,11 @@ const debugDropdownError = (options: any[], attId: string) => { <SelectContent> {availableTemplates.map(template => ( <SelectItem key={template.TMPL_ID} value={template.TMPL_ID}> - {template.NAME} ({template.TMPL_TYPE}) + {template.NAME} ({ + template.GRD_LST_SETUP && columnsJSON.length > 0 + ? 'GRD_LIST' + : template.TMPL_TYPE + }) </SelectItem> ))} </SelectContent> @@ -978,12 +1150,16 @@ const debugDropdownError = (options: any[], attId: string) => { {selectedTemplate && ( <div className="flex items-center gap-4 text-sm"> <span className="font-medium text-blue-600"> - Template Type: {selectedTemplate.TMPL_TYPE === 'SPREAD_LIST' ? 'List View (SPREAD_LIST)' : 'Item View (SPREAD_ITEM)'} + Template Type: { + templateType === 'SPREAD_LIST' ? 'List View (SPREAD_LIST)' : + templateType === 'SPREAD_ITEM' ? 'Item View (SPREAD_ITEM)' : + 'Grid List View (GRD_LIST)' + } </span> {templateType === 'SPREAD_ITEM' && selectedRow && ( <span>• Selected TAG_NO: {selectedRow.TAG_NO || 'N/A'}</span> )} - {templateType === 'SPREAD_LIST' && ( + {(templateType === 'SPREAD_LIST' || templateType === 'GRD_LIST') && ( <span>• {dataCount} rows</span> )} {hasChanges && ( @@ -1028,7 +1204,7 @@ const debugDropdownError = (options: any[], attId: string) => { <div className="flex-1 overflow-hidden"> {selectedTemplate && isClient && isDataValid ? ( <SpreadSheets - key={`${selectedTemplate.TMPL_TYPE}-${selectedTemplate.TMPL_ID}-${selectedTemplateId}`} + key={`${templateType}-${selectedTemplate.TMPL_ID}-${selectedTemplateId}`} workbookInitialized={initSpread} hostStyle={hostStyle} /> diff --git a/components/form-data/update-form-sheet.tsx b/components/form-data/update-form-sheet.tsx index 5666a116..91cb7a07 100644 --- a/components/form-data/update-form-sheet.tsx +++ b/components/form-data/update-form-sheet.tsx @@ -45,6 +45,8 @@ import { import { DataTableColumnJSON } from "./form-data-table-columns"; import { updateFormDataInDB } from "@/lib/forms/services"; import { cn } from "@/lib/utils"; +import { useParams } from "next/navigation"; +import { useTranslation } from "@/i18n/client"; /** ============================================================= * 🔄 UpdateTagSheet with grouped fields by `head` property @@ -86,6 +88,10 @@ export function UpdateTagSheet({ const [isPending, startTransition] = React.useTransition(); const router = useRouter(); + const params = useParams(); + const lng = (params?.lng as string) || "ko"; + const { t } = useTranslation(lng, "engineering"); + /* ---------------------------------------------------------------- * 1️⃣ Editable‑field helpers (unchanged) * --------------------------------------------------------------*/ @@ -105,17 +111,17 @@ export function UpdateTagSheet({ const isFieldReadOnly = React.useCallback((column: DataTableColumnJSON) => !isFieldEditable(column), [isFieldEditable]); const getReadOnlyReason = React.useCallback((column: DataTableColumnJSON) => { - if (column.shi) return "SHI‑only field (managed by SHI system)"; + if (column.shi) return t("updateTagSheet.readOnlyReasons.shiOnly"); if (column.key !== "TAG_NO" && column.key !== "TAG_DESC") { if (!rowData?.TAG_NO || !editableFieldsMap.has(rowData.TAG_NO)) { - return "No editable fields information for this TAG"; + return t("updateTagSheet.readOnlyReasons.noEditableFields"); } if (!editableFields.includes(column.key)) { - return "Not editable for this TAG class"; + return t("updateTagSheet.readOnlyReasons.notEditableForTag"); } } - return "Read‑only field"; - }, [rowData?.TAG_NO, editableFieldsMap, editableFields]); + return t("updateTagSheet.readOnlyReasons.readOnly"); + }, [rowData?.TAG_NO, editableFieldsMap, editableFields, t]); /* ---------------------------------------------------------------- * 2️⃣ Zod dynamic schema & form state (unchanged) @@ -220,7 +226,7 @@ export function UpdateTagSheet({ return; } - toast.success("Updated successfully!"); + toast.success(t("updateTagSheet.messages.updateSuccess")); const updatedData = { ...rowData, ...finalValues, TAG_NO: rowData?.TAG_NO }; onUpdateSuccess?.(updatedData); @@ -228,7 +234,7 @@ export function UpdateTagSheet({ onOpenChange(false); } catch (error) { console.error("Error updating form data:", error); - toast.error("An unexpected error occurred while updating"); + toast.error(t("updateTagSheet.messages.updateError")); } }); } @@ -243,15 +249,18 @@ export function UpdateTagSheet({ <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 – {rowData?.TAG_NO || "Unknown TAG"} + {t("updateTagSheet.title")} – {rowData?.TAG_NO || t("updateTagSheet.unknownTag")} </SheetTitle> <SheetDescription> - Modify the fields below and save changes. Fields with - <LockIcon className="inline h-3 w-3 mx-1" /> are read‑only. + {t("updateTagSheet.description")} + <LockIcon className="inline h-3 w-3 mx-1" /> + {t("updateTagSheet.readOnlyIndicator")} <br /> <span className="text-sm text-green-600"> - {editableFieldCount} of {columns.length} fields are editable for - this TAG. + {t("updateTagSheet.editableFieldsCount", { + editableCount: editableFieldCount, + totalCount: columns.length + })} </span> </SheetDescription> </SheetHeader> @@ -303,7 +312,7 @@ export function UpdateTagSheet({ value={field.value ?? ""} className={cn( isReadOnly && - "bg-gray-100 text-gray-600 cursor-not-allowed border-gray-300", + "bg-gray-100 text-gray-600 cursor-not-allowed border-gray-300", )} /> </FormControl> @@ -337,17 +346,20 @@ export function UpdateTagSheet({ "w-full justify-between", !field.value && "text-muted-foreground", isReadOnly && - "bg-gray-100 text-gray-600 cursor-not-allowed border-gray-300", + "bg-gray-100 text-gray-600 cursor-not-allowed border-gray-300", )} > - {field.value ? col.options?.find((o) => o === field.value) : "Select an option"} + {field.value ? + col.options?.find((o) => o === field.value) : + t("updateTagSheet.selectOption") + } <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> </Button> </PopoverTrigger> <PopoverContent className="w-full p-0"> <Command> - <CommandInput placeholder="Search options..." /> - <CommandEmpty>No option found.</CommandEmpty> + <CommandInput placeholder={t("updateTagSheet.searchOptions")} /> + <CommandEmpty>{t("updateTagSheet.noOptionFound")}</CommandEmpty> <CommandList> <CommandGroup> {col.options?.map((opt) => ( @@ -391,7 +403,7 @@ export function UpdateTagSheet({ {...field} className={cn( isReadOnly && - "bg-gray-100 text-gray-600 cursor-not-allowed border-gray-300", + "bg-gray-100 text-gray-600 cursor-not-allowed border-gray-300", )} /> </FormControl> @@ -415,11 +427,12 @@ export function UpdateTagSheet({ <SheetFooter className="gap-2 pt-2"> <SheetClose asChild> <Button type="button" variant="outline"> - Cancel + {t("buttons.cancel")} </Button> </SheetClose> <Button type="submit" disabled={isPending}> - {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />}Save + {isPending && <Loader className="mr-2 h-4 w-4 animate-spin" />} + {t("buttons.save")} </Button> </SheetFooter> </form> @@ -427,4 +440,4 @@ export function UpdateTagSheet({ </SheetContent> </Sheet> ); -} +}
\ No newline at end of file diff --git a/components/form-data/var-list-download-btn.tsx b/components/form-data/var-list-download-btn.tsx index bbadf893..9d09ab8c 100644 --- a/components/form-data/var-list-download-btn.tsx +++ b/components/form-data/var-list-download-btn.tsx @@ -8,6 +8,8 @@ import ExcelJS from "exceljs"; import { saveAs } from "file-saver"; import { Button } from "@/components/ui/button"; import { DataTableColumnJSON } from "./form-data-table-columns"; +import { useParams } from "next/navigation"; +import { useTranslation } from "@/i18n/client"; interface VarListDownloadBtnProps { columnsJSON: DataTableColumnJSON[]; @@ -19,6 +21,10 @@ export const VarListDownloadBtn: FC<VarListDownloadBtnProps> = ({ formCode, }) => { const { toast } = useToast(); + + const params = useParams(); + const lng = (params?.lng as string) || "ko"; + const { t } = useTranslation(lng, "engineering"); const downloadReportVarList = async () => { try { @@ -33,7 +39,10 @@ export const VarListDownloadBtn: FC<VarListDownloadBtnProps> = ({ validationSheet.state = "hidden"; // 시트 숨김 처리 // 1. 데이터 시트에 헤더 추가 - const headers = ["Table Column Label", "Report Variable"]; + const headers = [ + t("varListDownload.headers.tableColumnLabel"), + t("varListDownload.headers.reportVariable") + ]; worksheet.addRow(headers); // 헤더 스타일 적용 @@ -50,7 +59,7 @@ export const VarListDownloadBtn: FC<VarListDownloadBtnProps> = ({ // 2. 데이터 행 추가 columnsJSON.forEach((row) => { - console.log(row) + console.log(row); const { displayLabel, key } = row; // const labelConvert = label.replaceAll(" ", "_"); @@ -81,13 +90,14 @@ export const VarListDownloadBtn: FC<VarListDownloadBtnProps> = ({ }); const buffer = await workbook.xlsx.writeBuffer(); - saveAs(new Blob([buffer]), `${formCode}_report_varible_list.xlsx`); - toastMessage.success("Report Varible List File 다운로드 완료!"); + const fileName = `${formCode}${t("varListDownload.fileNameSuffix")}`; + saveAs(new Blob([buffer]), fileName); + toastMessage.success(t("varListDownload.messages.downloadComplete")); } catch (err) { console.log(err); toast({ - title: "Error", - description: "Variable List 파일을 찾을 수가 없습니다.", + title: t("varListDownload.messages.errorTitle"), + description: t("varListDownload.messages.errorDescription"), variant: "destructive", }); } @@ -97,16 +107,16 @@ export const VarListDownloadBtn: FC<VarListDownloadBtnProps> = ({ <Button variant="outline" className="relative px-[8px] py-[6px] flex-1" - aria-label="Variable List Download" + aria-label={t("varListDownload.buttonAriaLabel")} onClick={downloadReportVarList} > <Image src="/icons/var_list_icon.svg" - alt="Template Sample Download Icon" + alt={t("varListDownload.iconAltText")} width={16} height={16} /> - <div className='text-[12px]'>Variable List Download</div> + <div className="text-[12px]">{t("varListDownload.buttonText")}</div> </Button> ); };
\ No newline at end of file diff --git a/components/signup/join-form.tsx b/components/signup/join-form.tsx index e9773d28..c94d435e 100644 --- a/components/signup/join-form.tsx +++ b/components/signup/join-form.tsx @@ -1,6 +1,6 @@ 'use client' -import React, { useState, useEffect } from 'react'; +import React, { useState, useEffect, useCallback } from 'react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Progress } from '@/components/ui/progress'; @@ -49,13 +49,28 @@ import { } from '@/components/ui/file-list'; import { ScrollArea } from '@/components/ui/scroll-area'; import prettyBytes from 'pretty-bytes'; +import { useToast } from "@/hooks/use-toast"; -// 기존 JoinForm에서 가져온 데이터들 +// libphonenumber-js 관련 imports +import { + parsePhoneNumberFromString, + isPossiblePhoneNumber, + isValidPhoneNumber, + validatePhoneNumberLength, + getExampleNumber, + AsYouType, + getCountries, + getCountryCallingCode +} from 'libphonenumber-js' +import examples from 'libphonenumber-js/examples.mobile.json' + +// 기존 imports... 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 { getVendorTypes } from '@/lib/vendors/service'; import ConsentStep from './conset-step'; +import { checkEmailExists } from '@/lib/vendor-users/service'; i18nIsoCountries.registerLocale(enLocale); i18nIsoCountries.registerLocale(koLocale); @@ -75,8 +90,8 @@ const sortedCountryArray = [...countryArray].sort((a, b) => { const enhancedCountryArray = sortedCountryArray.map(country => ({ ...country, - label: locale === "ko" && country.code === "KR" - ? "대한민국 (South Korea)" + label: locale === "ko" && country.code === "KR" + ? "대한민국 (South Korea)" : country.label })); @@ -93,44 +108,6 @@ const contactTaskOptions = [ { value: "FIELD_SERVICE_ENGINEER", label: "FSE(야드작업자) Field Service Engineer" } ]; -export const countryDialCodes = { - 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; // 스텝 정의 @@ -140,6 +117,241 @@ const STEPS = [ { id: 3, title: '업체 등록', description: '업체 정보 및 서류 제출', icon: Building } ]; +// ========== 전화번호 처리 유틸리티 함수들 ========== + +/** + * 국가별 전화번호 예시를 가져오는 함수 + */ +function getPhoneExample(countryCode) { + if (!countryCode) return null; + try { + return getExampleNumber(countryCode, examples); + } catch { + return null; + } +} + +/** + * 국가별 전화번호 플레이스홀더를 생성하는 함수 + */ +function getPhonePlaceholder(countryCode) { + if (!countryCode) return "국가를 먼저 선택해주세요"; + + const example = getPhoneExample(countryCode); + if (example) { + // 국내 형식으로 표시 (더 친숙함) + return example.formatNational(); + } + + // 예시가 없는 경우 국가 코드 기반 플레이스홀더 + try { + const callingCode = getCountryCallingCode(countryCode); + return `+${callingCode} ...`; + } catch { + return "전화번호를 입력하세요"; + } +} + +/** + * 국가별 전화번호 설명을 생성하는 함수 + */ +function getPhoneDescription(countryCode) { + if (!countryCode) return "국가를 먼저 선택하여 올바른 전화번호 형식을 확인하세요."; + + const example = getPhoneExample(countryCode); + if (example) { + return `예시: ${example.formatNational()} 또는 ${example.formatInternational()}`; + } + + try { + const callingCode = getCountryCallingCode(countryCode); + return `국가 코드: +${callingCode}. 국내 형식 또는 국제 형식으로 입력 가능합니다.`; + } catch { + return "올바른 전화번호 형식으로 입력해주세요."; + } +} + +/** + * 전화번호 검증 결과를 반환하는 함수 + */ +function validatePhoneNumber(phoneNumber, countryCode) { + if (!phoneNumber || !countryCode) { + return { isValid: false, error: null, formatted: phoneNumber }; + } + + try { + // 1. 기본 파싱 시도 + const parsed = parsePhoneNumberFromString(phoneNumber, countryCode); + + if (!parsed) { + return { + isValid: false, + error: "올바른 전화번호 형식이 아닙니다.", + formatted: phoneNumber + }; + } + + // 2. 길이 검증 + const lengthValidation = validatePhoneNumberLength(phoneNumber, countryCode); + if (lengthValidation !== undefined) { + const lengthErrors = { + 'TOO_SHORT': '전화번호가 너무 짧습니다.', + 'TOO_LONG': '전화번호가 너무 깁니다.', + 'INVALID_LENGTH': '전화번호 길이가 올바르지 않습니다.' + }; + return { + isValid: false, + error: lengthErrors[lengthValidation] || '전화번호 길이가 올바르지 않습니다.', + formatted: phoneNumber + }; + } + + // 3. 가능성 검증 + if (!isPossiblePhoneNumber(phoneNumber, countryCode)) { + return { + isValid: false, + error: "이 국가에서 가능하지 않은 전화번호 형식입니다.", + formatted: phoneNumber + }; + } + + // 4. 유효성 검증 + if (!isValidPhoneNumber(phoneNumber, countryCode)) { + return { + isValid: false, + error: "유효하지 않은 전화번호입니다.", + formatted: phoneNumber + }; + } + + // 모든 검증 통과 + return { + isValid: true, + error: null, + formatted: parsed.formatNational(), + international: parsed.formatInternational() + }; + + } catch (error) { + return { + isValid: false, + error: "전화번호 형식을 확인해주세요.", + formatted: phoneNumber + }; + } +} + +/** + * 실시간 전화번호 포맷팅을 위한 커스텀 훅 + */ +function usePhoneFormatter(countryCode) { + const [formatter, setFormatter] = useState(null); + + useEffect(() => { + if (countryCode) { + setFormatter(new AsYouType(countryCode)); + } else { + setFormatter(null); + } + }, [countryCode]); + + const formatPhone = useCallback((value) => { + if (!formatter) return value; + + // AsYouType은 매번 새로운 인스턴스를 사용해야 함 + const newFormatter = new AsYouType(countryCode); + return newFormatter.input(value); + }, [countryCode, formatter]); + + return formatPhone; +} + +// ========== 전화번호 입력 컴포넌트 ========== + +function PhoneInput({ + value, + onChange, + countryCode, + placeholder, + disabled = false, + onBlur = null, + className = "", + showValidation = true +}) { + const [touched, setTouched] = useState(false); + const [localValue, setLocalValue] = useState(value || ''); + const formatPhone = usePhoneFormatter(countryCode); + + // value prop이 변경될 때 localValue 동기화 + useEffect(() => { + setLocalValue(value || ''); + }, [value]); + + const validation = validatePhoneNumber(localValue, countryCode); + + const handleInputChange = (e) => { + const inputValue = e.target.value; + + // 실시간 포맷팅 적용 + const formattedValue = countryCode ? formatPhone(inputValue) : inputValue; + + setLocalValue(formattedValue); + onChange(formattedValue); + }; + + const handleBlur = () => { + setTouched(true); + if (onBlur) onBlur(); + }; + + const showError = showValidation && touched && localValue && !validation.isValid; + const showSuccess = showValidation && touched && localValue && validation.isValid; + + return ( + <div className="space-y-2"> + <Input + type="tel" + value={localValue} + onChange={handleInputChange} + onBlur={handleBlur} + placeholder={placeholder || getPhonePlaceholder(countryCode)} + disabled={disabled} + className={cn( + className, + showError && "border-red-500 focus:border-red-500", + showSuccess && "border-green-500 focus:border-green-500" + )} + /> + + {showValidation && ( + <div className="text-xs space-y-1"> + {/* 설명 텍스트 */} + <p className="text-muted-foreground"> + {getPhoneDescription(countryCode)} + </p> + + {/* 오류 메시지 */} + {showError && ( + <p className="text-red-500 font-medium"> + {validation.error} + </p> + )} + + {/* 성공 메시지 */} + {showSuccess && ( + <p className="text-green-600 font-medium"> + ✓ 올바른 전화번호입니다. + {validation.international && ` (${validation.international})`} + </p> + )} + </div> + )} + </div> + ); +} + +// ========== 메인 컴포넌트 ========== + export default function JoinForm() { const params = useParams() || {}; const lng = params.lng ? String(params.lng) : "ko"; @@ -147,6 +359,7 @@ export default function JoinForm() { const router = useRouter(); const searchParams = useSearchParams() || new URLSearchParams(); const defaultTaxId = searchParams.get("taxID") ?? ""; + const { toast } = useToast(); const [currentStep, setCurrentStep] = useState(1); const [completedSteps, setCompletedSteps] = useState(new Set()); @@ -161,9 +374,8 @@ export default function JoinForm() { const [accountData, setAccountData] = useState({ name: '', email: '', - password: '', - confirmPassword: '', - phone: '' + phone: '', + country: 'KR' // 기본값을 한국으로 설정 }); const [vendorData, setVendorData] = useState({ @@ -174,7 +386,7 @@ export default function JoinForm() { address: "", email: "", phone: "", - country: "", + country: "KR", // 기본값을 한국으로 설정 website: "", representativeName: "", representativeBirth: "", @@ -211,20 +423,9 @@ export default function JoinForm() { // 정책 버전 및 업체 타입 로드 useEffect(() => { - fetchPolicyVersions(); loadVendorTypes(); }, []); - const fetchPolicyVersions = async () => { - try { - const response = await fetch('/api/consent/policy-versions'); - const versions = await response.json(); - setPolicyVersions(versions); - } catch (error) { - console.error('Failed to fetch policy versions:', error); - } - }; - const loadVendorTypes = async () => { setIsLoadingVendorTypes(true); try { @@ -257,42 +458,8 @@ export default function JoinForm() { } }; - // 전화번호 플레이스홀더 함수들 - const getPhonePlaceholder = (countryCode) => { - if (!countryCode || !countryDialCodes[countryCode]) return "+82 010-1234-5678"; - - const dialCode = countryDialCodes[countryCode]; - - switch (countryCode) { - case 'KR': return `${dialCode} 010-1234-5678`; - case 'US': - case 'CA': return `${dialCode} 555-123-4567`; - case 'JP': return `${dialCode} 90-1234-5678`; - case 'CN': return `${dialCode} 138-0013-8000`; - case 'GB': return `${dialCode} 20-7946-0958`; - case 'DE': return `${dialCode} 30-12345678`; - case 'FR': return `${dialCode} 1-42-86-83-16`; - default: return `${dialCode} 전화번호`; - } - }; - - const getPhoneDescription = (countryCode) => { - if (!countryCode) return "국가를 먼저 선택해주세요."; - - const dialCode = countryDialCodes[countryCode]; - - switch (countryCode) { - case 'KR': return `${dialCode}로 시작하는 국제번호 또는 010으로 시작하는 국내번호를 입력하세요.`; - case 'US': - case 'CA': return `${dialCode}로 시작하는 10자리 번호를 입력하세요.`; - case 'JP': return `${dialCode}로 시작하는 일본 전화번호를 입력하세요.`; - case 'CN': return `${dialCode}로 시작하는 중국 전화번호를 입력하세요.`; - default: return `${dialCode}로 시작하는 국제 전화번호를 입력하세요.`; - } - }; - return ( - <div className="container max-w-6xl mx-auto py-8"> + <div className="w-full max-w-4xl mx-auto py-8 px-4"> {/* 진행률 표시 */} <div className="mb-8"> <div className="flex items-center justify-between mb-4"> @@ -302,7 +469,7 @@ export default function JoinForm() { </span> </div> <Progress value={progress} className="mb-6" /> - + {/* 스텝 네비게이션 */} <div className="flex items-center justify-between"> {STEPS.map((step, index) => { @@ -310,21 +477,21 @@ export default function JoinForm() { const isCompleted = completedSteps.has(step.id); const isCurrent = currentStep === step.id; const isAccessible = step.id <= Math.max(...completedSteps) + 1; - + return ( <React.Fragment key={step.id}> - <div + <div className={cn( - "flex flex-col items-center cursor-pointer transition-all", + "flex flex-col items-center cursor-pointer transition-all ", isAccessible ? "opacity-100" : "opacity-50 cursor-not-allowed" )} onClick={() => handleStepClick(step.id)} > <div className={cn( "w-12 h-12 rounded-full flex items-center justify-center mb-2 border-2 transition-all", - isCompleted - ? "bg-green-500 border-green-500 text-white" - : isCurrent + isCompleted + ? "bg-green-500 border-green-500 text-white" + : isCurrent ? "bg-blue-500 border-blue-500 text-white" : "border-gray-300 text-gray-400" )}> @@ -346,7 +513,7 @@ export default function JoinForm() { </div> </div> </div> - + {index < STEPS.length - 1 && ( <ChevronRight className="w-5 h-5 text-gray-300 mx-4" /> )} @@ -359,31 +526,30 @@ export default function JoinForm() { {/* 스텝 콘텐츠 */} <div className="bg-white rounded-lg border shadow-sm p-6"> {currentStep === 1 && ( - <ConsentStep + <ConsentStep data={consentData} onChange={setConsentData} onNext={() => handleStepComplete(1)} - policyVersions={policyVersions} /> )} - + {currentStep === 2 && ( - <AccountStep + <AccountStep data={accountData} onChange={setAccountData} onNext={() => handleStepComplete(2)} onBack={() => setCurrentStep(1)} + enhancedCountryArray={enhancedCountryArray} /> )} - + {currentStep === 3 && ( - <VendorStep + <VendorStep data={vendorData} onChange={setVendorData} onBack={() => setCurrentStep(2)} onComplete={() => { handleStepComplete(3); - // 완료 후 대시보드로 이동 router.push(`/${lng}/partners/dashboard`); }} accountData={accountData} @@ -398,8 +564,6 @@ export default function JoinForm() { setCreditReportFiles={setCreditReportFiles} bankAccountFiles={bankAccountFiles} setBankAccountFiles={setBankAccountFiles} - getPhonePlaceholder={getPhonePlaceholder} - getPhoneDescription={getPhoneDescription} enhancedCountryArray={enhancedCountryArray} contactTaskOptions={contactTaskOptions} lng={lng} @@ -411,45 +575,45 @@ export default function JoinForm() { ); } - // Step 2: 계정 생성 -function AccountStep({ data, onChange, onNext, onBack }) { +function AccountStep({ + data, onChange, onNext, onBack, + enhancedCountryArray +}) { const [isLoading, setIsLoading] = useState(false); + const [emailCheckError, setEmailCheckError] = useState(''); - const isValid = data.name && data.email && data.password && - data.confirmPassword && data.phone && - data.password === data.confirmPassword; - + // 입력 핸들러 const handleInputChange = (field, value) => { onChange(prev => ({ ...prev, [field]: value })); }; + // 전화번호 validation + const phoneValidation = validatePhoneNumber(data.phone, data.country); + + // 전체 입력 유효성 + const isValid = + data.name && + data.email && + data.country && + data.phone && + phoneValidation.isValid; + + // 이메일 중복체크 + 다음단계 const handleNext = async () => { if (!isValid) return; setIsLoading(true); + setEmailCheckError(''); try { - // 이메일 중복 확인 - const response = await fetch('/api/auth/check-email', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email: data.email }) - }); - - const result = await response.json(); - - if (!response.ok) { - if (result.error === 'EMAIL_EXISTS') { - alert('이미 사용 중인 이메일입니다.'); - return; - } - throw new Error(result.error); + const isUsed = await checkEmailExists(data.email); + if (isUsed) { + setEmailCheckError('이미 사용 중인 이메일입니다.'); + return; } - onNext(); } catch (error) { - console.error('Email check error:', error); - alert('이메일 확인 중 오류가 발생했습니다.'); + setEmailCheckError('이메일 확인 중 오류가 발생했습니다.'); } finally { setIsLoading(false); } @@ -487,48 +651,69 @@ function AccountStep({ data, onChange, onNext, onBack }) { value={data.email} onChange={(e) => handleInputChange('email', e.target.value)} /> + {emailCheckError && ( + <p className="text-xs text-red-500 mt-1">{emailCheckError}</p> + )} </div> <div> <label className="block text-sm font-medium mb-1"> - 비밀번호 <span className="text-red-500">*</span> + 국가 <span className="text-red-500">*</span> </label> - <Input - type="password" - placeholder="비밀번호를 입력하세요" - value={data.password} - onChange={(e) => handleInputChange('password', e.target.value)} - /> + <Popover> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + className={cn( + "w-full justify-between", + !data.country && "text-muted-foreground" + )} + > + {enhancedCountryArray.find(c => c.code === data.country)?.label || "국가 선택"} + <ChevronsUpDown className="ml-2 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-full p-0"> + <Command> + <CommandInput placeholder="국가 검색..." /> + <CommandList> + <CommandEmpty>No country found.</CommandEmpty> + <CommandGroup> + {enhancedCountryArray.map((country) => ( + <CommandItem + key={country.code} + value={country.label} + onSelect={() => handleInputChange('country', country.code)} + > + <Check + className={cn( + "mr-2", + country.code === data.country + ? "opacity-100" + : "opacity-0" + )} + /> + {country.label} + </CommandItem> + ))} + </CommandGroup> + </CommandList> + </Command> + </PopoverContent> + </Popover> </div> <div> <label className="block text-sm font-medium mb-1"> - 비밀번호 확인 <span className="text-red-500">*</span> - </label> - <Input - type="password" - placeholder="비밀번호를 다시 입력하세요" - value={data.confirmPassword} - onChange={(e) => handleInputChange('confirmPassword', e.target.value)} - /> - {data.confirmPassword && data.password !== data.confirmPassword && ( - <p className="text-xs text-red-500 mt-1">비밀번호가 일치하지 않습니다.</p> - )} - </div> - - <div className="md:col-span-2"> - <label className="block text-sm font-medium mb-1"> 전화번호 <span className="text-red-500">*</span> </label> - <Input - type="tel" - placeholder="+82-10-1234-5678" + <PhoneInput value={data.phone} - onChange={(e) => handleInputChange('phone', e.target.value)} + onChange={(value) => handleInputChange('phone', value)} + countryCode={data.country} + showValidation={true} /> - <p className="text-xs text-gray-500 mt-1"> - SMS 인증에 사용됩니다. 국제번호 형식으로 입력해주세요. - </p> </div> </div> @@ -544,19 +729,18 @@ function AccountStep({ data, onChange, onNext, onBack }) { ); } -// Step 3: 업체 등록 (기존 JoinForm 내용) +// Step 3: 업체 등록 function VendorStep(props) { return <CompleteVendorForm {...props} />; } - -// 완전한 업체 등록 폼 컴포넌트 (기존 JoinForm 내용) +// 나머지 CompeleteVendorForm과 FileUploadSection은 기존과 동일하되, +// PhoneInput 컴포넌트를 사용하도록 수정 function CompleteVendorForm({ data, onChange, onBack, onComplete, accountData, consentData, vendorTypes, isLoadingVendorTypes, businessRegistrationFiles, setBusinessRegistrationFiles, isoCertificationFiles, setIsoCertificationFiles, creditReportFiles, setCreditReportFiles, - bankAccountFiles, setBankAccountFiles, getPhonePlaceholder, getPhoneDescription, - enhancedCountryArray, contactTaskOptions, lng, policyVersions + bankAccountFiles, setBankAccountFiles, enhancedCountryArray, contactTaskOptions, lng, policyVersions }) { const [isSubmitting, setIsSubmitting] = useState(false); @@ -585,7 +769,7 @@ function CompleteVendorForm({ const updateContact = (index, field, value) => { onChange(prev => ({ ...prev, - contacts: prev.contacts.map((contact, i) => + contacts: prev.contacts.map((contact, i) => i === index ? { ...contact, [field]: value } : contact ) })); @@ -626,33 +810,40 @@ function CompleteVendorForm({ // 유효성 검사 const validateRequiredFiles = () => { const errors = []; - + if (businessRegistrationFiles.length === 0) { errors.push("사업자등록증을 업로드해주세요."); } - + if (isoCertificationFiles.length === 0) { errors.push("ISO 인증서를 업로드해주세요."); } - + if (creditReportFiles.length === 0) { errors.push("신용평가보고서를 업로드해주세요."); } - + if (data.country !== "KR" && bankAccountFiles.length === 0) { errors.push("대금지급 통장사본을 업로드해주세요."); } - + return errors; }; - const isFormValid = data.vendorName && data.vendorTypeId && data.items && - data.country && data.phone && data.email && - data.contacts.length > 0 && - data.contacts[0].contactName && - validateRequiredFiles().length === 0; + // 전화번호 검증 + const vendorPhoneValidation = validatePhoneNumber(data.phone, data.country); + const contactsValid = data.contacts.length > 0 && + data.contacts[0].contactName && + data.contacts.every(contact => + contact.contactPhone ? validatePhoneNumber(contact.contactPhone, data.country).isValid : true + ); - // 최종 제출 + const isFormValid = data.vendorName && data.vendorTypeId && data.items && + data.country && data.phone && vendorPhoneValidation.isValid && data.email && + contactsValid && + validateRequiredFiles().length === 0; + + // 최종 제출 (기존과 동일) const handleSubmit = async () => { const fileErrors = validateRequiredFiles(); if (fileErrors.length > 0) { @@ -668,12 +859,11 @@ function CompleteVendorForm({ try { const formData = new FormData(); - // 통합 데이터 준비 const completeData = { account: accountData, vendor: { ...data, - email: data.email || accountData.email, // 업체 이메일이 없으면 계정 이메일 사용 + email: data.email || accountData.email, }, consents: { privacy_policy: { @@ -693,7 +883,6 @@ function CompleteVendorForm({ formData.append('completeData', JSON.stringify(completeData)); - // 파일들 추가 businessRegistrationFiles.forEach(file => { formData.append('businessRegistration', file); }); @@ -773,8 +962,8 @@ function CompleteVendorForm({ )} disabled={isSubmitting || isLoadingVendorTypes} > - {isLoadingVendorTypes - ? "Loading..." + {isLoadingVendorTypes + ? "Loading..." : vendorTypes.find(type => type.id === data.vendorTypeId)?.[lng === "ko" ? "nameKo" : "nameEn"] || "업체유형 선택"} <ChevronsUpDown className="ml-2 opacity-50" /> </Button> @@ -820,7 +1009,7 @@ function CompleteVendorForm({ disabled={isSubmitting} /> <p className="text-xs text-gray-500 mt-1"> - {data.country === "KR" + {data.country === "KR" ? "사업자 등록증에 표기된 정확한 회사명을 입력하세요." : "해외 업체의 경우 영문 회사명을 입력하세요."} </p> @@ -914,20 +1103,18 @@ function CompleteVendorForm({ </Popover> </div> - {/* 대표 전화 */} + {/* 대표 전화 - PhoneInput 사용 */} <div> <label className="block text-sm font-medium mb-1"> 대표 전화 <span className="text-red-500">*</span> </label> - <Input + <PhoneInput value={data.phone} - onChange={(e) => handleInputChange('phone', e.target.value)} - placeholder={getPhonePlaceholder(data.country)} + onChange={(value) => handleInputChange('phone', value)} + countryCode={data.country} disabled={isSubmitting} + showValidation={true} /> - <p className="text-xs text-gray-500 mt-1"> - {getPhoneDescription(data.country)} - </p> </div> {/* 대표 이메일 */} @@ -958,7 +1145,7 @@ function CompleteVendorForm({ </div> </div> - {/* 담당자 정보 */} + {/* 담당자 정보 - PhoneInput 사용 */} <div className="rounded-md border p-6 space-y-4"> <div className="flex items-center justify-between"> <h4 className="text-md font-semibold">담당자 정보 (최소 1명)</h4> @@ -1017,7 +1204,7 @@ function CompleteVendorForm({ <label className="block text-sm font-medium mb-1"> 담당업무 <span className="text-red-500">*</span> </label> - <Select + <Select value={contact.contactTask} onValueChange={(value) => updateContact(index, 'contactTask', value)} disabled={isSubmitting} @@ -1050,11 +1237,12 @@ function CompleteVendorForm({ <label className="block text-sm font-medium mb-1"> 전화번호 <span className="text-red-500">*</span> </label> - <Input + <PhoneInput value={contact.contactPhone} - onChange={(e) => updateContact(index, 'contactPhone', e.target.value)} - placeholder={getPhonePlaceholder(data.country)} + onChange={(value) => updateContact(index, 'contactPhone', value)} + countryCode={data.country} disabled={isSubmitting} + showValidation={true} /> </div> </div> @@ -1076,7 +1264,7 @@ function CompleteVendorForm({ </div> </div> - {/* 한국 사업자 정보 */} + {/* 한국 사업자 정보 - PhoneInput 사용 */} {data.country === "KR" && ( <div className="rounded-md border p-6 space-y-4"> <h4 className="text-md font-semibold">한국 사업자 정보</h4> @@ -1116,10 +1304,12 @@ function CompleteVendorForm({ <label className="block text-sm font-medium mb-1"> 대표자 전화번호 <span className="text-red-500">*</span> </label> - <Input + <PhoneInput value={data.representativePhone} - onChange={(e) => handleInputChange('representativePhone', e.target.value)} + onChange={(value) => handleInputChange('representativePhone', value)} + countryCode="KR" disabled={isSubmitting} + showValidation={true} /> </div> <div> @@ -1152,7 +1342,7 @@ function CompleteVendorForm({ {/* 필수 첨부 서류 */} <div className="rounded-md border p-6 space-y-6"> <h4 className="text-md font-semibold">필수 첨부 서류</h4> - + <FileUploadSection title="사업자등록증" description="사업자등록증 스캔본 또는 사진을 업로드해주세요." @@ -1215,16 +1405,16 @@ function CompleteVendorForm({ ); } -// 파일 업로드 섹션 컴포넌트 -function FileUploadSection({ - title, - description, - files, - onDropAccepted, - onDropRejected, +// 파일 업로드 섹션 컴포넌트 (기존과 동일) +function FileUploadSection({ + title, + description, + files, + onDropAccepted, + onDropRejected, removeFile, isSubmitting, - required = true + required = true }) { return ( <div className="space-y-4"> @@ -1235,7 +1425,7 @@ function FileUploadSection({ </h5> <p className="text-xs text-muted-foreground mt-1">{description}</p> </div> - + <Dropzone maxSize={MAX_FILE_SIZE} multiple @@ -1259,7 +1449,7 @@ function FileUploadSection({ </DropzoneZone> )} </Dropzone> - + {files.length > 0 && ( <div className="mt-2"> <ScrollArea className="max-h-32"> @@ -1286,4 +1476,4 @@ function FileUploadSection({ )} </div> ); -} +}
\ No newline at end of file |
