From e0b2367d88dd80eece67390574e60c9eacdee14d Mon Sep 17 00:00:00 2001 From: rlaks5757 Date: Wed, 26 Mar 2025 16:51:54 +0900 Subject: po, vendor-data-form-report --- db/schema/vendorData.ts | 325 ++++++++++++++++++++++++++++-------------------- 1 file changed, 192 insertions(+), 133 deletions(-) (limited to 'db') diff --git a/db/schema/vendorData.ts b/db/schema/vendorData.ts index f7baa883..048e5ef8 100644 --- a/db/schema/vendorData.ts +++ b/db/schema/vendorData.ts @@ -3,32 +3,41 @@ import { text, varchar, timestamp, - integer, numeric, date, unique, serial, jsonb, uniqueIndex -} from "drizzle-orm/pg-core" -import { contractItems } from "./contract" - -export const forms = pgTable("forms", { - id: integer("id").primaryKey().generatedAlwaysAsIdentity(), - contractItemId: integer("contract_item_id") + integer, + numeric, + date, + unique, + serial, + jsonb, + uniqueIndex, + } from "drizzle-orm/pg-core"; + import { contractItems } from "./contract"; + + export const forms = pgTable( + "forms", + { + id: integer("id").primaryKey().generatedAlwaysAsIdentity(), + contractItemId: integer("contract_item_id") .notNull() .references(() => contractItems.id, { onDelete: "cascade" }), - formCode: varchar("form_code", { length: 100 }).notNull(), - formName: varchar("form_name", { length: 255 }).notNull(), - // tagType: varchar("tag_type", { length: 50 }).notNull(), - // class: varchar("class", { length: 100 }).notNull(), - createdAt: timestamp("created_at").defaultNow().notNull(), - updatedAt: timestamp("updated_at").defaultNow().notNull(), -}, (table) => { - return { + formCode: varchar("form_code", { length: 100 }).notNull(), + formName: varchar("form_name", { length: 255 }).notNull(), + // tagType: varchar("tag_type", { length: 50 }).notNull(), + // class: varchar("class", { length: 100 }).notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), + }, + (table) => { + return { // contractItemId와 formCode의 조합을 유니크하게 설정 - contractItemFormCodeUnique: uniqueIndex("contract_item_form_code_unique").on( - table.contractItemId, - table.formCode - ), + contractItemFormCodeUnique: uniqueIndex( + "contract_item_form_code_unique" + ).on(table.contractItemId, table.formCode), + }; } -}) - -export const rfqAttachments = pgTable("form_templates", { + ); + + export const rfqAttachments = pgTable("form_templates", { id: serial("id").primaryKey(), formId: integer("form_id").references(() => forms.id), fileName: varchar("file_name", { length: 255 }).notNull(), @@ -36,170 +45,220 @@ export const rfqAttachments = pgTable("form_templates", { createdAt: timestamp("created_at").defaultNow().notNull(), udpatedAt: timestamp("updated_at").defaultNow().notNull(), + }); -}); - - -export const formMetas = pgTable("form_metas", { + export const formMetas = pgTable("form_metas", { id: serial("id").primaryKey(), formCode: varchar("form_code", { length: 50 }).notNull(), formName: varchar("form_name", { length: 255 }).notNull(), columns: jsonb("columns").notNull(), - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), -}) - -export const formEntries = pgTable("form_entries", { + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }); + + export const formEntries = pgTable("form_entries", { id: serial("id").primaryKey(), formCode: varchar("form_code", { length: 50 }).notNull(), data: jsonb("data").notNull(), contractItemId: integer("contract_item_id") - .notNull() - .references(() => contractItems.id, { onDelete: "cascade" }), - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), -}) - - -// ============ tags (각 계약 아이템에 대한 Tag) ============ -// "어느 계약의 어느 아이템에 대한 태그"임을 나타내려면 contract_items를 참조 -export const tags = pgTable("tags", { + .notNull() + .references(() => contractItems.id, { onDelete: "cascade" }), + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }); + + // ============ tags (각 계약 아이템에 대한 Tag) ============ + // "어느 계약의 어느 아이템에 대한 태그"임을 나타내려면 contract_items를 참조 + export const tags = pgTable("tags", { id: integer("id").primaryKey().generatedAlwaysAsIdentity(), - + // 이 Tag가 속한 "계약 내 아이템" (즉 contract_items.id) contractItemId: integer("contract_item_id") - .notNull() - .references(() => contractItems.id, { onDelete: "cascade" }), - - formId: integer("form_id") - .references(() => forms.id, { onDelete: "set null" }), - + .notNull() + .references(() => contractItems.id, { onDelete: "cascade" }), + + formId: integer("form_id").references(() => forms.id, { + onDelete: "set null", + }), + tagNo: varchar("tag_no", { length: 100 }).notNull(), tagType: varchar("tag_type", { length: 50 }).notNull(), class: varchar("class", { length: 100 }).notNull(), description: text("description"), - + createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), -}) - -export type Tag = typeof tags.$inferSelect -export type Form = typeof forms.$inferSelect -export type NewTag = typeof tags.$inferInsert - -export const tagTypes = pgTable("tag_types", { + }); + + export type Tag = typeof tags.$inferSelect; + export type Form = typeof forms.$inferSelect; + export type NewTag = typeof tags.$inferInsert; + + export const tagTypes = pgTable("tag_types", { code: varchar("code", { length: 50 }).primaryKey(), description: text("description").notNull(), - - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), -}) - -export const tagSubfields = pgTable("tag_subfields", { - id: serial("id").primaryKey(), - - // 외래키: tagTypeCode -> tagTypes.code - tagTypeCode: varchar("tag_type_code", { length: 50 }) + + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }); + + export const tagSubfields = pgTable( + "tag_subfields", + { + id: serial("id").primaryKey(), + + // 외래키: tagTypeCode -> tagTypes.code + tagTypeCode: varchar("tag_type_code", { length: 50 }) .notNull() .references(() => tagTypes.code, { onDelete: "cascade" }), - - /** - * 나머지 필드 - */ - // tagTypeDescription: -> 이제 불필요. tagTypes.description로 join - attributesId: varchar("attributes_id", { length: 50 }).notNull(), - attributesDescription: text("attributes_description").notNull(), - - expression: text("expression"), - delimiter: varchar("delimiter", { length: 10 }), - - sortOrder: integer("sort_order").default(0).notNull(), - - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), -}, (table) => { - return { + + /** + * 나머지 필드 + */ + // tagTypeDescription: -> 이제 불필요. tagTypes.description로 join + attributesId: varchar("attributes_id", { length: 50 }).notNull(), + attributesDescription: text("attributes_description").notNull(), + + expression: text("expression"), + delimiter: varchar("delimiter", { length: 10 }), + + sortOrder: integer("sort_order").default(0).notNull(), + + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => { + return { uniqTagTypeAttribute: unique("uniq_tag_type_attribute").on( - table.tagTypeCode, - table.attributesId + table.tagTypeCode, + table.attributesId ), - }; -}); - -export const tagSubfieldOptions = pgTable("tag_subfield_options", { + }; + } + ); + + export const tagSubfieldOptions = pgTable("tag_subfield_options", { id: serial("id").primaryKey(), - + // 어떤 subfield에 속하는 옵션인지 attributesId: varchar("attributes_id", { length: 50 }) - .notNull() - .references(() => tagSubfields.attributesId, { onDelete: "cascade" }), - + .notNull() + .references(() => tagSubfields.attributesId, { onDelete: "cascade" }), + /** * 실제 코드 (예: "PM", "AA", "VB", "VAR", "01", "02" ...) */ code: varchar("code", { length: 50 }).notNull(), - + /** * 사용자에게 보여줄 레이블 (예: "Pump", "Pneumatic Motor", "Ball Valve", ...) */ label: text("label").notNull(), - + /** * 생성/수정 시각 (선택) */ - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), -}) - -export const tagClasses = pgTable("tag_classes", { + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }); + + export const tagClasses = pgTable("tag_classes", { id: integer("id").primaryKey().generatedAlwaysAsIdentity(), - + // 기존 code/label code: varchar("code", { length: 100 }).notNull(), label: text("label").notNull(), - + // 새 필드: tagTypeCode -> references tagTypes.code tagTypeCode: varchar("tag_type_code", { length: 50 }) - .notNull() - .references(() => tagTypes.code, { onDelete: "cascade" }), - + .notNull() + .references(() => tagTypes.code, { onDelete: "cascade" }), + createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), -}) - -export const tagTypeClassFormMappings = pgTable("tag_type_class_form_mappings", { - id: serial("id").primaryKey(), - - tagTypeLabel: varchar("tag_type_label", { length: 255 }).notNull(), - classLabel: varchar("class_label", { length: 255 }).notNull(), - - formCode: varchar("form_code", { length: 50 }).notNull(), - formName: varchar("form_name", { length: 255 }).notNull(), - - createdAt: timestamp("created_at", { withTimezone: true }).defaultNow().notNull(), - updatedAt: timestamp("updated_at", { withTimezone: true }).defaultNow().notNull(), -}) - -export type TagTypeClassFormMappings = typeof tagTypeClassFormMappings.$inferSelect -export type TagSubfields = typeof tagSubfields.$inferSelect -export type TagSubfieldOption = typeof tagSubfieldOptions.$inferSelect -export type TagClasses = typeof tagClasses.$inferSelect - - -export const viewTagSubfields = pgTable("view_tag_subfields", { + }); + + export const tagTypeClassFormMappings = pgTable( + "tag_type_class_form_mappings", + { + id: serial("id").primaryKey(), + + tagTypeLabel: varchar("tag_type_label", { length: 255 }).notNull(), + classLabel: varchar("class_label", { length: 255 }).notNull(), + + formCode: varchar("form_code", { length: 50 }).notNull(), + formName: varchar("form_name", { length: 255 }).notNull(), + + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .notNull(), + } + ); + + export type TagTypeClassFormMappings = + typeof tagTypeClassFormMappings.$inferSelect; + export type TagSubfields = typeof tagSubfields.$inferSelect; + export type TagSubfieldOption = typeof tagSubfieldOptions.$inferSelect; + export type TagClasses = typeof tagClasses.$inferSelect; + + export const viewTagSubfields = pgTable("view_tag_subfields", { id: integer("id").primaryKey(), - + tagTypeCode: varchar("tag_type_code", { length: 50 }).notNull(), tagTypeDescription: text("tag_type_description"), attributesId: varchar("attributes_id", { length: 50 }).notNull(), attributesDescription: text("attributes_description").notNull(), - + expression: text("expression"), delimiter: varchar("delimiter", { length: 10 }), sortOrder: integer("sort_order").default(0).notNull(), - + createdAt: timestamp("created_at", { withTimezone: true }), updatedAt: timestamp("updated_at", { withTimezone: true }), -}) - -export type ViewTagSubfields = typeof viewTagSubfields.$inferSelect + }); + + export type ViewTagSubfields = typeof viewTagSubfields.$inferSelect; + + export const vendorDataReportTemps = pgTable("vendor_data_report_temps", { + id: serial("id").primaryKey(), + contractItemId: integer("contract_item_id") + .notNull() + .references(() => contractItems.id, { onDelete: "cascade" }), + formId: integer("form_id") + .notNull() + .references(() => forms.id, { onDelete: "cascade" }), + fileName: varchar("file_name", { length: 255 }).notNull(), + filePath: varchar("file_path", { length: 1024 }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + updatedAt: timestamp("updated_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }); + + export type VendorDataReportTemps = typeof vendorDataReportTemps.$inferSelect; + \ No newline at end of file -- cgit v1.2.3 From 23db698279eb8ea5f73f678ce6deb93267c4705e Mon Sep 17 00:00:00 2001 From: rlaks5757 Date: Thu, 27 Mar 2025 11:53:12 +0900 Subject: report batch download 개발 중 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.development | 4 +- .env.production | 4 +- .gitignore | 1 + .../form-data/form-data-report-batch-dialog.tsx | 307 ++++++++++++++ components/form-data/form-data-report-dialog.tsx | 2 +- components/form-data/form-data-table.tsx | 23 ++ db/migrations/meta/_journal.json | 2 +- package-lock.json | 445 ++++++++++++++++++++- package.json | 3 + pages/api/pdftron/createVendorDataReports.ts | 36 ++ 10 files changed, 822 insertions(+), 5 deletions(-) create mode 100644 components/form-data/form-data-report-batch-dialog.tsx create mode 100644 pages/api/pdftron/createVendorDataReports.ts (limited to 'db') diff --git a/.env.development b/.env.development index 435884dd..183c6f93 100644 --- a/.env.development +++ b/.env.development @@ -10,7 +10,9 @@ NEXT_PUBLIC_MUI_KEY=da30586e1f20b93856a9783012fc9258Tz04ODI0MyxFPTE3NDQ0NTM2Nzgw NEXT_PUBLIC_URL=http://3.36.56.124:3000 NEXT_PUBLIC_BASE_URL=http://3.36.56.124:3001 - NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY=demo:1739264618684:616161d7030000000091db1c97c6f386d41d3506ab5b507381ef2ee2bd +# PDFTRON KEYS +NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY=demo:1739264618684:616161d7030000000091db1c97c6f386d41d3506ab5b507381ef2ee2bd +NEXT_PUBLIC_PDFTRON_SERVER_KEY=demo:1740034881027:6175a0fc0300000000f155d153480e5ba091f17922a109cbd7cf6e40b3 # 기간계 시스템 연동 설정 ERP_API_URL=https://erp.example.com/api/vendors diff --git a/.env.production b/.env.production index 997653c5..1ada78ec 100644 --- a/.env.production +++ b/.env.production @@ -10,7 +10,9 @@ NEXT_PUBLIC_MUI_KEY=da30586e1f20b93856a9783012fc9258Tz04ODI0MyxFPTE3NDQ0NTM2Nzgw NEXT_PUBLIC_URL=https://evcp.dtsolution.io NEXT_PUBLIC_BASE_URL=https://evcp.dtsolution.io - NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY=demo:1739264618684:616161d7030000000091db1c97c6f386d41d3506ab5b507381ef2ee2bd +# PDFTRON KEYS +NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY=demo:1739264618684:616161d7030000000091db1c97c6f386d41d3506ab5b507381ef2ee2bd +NEXT_PUBLIC_PDFTRON_SERVER_KEY=demo:1740034881027:6175a0fc0300000000f155d153480e5ba091f17922a109cbd7cf6e40b3 # 기간계 시스템 연동 설정 ERP_API_URL=https://erp.example.com/api/vendors diff --git a/.gitignore b/.gitignore index 7a6dfa0e..4aa665ad 100644 --- a/.gitignore +++ b/.gitignore @@ -51,6 +51,7 @@ next-env.d.ts /public/uploads /public/vendors /public/profiles +/public/vendorFormData # 직접 참조가 불가능해 복사가 필요했던 라이브러리 (pdftrone) # node_modules/@pdftron/public 경로에서 core 및 ui 경로를 복사해 사용 diff --git a/components/form-data/form-data-report-batch-dialog.tsx b/components/form-data/form-data-report-batch-dialog.tsx new file mode 100644 index 00000000..e3fd7ea2 --- /dev/null +++ b/components/form-data/form-data-report-batch-dialog.tsx @@ -0,0 +1,307 @@ +"use client"; + +import React, { + FC, + Dispatch, + SetStateAction, + useState, + useEffect, + useRef, +} from "react"; +import { useToast } from "@/hooks/use-toast"; +import prettyBytes from "pretty-bytes"; +import { X, Loader2 } from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Dropzone, + DropzoneDescription, + DropzoneInput, + DropzoneTitle, + DropzoneUploadIcon, + DropzoneZone, +} from "@/components/ui/dropzone"; +import { + FileList, + FileListAction, + FileListDescription, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, +} from "@/components/ui/file-list"; +import { Button } from "@/components/ui/button"; +import { getReportTempList } from "@/lib/forms/services"; +import { DataTableColumnJSON } from "./form-data-table-columns"; + +const MAX_FILE_SIZE = 3000000; + +type ReportData = { + [key: string]: any; +}; + +interface tempFile { + fileName: string; + filePath: string; +} + +interface FormDataReportBatchDialogProps { + open: boolean; + setOpen: Dispatch>; + columnsJSON: DataTableColumnJSON[]; + reportData: ReportData[]; + packageId: number; + formId: number; + formCode: string; +} + +export const FormDataReportBatchDialog: FC = ({ + open, + setOpen, + columnsJSON, + reportData, + packageId, + formId, + formCode, +}) => { + const { toast } = useToast(); + const [tempList, setTempList] = useState([]); + const [selectTemp, setSelectTemp] = useState(""); + const [selectedFiles, setSelectedFiles] = useState([]); + const [isUploading, setIsUploading] = useState(false); + + useEffect(() => { + updateReportTempList(packageId, formId, setTempList); + }, [packageId, formId]); + + const onClose = () => { + if (isUploading) { + return; + } + setOpen(false); + }; + + // 드롭존 - 파일 드랍 처리 + const handleDropAccepted = (acceptedFiles: File[]) => { + const newFiles = [...acceptedFiles]; + setSelectedFiles(newFiles); + }; + + // 드롭존 - 파일 거부(에러) 처리 + const handleDropRejected = (fileRejections: any[]) => { + fileRejections.forEach((rejection) => { + toast({ + variant: "destructive", + title: "File Error", + description: `${rejection.file.name}: ${ + rejection.errors[0]?.message || "Upload failed" + }`, + }); + }); + }; + + // 파일 제거 핸들러 + const removeFile = (index: number) => { + const updatedFiles = [...selectedFiles]; + updatedFiles.splice(index, 1); + setSelectedFiles(updatedFiles); + }; + + const submitData = async () => { + setIsUploading(true); + + try { + const totalFiles = selectedFiles.length; + let successCount = 0; + + for (let i = 0; i < totalFiles; i++) { + const file = selectedFiles[i]; + + const formData = new FormData(); + formData.append("file", file); + formData.append("customFileName", file.name); + + // await uploadReportTemp(packageId, formId, formData); + + successCount++; + } + } catch (err) { + console.error(err); + toast({ + title: "Error", + description: "파일 업로드 중 오류가 발생했습니다.", + variant: "destructive", + }); + } finally { + setIsUploading(false); + setOpen(false); + } + }; + + return ( + + + + Batch Report Download + + Report Template을 선택하신 후 갑지를 업로드하여 주시기 바랍니다. + + +
+ + +
+
+ + + {({ maxSize }) => ( + <> + + +
+ +
+ 파일을 여기에 드롭하세요 + + 또는 클릭하여 파일을 선택하세요. 최대 크기:{" "} + {maxSize ? prettyBytes(maxSize) : "무제한"} + +
+
+
+ + + )} +
+
+ + {selectedFiles.length > 0 && ( +
+
+
+ 선택된 파일 ({selectedFiles.length}) +
+ {selectedFiles.length}개 파일 +
+ + + +
+ )} + + + + +
+
+ ); +}; + +interface UploadFileItemProps { + selectedFiles: File[]; + removeFile: (index: number) => void; + isUploading: boolean; +} + +const UploadFileItem: FC = ({ + selectedFiles, + removeFile, + isUploading, +}) => { + return ( + + {selectedFiles.map((file, index) => ( + + + + + {file.name} + + {prettyBytes(file.size)} + + + removeFile(index)} + disabled={isUploading} + > + + Remove + + + + ))} + + ); +}; + +type UpdateReportTempList = ( + packageId: number, + formId: number, + setPrevReportTemp: Dispatch> +) => void; + +const updateReportTempList: UpdateReportTempList = async ( + packageId, + formId, + setTempList +) => { + const tempList = await getReportTempList(packageId, formId); + + setTempList( + tempList.map((c) => { + const { fileName, filePath } = c; + return { fileName, filePath }; + }) + ); +}; diff --git a/components/form-data/form-data-report-dialog.tsx b/components/form-data/form-data-report-dialog.tsx index 5ddc5e0c..deb0873b 100644 --- a/components/form-data/form-data-report-dialog.tsx +++ b/components/form-data/form-data-report-dialog.tsx @@ -348,7 +348,7 @@ const importReportData: ImportReportData = async ( const objKey = label.split(" ").join("_"); - reportValueMapping[objKey] = reportValue[key]; + reportValueMapping[objKey] = reportValue?.[key] ?? ""; }); const doc = await createDocument(reportFileBlob, { diff --git a/components/form-data/form-data-table.tsx b/components/form-data/form-data-table.tsx index e3c5af8f..9feaf3b2 100644 --- a/components/form-data/form-data-table.tsx +++ b/components/form-data/form-data-table.tsx @@ -23,6 +23,7 @@ import ExcelJS from "exceljs"; import { saveAs } from "file-saver"; import { FormDataReportTempUploadDialog } from "./form-data-report-temp-upload-dialog"; import { FormDataReportDialog } from "./form-data-report-dialog"; +import { FormDataReportBatchDialog } from "./form-data-report-batch-dialog"; interface GenericData { [key: string]: any; @@ -57,6 +58,7 @@ export default function DynamicTable({ const [isSaving, setIsSaving] = React.useState(false); const [tempUpDialog, setTempUpDialog] = React.useState(false); const [reportData, setReportData] = React.useState([]); + const [batchDownDialog, setBatchDownDialog] = React.useState(false); // Reference to the table instance const tableRef = React.useRef(null); @@ -524,6 +526,13 @@ export default function DynamicTable({ {/* 버튼 그룹 */}
{/* 태그 불러오기 버튼 */} +