summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-04-08 03:08:19 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-04-08 03:08:19 +0000
commit9ceed79cf32c896f8a998399bf1b296506b2cd4a (patch)
treef84750fa6cac954d5e31221fc47a54c655fc06a9 /lib
parent230ce796836c25df26c130dbcd616ef97d12b2ec (diff)
로그인 및 미들웨어 처리. 구조 변경
Diffstat (limited to 'lib')
-rw-r--r--lib/equip-class/repository.ts75
-rw-r--r--lib/equip-class/service.ts26
-rw-r--r--lib/equip-class/table/equipClass-table-columns.tsx23
-rw-r--r--lib/equip-class/table/equipClass-table-toolbar-actions.tsx55
-rw-r--r--lib/equip-class/table/equipClass-table.tsx13
-rw-r--r--lib/equip-class/validation.ts46
-rw-r--r--lib/form-list/repository.ts58
-rw-r--r--lib/form-list/service.ts28
-rw-r--r--lib/form-list/table/formLists-table-columns.tsx14
-rw-r--r--lib/form-list/table/formLists-table-toolbar-actions.tsx47
-rw-r--r--lib/form-list/table/formLists-table.tsx22
-rw-r--r--lib/form-list/table/meta-sheet.tsx2
-rw-r--r--lib/form-list/validation.ts11
-rw-r--r--lib/forms/services.ts70
-rw-r--r--lib/mail/mailer.ts4
-rw-r--r--lib/mail/sendEmail.ts2
-rw-r--r--lib/pq/service.ts9
-rw-r--r--lib/projects/repository.ts44
-rw-r--r--lib/projects/service.ts87
-rw-r--r--lib/projects/table/feature-flags-provider.tsx108
-rw-r--r--lib/projects/table/projects-table-columns.tsx90
-rw-r--r--lib/projects/table/projects-table-toolbar-actions.tsx89
-rw-r--r--lib/projects/table/projects-table.tsx128
-rw-r--r--lib/projects/validation.ts36
-rw-r--r--lib/rfqs/service.ts122
-rw-r--r--lib/rfqs/table/ParentRfqSelector.tsx (renamed from lib/rfqs/table/BudgetaryRfqSelector.tsx)118
-rw-r--r--lib/rfqs/table/add-rfq-dialog.tsx227
-rw-r--r--lib/rfqs/table/rfqs-table.tsx1
-rw-r--r--lib/rfqs/table/update-rfq-sheet.tsx169
-rw-r--r--lib/rfqs/validations.ts10
-rw-r--r--lib/sedp/sedp-token.ts91
-rw-r--r--lib/sedp/sync-form.ts512
-rw-r--r--lib/sedp/sync-object-class.ts304
-rw-r--r--lib/sedp/sync-projects.ts194
-rw-r--r--lib/sedp/sync-tag-types.ts567
-rw-r--r--lib/tag-numbering/service.ts1
-rw-r--r--lib/tag-numbering/table/tagNumbering-table-toolbar-actions.tsx43
-rw-r--r--lib/tag-numbering/table/tagNumbering-table.tsx11
-rw-r--r--lib/tags/form-mapping-service.ts3
-rw-r--r--lib/tags/service.ts229
-rw-r--r--lib/tags/table/add-tag-dialog copy.tsx637
-rw-r--r--lib/tags/table/add-tag-dialog.tsx4
-rw-r--r--lib/tags/table/tags-table-toolbar-actions.tsx2
-rw-r--r--lib/tags/table/update-tag-sheet.tsx2
-rw-r--r--lib/tasks/table/update-task-sheet.tsx2
-rw-r--r--lib/tbe/table/tbe-table-columns.tsx4
-rw-r--r--lib/tbe/table/tbe-table.tsx2
-rw-r--r--lib/users/send-otp.ts118
-rw-r--r--lib/users/verifyOtp.ts31
-rw-r--r--lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-columns.tsx36
50 files changed, 3459 insertions, 1068 deletions
diff --git a/lib/equip-class/repository.ts b/lib/equip-class/repository.ts
index ddf98dd2..d4d6d58b 100644
--- a/lib/equip-class/repository.ts
+++ b/lib/equip-class/repository.ts
@@ -1,45 +1,56 @@
import db from "@/db/db";
+import { projects } from "@/db/schema";
import { Item, items } from "@/db/schema/items";
import { tagClasses } from "@/db/schema/vendorData";
import {
eq,
- inArray,
- not,
asc,
desc,
- and,
- ilike,
- gte,
- lte,
count,
- gt,
} from "drizzle-orm";
import { PgTransaction } from "drizzle-orm/pg-core";
export async function selectTagClassLists(
- tx: PgTransaction<any, any, any>,
- params: {
- where?: any; // drizzle-orm의 조건식 (and, eq...) 등
- orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[];
- offset?: number;
- limit?: number;
- }
- ) {
- const { where, orderBy, offset = 0, limit = 10 } = params;
-
- return tx
- .select()
- .from(tagClasses)
- .where(where)
- .orderBy(...(orderBy ?? []))
- .offset(offset)
- .limit(limit);
+ tx: PgTransaction<any, any, any>,
+ params: {
+ where?: any;
+ orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[];
+ offset?: number;
+ limit?: number;
}
- /** 총 개수 count */
- export async function countTagClassLists(
- tx: PgTransaction<any, any, any>,
- where?: any
- ) {
- const res = await tx.select({ count: count() }).from(tagClasses).where(where);
- return res[0]?.count ?? 0;
- } \ No newline at end of file
+) {
+ const { where, orderBy, offset = 0, limit = 10 } = params;
+
+ return tx
+ .select({
+ id: tagClasses.id,
+ projectId: tagClasses.projectId,
+ code: tagClasses.code,
+ label: tagClasses.label,
+ tagTypeCode: tagClasses.tagTypeCode,
+ createdAt: tagClasses.createdAt,
+ updatedAt: tagClasses.updatedAt,
+ // 프로젝트 정보 추가
+ projectCode: projects.code,
+ projectName: projects.name
+ })
+ .from(tagClasses)
+ .innerJoin(projects, eq(tagClasses.projectId, projects.id))
+ .where(where)
+ .orderBy(...(orderBy ?? []))
+ .offset(offset)
+ .limit(limit);
+}
+
+/** 총 개수 count */
+export async function countTagClassLists(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+) {
+ const res = await tx
+ .select({ count: count() })
+ .from(tagClasses)
+ .leftJoin(projects, eq(tagClasses.projectId, projects.id))
+ .where(where);
+ return res[0]?.count ?? 0;
+} \ No newline at end of file
diff --git a/lib/equip-class/service.ts b/lib/equip-class/service.ts
index c35f4fbe..deaacc58 100644
--- a/lib/equip-class/service.ts
+++ b/lib/equip-class/service.ts
@@ -8,6 +8,7 @@ import { tagClasses } from "@/db/schema/vendorData";
import { asc, desc, ilike, inArray, and, gte, lte, not, or } from "drizzle-orm";
import { GetTagClassesSchema } from "./validation";
import { countTagClassLists, selectTagClassLists } from "./repository";
+import { projects } from "@/db/schema";
export async function getTagClassists(input: GetTagClassesSchema) {
@@ -30,7 +31,9 @@ export async function getTagClassists(input: GetTagClassesSchema) {
let globalWhere
if (input.search) {
const s = `%${input.search}%`
- globalWhere = or(ilike(tagClasses.code, s), ilike(tagClasses.label, s)
+ globalWhere = or(ilike(tagClasses.code, s), ilike(tagClasses.label, s),
+ ilike(projects.name, s),
+ ilike(projects.code, s)
)
// 필요시 여러 칼럼 OR조건 (status, priority, etc)
}
@@ -49,12 +52,21 @@ export async function getTagClassists(input: GetTagClassesSchema) {
const orderBy =
- input.sort.length > 0
- ? input.sort.map((item) =>
- item.desc ? desc(tagClasses[item.id]) : asc(tagClasses[item.id])
- )
- : [asc(tagClasses.createdAt)];
-
+ input.sort.length > 0
+ ? input.sort.map((item) => {
+ // 프로젝트 관련 필드 정렬 처리
+ if (item.id === 'projectCode') {
+ return item.desc ? desc(projects.code) : asc(projects.code);
+ } else if (item.id === 'projectName') {
+ return item.desc ? desc(projects.name) : asc(projects.name);
+ } else {
+ // 기존 필드 정렬
+ return item.desc
+ ? desc(tagClasses[item.id as keyof typeof tagClasses.$inferSelect])
+ : asc(tagClasses[item.id as keyof typeof tagClasses.$inferSelect]);
+ }
+ })
+ : [asc(tagClasses.createdAt)];
// 트랜잭션 내부에서 Repository 호출
const { data, total } = await db.transaction(async (tx) => {
const data = await selectTagClassLists(tx, {
diff --git a/lib/equip-class/table/equipClass-table-columns.tsx b/lib/equip-class/table/equipClass-table-columns.tsx
index 1255abf3..d149c836 100644
--- a/lib/equip-class/table/equipClass-table-columns.tsx
+++ b/lib/equip-class/table/equipClass-table-columns.tsx
@@ -3,37 +3,28 @@
import * as React from "react"
import { type DataTableRowAction } from "@/types/table"
import { type ColumnDef } from "@tanstack/react-table"
-import { InfoIcon } from "lucide-react"
import { formatDate } from "@/lib/utils"
-import { Button } from "@/components/ui/button"
-import { Checkbox } from "@/components/ui/checkbox"
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger,
-} from "@/components/ui/tooltip"
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
-import { TagClasses } from "@/db/schema/vendorData"
import { equipclassColumnsConfig } from "@/config/equipClassColumnsConfig"
+import { ExtendedTagClasses } from "../validation"
interface GetColumnsProps {
- setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TagClasses> | null>>
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<ExtendedTagClasses> | null>>
}
/**
* tanstack table 컬럼 정의 (중첩 헤더 버전)
*/
-export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<TagClasses>[] {
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<ExtendedTagClasses>[] {
// ----------------------------------------------------------------
// 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
// ----------------------------------------------------------------
- // 3-1) groupMap: { [groupName]: ColumnDef<TagClasses>[] }
- const groupMap: Record<string, ColumnDef<TagClasses>[]> = {}
+ // 3-1) groupMap: { [groupName]: ColumnDef<ExtendedTagClasses>[] }
+ const groupMap: Record<string, ColumnDef<ExtendedTagClasses>[]> = {}
equipclassColumnsConfig.forEach((cfg) => {
// 만약 group가 없으면 "_noGroup" 처리
@@ -44,7 +35,7 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<TagClas
}
// child column 정의
- const childCol: ColumnDef<TagClasses> = {
+ const childCol: ColumnDef<ExtendedTagClasses> = {
accessorKey: cfg.id,
enableResizing: true,
header: ({ column }) => (
@@ -72,7 +63,7 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<TagClas
// ----------------------------------------------------------------
// 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기
// ----------------------------------------------------------------
- const nestedColumns: ColumnDef<TagClasses>[] = []
+ const nestedColumns: ColumnDef<ExtendedTagClasses>[] = []
// 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함
// 여기서는 그냥 Object.entries 순서
diff --git a/lib/equip-class/table/equipClass-table-toolbar-actions.tsx b/lib/equip-class/table/equipClass-table-toolbar-actions.tsx
index 5e03d800..03db30a3 100644
--- a/lib/equip-class/table/equipClass-table-toolbar-actions.tsx
+++ b/lib/equip-class/table/equipClass-table-toolbar-actions.tsx
@@ -2,35 +2,66 @@
import * as React from "react"
import { type Table } from "@tanstack/react-table"
-import { Download, RefreshCcw, Upload } from "lucide-react"
-import { toast } from "sonner"
+import { Download, RefreshCcw } from "lucide-react"
import { exportTableToExcel } from "@/lib/export"
import { Button } from "@/components/ui/button"
-import { TagClasses } from "@/db/schema/vendorData"
-
-
+import { ExtendedTagClasses } from "../validation"
+import { toast } from "sonner"
interface ItemsTableToolbarActionsProps {
- table: Table<TagClasses>
+ table: Table<ExtendedTagClasses>
}
export function EquipClassTableToolbarActions({ table }: ItemsTableToolbarActionsProps) {
- // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
- const fileInputRef = React.useRef<HTMLInputElement>(null)
+ const [isLoading, setIsLoading] = React.useState(false)
+
+ const syncObjectClasses = async () => {
+ try {
+ setIsLoading(true)
+ // API 엔드포인트 호출
+ const response = await fetch('/api/cron/object-classes')
+ if (!response.ok) {
+ const errorData = await response.json()
+ throw new Error(errorData.error || 'Failed to sync object classes')
+ }
+
+ const data = await response.json()
+
+ // 성공 메시지 표시
+ toast.success(
+ `object classes synced successfully! ${data.result.items} items processed.`
+ )
+
+ // 페이지 새로고침으로 테이블 데이터 업데이트
+ window.location.reload()
+ } catch (error) {
+ console.error('Error syncing object classes:', error)
+ toast.error(
+ error instanceof Error
+ ? error.message
+ : 'An error occurred while syncing object classes'
+ )
+ } finally {
+ setIsLoading(false)
+ }
+ }
return (
<div className="flex items-center gap-2">
- {/** 4) Export 버튼 */}
<Button
variant="samsung"
size="sm"
className="gap-2"
+ onClick={syncObjectClasses}
+ disabled={isLoading}
>
- <RefreshCcw className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">Get Equip Class</span>
+ <RefreshCcw className={`size-4 ${isLoading ? 'animate-spin' : ''}`} aria-hidden="true" />
+ <span className="hidden sm:inline">
+ {isLoading ? 'Syncing...' : 'Get Equip Class'}
+ </span>
</Button>
{/** 4) Export 버튼 */}
@@ -39,7 +70,7 @@ export function EquipClassTableToolbarActions({ table }: ItemsTableToolbarAction
size="sm"
onClick={() =>
exportTableToExcel(table, {
- filename: "tasks",
+ filename: "Equip Class",
excludeColumns: ["select", "actions"],
})
}
diff --git a/lib/equip-class/table/equipClass-table.tsx b/lib/equip-class/table/equipClass-table.tsx
index 56fd42aa..658718a6 100644
--- a/lib/equip-class/table/equipClass-table.tsx
+++ b/lib/equip-class/table/equipClass-table.tsx
@@ -12,10 +12,10 @@ import { DataTable } from "@/components/data-table/data-table"
import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
import { useFeatureFlags } from "./feature-flags-provider"
-import { TagClasses } from "@/db/schema/vendorData"
import { getTagClassists } from "../service"
import { EquipClassTableToolbarActions } from "./equipClass-table-toolbar-actions"
import { getColumns } from "./equipClass-table-columns"
+import { ExtendedTagClasses } from "../validation"
interface ItemsTableProps {
promises: Promise<
@@ -31,11 +31,8 @@ export function EquipClassTable({ promises }: ItemsTableProps) {
const [{ data, pageCount }] =
React.use(promises)
-
-console.log(data)
-
const [rowAction, setRowAction] =
- React.useState<DataTableRowAction<TagClasses> | null>(null)
+ React.useState<DataTableRowAction<ExtendedTagClasses> | null>(null)
const columns = React.useMemo(
() => getColumns({ setRowAction }),
@@ -53,7 +50,7 @@ console.log(data)
* @prop {React.ReactNode} [icon] - An optional icon to display next to the label.
* @prop {boolean} [withCount] - An optional boolean to display the count of the filter option.
*/
- const filterFields: DataTableFilterField<TagClasses>[] = [
+ const filterFields: DataTableFilterField<ExtendedTagClasses>[] = [
]
@@ -67,7 +64,7 @@ console.log(data)
* 3. Used with DataTableAdvancedToolbar: Enables a more sophisticated filtering UI.
* 4. Date and boolean types: Adds support for filtering by date ranges and boolean values.
*/
- const advancedFilterFields: DataTableAdvancedFilterField<TagClasses>[] = [
+ const advancedFilterFields: DataTableAdvancedFilterField<ExtendedTagClasses>[] = [
{
id: "code",
label: "Code",
@@ -125,9 +122,7 @@ console.log(data)
>
<EquipClassTableToolbarActions table={table} />
</DataTableAdvancedToolbar>
-
</DataTable>
-
</>
)
}
diff --git a/lib/equip-class/validation.ts b/lib/equip-class/validation.ts
index 48698ac4..3f62fb0f 100644
--- a/lib/equip-class/validation.ts
+++ b/lib/equip-class/validation.ts
@@ -8,27 +8,33 @@ import {
import * as z from "zod"
import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
-import { TagClasses } from "@/db/schema/vendorData";
+import { tagClasses } from "@/db/schema/vendorData";
-export const searchParamsCache = createSearchParamsCache({
- flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
- []
- ),
- page: parseAsInteger.withDefault(1),
- perPage: parseAsInteger.withDefault(10),
- sort: getSortingStateParser<TagClasses>().withDefault([
- { id: "createdAt", desc: true },
- ]),
- code: parseAsString.withDefault(""),
- label: parseAsString.withDefault(""),
-
- // advanced filter
- filters: getFiltersStateParser().withDefault([]),
- joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
- search: parseAsString.withDefault(""),
-
-})
+export type ExtendedTagClasses = typeof tagClasses.$inferSelect & {
+ projectCode: string;
+ projectName: string;
+};
+// 검색 파라미터 캐시 정의
+export const searchParamsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+
+ // 확장된 타입으로 정렬 파서 사용
+ sort: getSortingStateParser<ExtendedTagClasses>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+ // 기존 필터 옵션들
+ code: parseAsString.withDefault(""),
+ label: parseAsString.withDefault(""),
+
+ // 고급 필터
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+});
-export type GetTagClassesSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>
+// 타입 내보내기
+export type GetTagClassesSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>;
diff --git a/lib/form-list/repository.ts b/lib/form-list/repository.ts
index ced320db..d3c555bf 100644
--- a/lib/form-list/repository.ts
+++ b/lib/form-list/repository.ts
@@ -1,4 +1,5 @@
import db from "@/db/db";
+import { projects } from "@/db/schema";
import { Item, items } from "@/db/schema/items";
import { tagTypeClassFormMappings } from "@/db/schema/vendorData";
import {
@@ -17,30 +18,47 @@ import {
import { PgTransaction } from "drizzle-orm/pg-core";
export async function selectFormLists(
- tx: PgTransaction<any, any, any>,
- params: {
- where?: any; // drizzle-orm의 조건식 (and, eq...) 등
- orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[];
- offset?: number;
- limit?: number;
- }
- ) {
- const { where, orderBy, offset = 0, limit = 10 } = params;
-
- return tx
- .select()
- .from(tagTypeClassFormMappings)
- .where(where)
- .orderBy(...(orderBy ?? []))
- .offset(offset)
- .limit(limit);
+ tx: PgTransaction<any, any, any>,
+ params: {
+ where?: any;
+ orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[];
+ offset?: number;
+ limit?: number;
}
+) {
+ const { where, orderBy, offset = 0, limit = 10 } = params;
+
+ return tx
+ .select({
+ id: tagTypeClassFormMappings.id,
+ projectId: tagTypeClassFormMappings.projectId,
+ tagTypeLabel: tagTypeClassFormMappings.tagTypeLabel,
+ classLabel: tagTypeClassFormMappings.classLabel,
+ formCode: tagTypeClassFormMappings.formCode,
+ formName: tagTypeClassFormMappings.formName,
+ createdAt: tagTypeClassFormMappings.createdAt,
+ updatedAt: tagTypeClassFormMappings.updatedAt,
+ // 프로젝트 정보 추가
+ projectCode: projects.code,
+ projectName: projects.name
+ })
+ .from(tagTypeClassFormMappings)
+ .innerJoin(projects, eq(tagTypeClassFormMappings.projectId, projects.id))
+ .where(where)
+ .orderBy(...(orderBy ?? []))
+ .offset(offset)
+ .limit(limit);
+}
+
/** 총 개수 count */
export async function countFormLists(
tx: PgTransaction<any, any, any>,
where?: any
) {
- const res = await tx.select({ count: count() }).from(tagTypeClassFormMappings).where(where);
+ const res = await tx
+ .select({ count: count() })
+ .from(tagTypeClassFormMappings)
+ .leftJoin(projects, eq(tagTypeClassFormMappings.projectId, projects.id))
+ .where(where);
return res[0]?.count ?? 0;
- }
- \ No newline at end of file
+ } \ No newline at end of file
diff --git a/lib/form-list/service.ts b/lib/form-list/service.ts
index 64156cf4..310930be 100644
--- a/lib/form-list/service.ts
+++ b/lib/form-list/service.ts
@@ -8,6 +8,7 @@ import { filterColumns } from "@/lib/filter-columns";
import { tagTypeClassFormMappings } from "@/db/schema/vendorData";
import { asc, desc, ilike, inArray, and, gte, lte, not, or } from "drizzle-orm";
import { countFormLists, selectFormLists } from "./repository";
+import { projects } from "@/db/schema";
export async function getFormLists(input: GetFormListsSchema) {
@@ -31,7 +32,9 @@ export async function getFormLists(input: GetFormListsSchema) {
if (input.search) {
const s = `%${input.search}%`
globalWhere = or(ilike(tagTypeClassFormMappings.formCode, s), ilike(tagTypeClassFormMappings.formName, s)
- , ilike(tagTypeClassFormMappings.tagTypeLabel, s) , ilike(tagTypeClassFormMappings.classLabel, s)
+ , ilike(tagTypeClassFormMappings.tagTypeLabel, s) , ilike(tagTypeClassFormMappings.classLabel, s),
+ ilike(projects.name, s),
+ ilike(projects.code, s),
)
// 필요시 여러 칼럼 OR조건 (status, priority, etc)
}
@@ -48,12 +51,21 @@ export async function getFormLists(input: GetFormListsSchema) {
const orderBy =
- input.sort.length > 0
- ? input.sort.map((item) =>
- item.desc ? desc(tagTypeClassFormMappings[item.id]) : asc(tagTypeClassFormMappings[item.id])
- )
- : [asc(tagTypeClassFormMappings.createdAt)];
-
+ input.sort.length > 0
+ ? input.sort.map((item) => {
+ // 프로젝트 관련 필드 정렬 처리
+ if (item.id === 'projectCode') {
+ return item.desc ? desc(projects.code) : asc(projects.code);
+ } else if (item.id === 'projectName') {
+ return item.desc ? desc(projects.name) : asc(projects.name);
+ } else {
+ // 기존 필드 정렬
+ return item.desc
+ ? desc(tagTypeClassFormMappings[item.id])
+ : asc(tagTypeClassFormMappings[item.id]);
+ }
+ })
+ : [asc(tagTypeClassFormMappings.createdAt)];
// 트랜잭션 내부에서 Repository 호출
const { data, total } = await db.transaction(async (tx) => {
const data = await selectFormLists(tx, {
@@ -78,7 +90,7 @@ export async function getFormLists(input: GetFormListsSchema) {
[JSON.stringify(input)], // 캐싱 키
{
revalidate: 3600,
- tags: ["form-lists"], // revalidateTag("items") 호출 시 무효화
+ tags: ["form-lists"],
}
)();
} \ No newline at end of file
diff --git a/lib/form-list/table/formLists-table-columns.tsx b/lib/form-list/table/formLists-table-columns.tsx
index f638c4df..647a8af1 100644
--- a/lib/form-list/table/formLists-table-columns.tsx
+++ b/lib/form-list/table/formLists-table-columns.tsx
@@ -17,16 +17,16 @@ import {
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
import { formListsColumnsConfig } from "@/config/formListsColumnsConfig"
-import { TagTypeClassFormMappings } from "@/db/schema/vendorData"
+import { ExtendedFormMappings } from "../validation"
interface GetColumnsProps {
- setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TagTypeClassFormMappings> | null>>
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<ExtendedFormMappings> | null>>
}
/**
* tanstack table 컬럼 정의 (중첩 헤더 버전)
*/
-export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<TagTypeClassFormMappings>[] {
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<ExtendedFormMappings>[] {
// ----------------------------------------------------------------
// 1) select 컬럼 (체크박스)
// ----------------------------------------------------------------
@@ -35,7 +35,7 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<TagType
// ----------------------------------------------------------------
// 2) actions 컬럼 (단일 버튼 - Meta Info 바로 보기)
// ----------------------------------------------------------------
- const actionsColumn: ColumnDef<TagTypeClassFormMappings> = {
+ const actionsColumn: ColumnDef<ExtendedFormMappings> = {
id: "actions",
enableHiding: false,
cell: function Cell({ row }) {
@@ -65,7 +65,7 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<TagType
// 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
// ----------------------------------------------------------------
// 3-1) groupMap: { [groupName]: ColumnDef<TagTypeClassFormMappings>[] }
- const groupMap: Record<string, ColumnDef<TagTypeClassFormMappings>[]> = {}
+ const groupMap: Record<string, ColumnDef<ExtendedFormMappings>[]> = {}
formListsColumnsConfig.forEach((cfg) => {
// 만약 group가 없으면 "_noGroup" 처리
@@ -76,7 +76,7 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<TagType
}
// child column 정의
- const childCol: ColumnDef<TagTypeClassFormMappings> = {
+ const childCol: ColumnDef<ExtendedFormMappings> = {
accessorKey: cfg.id,
enableResizing: true,
header: ({ column }) => (
@@ -104,7 +104,7 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<TagType
// ----------------------------------------------------------------
// 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기
// ----------------------------------------------------------------
- const nestedColumns: ColumnDef<TagTypeClassFormMappings>[] = []
+ const nestedColumns: ColumnDef<ExtendedFormMappings>[] = []
// 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함
// 여기서는 그냥 Object.entries 순서
diff --git a/lib/form-list/table/formLists-table-toolbar-actions.tsx b/lib/form-list/table/formLists-table-toolbar-actions.tsx
index 346a3980..96494607 100644
--- a/lib/form-list/table/formLists-table-toolbar-actions.tsx
+++ b/lib/form-list/table/formLists-table-toolbar-actions.tsx
@@ -7,18 +7,49 @@ import { toast } from "sonner"
import { exportTableToExcel } from "@/lib/export"
import { Button } from "@/components/ui/button"
-import { TagTypeClassFormMappings } from "@/db/schema/vendorData"
+import { ExtendedFormMappings } from "../validation"
interface ItemsTableToolbarActionsProps {
- table: Table<TagTypeClassFormMappings>
+ table: Table<ExtendedFormMappings>
}
export function FormListsTableToolbarActions({ table }: ItemsTableToolbarActionsProps) {
- // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
- const fileInputRef = React.useRef<HTMLInputElement>(null)
+ const [isLoading, setIsLoading] = React.useState(false)
+ const syncForms = async () => {
+ try {
+ setIsLoading(true)
+
+ // API 엔드포인트 호출
+ const response = await fetch('/api/cron/forms')
+
+ if (!response.ok) {
+ const errorData = await response.json()
+ throw new Error(errorData.error || 'Failed to sync forms')
+ }
+
+ const data = await response.json()
+
+ // 성공 메시지 표시
+ toast.success(
+ `Forms synced successfully! ${data.result.items} items processed.`
+ )
+
+ // 페이지 새로고침으로 테이블 데이터 업데이트
+ window.location.reload()
+ } catch (error) {
+ console.error('Error syncing forms:', error)
+ toast.error(
+ error instanceof Error
+ ? error.message
+ : 'An error occurred while syncing forms'
+ )
+ } finally {
+ setIsLoading(false)
+ }
+ }
return (
@@ -29,8 +60,10 @@ export function FormListsTableToolbarActions({ table }: ItemsTableToolbarActions
size="sm"
className="gap-2"
>
- <RefreshCcw className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">Get Forms</span>
+ <RefreshCcw className={`size-4 ${isLoading ? 'animate-spin' : ''}`} aria-hidden="true" />
+ <span className="hidden sm:inline">
+ {isLoading ? 'Syncing...' : 'Get Forms'}
+ </span>
</Button>
{/** 4) Export 버튼 */}
@@ -39,7 +72,7 @@ export function FormListsTableToolbarActions({ table }: ItemsTableToolbarActions
size="sm"
onClick={() =>
exportTableToExcel(table, {
- filename: "tasks",
+ filename: "Forms",
excludeColumns: ["select", "actions"],
})
}
diff --git a/lib/form-list/table/formLists-table.tsx b/lib/form-list/table/formLists-table.tsx
index be252655..58ac4671 100644
--- a/lib/form-list/table/formLists-table.tsx
+++ b/lib/form-list/table/formLists-table.tsx
@@ -12,17 +12,17 @@ import { DataTable } from "@/components/data-table/data-table"
import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
import { useFeatureFlags } from "./feature-flags-provider"
-import { TagTypeClassFormMappings } from "@/db/schema/vendorData"
import { getFormLists } from "../service"
import { getColumns } from "./formLists-table-columns"
import { FormListsTableToolbarActions } from "./formLists-table-toolbar-actions"
import { ViewMetas } from "./meta-sheet"
+import { ExtendedFormMappings } from "../validation"
interface ItemsTableProps {
promises: Promise<
[
Awaited<ReturnType<typeof getFormLists>>,
- ]
+ ]
>
}
@@ -34,7 +34,7 @@ export function FormListsTable({ promises }: ItemsTableProps) {
const [rowAction, setRowAction] =
- React.useState<DataTableRowAction<TagTypeClassFormMappings> | null>(null)
+ React.useState<DataTableRowAction<ExtendedFormMappings> | null>(null)
const columns = React.useMemo(
() => getColumns({ setRowAction }),
@@ -52,7 +52,7 @@ export function FormListsTable({ promises }: ItemsTableProps) {
* @prop {React.ReactNode} [icon] - An optional icon to display next to the label.
* @prop {boolean} [withCount] - An optional boolean to display the count of the filter option.
*/
- const filterFields: DataTableFilterField<TagTypeClassFormMappings>[] = [
+ const filterFields: DataTableFilterField<ExtendedFormMappings>[] = [
]
@@ -67,18 +67,26 @@ export function FormListsTable({ promises }: ItemsTableProps) {
* 3. Used with DataTableAdvancedToolbar: Enables a more sophisticated filtering UI.
* 4. Date and boolean types: Adds support for filtering by date ranges and boolean values.
*/
- const advancedFilterFields: DataTableAdvancedFilterField<TagTypeClassFormMappings>[] = [
+ const advancedFilterFields: DataTableAdvancedFilterField<ExtendedFormMappings>[] = [
+ {
+ id: "projectCode",
+ label: "Project Code",
+ type: "text",
+ },
+ {
+ id: "projectName",
+ label: "Project Name",
+ type: "text",
+ },
{
id: "formCode",
label: "Form Code",
type: "text",
-
},
{
id: "formName",
label: "Form Name",
type: "text",
-
},
{
id: "tagTypeLabel",
diff --git a/lib/form-list/table/meta-sheet.tsx b/lib/form-list/table/meta-sheet.tsx
index 155e4f5a..694ee845 100644
--- a/lib/form-list/table/meta-sheet.tsx
+++ b/lib/form-list/table/meta-sheet.tsx
@@ -77,7 +77,7 @@ export function ViewMetas({ open, onOpenChange, form }: ViewMetasProps) {
setLoading(true)
try {
// 서버 액션 호출
- const metaData = await fetchFormMetadata(form.formCode)
+ const metaData = await fetchFormMetadata(form.formCode, form.projectId)
if (metaData) {
setMetadata(metaData)
} else {
diff --git a/lib/form-list/validation.ts b/lib/form-list/validation.ts
index c8baf960..497ec871 100644
--- a/lib/form-list/validation.ts
+++ b/lib/form-list/validation.ts
@@ -10,15 +10,22 @@ import * as z from "zod"
import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
import { TagTypeClassFormMappings } from "@/db/schema/vendorData";
+export type ExtendedFormMappings = TagTypeClassFormMappings & {
+ projectCode: string;
+ projectName: string;
+ };
+
+
export const searchParamsCache = createSearchParamsCache({
flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
[]
),
page: parseAsInteger.withDefault(1),
perPage: parseAsInteger.withDefault(10),
- sort: getSortingStateParser<TagTypeClassFormMappings>().withDefault([
+ sort: getSortingStateParser<ExtendedFormMappings>().withDefault([
{ id: "createdAt", desc: true },
- ]),
+ ]),
+
tagTypeLabel: parseAsString.withDefault(""),
classLabel: parseAsString.withDefault(""),
formCode: parseAsString.withDefault(""),
diff --git a/lib/forms/services.ts b/lib/forms/services.ts
index e3a8b2b2..d77f91d3 100644
--- a/lib/forms/services.ts
+++ b/lib/forms/services.ts
@@ -20,6 +20,7 @@ import { unstable_cache } from "next/cache";
import { revalidateTag } from "next/cache";
import { getErrorMessage } from "../handle-error";
import { DataTableColumnJSON } from "@/components/form-data/form-data-table-columns";
+import { contractItems, contracts, projects } from "@/db/schema";
export interface FormInfo {
id: number;
@@ -149,19 +150,45 @@ export async function getFormData(formCode: string, contractItemId: number) {
// 1) unstable_cache로 전체 로직을 감싼다
const result = await unstable_cache(
async () => {
- // --- 기존 로직 시작 ---
- // (1) form_metas 조회 (가정상 1개만 존재)
+ // --- 기존 로직 시작 (projectId 고려하도록 수정) ---
+
+ // (0) contractItemId로부터 projectId 조회
+ const contractItemResult = await db
+ .select({
+ projectId: projects.id
+ })
+ .from(contractItems)
+ .innerJoin(contracts, eq(contractItems.contractId, contracts.id))
+ .innerJoin(projects, eq(contracts.projectId, projects.id))
+ .where(eq(contractItems.id, contractItemId))
+ .limit(1);
+
+ if (contractItemResult.length === 0) {
+ console.warn(`[getFormData] No contract item found with ID: ${contractItemId}`);
+ return { columns: null, data: [] };
+ }
+
+ const projectId = contractItemResult[0].projectId;
+
+ // (1) form_metas 조회 - 이제 projectId도 조건에 포함
const metaRows = await db
.select()
.from(formMetas)
- .where(eq(formMetas.formCode, formCode))
+ .where(
+ and(
+ eq(formMetas.formCode, formCode),
+ eq(formMetas.projectId, projectId)
+ )
+ )
.orderBy(desc(formMetas.updatedAt))
.limit(1);
const meta = metaRows[0] ?? null;
if (!meta) {
+ console.warn(`[getFormData] No form meta found for formCode: ${formCode} and projectId: ${projectId}`);
return { columns: null, data: [] };
}
+
// (2) form_entries에서 (formCode, contractItemId)에 해당하는 "가장 최신" 한 행
const entryRows = await db
.select()
@@ -205,7 +232,7 @@ export async function getFormData(formCode: string, contractItemId: number) {
}
}
- return { columns, data };
+ return { columns, data, projectId }; // projectId도 반환 (필요시)
// --- 기존 로직 끝 ---
},
[cacheKey], // 캐시 키 의존성
@@ -225,16 +252,40 @@ export async function getFormData(formCode: string, contractItemId: number) {
`[getFormData] Fallback DB query for (${formCode}, ${contractItemId})`
);
- // (1) form_metas
+ // (0) contractItemId로부터 projectId 조회
+ const contractItemResult = await db
+ .select({
+ projectId: projects.id
+ })
+ .from(contractItems)
+ .innerJoin(contracts, eq(contractItems.contractId, contracts.id))
+ .innerJoin(projects, eq(contracts.projectId, projects.id))
+ .where(eq(contractItems.id, contractItemId))
+ .limit(1);
+
+ if (contractItemResult.length === 0) {
+ console.warn(`[getFormData] Fallback: No contract item found with ID: ${contractItemId}`);
+ return { columns: null, data: [] };
+ }
+
+ const projectId = contractItemResult[0].projectId;
+
+ // (1) form_metas - projectId 고려
const metaRows = await db
.select()
.from(formMetas)
- .where(eq(formMetas.formCode, formCode))
+ .where(
+ and(
+ eq(formMetas.formCode, formCode),
+ eq(formMetas.projectId, projectId)
+ )
+ )
.orderBy(desc(formMetas.updatedAt))
.limit(1);
const meta = metaRows[0] ?? null;
if (!meta) {
+ console.warn(`[getFormData] Fallback: No form meta found for formCode: ${formCode} and projectId: ${projectId}`);
return { columns: null, data: [] };
}
@@ -279,7 +330,7 @@ export async function getFormData(formCode: string, contractItemId: number) {
}
}
- return { columns, data };
+ return { columns, data, projectId }; // projectId도 반환 (필요시)
} catch (dbError) {
console.error(`[getFormData] Fallback DB query failed:`, dbError);
return { columns: null, data: [] };
@@ -674,14 +725,15 @@ interface MetadataResult {
* 없으면 null.
*/
export async function fetchFormMetadata(
- formCode: string
+ formCode: string,
+ projectId: number
): Promise<MetadataResult | null> {
try {
// 기존 방식: select().from().where()
const rows = await db
.select()
.from(formMetas)
- .where(eq(formMetas.formCode, formCode))
+ .where(and(eq(formMetas.formCode, formCode),eq(formMetas.projectId, projectId)))
.limit(1);
// rows는 배열
diff --git a/lib/mail/mailer.ts b/lib/mail/mailer.ts
index e0a90f1e..200a0ed9 100644
--- a/lib/mail/mailer.ts
+++ b/lib/mail/mailer.ts
@@ -7,8 +7,8 @@ import i18next from 'i18next';
// Nodemailer Transporter 생성
const transporter = nodemailer.createTransport({
host: process.env.Email_Host,
- port: 465,
- secure: true,
+ port: parseInt(process.env.Email_Port || '465'),
+ secure: process.env.Email_Secure === 'true',
auth: {
user: process.env.Email_User_Name,
pass: process.env.Email_Password,
diff --git a/lib/mail/sendEmail.ts b/lib/mail/sendEmail.ts
index 48cc1fbc..c4171082 100644
--- a/lib/mail/sendEmail.ts
+++ b/lib/mail/sendEmail.ts
@@ -26,7 +26,7 @@ export async function sendEmail({ to, subject, template, context, attachments =
const html = loadTemplate(template, context);
await transporter.sendMail({
- from: 'EVCP" <dujin.kim@dtsolution.co.kr>',
+ from: `"${process.env.Email_From_Name}" <${process.env.Email_From_Address}>`,
to,
subject,
html,
diff --git a/lib/pq/service.ts b/lib/pq/service.ts
index 6906ff52..ad7e60c4 100644
--- a/lib/pq/service.ts
+++ b/lib/pq/service.ts
@@ -1672,4 +1672,13 @@ export async function getVendorPQsList(vendorId: number): Promise<VendorPQsList>
projectPQs: []
};
}
+}
+
+
+export async function loadGeneralPQData(vendorId: number) {
+ return getPQDataByVendorId(vendorId)
+}
+
+export async function loadProjectPQData(vendorId: number, projectId: number) {
+ return getPQDataByVendorId(vendorId, projectId)
} \ No newline at end of file
diff --git a/lib/projects/repository.ts b/lib/projects/repository.ts
new file mode 100644
index 00000000..62b70778
--- /dev/null
+++ b/lib/projects/repository.ts
@@ -0,0 +1,44 @@
+import db from "@/db/db";
+import { projects } from "@/db/schema";
+import {
+ eq,
+ inArray,
+ not,
+ asc,
+ desc,
+ and,
+ ilike,
+ gte,
+ lte,
+ count,
+ gt,
+} from "drizzle-orm";
+import { PgTransaction } from "drizzle-orm/pg-core";
+
+export async function selectProjectLists(
+ tx: PgTransaction<any, any, any>,
+ params: {
+ where?: any; // drizzle-orm의 조건식 (and, eq...) 등
+ orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[];
+ offset?: number;
+ limit?: number;
+ }
+ ) {
+ const { where, orderBy, offset = 0, limit = 10 } = params;
+
+ return tx
+ .select()
+ .from(projects)
+ .where(where)
+ .orderBy(...(orderBy ?? []))
+ .offset(offset)
+ .limit(limit);
+ }
+/** 총 개수 count */
+export async function countProjectLists(
+ tx: PgTransaction<any, any, any>,
+ where?: any
+) {
+ const res = await tx.select({ count: count() }).from(projects).where(where);
+ return res[0]?.count ?? 0;
+}
diff --git a/lib/projects/service.ts b/lib/projects/service.ts
new file mode 100644
index 00000000..fe1052f6
--- /dev/null
+++ b/lib/projects/service.ts
@@ -0,0 +1,87 @@
+"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택)
+
+import { revalidateTag, unstable_noStore } from "next/cache";
+import db from "@/db/db";
+import { unstable_cache } from "@/lib/unstable-cache";
+import { filterColumns } from "@/lib/filter-columns";
+import { tagTypeClassFormMappings } from "@/db/schema/vendorData";
+import { asc, desc, ilike, inArray, and, gte, lte, not, or } from "drizzle-orm";
+import { countProjectLists, selectProjectLists } from "./repository";
+import { projects } from "@/db/schema";
+import { GetProjectListsSchema } from "./validation";
+
+export async function getProjectLists(input: GetProjectListsSchema) {
+
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // const advancedTable = input.flags.includes("advancedTable");
+ const advancedTable = true;
+
+ // advancedTable 모드면 filterColumns()로 where 절 구성
+ const advancedWhere = filterColumns({
+ table: projects,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere = or(
+ ilike(projects.name, s),
+ ilike(projects.code, s),
+ ilike(projects.type, s),
+ )
+ // 필요시 여러 칼럼 OR조건 (status, priority, etc)
+ }
+
+ const finalWhere = and(
+ // advancedWhere or your existing conditions
+ advancedWhere,
+ globalWhere // and()함수로 결합 or or() 등으로 결합
+ )
+
+
+ // 아니면 ilike, inArray, gte 등으로 where 절 구성
+ const where = finalWhere
+
+
+ const orderBy =
+ input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc ? desc(projects[item.id]) : asc(projects[item.id])
+ )
+ : [asc(projects.createdAt)];
+
+ // 트랜잭션 내부에서 Repository 호출
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectProjectLists(tx, {
+ where,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+
+ const total = await countProjectLists(tx, where);
+ return { data, total };
+ });
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data, pageCount };
+ } catch (err) {
+ // 에러 발생 시 디폴트
+ return { data: [], pageCount: 0 };
+ }
+ },
+ [JSON.stringify(input)], // 캐싱 키
+ {
+ revalidate: 3600,
+ tags: ["project-lists"],
+ }
+ )();
+ } \ No newline at end of file
diff --git a/lib/projects/table/feature-flags-provider.tsx b/lib/projects/table/feature-flags-provider.tsx
new file mode 100644
index 00000000..81131894
--- /dev/null
+++ b/lib/projects/table/feature-flags-provider.tsx
@@ -0,0 +1,108 @@
+"use client"
+
+import * as React from "react"
+import { useQueryState } from "nuqs"
+
+import { dataTableConfig, type DataTableConfig } from "@/config/data-table"
+import { cn } from "@/lib/utils"
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
+
+interface FeatureFlagsContextProps {
+ featureFlags: FeatureFlagValue[]
+ setFeatureFlags: (value: FeatureFlagValue[]) => void
+}
+
+const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
+ featureFlags: [],
+ setFeatureFlags: () => {},
+})
+
+export function useFeatureFlags() {
+ const context = React.useContext(FeatureFlagsContext)
+ if (!context) {
+ throw new Error(
+ "useFeatureFlags must be used within a FeatureFlagsProvider"
+ )
+ }
+ return context
+}
+
+interface FeatureFlagsProviderProps {
+ children: React.ReactNode
+}
+
+export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
+ const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
+ "flags",
+ {
+ defaultValue: [],
+ parse: (value) => value.split(",") as FeatureFlagValue[],
+ serialize: (value) => value.join(","),
+ eq: (a, b) =>
+ a.length === b.length && a.every((value, index) => value === b[index]),
+ clearOnDefault: true,
+ shallow: false,
+ }
+ )
+
+ return (
+ <FeatureFlagsContext.Provider
+ value={{
+ featureFlags,
+ setFeatureFlags: (value) => void setFeatureFlags(value),
+ }}
+ >
+ <div className="w-full overflow-x-auto">
+ <ToggleGroup
+ type="multiple"
+ variant="outline"
+ size="sm"
+ value={featureFlags}
+ onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
+ className="w-fit gap-0"
+ >
+ {dataTableConfig.featureFlags.map((flag, index) => (
+ <Tooltip key={flag.value}>
+ <ToggleGroupItem
+ value={flag.value}
+ className={cn(
+ "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90",
+ {
+ "rounded-l-sm border-r-0": index === 0,
+ "rounded-r-sm":
+ index === dataTableConfig.featureFlags.length - 1,
+ }
+ )}
+ asChild
+ >
+ <TooltipTrigger>
+ <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
+ {flag.label}
+ </TooltipTrigger>
+ </ToggleGroupItem>
+ <TooltipContent
+ align="start"
+ side="bottom"
+ sideOffset={6}
+ className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
+ >
+ <div>{flag.tooltipTitle}</div>
+ <div className="text-xs text-muted-foreground">
+ {flag.tooltipDescription}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ ))}
+ </ToggleGroup>
+ </div>
+ {children}
+ </FeatureFlagsContext.Provider>
+ )
+}
diff --git a/lib/projects/table/projects-table-columns.tsx b/lib/projects/table/projects-table-columns.tsx
new file mode 100644
index 00000000..77899212
--- /dev/null
+++ b/lib/projects/table/projects-table-columns.tsx
@@ -0,0 +1,90 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+
+import { formatDate } from "@/lib/utils"
+
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { Project } from "@/db/schema"
+import { projectsColumnsConfig } from "@/config/projectsColumnsConfig"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<Project> | null>>
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<Project>[] {
+
+
+ // ----------------------------------------------------------------
+ // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
+ // ----------------------------------------------------------------
+ // 3-1) groupMap: { [groupName]: ColumnDef<Project>[] }
+ const groupMap: Record<string, ColumnDef<Project>[]> = {}
+
+ projectsColumnsConfig.forEach((cfg) => {
+ // 만약 group가 없으면 "_noGroup" 처리
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // child column 정의
+ const childCol: ColumnDef<Project> = {
+ accessorKey: cfg.id,
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title={cfg.label} />
+ ),
+ meta: {
+ excelHeader: cfg.excelHeader,
+ group: cfg.group,
+ type: cfg.type,
+ },
+ cell: ({ row, cell }) => {
+
+ if (cfg.id === "createdAt"||cfg.id === "updatedAt") {
+ const dateVal = cell.getValue() as Date
+ return formatDate(dateVal)
+ }
+
+ return row.getValue(cfg.id) ?? ""
+ },
+ }
+
+ groupMap[groupName].push(childCol)
+ })
+
+ // ----------------------------------------------------------------
+ // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기
+ // ----------------------------------------------------------------
+ const nestedColumns: ColumnDef<Project>[] = []
+
+ // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함
+ // 여기서는 그냥 Object.entries 순서
+ Object.entries(groupMap).forEach(([groupName, colDefs]) => {
+ if (groupName === "_noGroup") {
+ // 그룹 없음 → 그냥 최상위 레벨 컬럼
+ nestedColumns.push(...colDefs)
+ } else {
+ // 상위 컬럼
+ nestedColumns.push({
+ id: groupName,
+ header: groupName, // "Basic Info", "Metadata" 등
+ columns: colDefs,
+ })
+ }
+ })
+
+ // ----------------------------------------------------------------
+ // 4) 최종 컬럼 배열: select, nestedColumns, actions
+ // ----------------------------------------------------------------
+ return [
+ ...nestedColumns,
+ ]
+} \ No newline at end of file
diff --git a/lib/projects/table/projects-table-toolbar-actions.tsx b/lib/projects/table/projects-table-toolbar-actions.tsx
new file mode 100644
index 00000000..dc55423d
--- /dev/null
+++ b/lib/projects/table/projects-table-toolbar-actions.tsx
@@ -0,0 +1,89 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download, RefreshCcw } from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { Project } from "@/db/schema"
+
+interface ItemsTableToolbarActionsProps {
+ table: Table<Project>
+}
+
+export function ProjectTableToolbarActions({ table }: ItemsTableToolbarActionsProps) {
+ const [isLoading, setIsLoading] = React.useState(false)
+
+ // 프로젝트 동기화 API 호출 함수
+ const syncProjects = async () => {
+ try {
+ setIsLoading(true)
+
+ // API 엔드포인트 호출
+ const response = await fetch('/api/cron/projects')
+
+ if (!response.ok) {
+ const errorData = await response.json()
+ throw new Error(errorData.error || 'Failed to sync projects')
+ }
+
+ const data = await response.json()
+
+ // 성공 메시지 표시
+ toast.success(
+ `Projects synced successfully! ${data.result.items} items processed.`
+ )
+
+ // 페이지 새로고침으로 테이블 데이터 업데이트
+ window.location.reload()
+ } catch (error) {
+ console.error('Error syncing projects:', error)
+ toast.error(
+ error instanceof Error
+ ? error.message
+ : 'An error occurred while syncing projects'
+ )
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+ <Button
+ variant="samsung"
+ size="sm"
+ className="gap-2"
+ onClick={syncProjects}
+ disabled={isLoading}
+ >
+ <RefreshCcw
+ className={`size-4 ${isLoading ? 'animate-spin' : ''}`}
+ aria-hidden="true"
+ />
+ <span className="hidden sm:inline">
+ {isLoading ? 'Syncing...' : 'Get Projects'}
+ </span>
+ </Button>
+
+ {/** 4) Export 버튼 */}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "Projects",
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ className="gap-2"
+ disabled={isLoading}
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Export</span>
+ </Button>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/projects/table/projects-table.tsx b/lib/projects/table/projects-table.tsx
new file mode 100644
index 00000000..3da54b7c
--- /dev/null
+++ b/lib/projects/table/projects-table.tsx
@@ -0,0 +1,128 @@
+"use client"
+
+import * as React from "react"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+import { useFeatureFlags } from "./feature-flags-provider"
+
+import { getColumns } from "./projects-table-columns"
+import { getProjectLists } from "../service"
+import { Project } from "@/db/schema"
+import { ProjectTableToolbarActions } from "./projects-table-toolbar-actions"
+
+interface ItemsTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getProjectLists>>,
+ ]
+ >
+}
+
+export function ProjectsTable({ promises }: ItemsTableProps) {
+ const { featureFlags } = useFeatureFlags()
+
+ const [{ data, pageCount }] =
+ React.use(promises)
+
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<Project> | null>(null)
+
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ /**
+ * This component can render either a faceted filter or a search filter based on the `options` prop.
+ *
+ * @prop options - An array of objects, each representing a filter option. If provided, a faceted filter is rendered. If not, a search filter is rendered.
+ *
+ * Each `option` object has the following properties:
+ * @prop {string} label - The label for the filter option.
+ * @prop {string} value - The value for the filter option.
+ * @prop {React.ReactNode} [icon] - An optional icon to display next to the label.
+ * @prop {boolean} [withCount] - An optional boolean to display the count of the filter option.
+ */
+ const filterFields: DataTableFilterField<Project>[] = [
+
+ ]
+
+ /**
+ * Advanced filter fields for the data table.
+ * These fields provide more complex filtering options compared to the regular filterFields.
+ *
+ * Key differences from regular filterFields:
+ * 1. More field types: Includes 'text', 'multi-select', 'date', and 'boolean'.
+ * 2. Enhanced flexibility: Allows for more precise and varied filtering options.
+ * 3. Used with DataTableAdvancedToolbar: Enables a more sophisticated filtering UI.
+ * 4. Date and boolean types: Adds support for filtering by date ranges and boolean values.
+ */
+ const advancedFilterFields: DataTableAdvancedFilterField<Project>[] = [
+ {
+ id: "code",
+ label: "Project Code",
+ type: "text",
+ // group: "Basic Info",
+ },
+ {
+ id: "name",
+ label: "Project Name",
+ type: "text",
+ // group: "Basic Info",
+ },
+
+
+ {
+ id: "createdAt",
+ label: "Created At",
+ type: "date",
+ // group: "Metadata",a
+ },
+ {
+ id: "updatedAt",
+ label: "Updated At",
+ type: "date",
+ // group: "Metadata",
+ },
+ ]
+
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable
+ table={table}
+ >
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <ProjectTableToolbarActions table={table} />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ </>
+ )
+}
diff --git a/lib/projects/validation.ts b/lib/projects/validation.ts
new file mode 100644
index 00000000..ed1cc9a1
--- /dev/null
+++ b/lib/projects/validation.ts
@@ -0,0 +1,36 @@
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { Project } from "@/db/schema";
+
+export const searchParamsProjectsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<Project>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+
+ code: parseAsString.withDefault(""),
+ name: parseAsString.withDefault(""),
+ type: parseAsString.withDefault(""),
+
+ // advanced filter
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+
+})
+
+
+
+export type GetProjectListsSchema = Awaited<ReturnType<typeof searchParamsProjectsCache.parse>>
diff --git a/lib/rfqs/service.ts b/lib/rfqs/service.ts
index 6b8b4738..b56349e2 100644
--- a/lib/rfqs/service.ts
+++ b/lib/rfqs/service.ts
@@ -208,6 +208,7 @@ export async function modifyRfq(input: UpdateRfqSchema & { id: number }) {
rfqCode: input.rfqCode,
projectId: input.projectId || null,
dueDate: input.dueDate,
+ rfqType: input.rfqType,
status: input.status as "DRAFT" | "PUBLISHED" | "EVALUATION" | "AWARDED",
createdBy: input.createdBy,
});
@@ -1246,6 +1247,11 @@ export async function getTBE(input: GetTBESchema, rfqId: number) {
}
export async function getTBEforVendor(input: GetTBESchema, vendorId: number) {
+
+ if (isNaN(vendorId) || vendorId === null || vendorId === undefined) {
+ throw new Error("유효하지 않은 vendorId: 숫자 값이 필요합니다");
+ }
+
return unstable_cache(
async () => {
// 1) 페이징
@@ -1801,13 +1807,6 @@ export interface BudgetaryRfq {
projectName: string | null;
}
-interface GetBudgetaryRfqsParams {
- search?: string;
- projectId?: number;
- limit?: number;
- offset?: number;
-}
-
type GetBudgetaryRfqsResponse =
| { rfqs: BudgetaryRfq[]; totalCount: number; error?: never }
| { error: string; rfqs?: never; totalCount: number }
@@ -1816,16 +1815,40 @@ type GetBudgetaryRfqsResponse =
* Purchase RFQ 생성 시 부모 RFQ로 선택할 수 있도록 함
* 페이징 및 필터링 기능 포함
*/
+export interface GetBudgetaryRfqsParams {
+ search?: string;
+ projectId?: number;
+ rfqId?: number; // 특정 ID로 단일 RFQ 검색
+ rfqTypes?: RfqType[]; // 특정 RFQ 타입들로 필터링
+ limit?: number;
+ offset?: number;
+}
+
export async function getBudgetaryRfqs(params: GetBudgetaryRfqsParams = {}): Promise<GetBudgetaryRfqsResponse> {
- const { search, projectId, limit = 50, offset = 0 } = params;
- const cacheKey = `budgetary-rfqs-${JSON.stringify(params)}`;
+ const { search, projectId, rfqId, rfqTypes, limit = 50, offset = 0 } = params;
+ const cacheKey = `rfqs-query-${JSON.stringify(params)}`;
+
return unstable_cache(
async () => {
try {
-
- const baseCondition = eq(rfqs.rfqType, RfqType.BUDGETARY);
-
- let where1
+ // 기본 검색 조건 구성
+ let baseCondition;
+
+ // 특정 RFQ 타입들로 필터링 (rfqTypes 배열이 주어진 경우)
+ if (rfqTypes && rfqTypes.length > 0) {
+ // 여러 타입으로 필터링 (OR 조건)
+ baseCondition = inArray(rfqs.rfqType, rfqTypes);
+ } else {
+ // 기본적으로 BUDGETARY 타입만 검색 (이전 동작 유지)
+ baseCondition = eq(rfqs.rfqType, RfqType.BUDGETARY);
+ }
+
+ // 특정 ID로 검색하는 경우
+ if (rfqId) {
+ baseCondition = and(baseCondition, eq(rfqs.id, rfqId));
+ }
+
+ let where1;
// 검색어 조건 추가 (있을 경우)
if (search && search.trim()) {
const searchTerm = `%${search.trim()}%`;
@@ -1835,30 +1858,31 @@ export async function getBudgetaryRfqs(params: GetBudgetaryRfqsParams = {}): Pro
ilike(projects.code, searchTerm),
ilike(projects.name, searchTerm)
);
- where1 = searchCondition
+ where1 = searchCondition;
}
-
- let where2
+
+ let where2;
// 프로젝트 ID 조건 추가 (있을 경우)
if (projectId) {
where2 = eq(rfqs.projectId, projectId);
}
-
- const finalWhere = and(where1, where2, baseCondition)
-
+
+ const finalWhere = and(baseCondition, where1, where2);
+
// 총 개수 조회
const [countResult] = await db
.select({ count: count() })
.from(rfqs)
.leftJoin(projects, eq(rfqs.projectId, projects.id))
.where(finalWhere);
-
+
// 실제 데이터 조회
- const budgetaryRfqs = await db
+ const resultRfqs = await db
.select({
id: rfqs.id,
rfqCode: rfqs.rfqCode,
description: rfqs.description,
+ rfqType: rfqs.rfqType, // RFQ 타입 필드 추가
projectId: rfqs.projectId,
projectCode: projects.code,
projectName: projects.name,
@@ -1869,15 +1893,15 @@ export async function getBudgetaryRfqs(params: GetBudgetaryRfqsParams = {}): Pro
.orderBy(desc(rfqs.createdAt))
.limit(limit)
.offset(offset);
-
+
return {
- rfqs: budgetaryRfqs as BudgetaryRfq[], // 타입 단언으로 호환성 보장
+ rfqs: resultRfqs,
totalCount: Number(countResult?.count) || 0
};
} catch (error) {
- console.error("Error fetching budgetary RFQs:", error);
+ console.error("Error fetching RFQs:", error);
return {
- error: "Failed to fetch budgetary RFQs",
+ error: "Failed to fetch RFQs",
totalCount: 0
};
}
@@ -1885,11 +1909,10 @@ export async function getBudgetaryRfqs(params: GetBudgetaryRfqsParams = {}): Pro
[cacheKey],
{
revalidate: 60, // 1분 캐시
- tags: ["rfqs-budgetary"],
+ tags: ["rfqs-query"],
}
)();
}
-
export async function getAllVendors() {
// Adjust the query as needed (add WHERE, ORDER, etc.)
const allVendors = await db.select().from(vendors)
@@ -2812,4 +2835,49 @@ export async function getCBE(input: GetCBESchema, rfqId: number) {
tags: ["cbe-vendors"],
}
)();
+}
+
+
+export async function generateNextRfqCode(rfqType: RfqType): Promise<{ code: string; error?: string }> {
+ try {
+ if (!rfqType) {
+ return { code: "", error: 'RFQ 타입이 필요합니다' };
+ }
+
+ // 현재 연도 가져오기
+ const currentYear = new Date().getFullYear();
+
+ // 현재 연도와 타입에 맞는 최신 RFQ 코드 찾기
+ const latestRfqs = await db.select({ rfqCode: rfqs.rfqCode })
+ .from(rfqs)
+ .where(and(
+ sql`SUBSTRING(${rfqs.rfqCode}, 5, 4) = ${currentYear.toString()}`,
+ eq(rfqs.rfqType, rfqType)
+ ))
+ .orderBy(desc(rfqs.rfqCode))
+ .limit(1);
+
+ let sequenceNumber = 1;
+
+ if (latestRfqs.length > 0 && latestRfqs[0].rfqCode) {
+ // null 체크 추가 - TypeScript 오류 해결
+ const latestCode = latestRfqs[0].rfqCode;
+ const matches = latestCode.match(/[A-Z]+-\d{4}-(\d{3})/);
+
+ if (matches && matches[1]) {
+ sequenceNumber = parseInt(matches[1], 10) + 1;
+ }
+ }
+
+ // 새로운 RFQ 코드 포맷팅
+ const typePrefix = rfqType === RfqType.BUDGETARY ? 'BUD' :
+ rfqType === RfqType.PURCHASE_BUDGETARY ? 'PBU' : 'RFQ';
+
+ const newCode = `${typePrefix}-${currentYear}-${String(sequenceNumber).padStart(3, '0')}`;
+
+ return { code: newCode };
+ } catch (error) {
+ console.error('Error generating next RFQ code:', error);
+ return { code: "", error: '코드 생성에 실패했습니다' };
+ }
} \ No newline at end of file
diff --git a/lib/rfqs/table/BudgetaryRfqSelector.tsx b/lib/rfqs/table/ParentRfqSelector.tsx
index cea53c1d..0edb1233 100644
--- a/lib/rfqs/table/BudgetaryRfqSelector.tsx
+++ b/lib/rfqs/table/ParentRfqSelector.tsx
@@ -8,48 +8,70 @@ import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover
import { cn } from "@/lib/utils"
import { useDebounce } from "@/hooks/use-debounce"
import { getBudgetaryRfqs, type BudgetaryRfq } from "../service"
+import { RfqType } from "../validations"
-interface BudgetaryRfqSelectorProps {
+// ParentRfq 타입 정의 (서비스의 BudgetaryRfq와 호환되어야 함)
+interface ParentRfq {
+ id: number;
+ rfqCode: string;
+ description: string | null;
+ rfqType: RfqType;
+ projectId: number | null;
+ projectCode: string | null;
+ projectName: string | null;
+}
+
+interface ParentRfqSelectorProps {
selectedRfqId?: number;
- onRfqSelect: (rfq: BudgetaryRfq | null) => void;
+ onRfqSelect: (rfq: ParentRfq | null) => void;
+ rfqType: RfqType; // 현재 생성 중인 RFQ 타입
+ parentRfqTypes: RfqType[]; // 선택 가능한 부모 RFQ 타입 목록
placeholder?: string;
}
-export function BudgetaryRfqSelector({
+export function ParentRfqSelector({
selectedRfqId,
onRfqSelect,
- placeholder = "Budgetary RFQ 선택..."
-}: BudgetaryRfqSelectorProps) {
+ rfqType,
+ parentRfqTypes,
+ placeholder = "부모 RFQ 선택..."
+}: ParentRfqSelectorProps) {
const [searchTerm, setSearchTerm] = React.useState("");
const debouncedSearchTerm = useDebounce(searchTerm, 300);
const [open, setOpen] = React.useState(false);
const [loading, setLoading] = React.useState(false);
- const [budgetaryRfqs, setBudgetaryRfqs] = React.useState<BudgetaryRfq[]>([]);
- const [selectedRfq, setSelectedRfq] = React.useState<BudgetaryRfq | null>(null);
+ const [parentRfqs, setParentRfqs] = React.useState<ParentRfq[]>([]);
+ const [selectedRfq, setSelectedRfq] = React.useState<ParentRfq | null>(null);
const [page, setPage] = React.useState(1);
const [hasMore, setHasMore] = React.useState(true);
const [totalCount, setTotalCount] = React.useState(0);
const listRef = React.useRef<HTMLDivElement>(null);
+ // 타입별로 적절한 검색 placeholder 생성
+ const getSearchPlaceholder = () => {
+ if (rfqType === RfqType.PURCHASE) {
+ return "BUDGETARY/PURCHASE_BUDGETARY RFQ 검색...";
+ } else if (rfqType === RfqType.PURCHASE_BUDGETARY) {
+ return "BUDGETARY RFQ 검색...";
+ }
+ return "RFQ 코드/설명/프로젝트 검색...";
+ };
+
// 초기 선택된 RFQ가 있을 경우 로드
React.useEffect(() => {
if (selectedRfqId && open) {
const loadSelectedRfq = async () => {
try {
+ // 단일 RFQ를 id로 조회하는 API 호출
const result = await getBudgetaryRfqs({
limit: 1,
- // null을 undefined로 변환하여 타입 오류 해결
- projectId: selectedRfq?.projectId ?? undefined
+ rfqId: selectedRfqId
});
- if ('rfqs' in result && result.rfqs) {
- // 옵셔널 체이닝 또는 조건부 검사로 undefined 체크
- const foundRfq = result.rfqs.find(rfq => rfq.id === selectedRfqId);
- if (foundRfq) {
- setSelectedRfq(foundRfq);
- }
+ if ('rfqs' in result && result.rfqs && result.rfqs.length > 0) {
+ setSelectedRfq(result.rfqs[0] as unknown as ParentRfq);
}
} catch (error) {
console.error("선택된 RFQ 로드 오류:", error);
@@ -67,14 +89,14 @@ export function BudgetaryRfqSelector({
if (open) {
setPage(1);
setHasMore(true);
- setBudgetaryRfqs([]);
- loadBudgetaryRfqs(1, true);
+ setParentRfqs([]);
+ loadParentRfqs(1, true);
}
- }, [debouncedSearchTerm, open]);
+ }, [debouncedSearchTerm, open, parentRfqTypes]);
// 데이터 로드 함수
- const loadBudgetaryRfqs = async (pageToLoad: number, reset = false) => {
- if (!open) return;
+ const loadParentRfqs = async (pageToLoad: number, reset = false) => {
+ if (!open || parentRfqTypes.length === 0) return;
setLoading(true);
try {
@@ -83,13 +105,14 @@ export function BudgetaryRfqSelector({
search: debouncedSearchTerm,
limit,
offset: (pageToLoad - 1) * limit,
+ rfqTypes: parentRfqTypes // 현재 RFQ 타입에 맞는 부모 RFQ 타입들로 필터링
});
if ('rfqs' in result && result.rfqs) {
if (reset) {
- setBudgetaryRfqs(result.rfqs);
+ setParentRfqs(result.rfqs as unknown as ParentRfq[]);
} else {
- setBudgetaryRfqs(prev => [...prev, ...result.rfqs]);
+ setParentRfqs(prev => [...prev, ...(result.rfqs as unknown as ParentRfq[])]);
}
setTotalCount(result.totalCount);
@@ -97,7 +120,7 @@ export function BudgetaryRfqSelector({
setPage(pageToLoad);
}
} catch (error) {
- console.error("Budgetary RFQs 로드 오류:", error);
+ console.error("부모 RFQ 로드 오류:", error);
} finally {
setLoading(false);
}
@@ -110,18 +133,18 @@ export function BudgetaryRfqSelector({
// 스크롤이 90% 이상 내려갔을 때 다음 페이지 로드
if (scrollTop + clientHeight >= scrollHeight * 0.9 && !loading && hasMore) {
- loadBudgetaryRfqs(page + 1);
+ loadParentRfqs(page + 1);
}
}
};
// RFQ를 프로젝트별로 그룹화하는 함수
- const groupRfqsByProject = (rfqs: BudgetaryRfq[]) => {
+ const groupRfqsByProject = (rfqs: ParentRfq[]) => {
const groups: Record<string, {
projectId: number | null;
projectCode: string | null;
projectName: string | null;
- rfqs: BudgetaryRfq[];
+ rfqs: ParentRfq[];
}> = {};
// 'No Project' 그룹 기본 생성
@@ -154,16 +177,30 @@ export function BudgetaryRfqSelector({
// 그룹화된 RFQ 목록
const groupedRfqs = React.useMemo(() => {
- return groupRfqsByProject(budgetaryRfqs);
- }, [budgetaryRfqs]);
+ return groupRfqsByProject(parentRfqs);
+ }, [parentRfqs]);
// RFQ 선택 처리
- const handleRfqSelect = (rfq: BudgetaryRfq | null) => {
+ const handleRfqSelect = (rfq: ParentRfq | null) => {
setSelectedRfq(rfq);
onRfqSelect(rfq);
setOpen(false);
};
+ // RFQ 타입에 따른 표시 형식
+ const getRfqTypeLabel = (type: RfqType) => {
+ switch(type) {
+ case RfqType.BUDGETARY:
+ return "BUDGETARY";
+ case RfqType.PURCHASE_BUDGETARY:
+ return "PURCHASE_BUDGETARY";
+ case RfqType.PURCHASE:
+ return "PURCHASE";
+ default:
+ return type;
+ }
+ };
+
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
@@ -182,7 +219,7 @@ export function BudgetaryRfqSelector({
<PopoverContent className="w-[400px] p-0">
<Command>
<CommandInput
- placeholder="Budgetary RFQ 코드/설명/프로젝트 검색..."
+ placeholder={getSearchPlaceholder()}
value={searchTerm}
onValueChange={setSearchTerm}
/>
@@ -233,10 +270,19 @@ export function BudgetaryRfqSelector({
: "opacity-0"
)}
/>
- <span className="font-medium">{rfq.rfqCode || ""}</span>
- <span className="ml-2 text-gray-500 truncate">
- - {rfq.description || ""}
- </span>
+ <div className="flex flex-col">
+ <div className="flex items-center">
+ <span className="font-medium">{rfq.rfqCode || ""}</span>
+ <span className="ml-2 text-xs px-1.5 py-0.5 rounded bg-slate-100 text-slate-700">
+ {getRfqTypeLabel(rfq.rfqType)}
+ </span>
+ </div>
+ {rfq.description && (
+ <span className="text-sm text-gray-500 truncate">
+ {rfq.description}
+ </span>
+ )}
+ </div>
</CommandItem>
))}
</CommandGroup>
@@ -248,9 +294,9 @@ export function BudgetaryRfqSelector({
</div>
)}
- {!loading && !hasMore && budgetaryRfqs.length > 0 && (
+ {!loading && !hasMore && parentRfqs.length > 0 && (
<div className="py-2 text-center text-sm text-muted-foreground">
- 총 {totalCount}개 중 {budgetaryRfqs.length}개 표시됨
+ 총 {totalCount}개 중 {parentRfqs.length}개 표시됨
</div>
)}
</CommandList>
diff --git a/lib/rfqs/table/add-rfq-dialog.tsx b/lib/rfqs/table/add-rfq-dialog.tsx
index 45390cd0..41055608 100644
--- a/lib/rfqs/table/add-rfq-dialog.tsx
+++ b/lib/rfqs/table/add-rfq-dialog.tsx
@@ -3,38 +3,29 @@
import * as React from "react"
import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
-import { Check, ChevronsUpDown } from "lucide-react"
import { toast } from "sonner"
import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
-import {
- Select,
- SelectContent,
- SelectGroup,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
-import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover"
-import { Command, CommandInput, CommandList, CommandEmpty, CommandGroup, CommandItem } from "@/components/ui/command"
import { useSession } from "next-auth/react"
import { createRfqSchema, type CreateRfqSchema, RfqType } from "../validations"
-import { createRfq, getBudgetaryRfqs } from "../service"
+import { createRfq, generateNextRfqCode, getBudgetaryRfqs } from "../service"
import { ProjectSelector } from "@/components/ProjectSelector"
import { type Project } from "../service"
-import { cn } from "@/lib/utils"
-import { BudgetaryRfqSelector } from "./BudgetaryRfqSelector"
-import { type BudgetaryRfq as ServiceBudgetaryRfq } from "../service";
+import { ParentRfqSelector } from "./ParentRfqSelector"
// 부모 RFQ 정보 타입 정의
-interface BudgetaryRfq {
+interface ParentRfq {
id: number;
rfqCode: string;
description: string | null;
+ rfqType: RfqType;
+ projectId: number | null;
+ projectCode: string | null;
+ projectName: string | null;
}
interface AddRfqDialogProps {
@@ -44,11 +35,10 @@ interface AddRfqDialogProps {
export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps) {
const [open, setOpen] = React.useState(false)
const { data: session, status } = useSession()
- const [budgetaryRfqs, setBudgetaryRfqs] = React.useState<BudgetaryRfq[]>([])
- const [isLoadingBudgetary, setIsLoadingBudgetary] = React.useState(false)
- const [budgetarySearchOpen, setBudgetarySearchOpen] = React.useState(false)
- const [budgetarySearchTerm, setBudgetarySearchTerm] = React.useState("")
- const [selectedBudgetaryRfq, setSelectedBudgetaryRfq] = React.useState<BudgetaryRfq | null>(null)
+ const [parentRfqs, setParentRfqs] = React.useState<ParentRfq[]>([])
+ const [isLoadingParents, setIsLoadingParents] = React.useState(false)
+ const [selectedParentRfq, setSelectedParentRfq] = React.useState<ParentRfq | null>(null)
+ const [isLoadingRfqCode, setIsLoadingRfqCode] = React.useState(false)
// Get the user ID safely, ensuring it's a valid number
const userId = React.useMemo(() => {
@@ -64,9 +54,30 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps)
// RfqType에 따른 타이틀 생성
const getTitle = () => {
- return rfqType === RfqType.PURCHASE
- ? "Purchase RFQ"
- : "Budgetary RFQ";
+ switch(rfqType) {
+ case RfqType.PURCHASE:
+ return "Purchase RFQ";
+ case RfqType.BUDGETARY:
+ return "Budgetary RFQ";
+ case RfqType.PURCHASE_BUDGETARY:
+ return "Purchase Budgetary RFQ";
+ default:
+ return "RFQ";
+ }
+ };
+
+ // RfqType 설명 가져오기
+ const getTypeDescription = () => {
+ switch(rfqType) {
+ case RfqType.PURCHASE:
+ return "실제 구매 발주 전에 가격을 요청";
+ case RfqType.BUDGETARY:
+ return "기술영업 단계에서 입찰가 산정을 위한 견적 요청";
+ case RfqType.PURCHASE_BUDGETARY:
+ return "프로젝트 수주 후, 공식 입찰 전 예산 책정을 위한 가격 요청";
+ default:
+ return "";
+ }
};
// RHF + Zod
@@ -92,40 +103,79 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps)
}
}, [status, userId, form]);
- // Budgetary RFQ 목록 로드 (Purchase RFQ 생성 시만)
+ // 다이얼로그가 열릴 때 자동으로 RFQ 코드 생성
React.useEffect(() => {
- if (rfqType === RfqType.PURCHASE && open) {
- const loadBudgetaryRfqs = async () => {
- setIsLoadingBudgetary(true);
+ if (open) {
+ const generateRfqCode = async () => {
+ setIsLoadingRfqCode(true);
try {
- const result = await getBudgetaryRfqs();
- if ('rfqs' in result) {
- setBudgetaryRfqs(result.rfqs as unknown as BudgetaryRfq[]);
- } else if ('error' in result) {
- console.error("Budgetary RFQs 로드 오류:", result.error);
+ // 서버 액션 호출
+ const result = await generateNextRfqCode(rfqType);
+
+ if (result.error) {
+ toast.error(`RFQ 코드 생성 실패: ${result.error}`);
+ return;
}
+
+ // 생성된 코드를 폼에 설정
+ form.setValue("rfqCode", result.code);
} catch (error) {
- console.error("Budgetary RFQs 로드 오류:", error);
+ console.error("RFQ 코드 생성 오류:", error);
+ toast.error("RFQ 코드 생성에 실패했습니다");
} finally {
- setIsLoadingBudgetary(false);
+ setIsLoadingRfqCode(false);
}
};
-
- loadBudgetaryRfqs();
+
+ generateRfqCode();
}
- }, [rfqType, open]);
+ }, [open, rfqType, form]);
+
+ // 현재 RFQ 타입에 따라 선택 가능한 부모 RFQ 타입들 결정
+ const getParentRfqTypes = (): RfqType[] => {
+ switch(rfqType) {
+ case RfqType.PURCHASE:
+ // PURCHASE는 BUDGETARY와 PURCHASE_BUDGETARY를 부모로 가질 수 있음
+ return [RfqType.BUDGETARY, RfqType.PURCHASE_BUDGETARY];
+ case RfqType.PURCHASE_BUDGETARY:
+ // PURCHASE_BUDGETARY는 BUDGETARY만 부모로 가질 수 있음
+ return [RfqType.BUDGETARY];
+ default:
+ return [];
+ }
+ };
- // 검색어로 필터링된 Budgetary RFQ 목록
- const filteredBudgetaryRfqs = React.useMemo(() => {
- if (!budgetarySearchTerm.trim()) return budgetaryRfqs;
+ // 선택 가능한 부모 RFQ 목록 로드
+ React.useEffect(() => {
+ if ((rfqType === RfqType.PURCHASE || rfqType === RfqType.PURCHASE_BUDGETARY) && open) {
+ const loadParentRfqs = async () => {
+ setIsLoadingParents(true);
+ try {
+ // 현재 RFQ 타입에 따라 선택 가능한, 부모가 될 수 있는 RFQ 타입들 가져오기
+ const parentTypes = getParentRfqTypes();
+
+ // 부모 RFQ 타입이 있을 때만 API 호출
+ if (parentTypes.length > 0) {
+ const result = await getBudgetaryRfqs({
+ rfqTypes: parentTypes // 서비스에 rfqTypes 파라미터 추가 필요
+ });
+
+ if ('rfqs' in result) {
+ setParentRfqs(result.rfqs as unknown as ParentRfq[]);
+ } else if ('error' in result) {
+ console.error("부모 RFQ 로드 오류:", result.error);
+ }
+ }
+ } catch (error) {
+ console.error("부모 RFQ 로드 오류:", error);
+ } finally {
+ setIsLoadingParents(false);
+ }
+ };
- const lowerSearch = budgetarySearchTerm.toLowerCase();
- return budgetaryRfqs.filter(
- rfq =>
- rfq.rfqCode.toLowerCase().includes(lowerSearch) ||
- (rfq.description && rfq.description.toLowerCase().includes(lowerSearch))
- );
- }, [budgetaryRfqs, budgetarySearchTerm]);
+ loadParentRfqs();
+ }
+ }, [rfqType, open]);
// 프로젝트 선택 처리
const handleProjectSelect = (project: Project | null) => {
@@ -136,11 +186,10 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps)
form.setValue("projectId", project.id);
};
- // Budgetary RFQ 선택 처리
- const handleBudgetaryRfqSelect = (rfq: BudgetaryRfq) => {
- setSelectedBudgetaryRfq(rfq);
- form.setValue("parentRfqId", rfq.id);
- setBudgetarySearchOpen(false);
+ // 부모 RFQ 선택 처리
+ const handleParentRfqSelect = (rfq: ParentRfq | null) => {
+ setSelectedParentRfq(rfq);
+ form.setValue("parentRfqId", rfq?.id);
};
async function onSubmit(data: CreateRfqSchema) {
@@ -166,14 +215,14 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps)
toast.success("RFQ가 성공적으로 생성되었습니다.");
form.reset();
- setSelectedBudgetaryRfq(null);
+ setSelectedParentRfq(null);
setOpen(false);
}
function handleDialogOpenChange(nextOpen: boolean) {
if (!nextOpen) {
form.reset();
- setSelectedBudgetaryRfq(null);
+ setSelectedParentRfq(null);
}
setOpen(nextOpen);
}
@@ -183,6 +232,28 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps)
return <Button variant="outline" size="sm" disabled>Loading...</Button>;
}
+ // 타입에 따라 부모 RFQ 선택 필드를 보여줄지 결정
+ const shouldShowParentRfqSelector = rfqType === RfqType.PURCHASE || rfqType === RfqType.PURCHASE_BUDGETARY;
+
+ // 부모 RFQ 선택기 레이블 및 설명 가져오기
+ const getParentRfqSelectorLabel = () => {
+ if (rfqType === RfqType.PURCHASE) {
+ return "부모 RFQ (BUDGETARY/PURCHASE_BUDGETARY)";
+ } else if (rfqType === RfqType.PURCHASE_BUDGETARY) {
+ return "부모 RFQ (BUDGETARY)";
+ }
+ return "부모 RFQ";
+ };
+
+ const getParentRfqDescription = () => {
+ if (rfqType === RfqType.PURCHASE) {
+ return "BUDGETARY 또는 PURCHASE_BUDGETARY 타입의 RFQ를 부모로 선택할 수 있습니다.";
+ } else if (rfqType === RfqType.PURCHASE_BUDGETARY) {
+ return "BUDGETARY 타입의 RFQ만 부모로 선택할 수 있습니다.";
+ }
+ return "";
+ };
+
return (
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
{/* 모달을 열기 위한 버튼 */}
@@ -197,6 +268,9 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps)
<DialogTitle>Create New {getTitle()}</DialogTitle>
<DialogDescription>
새 {getTitle()} 정보를 입력하고 <b>Create</b> 버튼을 누르세요.
+ <div className="mt-1 text-xs text-muted-foreground">
+ {getTypeDescription()}
+ </div>
</DialogDescription>
</DialogHeader>
@@ -231,31 +305,37 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps)
)}
/>
- {/* Budgetary RFQ Selector - 구매용 RFQ 생성 시에만 표시 */}
- {rfqType === RfqType.PURCHASE && (
+ {/* Parent RFQ Selector - PURCHASE 또는 PURCHASE_BUDGETARY 타입일 때만 표시 */}
+ {shouldShowParentRfqSelector && (
<FormField
control={form.control}
name="parentRfqId"
render={({ field }) => (
<FormItem>
- <FormLabel>Budgetary RFQ (Optional)</FormLabel>
+ <FormLabel>{getParentRfqSelectorLabel()}</FormLabel>
<FormControl>
- <BudgetaryRfqSelector
+ <ParentRfqSelector
selectedRfqId={field.value as number | undefined}
- onRfqSelect={(rfq) => {
- setSelectedBudgetaryRfq(rfq as any);
- form.setValue("parentRfqId", rfq?.id);
- }}
- placeholder="Budgetary RFQ 선택..."
+ onRfqSelect={handleParentRfqSelect}
+ rfqType={rfqType}
+ parentRfqTypes={getParentRfqTypes()}
+ placeholder={
+ rfqType === RfqType.PURCHASE
+ ? "BUDGETARY 또는 PURCHASE_BUDGETARY RFQ 선택..."
+ : "BUDGETARY RFQ 선택..."
+ }
/>
</FormControl>
+ <div className="text-xs text-muted-foreground mt-1">
+ {getParentRfqDescription()}
+ </div>
<FormMessage />
</FormItem>
)}
/>
)}
- {/* rfqCode */}
+ {/* rfqCode - 자동 생성되고 읽기 전용 */}
<FormField
control={form.control}
name="rfqCode"
@@ -263,8 +343,23 @@ export function AddRfqDialog({ rfqType = RfqType.PURCHASE }: AddRfqDialogProps)
<FormItem>
<FormLabel>RFQ Code</FormLabel>
<FormControl>
- <Input placeholder="e.g. RFQ-2025-001" {...field} />
+ <div className="flex">
+ <Input
+ placeholder="자동으로 생성 중..."
+ {...field}
+ disabled={true}
+ className="bg-muted"
+ />
+ {isLoadingRfqCode && (
+ <div className="ml-2 flex items-center">
+ <div className="h-4 w-4 animate-spin rounded-full border-2 border-primary border-t-transparent"></div>
+ </div>
+ )}
+ </div>
</FormControl>
+ <div className="text-xs text-muted-foreground mt-1">
+ RFQ 타입과 현재 날짜를 기준으로 자동 생성됩니다
+ </div>
<FormMessage />
</FormItem>
)}
diff --git a/lib/rfqs/table/rfqs-table.tsx b/lib/rfqs/table/rfqs-table.tsx
index 48c04930..e4ff47d8 100644
--- a/lib/rfqs/table/rfqs-table.tsx
+++ b/lib/rfqs/table/rfqs-table.tsx
@@ -231,7 +231,6 @@ export function RfqsTable({ promises, rfqType = RfqType.PURCHASE }: RfqsTablePro
open={rowAction?.type === "update"}
onOpenChange={() => setRowAction(null)}
rfq={rowAction?.row.original ?? null}
- rfqType={rfqType}
/>
<DeleteRfqsDialog
diff --git a/lib/rfqs/table/update-rfq-sheet.tsx b/lib/rfqs/table/update-rfq-sheet.tsx
index 769f25e7..22ca2c37 100644
--- a/lib/rfqs/table/update-rfq-sheet.tsx
+++ b/lib/rfqs/table/update-rfq-sheet.tsx
@@ -37,31 +37,127 @@ import { Input } from "@/components/ui/input"
import { Rfq, RfqWithItemCount } from "@/db/schema/rfq"
import { RfqType, updateRfqSchema, type UpdateRfqSchema } from "../validations"
-import { modifyRfq } from "../service"
+import { modifyRfq, getBudgetaryRfqs } from "../service"
import { ProjectSelector } from "@/components/ProjectSelector"
import { type Project } from "../service"
-import { BudgetaryRfqSelector } from "./BudgetaryRfqSelector"
+import { ParentRfqSelector } from "./ParentRfqSelector"
interface UpdateRfqSheetProps
extends React.ComponentPropsWithRef<typeof Sheet> {
rfq: RfqWithItemCount | null
- rfqType?: RfqType;
}
-
-interface BudgetaryRfq {
+// 부모 RFQ 정보 타입 정의
+interface ParentRfq {
id: number;
rfqCode: string;
description: string | null;
+ rfqType: RfqType;
+ projectId: number | null;
+ projectCode: string | null;
+ projectName: string | null;
}
-
-export function UpdateRfqSheet({ rfq,rfqType = RfqType.PURCHASE , ...props }: UpdateRfqSheetProps) {
+export function UpdateRfqSheet({ rfq, ...props }: UpdateRfqSheetProps) {
const [isUpdatePending, startUpdateTransition] = React.useTransition()
const { data: session } = useSession()
const userId = Number(session?.user?.id || 1)
- const [selectedBudgetaryRfq, setSelectedBudgetaryRfq] = React.useState<BudgetaryRfq | null>(null)
+ const [selectedParentRfq, setSelectedParentRfq] = React.useState<ParentRfq | null>(null)
+
+ // RFQ의 타입 가져오기
+ const rfqType = rfq?.rfqType || RfqType.PURCHASE;
+
+ // 초기 부모 RFQ ID 가져오기
+ const initialParentRfqId = rfq?.parentRfqId;
+
+ // 현재 RFQ 타입에 따라 선택 가능한 부모 RFQ 타입들 결정
+ const getParentRfqTypes = (): RfqType[] => {
+ switch(rfqType) {
+ case RfqType.PURCHASE:
+ // PURCHASE는 BUDGETARY와 PURCHASE_BUDGETARY를 부모로 가질 수 있음
+ return [RfqType.BUDGETARY, RfqType.PURCHASE_BUDGETARY];
+ case RfqType.PURCHASE_BUDGETARY:
+ // PURCHASE_BUDGETARY는 BUDGETARY만 부모로 가질 수 있음
+ return [RfqType.BUDGETARY];
+ default:
+ return [];
+ }
+ };
+
+ // 부모 RFQ 타입들
+ const parentRfqTypes = getParentRfqTypes();
+
+ // 부모 RFQ를 보여줄지 결정
+ const shouldShowParentRfqSelector = rfqType === RfqType.PURCHASE || rfqType === RfqType.PURCHASE_BUDGETARY;
+
+ // 타입에 따른 타이틀 생성
+ const getTypeTitle = () => {
+ switch(rfqType) {
+ case RfqType.PURCHASE:
+ return "Purchase RFQ";
+ case RfqType.BUDGETARY:
+ return "Budgetary RFQ";
+ case RfqType.PURCHASE_BUDGETARY:
+ return "Purchase Budgetary RFQ";
+ default:
+ return "RFQ";
+ }
+ };
+
+ // 타입 설명 가져오기
+ const getTypeDescription = () => {
+ switch(rfqType) {
+ case RfqType.PURCHASE:
+ return "실제 구매 발주 전에 가격을 요청";
+ case RfqType.BUDGETARY:
+ return "기술영업 단계에서 입찰가 산정을 위한 견적 요청";
+ case RfqType.PURCHASE_BUDGETARY:
+ return "프로젝트 수주 후, 공식 입찰 전 예산 책정을 위한 가격 요청";
+ default:
+ return "";
+ }
+ };
+ // 부모 RFQ 선택기 레이블 및 설명 가져오기
+ const getParentRfqSelectorLabel = () => {
+ if (rfqType === RfqType.PURCHASE) {
+ return "부모 RFQ (BUDGETARY/PURCHASE_BUDGETARY)";
+ } else if (rfqType === RfqType.PURCHASE_BUDGETARY) {
+ return "부모 RFQ (BUDGETARY)";
+ }
+ return "부모 RFQ";
+ };
+
+ const getParentRfqDescription = () => {
+ if (rfqType === RfqType.PURCHASE) {
+ return "BUDGETARY 또는 PURCHASE_BUDGETARY 타입의 RFQ를 부모로 선택할 수 있습니다.";
+ } else if (rfqType === RfqType.PURCHASE_BUDGETARY) {
+ return "BUDGETARY 타입의 RFQ만 부모로 선택할 수 있습니다.";
+ }
+ return "";
+ };
+
+ // 초기 부모 RFQ 로드
+ React.useEffect(() => {
+ if (initialParentRfqId && shouldShowParentRfqSelector) {
+ const loadInitialParentRfq = async () => {
+ try {
+ const result = await getBudgetaryRfqs({
+ rfqId: initialParentRfqId
+ });
+
+ if ('rfqs' in result && result.rfqs && result.rfqs.length > 0) {
+ setSelectedParentRfq(result.rfqs[0] as unknown as ParentRfq);
+ }
+ } catch (error) {
+ console.error("부모 RFQ 로드 오류:", error);
+ }
+ };
+
+ loadInitialParentRfq();
+ }
+ }, [initialParentRfqId, shouldShowParentRfqSelector]);
+
// RHF setup
const form = useForm<UpdateRfqSchema>({
resolver: zodResolver(updateRfqSchema),
@@ -70,6 +166,7 @@ export function UpdateRfqSheet({ rfq,rfqType = RfqType.PURCHASE , ...props }: Up
rfqCode: rfq?.rfqCode ?? "",
description: rfq?.description ?? "",
projectId: rfq?.projectId, // 프로젝트 ID
+ parentRfqId: rfq?.parentRfqId, // 부모 RFQ ID
dueDate: rfq?.dueDate ?? undefined, // null을 undefined로 변환
status: rfq?.status ?? "DRAFT",
createdBy: rfq?.createdBy ?? userId,
@@ -77,16 +174,27 @@ export function UpdateRfqSheet({ rfq,rfqType = RfqType.PURCHASE , ...props }: Up
});
// 프로젝트 선택 처리
- const handleProjectSelect = (project: Project) => {
+ const handleProjectSelect = (project: Project | null) => {
+ if (project === null) {
+ return;
+ }
form.setValue("projectId", project.id);
};
+ // 부모 RFQ 선택 처리
+ const handleParentRfqSelect = (rfq: ParentRfq | null) => {
+ setSelectedParentRfq(rfq);
+ form.setValue("parentRfqId", rfq?.id);
+ };
+
async function onSubmit(input: UpdateRfqSchema) {
startUpdateTransition(async () => {
if (!rfq) return
const { error } = await modifyRfq({
...input,
+ rfqType: rfqType as RfqType,
+
})
if (error) {
@@ -104,9 +212,12 @@ export function UpdateRfqSheet({ rfq,rfqType = RfqType.PURCHASE , ...props }: Up
<Sheet {...props}>
<SheetContent className="flex flex-col gap-6 sm:max-w-md">
<SheetHeader className="text-left">
- <SheetTitle>Update RFQ</SheetTitle>
+ <SheetTitle>Update {getTypeTitle()}</SheetTitle>
<SheetDescription>
- Update the RFQ details and save the changes
+ Update the {getTypeTitle()} details and save the changes
+ <div className="mt-1 text-xs text-muted-foreground">
+ {getTypeDescription()}
+ </div>
</SheetDescription>
</SheetHeader>
@@ -122,6 +233,15 @@ export function UpdateRfqSheet({ rfq,rfqType = RfqType.PURCHASE , ...props }: Up
<input type="hidden" {...field} />
)}
/>
+
+ {/* Hidden rfqType field */}
+ {/* <FormField
+ control={form.control}
+ name="rfqType"
+ render={({ field }) => (
+ <input type="hidden" {...field} />
+ )}
+ /> */}
{/* Project Selector - 재사용 컴포넌트 사용 */}
<FormField
@@ -142,31 +262,36 @@ export function UpdateRfqSheet({ rfq,rfqType = RfqType.PURCHASE , ...props }: Up
)}
/>
- {/* Budgetary RFQ Selector - 구매용 RFQ 생성 시에만 표시 */}
- {rfqType === RfqType.PURCHASE && (
+ {/* Parent RFQ Selector - PURCHASE 또는 PURCHASE_BUDGETARY 타입일 때만 표시 */}
+ {shouldShowParentRfqSelector && (
<FormField
control={form.control}
name="parentRfqId"
render={({ field }) => (
<FormItem>
- <FormLabel>Budgetary RFQ (Optional)</FormLabel>
+ <FormLabel>{getParentRfqSelectorLabel()}</FormLabel>
<FormControl>
- <BudgetaryRfqSelector
+ <ParentRfqSelector
selectedRfqId={field.value as number | undefined}
- onRfqSelect={(rfq) => {
- setSelectedBudgetaryRfq(rfq as any);
- form.setValue("parentRfqId", rfq?.id);
- }}
- placeholder="Budgetary RFQ 선택..."
+ onRfqSelect={handleParentRfqSelect}
+ rfqType={rfqType}
+ parentRfqTypes={parentRfqTypes}
+ placeholder={
+ rfqType === RfqType.PURCHASE
+ ? "BUDGETARY 또는 PURCHASE_BUDGETARY RFQ 선택..."
+ : "BUDGETARY RFQ 선택..."
+ }
/>
</FormControl>
+ <div className="text-xs text-muted-foreground mt-1">
+ {getParentRfqDescription()}
+ </div>
<FormMessage />
</FormItem>
)}
/>
)}
-
{/* rfqCode */}
<FormField
control={form.control}
@@ -197,8 +322,6 @@ export function UpdateRfqSheet({ rfq,rfqType = RfqType.PURCHASE , ...props }: Up
)}
/>
-
-
{/* dueDate (type="date") */}
<FormField
control={form.control}
diff --git a/lib/rfqs/validations.ts b/lib/rfqs/validations.ts
index 369e426c..9e9e96cc 100644
--- a/lib/rfqs/validations.ts
+++ b/lib/rfqs/validations.ts
@@ -11,6 +11,7 @@ import { Rfq, rfqs, RfqsView, VendorCbeView, VendorRfqViewBase, VendorTbeVi
import { Vendor, vendors } from "@/db/schema/vendors";
export const RfqType = {
+ PURCHASE_BUDGETARY: "PURCHASE_BUDGETARY",
PURCHASE: "PURCHASE",
BUDGETARY: "BUDGETARY"
} as const;
@@ -41,7 +42,7 @@ export const searchParamsCache = createSearchParamsCache({
filters: getFiltersStateParser().withDefault([]),
joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
search: parseAsString.withDefault(""),
- rfqType: parseAsStringEnum(["PURCHASE", "BUDGETARY"]).withDefault("PURCHASE"),
+ rfqType: parseAsStringEnum(["PURCHASE", "BUDGETARY", "PURCHASE_BUDGETARY"]).withDefault("PURCHASE"),
});
@@ -106,7 +107,7 @@ export const searchParamsTBECache = createSearchParamsCache({
tbeResult: parseAsString.withDefault(""),
tbeNote: parseAsString.withDefault(""),
tbeUpdated: parseAsString.withDefault(""),
- rfqType: parseAsStringEnum(["PURCHASE", "BUDGETARY"]).withDefault("PURCHASE"),
+ rfqType: parseAsStringEnum(["PURCHASE", "BUDGETARY", "PURCHASE_BUDGETARY"]).withDefault("PURCHASE"),
// 5) 상태 (배열) - Rfq["status"]는 "DRAFT"|"PUBLISHED"|"EVALUATION"|"AWARDED"
// rfqs.status.enumValues 로 가져온 문자열 배열을 z.enum([...])로 처리
@@ -131,7 +132,7 @@ export const createRfqSchema = z.object({
parentRfqId: z.number().nullable().optional(), // 부모 RFQ ID (선택적)
dueDate: z.date(),
status: z.enum(["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"]),
- rfqType: z.enum([RfqType.PURCHASE, RfqType.BUDGETARY]).default(RfqType.PURCHASE),
+ rfqType: z.enum([RfqType.PURCHASE, RfqType.BUDGETARY, RfqType.PURCHASE_BUDGETARY]).default(RfqType.PURCHASE),
createdBy: z.number(),
});
@@ -170,6 +171,7 @@ export const updateRfqSchema = z.object({
(val) => (val === null || val === '') ? undefined : val,
z.date().optional()
),
+ rfqType: z.enum(["PURCHASE", "BUDGETARY", "PURCHASE_BUDGETARY"]).optional(),
status: z.union([
z.enum(["DRAFT", "PUBLISHED", "EVALUATION", "AWARDED"]),
z.string().refine(
@@ -251,7 +253,7 @@ export const searchParamsCBECache = createSearchParamsCache({
cbeResult: parseAsString.withDefault(""),
cbeNote: parseAsString.withDefault(""),
cbeUpdated: parseAsString.withDefault(""),
- rfqType: parseAsStringEnum(["PURCHASE", "BUDGETARY"]).withDefault("PURCHASE"),
+ rfqType: parseAsStringEnum(["PURCHASE", "BUDGETARY", "PURCHASE_BUDGETARY"]).withDefault("PURCHASE"),
totalCost: parseAsInteger.withDefault(0),
diff --git a/lib/sedp/sedp-token.ts b/lib/sedp/sedp-token.ts
new file mode 100644
index 00000000..bac6bdca
--- /dev/null
+++ b/lib/sedp/sedp-token.ts
@@ -0,0 +1,91 @@
+// 환경 변수
+const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/dev/api';
+const SEDP_API_USER_ID = process.env.SEDP_API_USER_ID || 'EVCPUSER';
+const SEDP_API_PASSWORD = process.env.SEDP_API_PASSWORD || 'evcpuser@2025';
+
+/**
+ * SEDP API에서 인증 토큰을 가져옵니다.
+ * 매 호출 시마다 새로운 토큰을 발급받습니다.
+ */
+export async function getSEDPToken(): Promise<string> {
+ try {
+ const response = await fetch(
+ `${SEDP_API_BASE_URL}/Security/RequestToken`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'accept': '*/*'
+ },
+ body: JSON.stringify({
+ UserID: SEDP_API_USER_ID,
+ Password: SEDP_API_PASSWORD
+ })
+ }
+ );
+
+ if (!response.ok) {
+ throw new Error(`SEDP 토큰 요청 실패: ${response.status} ${response.statusText}`);
+ }
+
+ // 응답이 직접 토큰 문자열인 경우
+ const tokenData = await response.text();
+
+ // 응답이 JSON 형식이면 파싱
+ try {
+ const jsonData = JSON.parse(tokenData);
+ if (typeof jsonData === 'string') {
+ return jsonData; // JSON 문자열이지만 내용물이 토큰 문자열인 경우
+ } else if (jsonData.token) {
+ return jsonData.token; // { token: "..." } 형태인 경우
+ } else {
+ console.warn('예상치 못한 토큰 응답 형식:', jsonData);
+ // 가장 가능성 있는 필드를 찾아봄
+ for (const key of ['token', 'accessToken', 'access_token', 'Token', 'jwt']) {
+ if (jsonData[key]) return jsonData[key];
+ }
+ // 그래도 없으면 문자열로 변환
+ return JSON.stringify(jsonData);
+ }
+ } catch (e) {
+ // 파싱 실패 = 응답이 JSON이 아닌 순수 토큰 문자열
+ return tokenData.trim();
+ }
+ } catch (error) {
+ console.error('SEDP 토큰 가져오기 실패:', error);
+ throw error;
+ }
+}
+
+/**
+ * SEDP API에 인증된 요청을 보냅니다.
+ */
+export async function fetchSEDP(endpoint: string, options: RequestInit = {}): Promise<any> {
+ try {
+ // 토큰 가져오기
+ const token = await getSEDPToken();
+
+ // 헤더 준비
+ const headers = {
+ 'Content-Type': 'application/json',
+ 'accept': '*/*',
+ 'ApiKey': token,
+ ...(options.headers || {})
+ };
+
+ // 요청 보내기
+ const response = await fetch(`${SEDP_API_BASE_URL}${endpoint}`, {
+ ...options,
+ headers
+ });
+
+ if (!response.ok) {
+ throw new Error(`SEDP API 요청 실패 (${endpoint}): ${response.status} ${response.statusText}`);
+ }
+
+ return response.json();
+ } catch (error) {
+ console.error(`SEDP API 오류 (${endpoint}):`, error);
+ throw error;
+ }
+} \ No newline at end of file
diff --git a/lib/sedp/sync-form.ts b/lib/sedp/sync-form.ts
new file mode 100644
index 00000000..b9e6fa90
--- /dev/null
+++ b/lib/sedp/sync-form.ts
@@ -0,0 +1,512 @@
+// src/lib/cron/syncTagFormMappings.ts
+import db from "@/db/db";
+import { projects, tagTypes, tagClasses, tagTypeClassFormMappings, formMetas } from '@/db/schema';
+import { eq, and, inArray } from 'drizzle-orm';
+import { getSEDPToken } from "./sedp-token";
+
+// 환경 변수
+const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/dev/api';
+
+// 인터페이스 정의
+interface Register {
+ PROJ_NO: string;
+ TYPE_ID: string;
+ EP_ID: string;
+ DESC: string;
+ REMARK: string | null;
+ NEW_TAG_YN: boolean;
+ ALL_TAG_YN: boolean;
+ VND_YN: boolean;
+ SEQ: number;
+ CMPLX_YN: boolean;
+ CMPL_SETT: any | null;
+ MAP_ATT: any[];
+ MAP_CLS_ID: string[];
+ MAP_OPER: any | null;
+ LNK_ATT: LinkAttribute[];
+ JOIN_TABLS: any[];
+ DELETED: boolean;
+ CRTER_NO: string;
+ CRTE_DTM: string;
+ CHGER_NO: string | null;
+ CHGE_DTM: string | null;
+ _id: string;
+}
+
+interface LinkAttribute {
+ ATT_ID: string;
+ CPY_DESC: string;
+ JOIN_KEY_ATT_ID: string | null;
+ JOIN_VAL_ATT_ID: string | null;
+ KEY_YN: boolean;
+ EDIT_YN: boolean;
+ PUB_YN: boolean;
+ VND_YN: boolean;
+ DEF_VAL: string | null;
+ UOM_ID: string | null;
+}
+
+interface Attribute {
+ PROJ_NO: string;
+ ATT_ID: string;
+ DESC: string;
+ GROUP: string | null;
+ REMARK: string | null;
+ VAL_TYPE: string;
+ IGN_LIST_VAL: boolean;
+ CL_ID: string | null;
+ UOM_ID: string | null;
+ DEF_VAL: string | null;
+ MIN_VAL: number;
+ MAX_VAL: number;
+ ESS_YN: boolean;
+ SEQ: number;
+ FORMAT: string | null;
+ REG_EXPS: string | null;
+ ATTRIBUTES: any[];
+ DELETED: boolean;
+ CRTER_NO: string;
+ CRTE_DTM: string;
+ CHGER_NO: string | null;
+ CHGE_DTM: string | null;
+ _id: string;
+}
+
+interface CodeList {
+ PROJ_NO: string;
+ CL_ID: string;
+ DESC: string;
+ REMARK: string | null;
+ PRNT_CD_ID: string | null;
+ REG_TYPE_ID: string | null;
+ VAL_ATT_ID: string | null;
+ VALUES: CodeValue[];
+ LNK_ATT: any[];
+ DELETED: boolean;
+ CRTER_NO: string;
+ CRTE_DTM: string;
+ CHGER_NO: string | null;
+ CHGE_DTM: string | null;
+ _id: string;
+}
+
+interface CodeValue {
+ PRNT_VALUE: string | null;
+ VALUE: string;
+ DESC: string;
+ REMARK: string;
+ USE_YN: boolean;
+ SEQ: number;
+ ATTRIBUTES: any[];
+}
+
+interface UOM {
+ PROJ_NO: string;
+ UOM_ID: string;
+ DESC: string;
+ SYMBOL: string;
+ CONV_RATE: number;
+ DELETED: boolean;
+ CRTER_NO: string;
+ CRTE_DTM: string;
+ CHGER_NO: string | null;
+ CHGE_DTM: string | null;
+ _id: string;
+}
+
+interface Project {
+ id: number;
+ code: string;
+ name: string;
+ type?: string;
+ createdAt?: Date;
+ updatedAt?: Date;
+}
+
+interface SyncResult {
+ project: string;
+ success: boolean;
+ count?: number;
+ error?: string;
+}
+
+interface FormColumn {
+ key: string;
+ label: string;
+ type: string;
+ options?: string[];
+ uom?: string;
+ uomId?: string;
+}
+
+// 레지스터 데이터 가져오기
+async function getRegisters(projectCode: string): Promise<Register[]> {
+ try {
+ // 토큰(API 키) 가져오기
+ const apiKey = await getSEDPToken();
+
+ const response = await fetch(
+ `${SEDP_API_BASE_URL}/Register/Get`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'accept': '*/*',
+ 'ApiKey': apiKey,
+ 'ProjectNo': projectCode
+ },
+ body: JSON.stringify({
+ ContainDeleted: true
+ })
+ }
+ );
+
+ if (!response.ok) {
+ throw new Error(`레지스터 요청 실패: ${response.status} ${response.statusText}`);
+ }
+
+ const data = await response.json();
+
+ // 결과가 배열인지 확인
+ if (Array.isArray(data)) {
+ return data;
+ } else {
+ // 단일 객체인 경우 배열로 변환
+ return [data];
+ }
+ } catch (error) {
+ console.error(`프로젝트 ${projectCode}의 레지스터 가져오기 실패:`, error);
+ throw error;
+ }
+}
+
+// 특정 속성 가져오기
+async function getAttributeById(projectCode: string, attributeId: string): Promise<Attribute | null> {
+ try {
+ // 토큰(API 키) 가져오기
+ const apiKey = await getSEDPToken();
+
+ const response = await fetch(
+ `${SEDP_API_BASE_URL}/Attributes/GetByID`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'accept': '*/*',
+ 'ApiKey': apiKey,
+ 'ProjectNo': projectCode
+ },
+ body: JSON.stringify({
+ ATT_ID: attributeId
+ })
+ }
+ );
+
+ if (!response.ok) {
+ if (response.status === 404) {
+ console.warn(`속성 ID ${attributeId}를 찾을 수 없음`);
+ return null;
+ }
+ throw new Error(`속성 요청 실패: ${response.status} ${response.statusText}`);
+ }
+
+ return response.json();
+ } catch (error) {
+ console.error(`속성 ID ${attributeId} 가져오기 실패:`, error);
+ return null;
+ }
+}
+
+// 특정 코드 리스트 가져오기
+async function getCodeListById(projectCode: string, codeListId: string): Promise<CodeList | null> {
+ try {
+ // 토큰(API 키) 가져오기
+ const apiKey = await getSEDPToken();
+
+ const response = await fetch(
+ `${SEDP_API_BASE_URL}/CodeList/GetByID`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'accept': '*/*',
+ 'ApiKey': apiKey,
+ 'ProjectNo': projectCode
+ },
+ body: JSON.stringify({
+ CL_ID: codeListId
+ })
+ }
+ );
+
+ if (!response.ok) {
+ if (response.status === 404) {
+ console.warn(`코드 리스트 ID ${codeListId}를 찾을 수 없음`);
+ return null;
+ }
+ throw new Error(`코드 리스트 요청 실패: ${response.status} ${response.statusText}`);
+ }
+
+ return response.json();
+ } catch (error) {
+ console.error(`코드 리스트 ID ${codeListId} 가져오기 실패:`, error);
+ return null;
+ }
+}
+
+// UOM 가져오기
+async function getUomById(projectCode: string, uomId: string): Promise<UOM | null> {
+ try {
+ // 토큰(API 키) 가져오기
+ const apiKey = await getSEDPToken();
+
+ const response = await fetch(
+ `${SEDP_API_BASE_URL}/UOM/GetByID`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'accept': '*/*',
+ 'ApiKey': apiKey,
+ 'ProjectNo': projectCode
+ },
+ body: JSON.stringify({
+ UOM_ID: uomId
+ })
+ }
+ );
+
+ if (!response.ok) {
+ if (response.status === 404) {
+ console.warn(`UOM ID ${uomId}를 찾을 수 없음`);
+ return null;
+ }
+ throw new Error(`UOM 요청 실패: ${response.status} ${response.statusText}`);
+ }
+
+ return response.json();
+ } catch (error) {
+ console.error(`UOM ID ${uomId} 가져오기 실패:`, error);
+ return null;
+ }
+}
+
+// 데이터베이스에 태그 타입 클래스 폼 매핑 및 폼 메타 저장
+async function saveFormMappingsAndMetas(
+ projectId: number,
+ projectCode: string,
+ registers: Register[]
+): Promise<number> {
+ try {
+ // 프로젝트와 관련된 태그 타입 및 클래스 가져오기
+ const tagTypeRecords = await db.select()
+ .from(tagTypes)
+ .where(eq(tagTypes.projectId, projectId));
+
+ const tagClassRecords = await db.select()
+ .from(tagClasses)
+ .where(eq(tagClasses.projectId, projectId));
+
+ // 태그 타입과 클래스를 매핑
+ const tagTypeMap = new Map(tagTypeRecords.map(type => [type.code, type]));
+ const tagClassMap = new Map(tagClassRecords.map(cls => [cls.code, cls]));
+
+ // 저장할 매핑 목록과 폼 메타 정보
+ const mappingsToSave = [];
+ const formMetasToSave = [];
+
+ // 각 레지스터 처리
+ for (const register of registers) {
+ // 삭제된 레지스터는 건너뜀
+ if (register.DELETED) continue;
+
+ // 폼 메타 데이터를 위한 컬럼 정보 구성
+ const columns: FormColumn[] = [];
+
+ // 각 속성 정보 수집
+ for (const linkAtt of register.LNK_ATT) {
+ // 속성 가져오기
+ const attribute = await getAttributeById(projectCode, linkAtt.ATT_ID);
+
+ if (!attribute) continue;
+
+ // 기본 컬럼 정보
+ const column: FormColumn = {
+ key: linkAtt.ATT_ID,
+ label: linkAtt.CPY_DESC,
+ type: attribute.VAL_TYPE || 'STRING'
+ };
+
+ // 리스트 타입인 경우 옵션 추가
+ if ((attribute.VAL_TYPE === 'LIST' || attribute.VAL_TYPE === 'DYNAMICLIST') && attribute.CL_ID) {
+ const codeList = await getCodeListById(projectCode, attribute.CL_ID);
+
+ if (codeList && codeList.VALUES) {
+ // 유효한 옵션만 필터링
+ const options = codeList.VALUES
+ .filter(value => value.USE_YN)
+ .map(value => value.DESC);
+
+ if (options.length > 0) {
+ column.options = options;
+ }
+ }
+ }
+
+ // UOM 정보 추가
+ if (linkAtt.UOM_ID) {
+ const uom = await getUomById(projectCode, linkAtt.UOM_ID);
+
+ if (uom) {
+ column.uom = uom.SYMBOL;
+ column.uomId = uom.UOM_ID;
+ }
+ }
+
+ columns.push(column);
+ }
+
+ // 폼 메타 정보 저장
+ formMetasToSave.push({
+ projectId,
+ formCode: register.TYPE_ID,
+ formName: register.DESC,
+ columns: JSON.stringify(columns),
+ createdAt: new Date(),
+ updatedAt: new Date()
+ });
+
+ // 관련된 클래스 매핑 처리
+ for (const classId of register.MAP_CLS_ID) {
+ // 해당 클래스와 태그 타입 확인
+ const tagClass = tagClassMap.get(classId);
+
+ if (!tagClass) {
+ console.warn(`클래스 ID ${classId}를 프로젝트 ID ${projectId}에서 찾을 수 없음`);
+ continue;
+ }
+
+ const tagTypeCode = tagClass.tagTypeCode;
+ const tagType = tagTypeMap.get(tagTypeCode);
+
+ if (!tagType) {
+ console.warn(`태그 타입 ${tagTypeCode}를 프로젝트 ID ${projectId}에서 찾을 수 없음`);
+ continue;
+ }
+
+ // 매핑 정보 저장
+ mappingsToSave.push({
+ projectId,
+ tagTypeLabel: tagType.description,
+ classLabel: tagClass.label,
+ formCode: register.TYPE_ID,
+ formName: register.DESC,
+ createdAt: new Date(),
+ updatedAt: new Date()
+ });
+ }
+ }
+
+ // 기존 데이터 삭제 후 새로 저장
+ await db.delete(tagTypeClassFormMappings).where(eq(tagTypeClassFormMappings.projectId, projectId));
+ await db.delete(formMetas).where(eq(formMetas.projectId, projectId));
+
+ let totalSaved = 0;
+
+ // 매핑 정보 저장
+ if (mappingsToSave.length > 0) {
+ await db.insert(tagTypeClassFormMappings).values(mappingsToSave);
+ totalSaved += mappingsToSave.length;
+ console.log(`프로젝트 ID ${projectId}에 ${mappingsToSave.length}개의 태그 타입-클래스-폼 매핑 저장 완료`);
+ }
+
+ // 폼 메타 정보 저장
+ if (formMetasToSave.length > 0) {
+ await db.insert(formMetas).values(formMetasToSave);
+ totalSaved += formMetasToSave.length;
+ console.log(`프로젝트 ID ${projectId}에 ${formMetasToSave.length}개의 폼 메타 정보 저장 완료`);
+ }
+
+ return totalSaved;
+ } catch (error) {
+ console.error(`폼 매핑 및 메타 저장 실패 (프로젝트 ID: ${projectId}):`, error);
+ throw error;
+ }
+}
+
+// 메인 동기화 함수
+export async function syncTagFormMappings() {
+ try {
+ console.log('태그 폼 매핑 동기화 시작:', new Date().toISOString());
+
+ // 모든 프로젝트 가져오기
+ const allProjects = await db.select().from(projects);
+
+ // 각 프로젝트에 대해 폼 매핑 동기화
+ const results = await Promise.allSettled(
+ allProjects.map(async (project: Project) => {
+ try {
+ // 레지스터 데이터 가져오기
+ const registers = await getRegisters(project.code);
+
+ // 데이터베이스에 저장
+ const count = await saveFormMappingsAndMetas(project.id, project.code, registers);
+ return {
+ project: project.code,
+ success: true,
+ count
+ } as SyncResult;
+ } catch (error) {
+ console.error(`프로젝트 ${project.code} 폼 매핑 동기화 실패:`, error);
+ return {
+ project: project.code,
+ success: false,
+ error: error instanceof Error ? error.message : String(error)
+ } as SyncResult;
+ }
+ })
+ );
+
+ // 결과 처리를 위한 배열 준비
+ const successfulResults: SyncResult[] = [];
+ const failedResults: SyncResult[] = [];
+
+ // 결과 분류
+ results.forEach((result) => {
+ if (result.status === 'fulfilled') {
+ if (result.value.success) {
+ successfulResults.push(result.value);
+ } else {
+ failedResults.push(result.value);
+ }
+ } else {
+ // 거부된 프로미스는 실패로 간주
+ failedResults.push({
+ project: 'unknown',
+ success: false,
+ error: result.reason?.toString() || 'Unknown error'
+ });
+ }
+ });
+
+ const successCount = successfulResults.length;
+ const failCount = failedResults.length;
+
+ // 이제 안전하게 count 속성에 접근 가능
+ const totalItems = successfulResults.reduce((sum, result) =>
+ sum + (result.count || 0), 0
+ );
+
+ console.log(`태그 폼 매핑 동기화 완료: ${successCount}개 프로젝트 성공 (총 ${totalItems}개 항목), ${failCount}개 프로젝트 실패`);
+
+ return {
+ success: successCount,
+ failed: failCount,
+ items: totalItems,
+ timestamp: new Date().toISOString()
+ };
+ } catch (error) {
+ console.error('태그 폼 매핑 동기화 중 오류 발생:', error);
+ throw error;
+ }
+} \ No newline at end of file
diff --git a/lib/sedp/sync-object-class.ts b/lib/sedp/sync-object-class.ts
new file mode 100644
index 00000000..1cf0c23b
--- /dev/null
+++ b/lib/sedp/sync-object-class.ts
@@ -0,0 +1,304 @@
+import db from "@/db/db";
+import { projects, tagClasses, tagTypes } from '@/db/schema';
+import { eq, and } from 'drizzle-orm';
+import { getSEDPToken } from "./sedp-token";
+
+// 환경 변수
+const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/dev/api';
+
+// ObjectClass 인터페이스 정의
+interface ObjectClass {
+ PROJ_NO: string;
+ CLS_ID: string;
+ DESC: string;
+ TAG_TYPE_ID: string | null;
+ PRT_CLS_ID: string | null;
+ LNK_ATT: any[];
+ DELETED: boolean;
+ DEL_USER: string | null;
+ DEL_DTM: string | null;
+ CRTER_NO: string;
+ CRTE_DTM: string;
+ CHGER_NO: string | null;
+ CHGE_DTM: string | null;
+ _id: string;
+}
+
+interface Project {
+ id: number;
+ code: string;
+ name: string;
+ type?: string;
+ createdAt?: Date;
+ updatedAt?: Date;
+}
+
+// 동기화 결과 인터페이스
+interface SyncResult {
+ project: string;
+ success: boolean;
+ count?: number;
+ error?: string;
+}
+
+// 오브젝트 클래스 데이터 가져오기
+async function getObjectClasses(projectCode: string, token:string): Promise<ObjectClass[]> {
+ try {
+ const response = await fetch(
+ `${SEDP_API_BASE_URL}/ObjectClass/Get`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'accept': '*/*',
+ 'ApiKey': token,
+ 'ProjectNo': projectCode
+ },
+ body: JSON.stringify({
+ ContainDeleted: true
+ })
+ }
+ );
+
+ if (!response.ok) {
+ throw new Error(`오브젝트 클래스 요청 실패: ${response.status} ${response.statusText}`);
+ }
+
+ const data = await response.json();
+
+ // 결과가 배열인지 확인
+ if (Array.isArray(data)) {
+ return data;
+ } else {
+ // 단일 객체인 경우 배열로 변환
+ return [data];
+ }
+ } catch (error) {
+ console.error(`프로젝트 ${projectCode}의 오브젝트 클래스 가져오기 실패:`, error);
+ throw error;
+ }
+}
+
+// 태그 타입 존재 확인
+async function verifyTagTypes(projectId: number, tagTypeCodes: string[]): Promise<Set<string>> {
+ try {
+ // 프로젝트에 있는 태그 타입 코드 조회
+ const existingTagTypes = await db.select({ code: tagTypes.code })
+ .from(tagTypes)
+ .where(eq(tagTypes.projectId, projectId));
+
+ // 존재하는 태그 타입 코드 Set으로 반환
+ return new Set(existingTagTypes.map(type => type.code));
+ } catch (error) {
+ console.error(`프로젝트 ID ${projectId}의 태그 타입 확인 실패:`, error);
+ throw error;
+ }
+}
+
+// 데이터베이스에 오브젝트 클래스 저장 (upsert 사용)
+async function saveObjectClassesToDatabase(projectId: number, classes: ObjectClass[]): Promise<number> {
+ try {
+ // null이 아닌 TAG_TYPE_ID만 필터링
+ const validClasses = classes.filter(cls => cls.TAG_TYPE_ID !== null);
+
+ if (validClasses.length === 0) {
+ console.log(`프로젝트 ID ${projectId}에 저장할 유효한 오브젝트 클래스가 없습니다.`);
+ return 0;
+ }
+
+ // 모든 태그 타입 ID 목록 추출
+ const tagTypeCodes = validClasses.map(cls => cls.TAG_TYPE_ID!);
+
+ // 존재하는 태그 타입 확인
+ const existingTagTypeCodes = await verifyTagTypes(projectId, tagTypeCodes);
+
+ // 태그 타입이 존재하는 오브젝트 클래스만 필터링
+ const classesToSave = validClasses.filter(cls =>
+ cls.TAG_TYPE_ID !== null && existingTagTypeCodes.has(cls.TAG_TYPE_ID)
+ );
+
+ if (classesToSave.length === 0) {
+ console.log(`프로젝트 ID ${projectId}에 저장할 유효한 오브젝트 클래스가 없습니다 (태그 타입 존재하지 않음).`);
+ return 0;
+ }
+
+ // 현재 프로젝트의 오브젝트 클래스 코드 가져오기
+ const existingClasses = await db.select()
+ .from(tagClasses)
+ .where(eq(tagClasses.projectId, projectId));
+
+ // 코드 기준으로 맵 생성
+ const existingClassMap = new Map(
+ existingClasses.map(cls => [cls.code, cls])
+ );
+
+ // 새로 추가할 항목
+ const toInsert = [];
+
+ // 업데이트할 항목
+ const toUpdate = [];
+
+ // API에 있는 코드 목록
+ const apiClassCodes = new Set(classesToSave.map(cls => cls.CLS_ID));
+
+ // 삭제할 코드 목록
+ const codesToDelete = existingClasses
+ .map(cls => cls.code)
+ .filter(code => !apiClassCodes.has(code));
+
+ // 클래스 데이터 처리
+ for (const cls of classesToSave) {
+ // 데이터베이스 레코드 준비
+ const record = {
+ code: cls.CLS_ID,
+ projectId: projectId,
+ label: cls.DESC,
+ tagTypeCode: cls.TAG_TYPE_ID!,
+ updatedAt: new Date()
+ };
+
+ // 이미 존재하는 코드인지 확인
+ if (existingClassMap.has(cls.CLS_ID)) {
+ // 업데이트 항목에 추가
+ toUpdate.push(record);
+ } else {
+ // 새로 추가할 항목에 추가 (createdAt 필드 추가)
+ toInsert.push({
+ ...record,
+ createdAt: new Date()
+ });
+ }
+ }
+
+ // 트랜잭션 실행
+ let totalChanged = 0;
+
+ // 1. 새 항목 삽입
+ if (toInsert.length > 0) {
+ await db.insert(tagClasses).values(toInsert);
+ totalChanged += toInsert.length;
+ console.log(`프로젝트 ID ${projectId}에 ${toInsert.length}개의 새 오브젝트 클래스 추가 완료`);
+ }
+
+ // 2. 기존 항목 업데이트
+ for (const item of toUpdate) {
+ await db.update(tagClasses)
+ .set({
+ label: item.label,
+ tagTypeCode: item.tagTypeCode,
+ updatedAt: item.updatedAt
+ })
+ .where(
+ and(
+ eq(tagClasses.code, item.code),
+ eq(tagClasses.projectId, item.projectId)
+ )
+ );
+ totalChanged += 1;
+ }
+
+ if (toUpdate.length > 0) {
+ console.log(`프로젝트 ID ${projectId}의 ${toUpdate.length}개 오브젝트 클래스 업데이트 완료`);
+ }
+
+ // 3. 더 이상 존재하지 않는 항목 삭제
+ if (codesToDelete.length > 0) {
+ for (const code of codesToDelete) {
+ await db.delete(tagClasses)
+ .where(
+ and(
+ eq(tagClasses.code, code),
+ eq(tagClasses.projectId, projectId)
+ )
+ );
+ }
+ console.log(`프로젝트 ID ${projectId}에서 ${codesToDelete.length}개의 오브젝트 클래스 삭제 완료`);
+ totalChanged += codesToDelete.length;
+ }
+
+ return totalChanged;
+ } catch (error) {
+ console.error(`오브젝트 클래스 저장 실패 (프로젝트 ID: ${projectId}):`, error);
+ throw error;
+ }
+}
+
+// 메인 동기화 함수
+export async function syncObjectClasses() {
+ try {
+ console.log('오브젝트 클래스 동기화 시작:', new Date().toISOString());
+
+ // 1. 토큰 가져오기
+ const token = await getSEDPToken();
+
+ // 2. 모든 프로젝트 가져오기
+ const allProjects = await db.select().from(projects);
+
+ // 3. 각 프로젝트에 대해 오브젝트 클래스 동기화
+ const results = await Promise.allSettled(
+ allProjects.map(async (project: Project) => {
+ try {
+ // 오브젝트 클래스 데이터 가져오기
+ const objectClasses = await getObjectClasses(project.code, token);
+
+ // 데이터베이스에 저장
+ const count = await saveObjectClassesToDatabase(project.id, objectClasses);
+ return {
+ project: project.code,
+ success: true,
+ count
+ } as SyncResult;
+ } catch (error) {
+ console.error(`프로젝트 ${project.code} 동기화 실패:`, error);
+ return {
+ project: project.code,
+ success: false,
+ error: error instanceof Error ? error.message : String(error)
+ } as SyncResult;
+ }
+ })
+ );
+
+ // 결과 처리를 위한 배열 준비
+ const successfulResults: SyncResult[] = [];
+ const failedResults: SyncResult[] = [];
+
+ // 결과 분류
+ results.forEach((result) => {
+ if (result.status === 'fulfilled') {
+ if (result.value.success) {
+ successfulResults.push(result.value);
+ } else {
+ failedResults.push(result.value);
+ }
+ } else {
+ // 거부된 프로미스는 실패로 간주
+ failedResults.push({
+ project: 'unknown',
+ success: false,
+ error: result.reason?.toString() || 'Unknown error'
+ });
+ }
+ });
+
+ const successCount = successfulResults.length;
+ const failCount = failedResults.length;
+
+ // 이제 안전하게 count 속성에 접근 가능
+ const totalItems = successfulResults.reduce((sum, result) =>
+ sum + (result.count || 0), 0
+ );
+
+ console.log(`오브젝트 클래스 동기화 완료: ${successCount}개 프로젝트 성공 (총 ${totalItems}개 항목), ${failCount}개 프로젝트 실패`);
+
+ return {
+ success: successCount,
+ failed: failCount,
+ items: totalItems,
+ timestamp: new Date().toISOString()
+ };
+ } catch (error) {
+ console.error('오브젝트 클래스 동기화 중 오류 발생:', error);
+ throw error;
+ }
+} \ No newline at end of file
diff --git a/lib/sedp/sync-projects.ts b/lib/sedp/sync-projects.ts
new file mode 100644
index 00000000..1094b55f
--- /dev/null
+++ b/lib/sedp/sync-projects.ts
@@ -0,0 +1,194 @@
+// src/lib/cron/syncProjects.ts
+import db from "@/db/db";
+import { projects } from '@/db/schema';
+import { eq } from 'drizzle-orm';
+import { getSEDPToken } from "./sedp-token";
+
+// 환경 변수
+const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/dev/api';
+
+// 인터페이스 정의
+interface Project {
+ PROJ_NO: string;
+ DESC: string;
+ TYPE?: string;
+ DELETED?: boolean;
+ DEL_USER?: string | null;
+ DEL_DTM?: string | null;
+ CRTER_NO?: string;
+ CRTE_DTM?: string;
+ CHGER_NO?: string | null;
+ CHGE_DTM?: string | null;
+ _id?: string;
+}
+
+interface SyncResult {
+ success: number;
+ failed: number;
+ items: number;
+ timestamp: string;
+}
+
+// 프로젝트 데이터 가져오기
+async function getProjects(): Promise<Project[]> {
+ try {
+ // 토큰(API 키) 가져오기
+ const apiKey = await getSEDPToken();
+
+ const response = await fetch(
+ `${SEDP_API_BASE_URL}/Project/Get`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'accept': '*/*',
+ 'ApiKey': apiKey
+ },
+ body: JSON.stringify({
+ ContainDeleted: true
+ })
+ }
+ );
+
+ if (!response.ok) {
+ throw new Error(`프로젝트 요청 실패: ${response.status} ${response.statusText}`);
+ }
+
+ const data = await response.json();
+
+ // 결과가 배열인지 확인
+ if (Array.isArray(data)) {
+ return data;
+ } else {
+ // 단일 객체인 경우 배열로 변환
+ return [data];
+ }
+ } catch (error) {
+ console.error('프로젝트 목록 가져오기 실패:', error);
+ throw error;
+ }
+}
+
+// 데이터베이스에 프로젝트 저장
+async function saveProjectsToDatabase(projectsData: Project[]): Promise<number> {
+ try {
+ // 기존 프로젝트 조회
+ const existingProjects = await db.select().from(projects);
+
+ // 코드 기준으로 맵 생성
+ const existingProjectMap = new Map(
+ existingProjects.map(project => [project.code, project])
+ );
+
+ // 새로 추가할 항목
+ const toInsert = [];
+
+ // 업데이트할 항목
+ const toUpdate = [];
+
+ // API에 있는 코드 목록
+ const apiProjectCodes = new Set(projectsData.map(project => project.PROJ_NO));
+
+ // 삭제할 코드 목록
+ const codesToDelete = [...existingProjectMap.keys()]
+ .filter(code => !apiProjectCodes.has(code));
+
+ // 프로젝트 데이터 처리
+ for (const project of projectsData) {
+ // 삭제된 프로젝트는 건너뜀
+ if (project.DELETED) continue;
+
+ // 프로젝트 레코드 준비
+ const projectRecord = {
+ code: project.PROJ_NO,
+ name: project.DESC || project.PROJ_NO,
+ type: project.TYPE || 'ship',
+ updatedAt: new Date()
+ };
+
+ // 이미 존재하는 코드인지 확인
+ if (existingProjectMap.has(project.PROJ_NO)) {
+ // 업데이트 항목에 추가
+ toUpdate.push(projectRecord);
+ } else {
+ // 새로 추가할 항목에 추가
+ toInsert.push({
+ ...projectRecord,
+ createdAt: new Date()
+ });
+ }
+ }
+
+ // 트랜잭션 실행
+ let totalChanged = 0;
+
+ // 1. 새 프로젝트 삽입
+ if (toInsert.length > 0) {
+ await db.insert(projects).values(toInsert);
+ totalChanged += toInsert.length;
+ console.log(`${toInsert.length}개의 새 프로젝트 추가 완료`);
+ }
+
+ // 2. 기존 프로젝트 업데이트
+ for (const item of toUpdate) {
+ await db.update(projects)
+ .set({
+ name: item.name,
+ type: item.type,
+ updatedAt: item.updatedAt
+ })
+ .where(eq(projects.code, item.code));
+ totalChanged += 1;
+ }
+
+ if (toUpdate.length > 0) {
+ console.log(`${toUpdate.length}개 프로젝트 업데이트 완료`);
+ }
+
+ // 3. 더 이상 존재하지 않는 프로젝트 삭제
+ if (codesToDelete.length > 0) {
+ for (const code of codesToDelete) {
+ await db.delete(projects)
+ .where(eq(projects.code, code));
+ }
+ console.log(`${codesToDelete.length}개의 프로젝트 삭제 완료`);
+ totalChanged += codesToDelete.length;
+ }
+
+ return totalChanged;
+ } catch (error) {
+ console.error('프로젝트 저장 실패:', error);
+ throw error;
+ }
+}
+
+// 메인 동기화 함수
+export async function syncProjects(): Promise<SyncResult> {
+ try {
+ console.log('프로젝트 동기화 시작:', new Date().toISOString());
+
+ // 1. 프로젝트 데이터 가져오기
+ const projectsData = await getProjects();
+ console.log(`${projectsData.length}개의 프로젝트 정보를 가져왔습니다.`);
+
+ // 2. 데이터베이스에 저장
+ const totalItems = await saveProjectsToDatabase(projectsData);
+
+ console.log(`프로젝트 동기화 완료: 총 ${totalItems}개 항목 처리됨`);
+
+ return {
+ success: 1, // 단일 작업이므로 성공은 1
+ failed: 0,
+ items: totalItems,
+ timestamp: new Date().toISOString()
+ };
+ } catch (error) {
+ console.error('프로젝트 동기화 중 오류 발생:', error);
+ return {
+ success: 0,
+ failed: 1,
+ items: 0,
+ timestamp: new Date().toISOString()
+ };
+ }
+} \ No newline at end of file
diff --git a/lib/sedp/sync-tag-types.ts b/lib/sedp/sync-tag-types.ts
new file mode 100644
index 00000000..2d19fc19
--- /dev/null
+++ b/lib/sedp/sync-tag-types.ts
@@ -0,0 +1,567 @@
+// src/lib/cron/syncTagSubfields.ts
+import db from "@/db/db";
+import { projects, tagTypes, tagSubfields, tagSubfieldOptions } from '@/db/schema';
+import { eq, and, inArray } from 'drizzle-orm';
+import { getSEDPToken } from "./sedp-token";
+
+// 환경 변수
+const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/dev/api';
+
+// 인터페이스 정의
+interface TagType {
+ PROJ_NO: string;
+ TYPE_ID: string;
+ DESC: string | null;
+ REMARK?: string | null;
+ SEQ?: number;
+ LNK_CODE: LinkCode[];
+ DELETED?: boolean;
+ CRTER_NO?: string;
+ CRTE_DTM?: string;
+ CHGER_NO?: string | null;
+ CHGE_DTM?: string | null;
+ _id?: string;
+}
+
+interface LinkCode {
+ SEQ: number;
+ ATT_ID: string;
+ DL_VAL: string;
+ REPR_YN: boolean;
+ START: number;
+ LENGTH: number;
+ IS_SEQ: boolean;
+}
+
+interface Attribute {
+ PROJ_NO: string;
+ ATT_ID: string;
+ DESC: string;
+ GROUP?: string | null;
+ REMARK?: string | null;
+ VAL_TYPE?: string;
+ IGN_LIST_VAL?: boolean;
+ CL_ID?: string | null;
+ UOM_ID?: string | null;
+ DEF_VAL?: string | null;
+ MIN_VAL?: number;
+ MAX_VAL?: number;
+ ESS_YN?: boolean;
+ SEQ?: number;
+ FORMAT?: string | null;
+ REG_EXPS?: string | null;
+ ATTRIBUTES?: any[];
+ DELETED?: boolean;
+ CRTER_NO?: string;
+ CRTE_DTM?: string;
+ CHGER_NO?: string | null;
+ CHGE_DTM?: string | null;
+ _id?: string;
+}
+
+interface CodeList {
+ PROJ_NO: string;
+ CL_ID: string;
+ DESC: string;
+ REMARK?: string | null;
+ PRNT_CD_ID?: string | null;
+ REG_TYPE_ID?: string | null;
+ VAL_ATT_ID?: string | null;
+ VALUES: CodeValue[];
+ LNK_ATT?: any[];
+ DELETED?: boolean;
+ CRTER_NO?: string;
+ CRTE_DTM?: string;
+ CHGER_NO?: string | null;
+ CHGE_DTM?: string | null;
+ _id?: string;
+}
+
+interface CodeValue {
+ PRNT_VALUE?: string | null;
+ VALUE: string;
+ DESC: string;
+ REMARK?: string;
+ USE_YN: boolean;
+ SEQ: number;
+ ATTRIBUTES?: any[];
+}
+
+interface Project {
+ id: number;
+ code: string;
+ name: string;
+ type?: string;
+ createdAt?: Date;
+ updatedAt?: Date;
+}
+
+interface SyncResult {
+ project: string;
+ success: boolean;
+ count?: number;
+ error?: string;
+}
+
+// 태그 타입 데이터 가져오기
+async function getTagTypes(projectCode: string, token: string): Promise<TagType[] | TagType> {
+ try {
+ const response = await fetch(
+ `${SEDP_API_BASE_URL}/TagType/Get`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'accept': '*/*',
+ 'ApiKey': token,
+ 'ProjectNo': projectCode
+ },
+ body: JSON.stringify({
+ ProjectNo: projectCode,
+ ContainDeleted: true
+ })
+ }
+ );
+
+ if (!response.ok) {
+ throw new Error(`태그 타입 요청 실패: ${response.status} ${response.statusText}`);
+ }
+
+ return response.json();
+ } catch (error) {
+ console.error(`프로젝트 ${projectCode}의 태그 타입 가져오기 실패:`, error);
+ throw error;
+ }
+}
+
+// 속성 데이터 가져오기
+async function getAttributes(projectCode: string, token: string): Promise<Attribute[]> {
+ try {
+ const response = await fetch(
+ `${SEDP_API_BASE_URL}/Attributes/Get`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'accept': '*/*',
+ 'ApiKey': token,
+ 'ProjectNo': projectCode
+ },
+ body: JSON.stringify({
+ ProjectNo: projectCode,
+ ContainDeleted: true
+ })
+ }
+ );
+
+ if (!response.ok) {
+ throw new Error(`속성 요청 실패: ${response.status} ${response.statusText}`);
+ }
+
+ const data = await response.json();
+ return Array.isArray(data) ? data : [data];
+ } catch (error) {
+ console.error(`프로젝트 ${projectCode}의 속성 가져오기 실패:`, error);
+ throw error;
+ }
+}
+
+// 코드 리스트 가져오기
+async function getCodeList(projectCode: string, codeListId: string, token: string): Promise<CodeList | null> {
+ try {
+ const response = await fetch(
+ `${SEDP_API_BASE_URL}/CodeList/Get`,
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'accept': '*/*',
+ 'ApiKey': token,
+ 'ProjectNo': projectCode
+ },
+ body: JSON.stringify({
+ ProjectNo: projectCode,
+ CL_ID: codeListId,
+ ContainDeleted: true
+ })
+ }
+ );
+
+ if (!response.ok) {
+ throw new Error(`코드 리스트 요청 실패: ${response.status} ${response.statusText}`);
+ }
+
+ return response.json();
+ } catch (error) {
+ console.error(`프로젝트 ${projectCode}의 코드 리스트 가져오기 실패:`, error);
+ return null; // 코드 리스트를 가져오지 못해도 전체 프로세스는 계속 진행
+ }
+}
+
+// 태그 서브필드 처리 및 저장
+async function processAndSaveTagSubfields(
+ projectId: number,
+ projectCode: string,
+ tagTypesData: TagType[],
+ attributesData: Attribute[],
+ token: string
+): Promise<number> {
+ try {
+ // 속성 ID를 키로 하는 맵 생성
+ const attributesMap = new Map<string, Attribute>();
+ attributesData.forEach(attr => {
+ attributesMap.set(attr.ATT_ID, attr);
+ });
+
+ // 현재 DB에 있는 태그 서브필드 가져오기
+ const existingSubfields = await db.select().from(tagSubfields)
+ .where(eq(tagSubfields.projectId, projectId));
+
+ // 서브필드 키 생성 함수
+ const createSubfieldKey = (tagTypeCode: string, attributeId: string) =>
+ `${tagTypeCode}:${attributeId}`;
+
+ // 현재 DB에 있는 서브필드를 키-값 맵으로 변환
+ const existingSubfieldsMap = new Map();
+ existingSubfields.forEach(subfield => {
+ const key = createSubfieldKey(subfield.tagTypeCode, subfield.attributesId);
+ existingSubfieldsMap.set(key, subfield);
+ });
+
+ // 새로 추가할 서브필드
+ const toInsert = [];
+
+ // 업데이트할 서브필드
+ const toUpdate = [];
+
+ // API에서 가져온 서브필드 키 목록
+ const apiSubfieldKeys = new Set<string>();
+
+ // 코드 리스트 ID 목록 (나중에 코드 리스트 옵션을 가져오기 위함)
+ const codeListsToFetch = new Map<string, { attributeId: string, clId: string }>();
+
+ // 태그 타입별로 처리
+ for (const tagType of tagTypesData) {
+ // 링크 코드가 있는 경우만 처리
+ if (tagType.LNK_CODE && tagType.LNK_CODE.length > 0) {
+ // 각 링크 코드에 대해 서브필드 생성
+ for (const linkCode of tagType.LNK_CODE) {
+ const attributeId = linkCode.ATT_ID;
+ const attribute = attributesMap.get(attributeId);
+
+ // 해당 속성이 있는 경우만 처리
+ if (attribute) {
+ const subFieldKey = createSubfieldKey(tagType.TYPE_ID, attributeId);
+ apiSubfieldKeys.add(subFieldKey);
+
+ // 서브필드 데이터 준비
+ const subfieldData = {
+ projectId: projectId,
+ tagTypeCode: tagType.TYPE_ID,
+ attributesId: attributeId,
+ attributesDescription: attribute.DESC || attributeId,
+ expression: attribute.REG_EXPS || null,
+ delimiter: linkCode.DL_VAL || null,
+ sortOrder: linkCode.SEQ || 0,
+ updatedAt: new Date()
+ };
+
+ // 이미 존재하는 서브필드인지 확인
+ if (existingSubfieldsMap.has(subFieldKey)) {
+ // 업데이트 항목에 추가
+ toUpdate.push(subfieldData);
+ } else {
+ // 새로 추가할 항목에 추가
+ toInsert.push({
+ ...subfieldData,
+ createdAt: new Date()
+ });
+ }
+
+ // 코드 리스트가 있으면 나중에 가져올 목록에 추가
+ if (attribute.CL_ID) {
+ codeListsToFetch.set(attribute.CL_ID, {
+ attributeId: attributeId,
+ clId: attribute.CL_ID
+ });
+ }
+ }
+ }
+ }
+ }
+
+ // 삭제할 서브필드 키 목록
+ const keysToDelete = [...existingSubfieldsMap.keys()]
+ .filter(key => !apiSubfieldKeys.has(key));
+
+ // 트랜잭션 실행
+ let totalChanged = 0;
+
+ // 1. 새 서브필드 삽입
+ if (toInsert.length > 0) {
+ await db.insert(tagSubfields).values(toInsert);
+ totalChanged += toInsert.length;
+ console.log(`프로젝트 ID ${projectId}에 ${toInsert.length}개의 새 태그 서브필드 추가 완료`);
+ }
+
+ // 2. 기존 서브필드 업데이트
+ for (const item of toUpdate) {
+ await db.update(tagSubfields)
+ .set({
+ attributesDescription: item.attributesDescription,
+ expression: item.expression,
+ delimiter: item.delimiter,
+ sortOrder: item.sortOrder,
+ updatedAt: item.updatedAt
+ })
+ .where(
+ and(
+ eq(tagSubfields.projectId, item.projectId),
+ eq(tagSubfields.tagTypeCode, item.tagTypeCode),
+ eq(tagSubfields.attributesId, item.attributesId)
+ )
+ );
+ totalChanged += 1;
+ }
+
+ if (toUpdate.length > 0) {
+ console.log(`프로젝트 ID ${projectId}의 ${toUpdate.length}개 태그 서브필드 업데이트 완료`);
+ }
+
+ // 3. 더 이상 존재하지 않는 서브필드 삭제
+ if (keysToDelete.length > 0) {
+ for (const key of keysToDelete) {
+ const [tagTypeCode, attributeId] = key.split(':');
+ await db.delete(tagSubfields)
+ .where(
+ and(
+ eq(tagSubfields.projectId, projectId),
+ eq(tagSubfields.tagTypeCode, tagTypeCode),
+ eq(tagSubfields.attributesId, attributeId)
+ )
+ );
+ }
+ console.log(`프로젝트 ID ${projectId}에서 ${keysToDelete.length}개의 태그 서브필드 삭제 완료`);
+ totalChanged += keysToDelete.length;
+ }
+
+ // 4. 코드 리스트 옵션 가져와서 저장
+ let optionsChanged = 0;
+
+ if (codeListsToFetch.size > 0) {
+ console.log(`프로젝트 ID ${projectId}의 ${codeListsToFetch.size}개 코드 리스트에 대한 옵션 처리 시작`);
+
+ for (const [clId, { attributeId }] of codeListsToFetch.entries()) {
+ try {
+ // 코드 리스트 가져오기
+ const codeList = await getCodeList(projectCode, clId, token);
+
+ if (codeList && codeList.VALUES && codeList.VALUES.length > 0) {
+ // 현재 DB에 있는 옵션 가져오기
+ const existingOptions = await db.select().from(tagSubfieldOptions)
+ .where(
+ and(
+ eq(tagSubfieldOptions.projectId, projectId),
+ eq(tagSubfieldOptions.attributesId, attributeId)
+ )
+ );
+
+ // 현재 DB에 있는 옵션 맵
+ const existingOptionsMap = new Map();
+ existingOptions.forEach(option => {
+ existingOptionsMap.set(option.code, option);
+ });
+
+ // 새로 추가할 옵션
+ const optionsToInsert = [];
+
+ // 업데이트할 옵션
+ const optionsToUpdate = [];
+
+ // API에서 가져온 코드 목록
+ const apiOptionCodes = new Set<string>();
+
+ // 각 코드 값을 옵션으로 추가
+ for (const value of codeList.VALUES) {
+ // 사용 가능한 코드만 추가
+ if (value.USE_YN) {
+ const code = value.VALUE;
+ apiOptionCodes.add(code);
+
+ // 옵션 데이터 준비
+ const optionData = {
+ projectId: projectId,
+ attributesId: attributeId,
+ code: code,
+ label: value.DESC || code,
+ updatedAt: new Date()
+ };
+
+ // 이미 존재하는 옵션인지 확인
+ if (existingOptionsMap.has(code)) {
+ // 업데이트 항목에 추가
+ optionsToUpdate.push(optionData);
+ } else {
+ // 새로 추가할 항목에 추가
+ optionsToInsert.push({
+ ...optionData,
+ createdAt: new Date()
+ });
+ }
+ }
+ }
+
+ // 삭제할 옵션 코드 목록
+ const optionCodesToDelete = [...existingOptionsMap.keys()]
+ .filter(code => !apiOptionCodes.has(code));
+
+ // a. 새 옵션 삽입
+ if (optionsToInsert.length > 0) {
+ await db.insert(tagSubfieldOptions).values(optionsToInsert);
+ optionsChanged += optionsToInsert.length;
+ console.log(`속성 ${attributeId}에 ${optionsToInsert.length}개의 새 옵션 추가 완료`);
+ }
+
+ // b. 기존 옵션 업데이트
+ for (const option of optionsToUpdate) {
+ await db.update(tagSubfieldOptions)
+ .set({
+ label: option.label,
+ updatedAt: option.updatedAt
+ })
+ .where(
+ and(
+ eq(tagSubfieldOptions.projectId, option.projectId),
+ eq(tagSubfieldOptions.attributesId, option.attributesId),
+ eq(tagSubfieldOptions.code, option.code)
+ )
+ );
+ optionsChanged += 1;
+ }
+
+ if (optionsToUpdate.length > 0) {
+ console.log(`속성 ${attributeId}의 ${optionsToUpdate.length}개 옵션 업데이트 완료`);
+ }
+
+ // c. 더 이상 존재하지 않는 옵션 삭제
+ if (optionCodesToDelete.length > 0) {
+ for (const code of optionCodesToDelete) {
+ await db.delete(tagSubfieldOptions)
+ .where(
+ and(
+ eq(tagSubfieldOptions.projectId, projectId),
+ eq(tagSubfieldOptions.attributesId, attributeId),
+ eq(tagSubfieldOptions.code, code)
+ )
+ );
+ }
+ console.log(`속성 ${attributeId}에서 ${optionCodesToDelete.length}개의 옵션 삭제 완료`);
+ optionsChanged += optionCodesToDelete.length;
+ }
+ }
+ } catch (error) {
+ console.error(`코드 리스트 ${clId} 처리 중 오류:`, error);
+ // 특정 코드 리스트 처리 실패해도 계속 진행
+ }
+ }
+
+ console.log(`프로젝트 ID ${projectId}의 코드 리스트 옵션 처리 완료: 총 ${optionsChanged}개 변경됨`);
+ }
+
+ return totalChanged + optionsChanged;
+ } catch (error) {
+ console.error(`태그 서브필드 처리 실패 (프로젝트 ID: ${projectId}):`, error);
+ throw error;
+ }
+}
+
+// 메인 동기화 함수
+export async function syncTagSubfields() {
+ try {
+ console.log('태그 서브필드 동기화 시작:', new Date().toISOString());
+
+ // 1. 토큰 가져오기
+ const token = await getSEDPToken();
+
+ // 2. 모든 프로젝트 가져오기
+ const allProjects = await db.select().from(projects);
+
+ // 3. 각 프로젝트에 대해 태그 서브필드 동기화
+ const results = await Promise.allSettled(
+ allProjects.map(async (project: Project) => {
+ try {
+ // 태그 타입 데이터 가져오기
+ const tagTypesData = await getTagTypes(project.code, token);
+ const tagTypesArray = Array.isArray(tagTypesData) ? tagTypesData : [tagTypesData];
+
+ // 속성 데이터 가져오기
+ const attributesData = await getAttributes(project.code, token);
+
+ // 서브필드 처리 및 저장
+ const count = await processAndSaveTagSubfields(
+ project.id,
+ project.code,
+ tagTypesArray,
+ attributesData,
+ token
+ );
+
+ return {
+ project: project.code,
+ success: true,
+ count
+ } as SyncResult;
+ } catch (error) {
+ console.error(`프로젝트 ${project.code} 서브필드 동기화 실패:`, error);
+ return {
+ project: project.code,
+ success: false,
+ error: error instanceof Error ? error.message : String(error)
+ } as SyncResult;
+ }
+ })
+ );
+
+ // 결과 처리를 위한 배열 준비
+ const successfulResults: SyncResult[] = [];
+ const failedResults: SyncResult[] = [];
+
+ // 결과 분류
+ results.forEach((result) => {
+ if (result.status === 'fulfilled') {
+ if (result.value.success) {
+ successfulResults.push(result.value);
+ } else {
+ failedResults.push(result.value);
+ }
+ } else {
+ // 거부된 프로미스는 실패로 간주
+ failedResults.push({
+ project: 'unknown',
+ success: false,
+ error: result.reason?.toString() || 'Unknown error'
+ });
+ }
+ });
+
+ const successCount = successfulResults.length;
+ const failCount = failedResults.length;
+
+ // 이제 안전하게 count 속성에 접근 가능
+ const totalItems = successfulResults.reduce((sum, result) =>
+ sum + (result.count || 0), 0
+ );
+
+ console.log(`태그 서브필드 동기화 완료: ${successCount}개 프로젝트 성공 (총 ${totalItems}개 항목), ${failCount}개 프로젝트 실패`);
+
+ return {
+ success: successCount,
+ failed: failCount,
+ items: totalItems,
+ timestamp: new Date().toISOString()
+ };
+ } catch (error) {
+ console.error('태그 서브필드 동기화 중 오류 발생:', error);
+ throw error;
+ }
+} \ No newline at end of file
diff --git a/lib/tag-numbering/service.ts b/lib/tag-numbering/service.ts
index 9b1c1172..6041f07c 100644
--- a/lib/tag-numbering/service.ts
+++ b/lib/tag-numbering/service.ts
@@ -32,6 +32,7 @@ export async function getTagNumbering(input: GetTagNumberigSchema) {
const s = `%${input.search}%`
globalWhere = or(ilike(viewTagSubfields.tagTypeCode, s), ilike(viewTagSubfields.tagTypeDescription, s)
, ilike(viewTagSubfields.attributesId, s) , ilike(viewTagSubfields.attributesDescription, s), ilike(viewTagSubfields.expression, s)
+ , ilike(viewTagSubfields.projectCode, s), ilike(viewTagSubfields.projectName, s)
)
// 필요시 여러 칼럼 OR조건 (status, priority, etc)
}
diff --git a/lib/tag-numbering/table/tagNumbering-table-toolbar-actions.tsx b/lib/tag-numbering/table/tagNumbering-table-toolbar-actions.tsx
index 1a7af254..7a14817f 100644
--- a/lib/tag-numbering/table/tagNumbering-table-toolbar-actions.tsx
+++ b/lib/tag-numbering/table/tagNumbering-table-toolbar-actions.tsx
@@ -16,10 +16,40 @@ interface ItemsTableToolbarActionsProps {
}
export function TagNumberingTableToolbarActions({ table }: ItemsTableToolbarActionsProps) {
- // 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
- const fileInputRef = React.useRef<HTMLInputElement>(null)
+ const [isLoading, setIsLoading] = React.useState(false)
+ const syncTags = async () => {
+ try {
+ setIsLoading(true)
+ // API 엔드포인트 호출
+ const response = await fetch('/api/cron/object-classes')
+
+ if (!response.ok) {
+ const errorData = await response.json()
+ throw new Error(errorData.error || 'Failed to sync tag numberings')
+ }
+
+ const data = await response.json()
+
+ // 성공 메시지 표시
+ toast.success(
+ `tag numberings synced successfully! ${data.result.items} items processed.`
+ )
+
+ // 페이지 새로고침으로 테이블 데이터 업데이트
+ window.location.reload()
+ } catch (error) {
+ console.error('Error syncing tag numberings:', error)
+ toast.error(
+ error instanceof Error
+ ? error.message
+ : 'An error occurred while syncing tag numberings'
+ )
+ } finally {
+ setIsLoading(false)
+ }
+ }
return (
<div className="flex items-center gap-2">
@@ -28,9 +58,14 @@ export function TagNumberingTableToolbarActions({ table }: ItemsTableToolbarActi
variant="samsung"
size="sm"
className="gap-2"
+ onClick={syncTags}
+ disabled={isLoading}
>
- <RefreshCcw className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">Get Tag Numbering</span>
+
+ <RefreshCcw className={`size-4 ${isLoading ? 'animate-spin' : ''}`} aria-hidden="true" />
+ <span className="hidden sm:inline">
+ {isLoading ? 'Syncing...' : 'Get Tag Numbering'}
+ </span>
</Button>
{/** 4) Export 버튼 */}
diff --git a/lib/tag-numbering/table/tagNumbering-table.tsx b/lib/tag-numbering/table/tagNumbering-table.tsx
index 7997aad9..6ca46e05 100644
--- a/lib/tag-numbering/table/tagNumbering-table.tsx
+++ b/lib/tag-numbering/table/tagNumbering-table.tsx
@@ -32,7 +32,6 @@ export function TagNumberingTable({ promises }: ItemsTableProps) {
const [{ data, pageCount }] =
React.use(promises)
-
const [rowAction, setRowAction] =
React.useState<DataTableRowAction<ViewTagSubfields> | null>(null)
@@ -68,6 +67,16 @@ export function TagNumberingTable({ promises }: ItemsTableProps) {
*/
const advancedFilterFields: DataTableAdvancedFilterField<ViewTagSubfields>[] = [
{
+ id: "projectCode",
+ label: "Project Code",
+ type: "text",
+ },
+ {
+ id: "projectName",
+ label: "Project Name",
+ type: "text",
+ },
+ {
id: "tagTypeCode",
label: "Tag Type Code",
type: "text",
diff --git a/lib/tags/form-mapping-service.ts b/lib/tags/form-mapping-service.ts
index 4b772ab6..19b3ab14 100644
--- a/lib/tags/form-mapping-service.ts
+++ b/lib/tags/form-mapping-service.ts
@@ -17,6 +17,7 @@ export interface FormMapping {
*/
export async function getFormMappingsByTagType(
tagType: string,
+ projectId: number,
classCode?: string
): Promise<FormMapping[]> {
@@ -32,6 +33,7 @@ export async function getFormMappingsByTagType(
.from(tagTypeClassFormMappings)
.where(and(
eq(tagTypeClassFormMappings.tagTypeLabel, tagType),
+ eq(tagTypeClassFormMappings.projectId, projectId),
eq(tagTypeClassFormMappings.classLabel, classCode)
))
@@ -51,6 +53,7 @@ export async function getFormMappingsByTagType(
.from(tagTypeClassFormMappings)
.where(and(
eq(tagTypeClassFormMappings.tagTypeLabel, tagType),
+ eq(tagTypeClassFormMappings.projectId, projectId),
eq(tagTypeClassFormMappings.classLabel, "DEFAULT")
))
diff --git a/lib/tags/service.ts b/lib/tags/service.ts
index 034c106f..8477b1fb 100644
--- a/lib/tags/service.ts
+++ b/lib/tags/service.ts
@@ -8,10 +8,10 @@ import { revalidateTag, unstable_noStore } from "next/cache";
import { filterColumns } from "@/lib/filter-columns";
import { unstable_cache } from "@/lib/unstable-cache";
import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq, sql, ne ,count,isNull} from "drizzle-orm";
-import { countTags, deleteTagById, deleteTagsByIds, insertTag, selectTags } from "./repository";
+import { countTags, insertTag, selectTags } from "./repository";
import { getErrorMessage } from "../handle-error";
import { getFormMappingsByTagType } from './form-mapping-service';
-import { contractItems } from "@/db/schema/contract";
+import { contractItems, contracts } from "@/db/schema/contract";
// 폼 결과를 위한 인터페이스 정의
@@ -110,16 +110,21 @@ export async function createTag(
return await db.transaction(async (tx) => {
// 1) 선택된 contractItem의 contractId 가져오기
const contractItemResult = await tx
- .select({ contractId: contractItems.contractId })
- .from(contractItems)
- .where(eq(contractItems.id, selectedPackageId))
- .limit(1)
+ .select({
+ contractId: contractItems.contractId,
+ projectId: contracts.projectId // projectId 추가
+ })
+ .from(contractItems)
+ .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) // contracts 테이블 조인
+ .where(eq(contractItems.id, selectedPackageId))
+ .limit(1)
if (contractItemResult.length === 0) {
return { error: "Contract item not found" }
}
const contractId = contractItemResult[0].contractId
+ const projectId = contractItemResult[0].projectId
// 2) 해당 계약 내에서 같은 tagNo를 가진 태그가 있는지 확인
const duplicateCheck = await tx
@@ -142,6 +147,7 @@ export async function createTag(
// 3) 태그 타입에 따른 폼 정보 가져오기
const formMappings = await getFormMappingsByTagType(
validated.data.tagType,
+ projectId, // projectId 전달
validated.data.class
)
@@ -149,9 +155,12 @@ export async function createTag(
if (!formMappings || formMappings.length === 0) {
console.log(
"No form mappings found for tag type:",
- validated.data.tagType
+ validated.data.tagType,
+ "in project:",
+ projectId
)
}
+
// 4) 이 태그 타입에 대한 주요 폼(첫 번째 폼)을 찾거나 생성
let primaryFormId: number | null = null
@@ -283,16 +292,21 @@ export async function updateTag(
// 2) 선택된 contractItem의 contractId 가져오기
const contractItemResult = await tx
- .select({ contractId: contractItems.contractId })
- .from(contractItems)
- .where(eq(contractItems.id, selectedPackageId))
- .limit(1)
+ .select({
+ contractId: contractItems.contractId,
+ projectId: contracts.projectId // projectId 추가
+ })
+ .from(contractItems)
+ .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) // contracts 테이블 조인
+ .where(eq(contractItems.id, selectedPackageId))
+ .limit(1)
if (contractItemResult.length === 0) {
return { error: "Contract item not found" }
}
const contractId = contractItemResult[0].contractId
+ const projectId = contractItemResult[0].projectId
// 3) 태그 번호가 변경되었고, 해당 계약 내에서 같은 tagNo를 가진 다른 태그가 있는지 확인
if (originalTag.tagNo !== validated.data.tagNo) {
@@ -327,6 +341,7 @@ export async function updateTag(
// 4-1) 태그 타입에 따른 폼 정보 가져오기
const formMappings = await getFormMappingsByTagType(
validated.data.tagType,
+ projectId, // projectId 전달
validated.data.class
)
@@ -334,7 +349,9 @@ export async function updateTag(
if (!formMappings || formMappings.length === 0) {
console.log(
"No form mappings found for tag type:",
- validated.data.tagType
+ validated.data.tagType,
+ "in project:",
+ projectId
)
}
@@ -450,10 +467,14 @@ export async function bulkCreateTags(
try {
// 단일 트랜잭션으로 모든 작업 처리
return await db.transaction(async (tx) => {
- // 1. 컨트랙트 ID 조회 (한 번만)
+ // 1. 컨트랙트 ID 및 프로젝트 ID 조회 (한 번만)
const contractItemResult = await tx
- .select({ contractId: contractItems.contractId })
+ .select({
+ contractId: contractItems.contractId,
+ projectId: contracts.projectId // projectId 추가
+ })
.from(contractItems)
+ .innerJoin(contracts, eq(contractItems.contractId, contracts.id)) // contracts 테이블 조인
.where(eq(contractItems.id, selectedPackageId))
.limit(1);
@@ -462,6 +483,7 @@ export async function bulkCreateTags(
}
const contractId = contractItemResult[0].contractId;
+ const projectId = contractItemResult[0].projectId; // projectId 추출
// 2. 모든 태그 번호 중복 검사 (한 번에)
const tagNos = tagsfromExcel.map(tag => tag.tagNo);
@@ -482,25 +504,111 @@ export async function bulkCreateTags(
// 3. 태그별 폼 정보 처리 및 태그 생성
const createdTags = [];
+ const allFormsInfo = []; // 모든 태그에 대한 폼 정보 저장
+
+ // 태그 유형별 폼 매핑 캐싱 (성능 최적화)
+ const formMappingsCache = new Map();
for (const tagData of tagsfromExcel) {
- // 각 태그 유형에 대한 폼 처리 (createTag 함수와 유사한 로직)
- const formMappings = await getFormMappingsByTagType(tagData.tagType, tagData.class);
- let primaryFormId = null;
+ // 캐시 키 생성 (tagType + class)
+ const cacheKey = `${tagData.tagType}|${tagData.class || 'NONE'}`;
+
+ // 폼 매핑 가져오기 (캐시 사용)
+ let formMappings;
+ if (formMappingsCache.has(cacheKey)) {
+ formMappings = formMappingsCache.get(cacheKey);
+ } else {
+ // 각 태그 유형에 대한 폼 매핑 조회 (projectId 전달)
+ formMappings = await getFormMappingsByTagType(
+ tagData.tagType,
+ projectId, // projectId 전달
+ tagData.class
+ );
+ formMappingsCache.set(cacheKey, formMappings);
+ }
+
+ // 폼 처리 로직
+ let primaryFormId: number | null = null;
+ const createdOrExistingForms: CreatedOrExistingForm[] = [];
- // 폼 처리 로직 (생략...)
+ if (formMappings && formMappings.length > 0) {
+ for (const formMapping of formMappings) {
+ // 해당 폼이 이미 존재하는지 확인
+ const existingForm = await tx
+ .select({ id: forms.id })
+ .from(forms)
+ .where(
+ and(
+ eq(forms.contractItemId, selectedPackageId),
+ eq(forms.formCode, formMapping.formCode)
+ )
+ )
+ .limit(1);
+
+ let formId: number;
+ if (existingForm.length > 0) {
+ // 이미 존재하면 해당 ID 사용
+ formId = existingForm[0].id;
+ createdOrExistingForms.push({
+ id: formId,
+ formCode: formMapping.formCode,
+ formName: formMapping.formName,
+ isNewlyCreated: false,
+ });
+ } else {
+ // 존재하지 않으면 새로 생성
+ const insertResult = await tx
+ .insert(forms)
+ .values({
+ contractItemId: selectedPackageId,
+ formCode: formMapping.formCode,
+ formName: formMapping.formName,
+ })
+ .returning({ id: forms.id, formCode: forms.formCode, formName: forms.formName });
+
+ formId = insertResult[0].id;
+ createdOrExistingForms.push({
+ id: formId,
+ formCode: insertResult[0].formCode,
+ formName: insertResult[0].formName,
+ isNewlyCreated: true,
+ });
+ }
+
+ // 첫 번째 폼을 "주요 폼"으로 설정하여 태그 생성 시 사용
+ if (primaryFormId === null) {
+ primaryFormId = formId;
+ }
+ }
+ } else {
+ console.log(
+ "No form mappings found for tag type:",
+ tagData.tagType,
+ "class:",
+ tagData.class || "NONE",
+ "in project:",
+ projectId
+ );
+ }
// 태그 생성
const [newTag] = await insertTag(tx, {
contractItemId: selectedPackageId,
formId: primaryFormId,
tagNo: tagData.tagNo,
- class: tagData.class,
+ class: tagData.class || "",
tagType: tagData.tagType,
description: tagData.description || null,
});
createdTags.push(newTag);
+
+ // 해당 태그의 폼 정보 저장
+ allFormsInfo.push({
+ tagNo: tagData.tagNo,
+ forms: createdOrExistingForms,
+ primaryFormId,
+ });
}
// 4. 캐시 무효화 (한 번만)
@@ -512,17 +620,17 @@ export async function bulkCreateTags(
success: true,
data: {
createdCount: createdTags.length,
- tags: createdTags
+ tags: createdTags,
+ formsInfo: allFormsInfo
}
};
});
} catch (err: any) {
console.error("bulkCreateTags error:", err);
- return { error: err.message || "Failed to create tags" };
+ return { error: getErrorMessage(err) || "Failed to create tags" };
}
}
-
/** 복수 삭제 */
interface RemoveTagsInput {
ids: number[];
@@ -548,6 +656,22 @@ export async function removeTags(input: RemoveTagsInput) {
try {
await db.transaction(async (tx) => {
+
+ const packageInfo = await tx
+ .select({
+ projectId: contracts.projectId
+ })
+ .from(contractItems)
+ .innerJoin(contracts, eq(contractItems.contractId, contracts.id))
+ .where(eq(contractItems.id, selectedPackageId))
+ .limit(1);
+
+ if (packageInfo.length === 0) {
+ throw new Error(`Contract item with ID ${selectedPackageId} not found`);
+ }
+
+ const projectId = packageInfo[0].projectId;
+
// 1) 삭제 대상 tag들을 미리 조회
const tagsToDelete = await tx
.select({
@@ -583,7 +707,7 @@ export async function removeTags(input: RemoveTagsInput) {
)
// 3-2) 이 태그 타입/클래스에 연결된 폼 매핑 가져오기
- const formMappings = await getFormMappingsByTagType(tagType, classValue);
+ const formMappings = await getFormMappingsByTagType(tagType,projectId,classValue);
if (!formMappings.length) continue;
@@ -707,21 +831,45 @@ interface SubFieldDef {
delimiter: string | null
}
-export async function getSubfieldsByTagType(tagTypeCode: string) {
+export async function getSubfieldsByTagType(tagTypeCode: string, selectedPackageId: number) {
try {
+ // 1. 먼저 contractItems에서 projectId 조회
+ const packageInfo = await db
+ .select({
+ projectId: contracts.projectId
+ })
+ .from(contractItems)
+ .innerJoin(contracts, eq(contractItems.contractId, contracts.id))
+ .where(eq(contractItems.id, selectedPackageId))
+ .limit(1);
+
+ if (packageInfo.length === 0) {
+ throw new Error(`Contract item with ID ${selectedPackageId} not found`);
+ }
+
+ const projectId = packageInfo[0].projectId;
+
+ // 2. 올바른 projectId를 사용하여 tagSubfields 조회
const rows = await db
.select()
.from(tagSubfields)
- .where(eq(tagSubfields.tagTypeCode, tagTypeCode))
- .orderBy(asc(tagSubfields.sortOrder))
+ .where(
+ and(
+ eq(tagSubfields.tagTypeCode, tagTypeCode),
+ eq(tagSubfields.projectId, projectId)
+ )
+ )
+ .orderBy(asc(tagSubfields.sortOrder));
// 각 row -> SubFieldDef
- const formattedSubFields: SubFieldDef[] = []
+ const formattedSubFields: SubFieldDef[] = [];
for (const sf of rows) {
- const subfieldType = await getSubfieldType(sf.attributesId)
+ // projectId가 필요한 경우 getSubfieldType과 getSubfieldOptions 함수에도 전달
+ const subfieldType = await getSubfieldType(sf.attributesId, projectId);
+
const subfieldOptions = subfieldType === "select"
- ? await getSubfieldOptions(sf.attributesId)
- : []
+ ? await getSubfieldOptions(sf.attributesId, projectId)
+ : [];
formattedSubFields.push({
name: sf.attributesId.toLowerCase(),
@@ -730,22 +878,22 @@ export async function getSubfieldsByTagType(tagTypeCode: string) {
options: subfieldOptions,
expression: sf.expression,
delimiter: sf.delimiter,
- })
+ });
}
- return { subFields: formattedSubFields }
+ return { subFields: formattedSubFields };
} catch (error) {
- console.error("Error fetching subfields by tag type:", error)
- throw new Error("Failed to fetch subfields")
+ console.error("Error fetching subfields by tag type:", error);
+ throw new Error("Failed to fetch subfields");
}
}
-async function getSubfieldType(attributesId: string): Promise<"select" | "text"> {
+async function getSubfieldType(attributesId: string, projectId:number): Promise<"select" | "text"> {
const optRows = await db
.select()
.from(tagSubfieldOptions)
- .where(eq(tagSubfieldOptions.attributesId, attributesId))
+ .where(and(eq(tagSubfieldOptions.attributesId, attributesId),eq(tagSubfieldOptions.projectId,projectId)))
return optRows.length > 0 ? "select" : "text"
}
@@ -769,7 +917,7 @@ export interface SubfieldOption {
/**
* SubField의 옵션 목록을 가져오는 보조 함수
*/
-async function getSubfieldOptions(attributesId: string): Promise<SubfieldOption[]> {
+async function getSubfieldOptions(attributesId: string, projectId:number): Promise<SubfieldOption[]> {
try {
const rows = await db
.select({
@@ -777,7 +925,12 @@ async function getSubfieldOptions(attributesId: string): Promise<SubfieldOption[
label: tagSubfieldOptions.label
})
.from(tagSubfieldOptions)
- .where(eq(tagSubfieldOptions.attributesId, attributesId))
+ .where(
+ and(
+ eq(tagSubfieldOptions.attributesId, attributesId),
+ eq(tagSubfieldOptions.projectId, projectId),
+ )
+ )
return rows.map((row) => ({
value: row.code,
diff --git a/lib/tags/table/add-tag-dialog copy.tsx b/lib/tags/table/add-tag-dialog copy.tsx
deleted file mode 100644
index e9f84933..00000000
--- a/lib/tags/table/add-tag-dialog copy.tsx
+++ /dev/null
@@ -1,637 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useRouter } from "next/navigation" // <-- 1) Import router from App Router
-import { useForm, useWatch } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { toast } from "sonner"
-import { Loader2, ChevronsUpDown, Check } from "lucide-react"
-
-import {
- Dialog,
- DialogTrigger,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogDescription,
- DialogFooter,
-} from "@/components/ui/dialog"
-import { Button } from "@/components/ui/button"
-import { Input } from "@/components/ui/input"
-import {
- Form,
- FormField,
- FormItem,
- FormControl,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import {
- Popover,
- PopoverTrigger,
- PopoverContent,
-} from "@/components/ui/popover"
-import {
- Command,
- CommandInput,
- CommandList,
- CommandGroup,
- CommandItem,
- CommandEmpty,
-} from "@/components/ui/command"
-import { Select, SelectTrigger, SelectContent, SelectItem, SelectValue } from "@/components/ui/select"
-import { cn } from "@/lib/utils"
-
-import type { CreateTagSchema } from "@/lib/tags/validations"
-import { createTagSchema } from "@/lib/tags/validations"
-import {
- createTag,
- getSubfieldsByTagType,
- getClassOptions,
- type ClassOption,
- TagTypeOption,
-} from "@/lib/tags/service"
-
-// SubFieldDef for clarity
-interface SubFieldDef {
- name: string
- label: string
- type: "select" | "text"
- options?: { value: string; label: string }[]
- expression?: string
- delimiter?: string
-}
-
-// 클래스 옵션 인터페이스
-interface UpdatedClassOption extends ClassOption {
- tagTypeCode: string
- tagTypeDescription?: string
-}
-
-interface AddTagDialogProps {
- selectedPackageId: number | null
-}
-
-export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) {
- const router = useRouter() // <-- 2) Use the router hook
-
- const [open, setOpen] = React.useState(false)
- const [tagTypeList, setTagTypeList] = React.useState<TagTypeOption[]>([])
- const [selectedTagTypeCode, setSelectedTagTypeCode] = React.useState<string | null>(null)
- const [subFields, setSubFields] = React.useState<SubFieldDef[]>([])
- const [classOptions, setClassOptions] = React.useState<UpdatedClassOption[]>([])
- const [classSearchTerm, setClassSearchTerm] = React.useState("")
- const [isLoadingClasses, setIsLoadingClasses] = React.useState(false)
- const [isLoadingSubFields, setIsLoadingSubFields] = React.useState(false)
- const [isSubmitting, setIsSubmitting] = React.useState(false)
-
- // ID management
- const selectIdRef = React.useRef(0)
- const getUniqueSelectId = React.useCallback(() => `select-${selectIdRef.current++}`, [])
- const fieldIdsRef = React.useRef<Record<string, string>>({})
- const classOptionIdsRef = React.useRef<Record<string, string>>({})
-
- // ---------------
- // Load Class Options
- // ---------------
- React.useEffect(() => {
- const loadClassOptions = async () => {
- setIsLoadingClasses(true)
- try {
- const result = await getClassOptions()
- setClassOptions(result)
- } catch (err) {
- toast.error("Failed to load class options")
- } finally {
- setIsLoadingClasses(false)
- }
- }
-
- if (open) {
- loadClassOptions()
- }
- }, [open])
-
- // ---------------
- // react-hook-form
- // ---------------
- const form = useForm<CreateTagSchema>({
- resolver: zodResolver(createTagSchema),
- defaultValues: {
- tagType: "",
- tagNo: "",
- description: "",
- functionCode: "",
- seqNumber: "",
- valveAcronym: "",
- processUnit: "",
- class: "",
- },
- })
-
- // watch
- const { tagNo, ...fieldsToWatch } = useWatch({
- control: form.control,
- })
-
- // ---------------
- // Load subfields by TagType code
- // ---------------
- async function loadSubFieldsByTagTypeCode(tagTypeCode: string) {
- setIsLoadingSubFields(true)
- try {
- const { subFields: apiSubFields } = await getSubfieldsByTagType(tagTypeCode)
- const formattedSubFields: SubFieldDef[] = apiSubFields.map(field => ({
- name: field.name,
- label: field.label,
- type: field.type,
- options: field.options || [],
- expression: field.expression ?? undefined,
- delimiter: field.delimiter ?? undefined,
- }))
- setSubFields(formattedSubFields)
- selectIdRef.current = 0
- return true
- } catch (err) {
- toast.error("Failed to load subfields")
- setSubFields([])
- return false
- } finally {
- setIsLoadingSubFields(false)
- }
- }
-
- // ---------------
- // Handle class selection
- // ---------------
- async function handleSelectClass(classOption: UpdatedClassOption) {
- form.setValue("class", classOption.label)
- if (classOption.tagTypeCode) {
- setSelectedTagTypeCode(classOption.tagTypeCode)
- // If you have tagTypeList, you can find the label
- const tagType = tagTypeList.find(t => t.id === classOption.tagTypeCode)
- if (tagType) {
- form.setValue("tagType", tagType.label)
- } else if (classOption.tagTypeDescription) {
- form.setValue("tagType", classOption.tagTypeDescription)
- }
- await loadSubFieldsByTagTypeCode(classOption.tagTypeCode)
- }
- }
-
- // ---------------
- // Render subfields
- // ---------------
- function renderSubFields() {
- if (isLoadingSubFields) {
- return (
- <div className="flex justify-center items-center py-8">
- <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
- <span className="ml-3 text-muted-foreground">Loading fields...</span>
- </div>
- )
- }
- if (subFields.length === 0 && selectedTagTypeCode) {
- return (
- <div className="py-4 text-center text-muted-foreground">
- No fields available for this tag type.
- </div>
- )
- }
- if (subFields.length === 0) {
- return null
- }
-
- return subFields.map((sf, index) => {
- if (!fieldIdsRef.current[`${sf.name}-${index}`]) {
- fieldIdsRef.current[`${sf.name}-${index}`] =
- `field-${sf.name}-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`
- }
- const fieldId = fieldIdsRef.current[`${sf.name}-${index}`]
- const selectId = getUniqueSelectId()
-
- return (
- <FormField
- key={fieldId}
- control={form.control}
- name={sf.name as keyof CreateTagSchema}
- render={({ field }) => (
- <FormItem>
- <FormLabel>{sf.label}</FormLabel>
- <FormControl>
- {sf.type === "select" ? (
- <Select
- value={field.value || ""}
- onValueChange={field.onChange}
- >
- <SelectTrigger className="w-full">
- <SelectValue
- placeholder={`Select ${sf.label}`}
- className={
- !field.value ? "text-muted-foreground text-opacity-60" : ""
- }
- />
- </SelectTrigger>
- <SelectContent
- align="start"
- side="bottom"
- style={{ width: 400, maxWidth: 400 }}
- sideOffset={4}
- id={selectId}
- >
- {sf.options?.map((opt, optIndex) => {
- const optionKey = `${fieldId}-option-${opt.value}-${optIndex}`
- return (
- <SelectItem
- key={optionKey}
- value={opt.value}
- className="multi-line-select-item pr-6"
- title={opt.label}
- >
- {opt.label}
- </SelectItem>
- )
- })}
- </SelectContent>
- </Select>
- ) : (
- <Input
- placeholder={`Enter ${sf.label}`}
- {...field}
- className={
- !field.value
- ? "placeholder:text-muted-foreground placeholder:text-opacity-60"
- : ""
- }
- />
- )}
- </FormControl>
- <FormMessage>
- {sf.expression && (
- <span
- className="text-xs text-muted-foreground truncate block"
- title={sf.expression}
- >
- 형식: {sf.expression}
- </span>
- )}
- </FormMessage>
- </FormItem>
- )}
- />
- )
- })
- }
-
- // ---------------
- // Build TagNo from subfields automatically
- // ---------------
- React.useEffect(() => {
- if (subFields.length === 0) {
- form.setValue("tagNo", "", { shouldDirty: false })
- }
-
- const subscription = form.watch((value, { name }) => {
- if (!name || name === "tagNo" || subFields.length === 0) {
- return
- }
- let combined = ""
- subFields.forEach((sf, idx) => {
- const fieldValue = form.getValues(sf.name as keyof CreateTagSchema) || ""
- combined += fieldValue
- if (fieldValue && idx < subFields.length - 1 && sf.delimiter) {
- combined += sf.delimiter
- }
- })
- const currentTagNo = form.getValues("tagNo")
- if (currentTagNo !== combined) {
- form.setValue("tagNo", combined, {
- shouldDirty: false,
- shouldTouch: false,
- shouldValidate: false,
- })
- }
- })
-
- return () => subscription.unsubscribe()
- }, [subFields, form])
-
- // ---------------
- // Basic validation for TagNo
- // ---------------
- const isTagNoValid = React.useMemo(() => {
- const val = form.getValues("tagNo")
- return val && val.trim() !== "" && !val.includes("??")
- }, [fieldsToWatch])
-
- // ---------------
- // Submit handler
- // ---------------
- async function onSubmit(data: CreateTagSchema) {
- if (!selectedPackageId) {
- toast.error("No selectedPackageId.")
- return
- }
- setIsSubmitting(true)
- try {
- const res = await createTag(data, selectedPackageId)
- if ("error" in res) {
- toast.error(`Error: ${res.error}`)
- return
- }
-
- toast.success("Tag created successfully!")
-
- // 3) Refresh or navigate after creation:
- // Option A: If you just want to refresh the same route:
- router.refresh()
-
- // Option B: If you want to go to /partners/vendor-data/tag/{selectedPackageId}
- // router.push(`/partners/vendor-data/tag/${selectedPackageId}?r=${Date.now()}`)
-
- // (If you want to reset the form dialog or close it, do that too)
- form.reset()
- setOpen(false)
- } catch (err) {
- toast.error("Failed to create tag.")
- } finally {
- setIsSubmitting(false)
- }
- }
-
- // ---------------
- // Render Class field
- // ---------------
- function renderClassField(field: any) {
- const [popoverOpen, setPopoverOpen] = React.useState(false)
-
- const buttonId = React.useMemo(
- () => `class-button-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
- []
- )
- const popoverContentId = React.useMemo(
- () => `class-popover-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
- []
- )
- const commandId = React.useMemo(
- () => `class-command-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
- []
- )
-
- return (
- <FormItem>
- <FormLabel>Class</FormLabel>
- <FormControl>
- <Popover open={popoverOpen} onOpenChange={setPopoverOpen}>
- <PopoverTrigger asChild>
- <Button
- key={buttonId}
- type="button"
- variant="outline"
- className="w-full justify-between"
- disabled={isLoadingClasses}
- >
- {isLoadingClasses ? (
- <>
- <span>Loading classes...</span>
- <Loader2 className="ml-2 h-4 w-4 animate-spin" />
- </>
- ) : (
- <>
- <span className="truncate">
- {field.value || "Select Class..."}
- </span>
- <ChevronsUpDown className="ml-2 h-4 w-4 opacity-50" />
- </>
- )}
- </Button>
- </PopoverTrigger>
- <PopoverContent key={popoverContentId} className="w-full p-0">
- <Command key={commandId}>
- <CommandInput
- key={`${commandId}-input`}
- placeholder="Search Class..."
- value={classSearchTerm}
- onValueChange={setClassSearchTerm}
- />
- <CommandList key={`${commandId}-list`}>
- <CommandEmpty key={`${commandId}-empty`}>No class found.</CommandEmpty>
- <CommandGroup key={`${commandId}-group`}>
- {classOptions.map((opt) => {
- if (!classOptionIdsRef.current[opt.code]) {
- classOptionIdsRef.current[opt.code] =
- `class-${opt.code}-${Date.now()}-${Math.random()
- .toString(36)
- .slice(2, 9)}`
- }
- const optionId = classOptionIdsRef.current[opt.code]
-
- return (
- <CommandItem
- key={optionId}
- onSelect={() => {
- field.onChange(opt.label)
- setPopoverOpen(false)
- handleSelectClass(opt)
- }}
- value={opt.label}
- className="truncate"
- title={opt.label}
- >
- <span className="truncate">{opt.label}</span>
- <Check
- key={`${optionId}-check`}
- className={cn(
- "ml-auto h-4 w-4 flex-shrink-0",
- field.value === opt.label ? "opacity-100" : "opacity-0"
- )}
- />
- </CommandItem>
- )
- })}
- </CommandGroup>
- </CommandList>
- </Command>
- </PopoverContent>
- </Popover>
- </FormControl>
- <FormMessage />
- </FormItem>
- )
- }
-
- // ---------------
- // Render TagType field (readonly after class selection)
- // ---------------
- function renderTagTypeField(field: any) {
- const isReadOnly = !!selectedTagTypeCode
- const inputId = React.useMemo(
- () =>
- `tag-type-input-${isReadOnly ? "readonly" : "editable"}-${Date.now()}-${Math.random()
- .toString(36)
- .slice(2, 9)}`,
- [isReadOnly]
- )
-
- return (
- <FormItem>
- <FormLabel>Tag Type</FormLabel>
- <FormControl>
- {isReadOnly ? (
- <Input
- key={`tag-type-readonly-${inputId}`}
- {...field}
- readOnly
- className="bg-muted"
- />
- ) : (
- <Input
- key={`tag-type-placeholder-${inputId}`}
- {...field}
- readOnly
- placeholder="Tag Type is determined by selected Class"
- className="bg-muted"
- />
- )}
- </FormControl>
- <FormMessage />
- </FormItem>
- )
- }
-
- // ---------------
- // Reset IDs/states when dialog closes
- // ---------------
- React.useEffect(() => {
- if (!open) {
- fieldIdsRef.current = {}
- classOptionIdsRef.current = {}
- selectIdRef.current = 0
- }
- }, [open])
-
- return (
- <Dialog
- open={open}
- onOpenChange={(o) => {
- if (!o) {
- form.reset()
- setSelectedTagTypeCode(null)
- setSubFields([])
- }
- setOpen(o)
- }}
- >
- <DialogTrigger asChild>
- <Button variant="default" size="sm">
- Add Tag
- </Button>
- </DialogTrigger>
-
- <DialogContent className="max-h-[80vh] flex flex-col">
- <DialogHeader>
- <DialogTitle>Add New Tag</DialogTitle>
- <DialogDescription>
- Choose a Class, and the Tag Type and subfields will be automatically loaded.
- </DialogDescription>
- </DialogHeader>
-
- <Form {...form}>
- <form
- onSubmit={form.handleSubmit(onSubmit)}
- className="max-h-[70vh] flex flex-col"
- >
- <div className="flex-1 overflow-auto px-4 space-y-4">
- {/* Class */}
- <FormField
- key="class-field"
- control={form.control}
- name="class"
- render={({ field }) => renderClassField(field)}
- />
-
- {/* TagType (read-only) */}
- <FormField
- key="tag-type-field"
- control={form.control}
- name="tagType"
- render={({ field }) => renderTagTypeField(field)}
- />
-
- {/* SubFields */}
- <div className="flex-1 overflow-auto px-2 py-2 space-y-4 max-h-[300px]">
- {renderSubFields()}
- </div>
-
- {/* TagNo (read-only) */}
- <FormField
- key="tag-no-field"
- control={form.control}
- name="tagNo"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Tag No</FormLabel>
- <FormControl>
- <Input
- {...field}
- readOnly
- className="bg-muted truncate"
- title={field.value || ""}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* Description */}
- <FormField
- key="description-field"
- control={form.control}
- name="description"
- render={({ field }) => (
- <FormItem>
- <FormLabel>Description</FormLabel>
- <FormControl>
- <Input
- {...field}
- placeholder="Enter description..."
- className="truncate"
- title={field.value || ""}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
-
- {/* Footer */}
- <DialogFooter className="bg-background z-10 pt-4 px-4 py-4">
- <Button
- type="button"
- variant="outline"
- onClick={() => {
- form.reset()
- setOpen(false)
- setSubFields([])
- setSelectedTagTypeCode(null)
- }}
- disabled={isSubmitting || isLoadingSubFields}
- >
- Cancel
- </Button>
- <Button
- type="submit"
- disabled={isSubmitting || isLoadingSubFields || !isTagNoValid}
- >
- {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
- Create
- </Button>
- </DialogFooter>
- </form>
- </Form>
- </DialogContent>
- </Dialog>
- )
-} \ No newline at end of file
diff --git a/lib/tags/table/add-tag-dialog.tsx b/lib/tags/table/add-tag-dialog.tsx
index e1e176cf..8efb6b02 100644
--- a/lib/tags/table/add-tag-dialog.tsx
+++ b/lib/tags/table/add-tag-dialog.tsx
@@ -90,7 +90,7 @@ interface UpdatedClassOption extends ClassOption {
}
interface AddTagDialogProps {
- selectedPackageId: number | null
+ selectedPackageId: number
}
export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) {
@@ -159,7 +159,7 @@ export function AddTagDialog({ selectedPackageId }: AddTagDialogProps) {
async function loadSubFieldsByTagTypeCode(tagTypeCode: string) {
setIsLoadingSubFields(true)
try {
- const { subFields: apiSubFields } = await getSubfieldsByTagType(tagTypeCode)
+ const { subFields: apiSubFields } = await getSubfieldsByTagType(tagTypeCode, selectedPackageId)
const formattedSubFields: SubFieldDef[] = apiSubFields.map(field => ({
name: field.name,
label: field.label,
diff --git a/lib/tags/table/tags-table-toolbar-actions.tsx b/lib/tags/table/tags-table-toolbar-actions.tsx
index 8d53d3f3..497b2278 100644
--- a/lib/tags/table/tags-table-toolbar-actions.tsx
+++ b/lib/tags/table/tags-table-toolbar-actions.tsx
@@ -160,7 +160,7 @@ export function TagsTableToolbarActions({
}
try {
- const { subFields } = await getSubfieldsByTagType(tagTypeCode)
+ const { subFields } = await getSubfieldsByTagType(tagTypeCode, selectedPackageId)
// API 응답을 SubFieldDef 형식으로 변환
const formattedSubFields: SubFieldDef[] = subFields.map(field => ({
diff --git a/lib/tags/table/update-tag-sheet.tsx b/lib/tags/table/update-tag-sheet.tsx
index 27a1bdcb..7d213fc3 100644
--- a/lib/tags/table/update-tag-sheet.tsx
+++ b/lib/tags/table/update-tag-sheet.tsx
@@ -165,7 +165,7 @@ export function UpdateTagSheet({ tag, selectedPackageId, ...props }: UpdateTagSh
async function loadSubFieldsByTagTypeCode(tagTypeCode: string) {
setIsLoadingSubFields(true)
try {
- const { subFields: apiSubFields } = await getSubfieldsByTagType(tagTypeCode)
+ const { subFields: apiSubFields } = await getSubfieldsByTagType(tagTypeCode, selectedPackageId)
const formattedSubFields: SubFieldDef[] = apiSubFields.map(field => ({
name: field.name,
label: field.label,
diff --git a/lib/tasks/table/update-task-sheet.tsx b/lib/tasks/table/update-task-sheet.tsx
index 1f4f5aa8..4001ab44 100644
--- a/lib/tasks/table/update-task-sheet.tsx
+++ b/lib/tasks/table/update-task-sheet.tsx
@@ -46,6 +46,8 @@ interface UpdateTaskSheetProps
export function UpdateTaskSheet({ task, ...props }: UpdateTaskSheetProps) {
const [isUpdatePending, startUpdateTransition] = React.useTransition()
+ console.log(task)
+
const form = useForm<UpdateTaskSchema>({
resolver: zodResolver(updateTaskSchema),
defaultValues: {
diff --git a/lib/tbe/table/tbe-table-columns.tsx b/lib/tbe/table/tbe-table-columns.tsx
index f2bc2ced..3b62fe06 100644
--- a/lib/tbe/table/tbe-table-columns.tsx
+++ b/lib/tbe/table/tbe-table-columns.tsx
@@ -198,7 +198,7 @@ const filesColumn: ColumnDef<VendorWithTbeFields> = {
)
},
enableSorting: false,
- maxSize: 80,
+ minSize: 80,
}
// 댓글 칼럼
@@ -233,7 +233,7 @@ const commentsColumn: ColumnDef<VendorWithTbeFields> = {
)
},
enableSorting: false,
- maxSize: 80,
+ minSize: 80,
}
// ----------------------------------------------------------------
// 5) 최종 컬럼 배열 - Update to include the files column
diff --git a/lib/tbe/table/tbe-table.tsx b/lib/tbe/table/tbe-table.tsx
index ed323800..e67b1d3d 100644
--- a/lib/tbe/table/tbe-table.tsx
+++ b/lib/tbe/table/tbe-table.tsx
@@ -163,7 +163,7 @@ export function AllTbeTable({ promises }: VendorsTableProps) {
sorting: [{ id: "rfqVendorUpdated", desc: true }],
columnPinning: { right: ["actions"] },
},
- getRowId: (originalRow) => String(originalRow.id),
+ getRowId: (originalRow) => (`${originalRow.id}${originalRow.rfqId}`),
shallow: false,
clearOnDefault: true,
})
diff --git a/lib/users/send-otp.ts b/lib/users/send-otp.ts
index c8cfb83d..55c08eaf 100644
--- a/lib/users/send-otp.ts
+++ b/lib/users/send-otp.ts
@@ -7,65 +7,79 @@ import { findUserByEmail, addNewOtp } from '@/lib/users/service';
export async function sendOtpAction(email: string, lng: string) {
- // Next.js의 headers() API로 헤더 정보를 얻을 수 있습니다.
- const headersList = await headers();
- // 호스트 정보 (request.nextUrl.host 대체)
- const host = headersList.get('host') || 'localhost:3000';
+ try {
+ // Next.js의 headers() API로 헤더 정보를 얻을 수 있습니다.
+ const headersList = await headers();
- // 사용자 조회
- const user = await findUserByEmail(email);
+ // 호스트 정보 (request.nextUrl.host 대체)
+ const host = headersList.get('host') || 'localhost:3000';
- if (!user) {
- // 서버 액션에서 에러 던지면, 클라이언트 컴포넌트에서 try-catch로 잡을 수 있습니다.
- throw new Error('User does not exist');
- }
+ // 사용자 조회
+ const user = await findUserByEmail(email);
- // OTP 및 만료 시간 생성
- const otp = Math.floor(100000 + Math.random() * 900000).toString();
- const expires = new Date(Date.now() + 10 * 60 * 1000); // 10분 후 만료
- const token = jwt.sign(
- {
- email,
- otp,
- exp: Math.floor(expires.getTime() / 1000),
- },
- process.env.JWT_SECRET!
- );
+ if (!user) {
+ // Return error object instead of throwing
+ return {
+ success: false,
+ error: 'userNotFound',
+ message: 'User does not exist'
+ };
+ }
- // DB에 OTP 추가
- await addNewOtp(email, otp, new Date(), token, expires);
+ // OTP 및 만료 시간 생성
+ const otp = Math.floor(100000 + Math.random() * 900000).toString();
+ const expires = new Date(Date.now() + 10 * 60 * 1000); // 10분 후 만료
+ const token = jwt.sign(
+ {
+ email,
+ otp,
+ exp: Math.floor(expires.getTime() / 1000),
+ },
+ process.env.JWT_SECRET!
+ );
- // 이메일에서 사용할 URL 구성
- const verificationUrl = `http://${host}/ko/login?token=${token}`;
+ // DB에 OTP 추가
+ await addNewOtp(email, otp, new Date(), token, expires);
- // IP 정보로부터 지역 조회 (ip-api 사용)
- const ip = headersList.get('x-forwarded-for')?.split(',')[0]?.trim() || '';
- let location = '';
- try {
- const response = await fetch(`http://ip-api.com/json/${ip}?fields=country,city`);
- const data = await response.json();
- location = data.city && data.country ? `${data.city}, ${data.country}` : '';
- } catch (error) {
- // 위치 조회 실패 시 무시
- }
+ // 이메일에서 사용할 URL 구성
+ const verificationUrl = `http://${host}/ko/login?token=${token}`;
+
+ // IP 정보로부터 지역 조회 (ip-api 사용)
+ const ip = headersList.get('x-forwarded-for')?.split(',')[0]?.trim() || '';
+ let location = '';
+ try {
+ const response = await fetch(`http://ip-api.com/json/${ip}?fields=country,city`);
+ const data = await response.json();
+ location = data.city && data.country ? `${data.city}, ${data.country}` : '';
+ } catch (error) {
+ // 위치 조회 실패 시 무시
+ }
- // OTP 이메일 발송
- await sendEmail({
- to: email,
- subject: `${otp} - SHI eVCP Sign-in Verification`,
- template: 'otp',
- context: {
- name: user.name,
- otp,
- verificationUrl,
- location,
- language: lng,
- },
- });
+ // OTP 이메일 발송
+ await sendEmail({
+ to: email,
+ subject: `${otp} - SHI eVCP Sign-in Verification`,
+ template: 'otp',
+ context: {
+ name: user.name,
+ otp,
+ verificationUrl,
+ location,
+ language: lng,
+ },
+ });
- // 클라이언트로 반환할 수 있는 값
- return {
- success: true,
- };
+ // 클라이언트로 반환할 수 있는 값
+ return {
+ success: true,
+ };
+ } catch (error) {
+ // Handle unexpected errors
+ return {
+ success: false,
+ error: 'serverError',
+ message: error instanceof Error ? error.message : 'An unexpected error occurred'
+ };
+ }
} \ No newline at end of file
diff --git a/lib/users/verifyOtp.ts b/lib/users/verifyOtp.ts
index 5de76f90..aa759338 100644
--- a/lib/users/verifyOtp.ts
+++ b/lib/users/verifyOtp.ts
@@ -25,4 +25,33 @@ export async function verifyOtp(email: string, code: string) {
companyId: otpRecord.companyId,
domain: otpRecord.domain,
}
-} \ No newline at end of file
+}
+
+
+export async function verifyExternalCredentials(username: string, password: string) {
+ // DB에서 email과 code가 맞는지, 만료 안됐는지 검증
+ const otpRecord = await findEmailandOtp(username, password)
+ if (!otpRecord) {
+ return null
+ }
+
+ // 만료 체크
+ if (otpRecord.otpExpires && otpRecord.otpExpires < new Date()) {
+ return null
+ }
+
+ // 여기서 otpRecord에 유저 정보가 있다고 가정
+ // 예: otpRecord.userId, otpRecord.userName, otpRecord.email 등
+ // 실제 DB 설계에 맞춰 필드명을 조정하세요.
+ return {
+ email: otpRecord.email,
+ name: otpRecord.name,
+ id: otpRecord.id,
+ imageUrl: otpRecord.imageUrl,
+ companyId: otpRecord.companyId,
+ domain: otpRecord.domain,
+ }
+}
+
+
+
diff --git a/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-columns.tsx b/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-columns.tsx
index ac8fa35e..70b91176 100644
--- a/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-columns.tsx
+++ b/lib/vendor-rfq-response/vendor-rfq-table/rfqs-table-columns.tsx
@@ -177,20 +177,33 @@ export function getColumns({
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="RFQ Code" />
),
- cell: ({ row }) => {
- return (
- <Button
- variant="link"
- className="p-0 h-auto font-medium"
- onClick={() => router.push(`/vendor/rfqs/${row.original.rfqId}`)}
- >
- {row.original.rfqCode}
- </Button>
- )
- },
+ // cell: ({ row }) => {
+ // return (
+ // <Button
+ // variant="link"
+ // className="p-0 h-auto font-medium"
+ // onClick={() => router.push(`/vendor/rfqs/${row.original.rfqId}`)}
+ // >
+ // {row.original.rfqCode}
+ // </Button>
+ // )
+ // },
+ cell: ({ row }) => row.original.rfqCode || "-",
size: 150,
}
+ const rfqTypeColumn: ColumnDef<RfqWithAll> = {
+ id: "rfqType",
+ accessorKey: "rfqType",
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ Type" />
+ ),
+ cell: ({ row }) => row.original.rfqType || "-",
+ size: 150,
+ }
+
+
// 4) 응답 상태 컬럼
const responseStatusColumn: ColumnDef<RfqWithAll> = {
id: "responseStatus",
@@ -408,6 +421,7 @@ export function getColumns({
return [
selectColumn,
rfqCodeColumn,
+ rfqTypeColumn,
responseStatusColumn,
projectNameColumn,
descriptionColumn,