diff options
Diffstat (limited to 'lib')
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, |
