diff options
Diffstat (limited to 'lib')
62 files changed, 17911 insertions, 1387 deletions
diff --git a/lib/api-utils.ts b/lib/api-utils.ts new file mode 100644 index 00000000..fa954cac --- /dev/null +++ b/lib/api-utils.ts @@ -0,0 +1,45 @@ +export class ApiClient { + private static baseUrl = '/api' + + static async get(endpoint: string, params?: Record<string, string | number>) { + const url = new URL(`${this.baseUrl}${endpoint}`, window.location.origin) + + if (params) { + Object.entries(params).forEach(([key, value]) => { + url.searchParams.append(key, String(value)) + }) + } + + const response = await fetch(url.toString()) + + if (!response.ok) { + const error = new Error(`API Error: ${response.status}`) + ;(error as any).status = response.status + ;(error as any).url = url.toString() + throw error + } + + return response.json() + } + + static async post(endpoint: string, data: any) { + const response = await fetch(`${this.baseUrl}${endpoint}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(data), + }) + + if (!response.ok) { + const errorData = await response.json().catch(() => ({})) + const error = new Error(errorData.message || `API Error: ${response.status}`) + ;(error as any).status = response.status + ;(error as any).url = `${this.baseUrl}${endpoint}` + throw error + } + + return response.json() + } + } +
\ No newline at end of file diff --git a/lib/filter-columns.ts b/lib/filter-columns.ts index 4b995925..4e561d05 100644 --- a/lib/filter-columns.ts +++ b/lib/filter-columns.ts @@ -22,39 +22,48 @@ import type { PgTable, PgView } from "drizzle-orm/pg-core" type TableOrView = PgTable | PgView<any> +// 조인된 테이블들의 타입 정의 +export interface JoinedTables { + [key: string]: TableOrView +} + +// 커스텀 컬럼 매핑 타입 정의 +export interface CustomColumnMapping { + [filterId: string]: { + table: TableOrView + column: string + } | AnyColumn +} + /** - * Construct SQL conditions based on the provided filters for a specific table. - * - * This function takes a table and an array of filters, and returns a SQL - * expression that represents the logical combination of these conditions. The conditions - * are combined using the specified join operator (either 'AND' or 'OR'), which is determined - * by the first filter's joinOperator property. - * - * Each filter can specify various operators (e.g., equality, inequality, - * comparison for numbers and dates, etc.) and the function will generate the appropriate - * SQL expressions based on the filter's type and value. - * - * @param table - The table to apply the filters on. - * @param filters - An array of filters to be applied to the table. - * @param joinOperator - The join operator to use for combining the filters. - * @returns A SQL expression representing the combined filters, or undefined if no valid - * filters are found. + * Enhanced filterColumns function that supports joined tables and custom column mapping. + * + * This function can handle filters that reference columns from different tables + * in a joined query, and allows for custom mapping of filter IDs to actual table columns. */ - export function filterColumns<T extends TableOrView>({ table, filters, joinOperator, + joinedTables, + customColumnMapping, }: { table: T filters: Filter<T>[] joinOperator: JoinOperator + joinedTables?: JoinedTables + customColumnMapping?: CustomColumnMapping }): SQL | undefined { const joinFn = joinOperator === "and" ? and : or const conditions = filters.map((filter) => { - const column = getColumn(table, filter.id) + const column = getColumnWithMapping(table, filter.id, joinedTables, customColumnMapping) + + if (!column) { + console.warn(`Column not found for filter ID: ${filter.id}`) + return undefined + } switch (filter.operator) { case "eq": @@ -174,20 +183,102 @@ export function filterColumns<T extends TableOrView>({ (condition) => condition !== undefined ) - return validConditions.length > 0 ? joinFn(...validConditions) : undefined } /** - * Get table column. - * @param table The table to get the column from. - * @param columnKey The key of the column to retrieve from the table. - * @returns The column corresponding to the provided key. + * Enhanced column getter - 간단한 수정 버전 */ +function getColumnWithMapping<T extends TableOrView>( + table: T, + columnKey: keyof T | string, + joinedTables?: JoinedTables, + customColumnMapping?: CustomColumnMapping +): AnyColumn | undefined { + + // 1. 커스텀 매핑이 있는 경우 우선 확인 + if (customColumnMapping && columnKey in customColumnMapping) { + const mapping = customColumnMapping[columnKey as string] + + if (typeof mapping === 'object' && 'dataType' in mapping) { + return mapping as AnyColumn + } + + if (typeof mapping === 'object' && 'table' in mapping && 'column' in mapping) { + try { + if (mapping.table && typeof mapping.column === 'string') { + const column = (mapping.table as any)[mapping.column]; + if (column !== undefined) { + return column as AnyColumn; + } + } + } catch (error) { + console.warn(`Failed to get column ${mapping.column} from table:`, error) + } + } + } + + // 2. 메인 테이블에서 컬럼 찾기 - 수정된 부분 + if (typeof columnKey === 'string') { + // ✅ in 연산자 대신 직접 접근해서 undefined 체크 + try { + const column = (table as any)[columnKey]; + if (column !== undefined && column !== null) { + return column as AnyColumn; + } + } catch (error) { + // 직접 접근 실패 시 조용히 넘어감 + } + } else { + // keyof T 타입인 경우 기존 함수 사용 + try { + return getColumn(table, columnKey); + } catch (error) { + // getColumn 실패 시 조용히 넘어감 + } + } + + // 3. 조인된 테이블들에서 컬럼 찾기 + if (joinedTables && typeof columnKey === 'string') { + for (const [tableName, joinedTable] of Object.entries(joinedTables)) { + try { + const column = (joinedTable as any)[columnKey]; + if (column !== undefined && column !== null) { + return column as AnyColumn; + } + } catch (error) { + // 조용히 넘어감 + } + } + } + + // 4. 컬럼을 찾지 못한 경우 + return undefined; +} +/** + * Get table column (기존 함수 유지). + */ export function getColumn<T extends TableOrView>( table: T, columnKey: keyof T ): AnyColumn { return table[columnKey] as AnyColumn -}
\ No newline at end of file +} + +/** + * Safe helper to get column from any table with string key + */ +export function getColumnSafe(table: TableOrView, columnKey: string): AnyColumn | undefined { + try { + if (columnKey in table) { + return (table as any)[columnKey] as AnyColumn + } + } catch (error) { + console.warn(`Failed to get column ${columnKey} from table:`, error) + } + return undefined +} + +// 사용 예시를 위한 타입 정의 +export type FilterColumnsParams<T extends TableOrView> = Parameters<typeof filterColumns<T>>[0]
\ No newline at end of file diff --git a/lib/form-list/repository.ts b/lib/form-list/repository.ts index 9c7f6891..ef8000c5 100644 --- a/lib/form-list/repository.ts +++ b/lib/form-list/repository.ts @@ -1,7 +1,7 @@ import db from "@/db/db"; import { projects } from "@/db/schema"; import { Item, items } from "@/db/schema/items"; -import { tagTypeClassFormMappings } from "@/db/schema/vendorData"; +import { formListsView, tagTypeClassFormMappings } from "@/db/schema/vendorData"; import { eq, inArray, @@ -29,23 +29,8 @@ export async function selectFormLists( 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, - ep: tagTypeClassFormMappings.ep, - remark: tagTypeClassFormMappings.remark, - createdAt: tagTypeClassFormMappings.createdAt, - updatedAt: tagTypeClassFormMappings.updatedAt, - // 프로젝트 정보 추가 - projectCode: projects.code, - projectName: projects.name - }) - .from(tagTypeClassFormMappings) - .innerJoin(projects, eq(tagTypeClassFormMappings.projectId, projects.id)) + .select() + .from(formListsView) .where(where) .orderBy(...(orderBy ?? [])) .offset(offset) @@ -59,8 +44,7 @@ export async function selectFormLists( ) { const res = await tx .select({ count: count() }) - .from(tagTypeClassFormMappings) - .leftJoin(projects, eq(tagTypeClassFormMappings.projectId, projects.id)) + .from(formListsView) .where(where); return res[0]?.count ?? 0; }
\ No newline at end of file diff --git a/lib/form-list/service.ts b/lib/form-list/service.ts index 9887609f..d49dc5fc 100644 --- a/lib/form-list/service.ts +++ b/lib/form-list/service.ts @@ -5,94 +5,87 @@ import db from "@/db/db"; import { unstable_cache } from "@/lib/unstable-cache"; import { GetFormListsSchema } from "./validation"; import { filterColumns } from "@/lib/filter-columns"; -import { tagTypeClassFormMappings } from "@/db/schema/vendorData"; +import { formListsView } 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) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + const advancedTable = true; - 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: tagTypeClassFormMappings, - filters: input.filters, - joinOperator: input.joinOperator, - }); - - - let globalWhere - 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(projects.name, s), - ilike(projects.code, 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 = + + // advancedTable 모드면 filterColumns()로 where 절 구성 + const advancedWhere = filterColumns({ + table: formListsView, // 뷰 테이블 사용 + filters: input.filters, + joinOperator: input.joinOperator, + }); + + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(formListsView.formCode, s), + ilike(formListsView.formName, s), + ilike(formListsView.tagTypeLabel, s), + ilike(formListsView.classLabel, s), + ilike(formListsView.projectName, s), // 뷰 테이블의 projectName 사용 + ilike(formListsView.projectCode, s), // 뷰 테이블의 projectCode 사용 + ); + } + + const finalWhere = and( + advancedWhere, + globalWhere + ); + + const where = finalWhere; + + const orderBy = 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]); - } + // 뷰 테이블은 모든 필드가 포함되어 있으므로 간단하게 처리 + return item.desc + ? desc(formListsView[item.id]) + : asc(formListsView[item.id]); }) - : [asc(tagTypeClassFormMappings.createdAt)]; - // 트랜잭션 내부에서 Repository 호출 - const { data, total } = await db.transaction(async (tx) => { - const data = await selectFormLists(tx, { - where, - orderBy, - offset, - limit: input.perPage, - }); + : [asc(formListsView.createdAt)]; - console.log("dbdata") - - const total = await countFormLists(tx, where); - return { data, total }; + // 트랜잭션 내부에서 Repository 호출 + const { data, total } = await db.transaction(async (tx) => { + // 뷰 테이블을 사용하는 새로운 select 함수 + const data = await selectFormLists(tx, { + where, + orderBy, + offset, + limit: input.perPage, }); - - const pageCount = Math.ceil(total / input.perPage); - - return { data, pageCount }; - } catch (err) { - // 에러 발생 시 디폴트 - return { data: [], pageCount: 0 }; - } - }, - [JSON.stringify(input)], // 캐싱 키 - { - revalidate: 3600, - tags: ["form-lists"], + + + + // 뷰 테이블을 사용하는 새로운 count 함수 + const total = await countFormLists(tx, where); + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + + + return { data, pageCount }; + } catch (err) { + console.log(err) + // 에러 발생 시 디폴트 + return { data: [], pageCount: 0 }; } - )(); - }
\ No newline at end of file + }, + [JSON.stringify(input)], // 캐싱 키 + { + revalidate: 3600, + 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 647a8af1..5b120796 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 { ExtendedFormMappings } from "../validation" +import { FormListsView } from "@/db/schema" interface GetColumnsProps { - setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<ExtendedFormMappings> | null>> + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<FormListsView> | null>> } /** * tanstack table 컬럼 정의 (중첩 헤더 버전) */ -export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<ExtendedFormMappings>[] { +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<FormListsView>[] { // ---------------------------------------------------------------- // 1) select 컬럼 (체크박스) // ---------------------------------------------------------------- @@ -35,7 +35,7 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<Extende // ---------------------------------------------------------------- // 2) actions 컬럼 (단일 버튼 - Meta Info 바로 보기) // ---------------------------------------------------------------- - const actionsColumn: ColumnDef<ExtendedFormMappings> = { + const actionsColumn: ColumnDef<FormListsView> = { id: "actions", enableHiding: false, cell: function Cell({ row }) { @@ -65,7 +65,7 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<Extende // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성 // ---------------------------------------------------------------- // 3-1) groupMap: { [groupName]: ColumnDef<TagTypeClassFormMappings>[] } - const groupMap: Record<string, ColumnDef<ExtendedFormMappings>[]> = {} + const groupMap: Record<string, ColumnDef<FormListsView>[]> = {} formListsColumnsConfig.forEach((cfg) => { // 만약 group가 없으면 "_noGroup" 처리 @@ -76,7 +76,7 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<Extende } // child column 정의 - const childCol: ColumnDef<ExtendedFormMappings> = { + const childCol: ColumnDef<FormListsView> = { accessorKey: cfg.id, enableResizing: true, header: ({ column }) => ( @@ -104,7 +104,7 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<Extende // ---------------------------------------------------------------- // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기 // ---------------------------------------------------------------- - const nestedColumns: ColumnDef<ExtendedFormMappings>[] = [] + const nestedColumns: ColumnDef<FormListsView>[] = [] // 순서를 고정하고 싶다면 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 97db9a91..fc1e9c80 100644 --- a/lib/form-list/table/formLists-table-toolbar-actions.tsx +++ b/lib/form-list/table/formLists-table-toolbar-actions.tsx @@ -7,10 +7,10 @@ import { toast } from "sonner" import { exportTableToExcel } from "@/lib/export" import { Button } from "@/components/ui/button" -import { ExtendedFormMappings } from "../validation" +import { FormListsView } from "@/db/schema" interface ItemsTableToolbarActionsProps { - table: Table<ExtendedFormMappings> + table: Table<FormListsView> } export function FormListsTableToolbarActions({ table }: ItemsTableToolbarActionsProps) { diff --git a/lib/form-list/table/formLists-table.tsx b/lib/form-list/table/formLists-table.tsx index 9f35db03..a9a56338 100644 --- a/lib/form-list/table/formLists-table.tsx +++ b/lib/form-list/table/formLists-table.tsx @@ -16,7 +16,7 @@ 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" +import { FormListsView } from "@/db/schema" interface ItemsTableProps { promises: Promise< @@ -33,7 +33,7 @@ export function FormListsTable({ promises }: ItemsTableProps) { React.use(promises) const [rowAction, setRowAction] = - React.useState<DataTableRowAction<ExtendedFormMappings> | null>(null) + React.useState<DataTableRowAction<FormListsView> | null>(null) const columns = React.useMemo( () => getColumns({ setRowAction }), @@ -51,7 +51,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<ExtendedFormMappings>[] = [ + const filterFields: DataTableFilterField<FormListsView>[] = [ ] @@ -66,7 +66,7 @@ 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<ExtendedFormMappings>[] = [ + const advancedFilterFields: DataTableAdvancedFilterField<FormListsView>[] = [ { id: "projectCode", label: "Project Code", diff --git a/lib/form-list/table/meta-sheet.tsx b/lib/form-list/table/meta-sheet.tsx index 03e7d257..24140a95 100644 --- a/lib/form-list/table/meta-sheet.tsx +++ b/lib/form-list/table/meta-sheet.tsx @@ -32,8 +32,8 @@ import { CardHeader, CardTitle } from "@/components/ui/card" -import type { TagTypeClassFormMappings } from "@/db/schema/vendorData" // or your actual type import { fetchFormMetadata, FormColumn } from "@/lib/forms/services" +import { FormListsView } from "@/db/schema" // 옵션을 표시하기 위한 새로운 컴포넌트 const CollapsibleOptions = ({ options }: { options?: string[] }) => { @@ -89,7 +89,7 @@ const CollapsibleOptions = ({ options }: { options?: string[] }) => { interface ViewMetasProps { open: boolean onOpenChange: (open: boolean) => void - form: TagTypeClassFormMappings | null + form: FormListsView | null } export function ViewMetas({ open, onOpenChange, form }: ViewMetasProps) { @@ -124,6 +124,7 @@ export function ViewMetas({ open, onOpenChange, form }: ViewMetasProps) { React.useEffect(() => { async function fetchMeta() { if (!form || !open) return + if (form.formCode === null || form.projectId === null) return setLoading(true) try { diff --git a/lib/form-list/validation.ts b/lib/form-list/validation.ts index 497ec871..78ed6b72 100644 --- a/lib/form-list/validation.ts +++ b/lib/form-list/validation.ts @@ -8,13 +8,9 @@ import { import * as z from "zod" import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" -import { TagTypeClassFormMappings } from "@/db/schema/vendorData"; +import { FormListsView } from "@/db/schema/vendorData"; + -export type ExtendedFormMappings = TagTypeClassFormMappings & { - projectCode: string; - projectName: string; - }; - export const searchParamsCache = createSearchParamsCache({ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( @@ -22,7 +18,7 @@ export const searchParamsCache = createSearchParamsCache({ ), page: parseAsInteger.withDefault(1), perPage: parseAsInteger.withDefault(10), - sort: getSortingStateParser<ExtendedFormMappings>().withDefault([ + sort: getSortingStateParser<FormListsView>().withDefault([ { id: "createdAt", desc: true }, ]), diff --git a/lib/pq/helper.ts b/lib/pq/helper.ts new file mode 100644 index 00000000..16aed0e4 --- /dev/null +++ b/lib/pq/helper.ts @@ -0,0 +1,96 @@ +import { + vendorPQSubmissions, + vendors, + projects, + users, + vendorInvestigations +} from "@/db/schema" +import { CustomColumnMapping } from "../filter-columns" + +/** + * Helper function to create custom column mapping for PQ submissions + */ +export function createPQFilterMapping(): CustomColumnMapping { + return { + // PQ 제출 관련 + pqNumber: { table: vendorPQSubmissions, column: "pqNumber" }, + status: { table: vendorPQSubmissions, column: "status" }, + type: { table: vendorPQSubmissions, column: "type" }, + createdAt: { table: vendorPQSubmissions, column: "createdAt" }, + updatedAt: { table: vendorPQSubmissions, column: "updatedAt" }, + submittedAt: { table: vendorPQSubmissions, column: "submittedAt" }, + approvedAt: { table: vendorPQSubmissions, column: "approvedAt" }, + rejectedAt: { table: vendorPQSubmissions, column: "rejectedAt" }, + + // 협력업체 관련 + vendorName: { table: vendors, column: "vendorName" }, + vendorCode: { table: vendors, column: "vendorCode" }, + taxId: { table: vendors, column: "taxId" }, + vendorStatus: { table: vendors, column: "status" }, + + // 프로젝트 관련 + projectName: { table: projects, column: "name" }, + projectCode: { table: projects, column: "code" }, + + // 요청자 관련 + requesterName: { table: users, column: "name" }, + requesterEmail: { table: users, column: "email" }, + + // 실사 관련 + evaluationResult: { table: vendorInvestigations, column: "evaluationResult" }, + evaluationType: { table: vendorInvestigations, column: "evaluationType" }, + investigationStatus: { table: vendorInvestigations, column: "investigationStatus" }, + investigationAddress: { table: vendorInvestigations, column: "investigationAddress" }, + qmManagerId: { table: vendorInvestigations, column: "qmManagerId" }, + } +} + +/** + * PQ 관련 조인 테이블들 + */ +export function getPQJoinedTables() { + return { + vendors, + projects, + users, + vendorInvestigations, + } +} + +/** + * 직접 컬럼 참조 방식의 매핑 (더 타입 안전) + */ +export function createPQDirectColumnMapping(): CustomColumnMapping { + return { + // PQ 제출 관련 - 직접 컬럼 참조 + pqNumber: vendorPQSubmissions.pqNumber, + status: vendorPQSubmissions.status, + type: vendorPQSubmissions.type, + createdAt: vendorPQSubmissions.createdAt, + updatedAt: vendorPQSubmissions.updatedAt, + submittedAt: vendorPQSubmissions.submittedAt, + approvedAt: vendorPQSubmissions.approvedAt, + rejectedAt: vendorPQSubmissions.rejectedAt, + + // 협력업체 관련 + vendorName: vendors.vendorName, + vendorCode: vendors.vendorCode, + taxId: vendors.taxId, + vendorStatus: vendors.status, + + // 프로젝트 관련 + projectName: projects.name, + projectCode: projects.code, + + // 요청자 관련 + requesterName: users.name, + requesterEmail: users.email, + + // 실사 관련 + evaluationResult: vendorInvestigations.evaluationResult, + evaluationType: vendorInvestigations.evaluationType, + investigationStatus: vendorInvestigations.investigationStatus, + investigationAddress: vendorInvestigations.investigationAddress, + qmManagerId: vendorInvestigations.qmManagerId, + } +}
\ No newline at end of file diff --git a/lib/pq/pq-review-table-new/cancel-investigation-dialog.tsx b/lib/pq/pq-review-table-new/cancel-investigation-dialog.tsx new file mode 100644 index 00000000..03045537 --- /dev/null +++ b/lib/pq/pq-review-table-new/cancel-investigation-dialog.tsx @@ -0,0 +1,69 @@ +"use client" + +import * as React from "react" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" + +interface CancelInvestigationDialogProps { + isOpen: boolean + onClose: () => void + onConfirm: () => Promise<void> + selectedCount: number +} + +export function CancelInvestigationDialog({ + isOpen, + onClose, + onConfirm, + selectedCount, +}: CancelInvestigationDialogProps) { + const [isPending, setIsPending] = React.useState(false) + + async function handleConfirm() { + setIsPending(true) + try { + await onConfirm() + } finally { + setIsPending(false) + } + } + + return ( + <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}> + <DialogContent> + <DialogHeader> + <DialogTitle>실사 의뢰 취소</DialogTitle> + <DialogDescription> + 선택한 {selectedCount}개 협력업체의 실사 의뢰를 취소하시겠습니까? + 계획 상태인 실사만 취소할 수 있습니다. + </DialogDescription> + </DialogHeader> + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={onClose} + disabled={isPending} + > + 취소 + </Button> + <Button + variant="destructive" + onClick={handleConfirm} + disabled={isPending} + > + {isPending ? "처리 중..." : "실사 의뢰 취소"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/pq/pq-review-table-new/feature-flags-provider.tsx b/lib/pq/pq-review-table-new/feature-flags-provider.tsx new file mode 100644 index 00000000..81131894 --- /dev/null +++ b/lib/pq/pq-review-table-new/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/pq/pq-review-table-new/pq-container.tsx b/lib/pq/pq-review-table-new/pq-container.tsx new file mode 100644 index 00000000..ebe46809 --- /dev/null +++ b/lib/pq/pq-review-table-new/pq-container.tsx @@ -0,0 +1,151 @@ +"use client" + +import { useState, useEffect, useCallback, useRef } from "react" +import { useSearchParams } from "next/navigation" +import { Button } from "@/components/ui/button" +import { PanelLeftClose, PanelLeftOpen } from "lucide-react" + +import { cn } from "@/lib/utils" +import { getPQSubmissions } from "../service" +import { PQSubmissionsTable } from "./vendors-table" +import { PQFilterSheet } from "./pq-filter-sheet" + +interface PQContainerProps { + // Promise.all로 감싼 promises를 받음 + promises: Promise<[Awaited<ReturnType<typeof getPQSubmissions>>]> + // 컨테이너 클래스명 (옵션) + className?: string +} + +export default function PQContainer({ + promises, + className +}: PQContainerProps) { + const searchParams = useSearchParams() + + // Whether the filter panel is open + const [isFilterPanelOpen, setIsFilterPanelOpen] = useState(false) + + // Container wrapper의 위치를 측정하기 위한 ref + const containerRef = useRef<HTMLDivElement>(null) + const [containerTop, setContainerTop] = useState(0) + + // Container 위치 측정 함수 - top만 측정 + const updateContainerBounds = useCallback(() => { + if (containerRef.current) { + const rect = containerRef.current.getBoundingClientRect() + setContainerTop(rect.top) + } + }, []) + + // 컴포넌트 마운트 시와 윈도우 리사이즈 시 위치 업데이트 + useEffect(() => { + updateContainerBounds() + + const handleResize = () => { + updateContainerBounds() + } + + window.addEventListener('resize', handleResize) + window.addEventListener('scroll', updateContainerBounds) + + return () => { + window.removeEventListener('resize', handleResize) + window.removeEventListener('scroll', updateContainerBounds) + } + }, [updateContainerBounds]) + + // 조회 버튼 클릭 핸들러 - PQFilterSheet에 전달 + const handleSearch = () => { + // Close the panel after search + setIsFilterPanelOpen(false) + } + + // Get active filter count for UI display (서버 사이드 필터만 계산) + const getActiveFilterCount = () => { + try { + // 새로운 이름 우선, 기존 이름도 지원 + const basicFilters = searchParams.get('basicFilters') || searchParams.get('pqBasicFilters') + return basicFilters ? JSON.parse(basicFilters).length : 0 + } catch (e) { + return 0 + } + } + + // Filter panel width + const FILTER_PANEL_WIDTH = 400; + + return ( + <> + {/* Filter Panel - fixed positioning으로 화면 최대 좌측에서 시작 */} + <div + className={cn( + "fixed left-0 bg-background border-r z-50 flex flex-col transition-all duration-300 ease-in-out overflow-hidden", + isFilterPanelOpen ? "border-r shadow-lg" : "border-r-0" + )} + style={{ + width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px', + top: `${containerTop}px`, + height: `calc(100vh - ${containerTop}px)` + }} + > + {/* Filter Content */} + <div className="h-full"> + <PQFilterSheet + isOpen={isFilterPanelOpen} + onClose={() => setIsFilterPanelOpen(false)} + onSearch={handleSearch} + isLoading={false} // 로딩 상태 제거 + /> + </div> + </div> + + {/* Main Content Container */} + <div + ref={containerRef} + className={cn("relative w-full overflow-hidden", className)} + > + <div className="flex w-full h-full"> + {/* Main Content - 너비 조정으로 필터 패널 공간 확보 */} + <div + className="flex flex-col min-w-0 overflow-hidden transition-all duration-300 ease-in-out" + style={{ + width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : '100%', + marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px' + }} + > + {/* Header Bar */} + <div className="flex items-center justify-between p-4 bg-background shrink-0"> + <div className="flex items-center gap-3"> + <Button + variant="outline" + size="sm" + type='button' + onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)} + className="flex items-center shadow-sm" + > + { + isFilterPanelOpen ? <PanelLeftClose className="size-4"/> : <PanelLeftOpen className="size-4"/> + } + {getActiveFilterCount() > 0 && ( + <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs"> + {getActiveFilterCount()} + </span> + )} + </Button> + </div> + </div> + + {/* Table Content Area */} + <div className="flex-1 overflow-hidden" style={{ height: 'calc(100vh - 380px)' }}> + <div className="h-full w-full"> + {/* Promise를 직접 전달 - Items와 동일한 패턴 */} + <PQSubmissionsTable promises={promises} /> + </div> + </div> + </div> + </div> + </div> + </> + ) +}
\ No newline at end of file diff --git a/lib/pq/pq-review-table-new/pq-filter-sheet.tsx b/lib/pq/pq-review-table-new/pq-filter-sheet.tsx new file mode 100644 index 00000000..979f25a2 --- /dev/null +++ b/lib/pq/pq-review-table-new/pq-filter-sheet.tsx @@ -0,0 +1,651 @@ +"use client" + +import { useEffect, useTransition, useState, useRef } from "react" +import { useRouter, useParams } from "next/navigation" +import { z } from "zod" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { CalendarIcon, ChevronRight, Search, X } from "lucide-react" +import { customAlphabet } from "nanoid" +import { parseAsStringEnum, useQueryState } from "nuqs" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { cn } from "@/lib/utils" +import { useTranslation } from '@/i18n/client' +import { getFiltersStateParser } from "@/lib/parsers" +import { DateRangePicker } from "@/components/date-range-picker" + +// nanoid 생성기 +const generateId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 6) + +// PQ 필터 스키마 정의 +const pqFilterSchema = z.object({ + requesterName: z.string().optional(), + pqNumber: z.string().optional(), + vendorName: z.string().optional(), + status: z.string().optional(), + evaluationResult: z.string().optional(), + createdAtRange: z.object({ + from: z.date().optional(), + to: z.date().optional(), + }).optional(), +}) + +// PQ 상태 옵션 정의 +const pqStatusOptions = [ + { value: "REQUESTED", label: "요청됨" }, + { value: "IN_PROGRESS", label: "진행 중" }, + { value: "SUBMITTED", label: "제출됨" }, + { value: "APPROVED", label: "승인됨" }, + { value: "REJECTED", label: "거부됨" }, +] + +// 평가 결과 옵션 정의 +const evaluationResultOptions = [ + { value: "APPROVED", label: "승인" }, + { value: "SUPPLEMENT", label: "보완" }, + { value: "REJECTED", label: "불가" }, +] + +type PQFilterFormValues = z.infer<typeof pqFilterSchema> + +interface PQFilterSheetProps { + isOpen: boolean; + onClose: () => void; + onSearch?: () => void; + isLoading?: boolean; +} + +export function PQFilterSheet({ + isOpen, + onClose, + onSearch, + isLoading = false +}: PQFilterSheetProps) { + const router = useRouter() + const params = useParams(); + const lng = params ? (params.lng as string) : 'ko'; + const { t } = useTranslation(lng); + + const [isPending, startTransition] = useTransition() + + // 초기화 상태 추가 - 폼 초기화 중에는 상태 변경을 방지 + const [isInitializing, setIsInitializing] = useState(false) + // 마지막으로 적용된 필터를 추적하기 위한 ref + const lastAppliedFilters = useRef<string>("") + + // nuqs로 URL 상태 관리 - 파라미터명을 'pqBasicFilters'로 변경 + const [filters, setFilters] = useQueryState( + "basicFilters", + getFiltersStateParser().withDefault([]) + ) + + // joinOperator 설정 + const [joinOperator, setJoinOperator] = useQueryState( + "basicJoinOperator", + parseAsStringEnum(["and", "or"]).withDefault("and") + ) + + // 현재 URL의 페이지 파라미터도 가져옴 + const [page, setPage] = useQueryState("page", { defaultValue: "1" }) + + // 폼 상태 초기화 + const form = useForm<PQFilterFormValues>({ + resolver: zodResolver(pqFilterSchema), + defaultValues: { + requesterName: "", + pqNumber: "", + vendorName: "", + status: "", + evaluationResult: "", + createdAtRange: { + from: undefined, + to: undefined, + }, + }, + }) + + // URL 필터에서 초기 폼 상태 설정 + useEffect(() => { + // 현재 필터를 문자열로 직렬화 + const currentFiltersString = JSON.stringify(filters); + + // 패널이 열렸고, 필터가 있고, 마지막에 적용된 필터와 다를 때만 업데이트 + if (isOpen && filters && filters.length > 0 && currentFiltersString !== lastAppliedFilters.current) { + setIsInitializing(true); + + const formValues = { ...form.getValues() }; + let formUpdated = false; + + filters.forEach(filter => { + if (filter.id === "createdAt" && Array.isArray(filter.value) && filter.value.length > 0) { + formValues.createdAtRange = { + from: filter.value[0] ? new Date(filter.value[0]) : undefined, + to: filter.value[1] ? new Date(filter.value[1]) : undefined, + }; + formUpdated = true; + } else if (filter.id in formValues) { + // @ts-ignore - 동적 필드 접근 + formValues[filter.id] = filter.value; + formUpdated = true; + } + }); + + // 폼 값이 변경된 경우에만 reset으로 한 번에 업데이트 + if (formUpdated) { + form.reset(formValues); + lastAppliedFilters.current = currentFiltersString; + } + + setIsInitializing(false); + } + }, [filters, isOpen]) + + // 현재 적용된 필터 카운트 + const getActiveFilterCount = () => { + return filters?.length || 0 + } + +// 폼 제출 핸들러 - 수동 URL 업데이트 버전 +async function onSubmit(data: PQFilterFormValues) { + // 초기화 중이면 제출 방지 + if (isInitializing) return; + + startTransition(async () => { + try { + // 필터 배열 생성 + const newFilters = [] + + if (data.requesterName?.trim()) { + newFilters.push({ + id: "requesterName", + value: data.requesterName.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + + if (data.pqNumber?.trim()) { + newFilters.push({ + id: "pqNumber", + value: data.pqNumber.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + + if (data.vendorName?.trim()) { + newFilters.push({ + id: "vendorName", + value: data.vendorName.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + + if (data.status?.trim()) { + newFilters.push({ + id: "status", + value: data.status.trim(), + type: "select", + operator: "eq", + rowId: generateId() + }) + } + + if (data.evaluationResult?.trim()) { + newFilters.push({ + id: "evaluationResult", + value: data.evaluationResult.trim(), + type: "select", + operator: "eq", + rowId: generateId() + }) + } + + // 생성일 범위 추가 + if (data.createdAtRange?.from) { + newFilters.push({ + id: "createdAt", + value: [ + data.createdAtRange.from.toISOString().split('T')[0], + data.createdAtRange.to ? data.createdAtRange.to.toISOString().split('T')[0] : undefined + ].filter(Boolean), + type: "date", + operator: "isBetween", + rowId: generateId() + }) + } + + // 수동으로 URL 업데이트 (nuqs 대신) + const currentUrl = new URL(window.location.href); + const params = new URLSearchParams(currentUrl.search); + + // 기존 필터 관련 파라미터 제거 + params.delete('basicFilters'); + params.delete('pqBasicFilters'); + params.delete('basicJoinOperator'); + params.delete('pqBasicJoinOperator'); + params.delete('page'); + + // 새로운 필터 추가 + if (newFilters.length > 0) { + params.set('basicFilters', JSON.stringify(newFilters)); + params.set('basicJoinOperator', joinOperator); + } + + // 페이지를 1로 설정 + params.set('page', '1'); + + const newUrl = `${currentUrl.pathname}?${params.toString()}`; + console.log("New URL:", newUrl); + + // 페이지 완전 새로고침으로 서버 렌더링 강제 + window.location.href = newUrl; + + // 마지막 적용된 필터 업데이트 + lastAppliedFilters.current = JSON.stringify(newFilters); + + // 필터 업데이트 후 조회 핸들러 호출 (제공된 경우) + if (onSearch) { + console.log("Calling onSearch..."); + onSearch(); + } + + console.log("=== PQ Filter Submit Complete ==="); + } catch (error) { + console.error("PQ 필터 적용 오류:", error); + } + }) +} + + // 필터 초기화 핸들러 + // 필터 초기화 핸들러 + async function handleReset() { + try { + setIsInitializing(true); + + form.reset({ + requesterName: "", + pqNumber: "", + vendorName: "", + status: "", + evaluationResult: "", + createdAtRange: { from: undefined, to: undefined }, + }); + + console.log("=== PQ Filter Reset Debug ==="); + console.log("Current URL before reset:", window.location.href); + + // 수동으로 URL 초기화 + const currentUrl = new URL(window.location.href); + const params = new URLSearchParams(currentUrl.search); + + // 필터 관련 파라미터 제거 + params.delete('basicFilters'); + params.delete('pqBasicFilters'); + params.delete('basicJoinOperator'); + params.delete('pqBasicJoinOperator'); + params.set('page', '1'); + + const newUrl = `${currentUrl.pathname}?${params.toString()}`; + console.log("Reset URL:", newUrl); + + // 페이지 완전 새로고침 + window.location.href = newUrl; + + // 마지막 적용된 필터 초기화 + lastAppliedFilters.current = ""; + + console.log("PQ 필터 초기화 완료"); + setIsInitializing(false); + } catch (error) { + console.error("PQ 필터 초기화 오류:", error); + setIsInitializing(false); + } + } + + // Don't render if not open (for side panel use) + if (!isOpen) { + return null; + } + + return ( + <div className="flex flex-col h-full max-h-full bg-[#F5F7FB] px-6 sm:px-8" style={{backgroundColor:"#F5F7FB", paddingLeft:"2rem", paddingRight:"2rem"}}> + {/* Filter Panel Header */} + <div className="flex items-center justify-between px-6 min-h-[60px] shrink-0"> + <h3 className="text-lg font-semibold whitespace-nowrap">PQ 검색 필터</h3> + <div className="flex items-center gap-2"> + {getActiveFilterCount() > 0 && ( + <Badge variant="secondary" className="px-2 py-1"> + {getActiveFilterCount()}개 필터 적용됨 + </Badge> + )} + </div> + </div> + + {/* Join Operator Selection */} + <div className="px-6 shrink-0"> + <label className="text-sm font-medium">조건 결합 방식</label> + <Select + value={joinOperator} + onValueChange={(value: "and" | "or") => setJoinOperator(value)} + disabled={isInitializing} + > + <SelectTrigger className="h-8 w-[180px] mt-2 bg-white"> + <SelectValue placeholder="조건 결합 방식" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="and">모든 조건 충족 (AND)</SelectItem> + <SelectItem value="or">하나라도 충족 (OR)</SelectItem> + </SelectContent> + </Select> + </div> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col h-full min-h-0"> + {/* Scrollable content area */} + <div className="flex-1 min-h-0 overflow-y-auto px-6 pb-4"> + <div className="space-y-4 pt-2"> + {/* 요청자명 */} + <FormField + control={form.control} + name="requesterName" + render={({ field }) => ( + <FormItem> + <FormLabel>요청자명</FormLabel> + <FormControl> + <div className="relative"> + <Input + placeholder="요청자명 입력" + {...field} + className={cn(field.value && "pr-8", "bg-white")} + disabled={isInitializing} + /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-0 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("requesterName", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* PQ 번호 */} + <FormField + control={form.control} + name="pqNumber" + render={({ field }) => ( + <FormItem> + <FormLabel>PQ 번호</FormLabel> + <FormControl> + <div className="relative"> + <Input + placeholder="PQ 번호 입력" + {...field} + className={cn(field.value && "pr-8", "bg-white")} + disabled={isInitializing} + /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-0 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("pqNumber", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 협력업체명 */} + <FormField + control={form.control} + name="vendorName" + render={({ field }) => ( + <FormItem> + <FormLabel>협력업체명</FormLabel> + <FormControl> + <div className="relative"> + <Input + placeholder="협력업체명 입력" + {...field} + className={cn(field.value && "pr-8", "bg-white")} + disabled={isInitializing} + /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-0 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("vendorName", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* PQ 상태 */} + <FormField + control={form.control} + name="status" + render={({ field }) => ( + <FormItem> + <FormLabel>PQ 상태</FormLabel> + <Select + value={field.value} + onValueChange={field.onChange} + disabled={isInitializing} + > + <FormControl> + <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> + <div className="flex justify-between w-full"> + <SelectValue placeholder="PQ 상태 선택" /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="h-4 w-4 -mr-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("status", ""); + }} + disabled={isInitializing} + > + <X className="size-3" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </SelectTrigger> + </FormControl> + <SelectContent> + {pqStatusOptions.map(option => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* 평가 결과 */} + <FormField + control={form.control} + name="evaluationResult" + render={({ field }) => ( + <FormItem> + <FormLabel>평가 결과</FormLabel> + <Select + value={field.value} + onValueChange={field.onChange} + disabled={isInitializing} + > + <FormControl> + <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> + <div className="flex justify-between w-full"> + <SelectValue placeholder="평가 결과 선택" /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="h-4 w-4 -mr-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("evaluationResult", ""); + }} + disabled={isInitializing} + > + <X className="size-3" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </SelectTrigger> + </FormControl> + <SelectContent> + {evaluationResultOptions.map(option => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* PQ 생성일 */} + <FormField + control={form.control} + name="createdAtRange" + render={({ field }) => ( + <FormItem> + <FormLabel>PQ 생성일</FormLabel> + <FormControl> + <div className="relative"> + <DateRangePicker + triggerSize="default" + triggerClassName="w-full bg-white" + align="start" + showClearButton={true} + placeholder="PQ 생성일 범위를 선택하세요" + value={field.value || undefined} + onChange={field.onChange} + disabled={isInitializing} + /> + {(field.value?.from || field.value?.to) && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-10 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("createdAtRange", { from: undefined, to: undefined }); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + </div> + + {/* Fixed buttons at bottom */} + <div className="p-4 shrink-0"> + <div className="flex gap-2 justify-end"> + <Button + type="button" + variant="outline" + onClick={handleReset} + disabled={isPending || getActiveFilterCount() === 0 || isInitializing} + className="px-4" + > + 초기화 + </Button> + <Button + type="submit" + variant="samsung" + disabled={isPending || isLoading || isInitializing} + className="px-4" + > + <Search className="size-4 mr-2" /> + {isPending || isLoading ? "조회 중..." : "조회"} + </Button> + </div> + </div> + </form> + </Form> + </div> + ) +}
\ No newline at end of file diff --git a/lib/pq/pq-review-table-new/request-investigation-dialog.tsx b/lib/pq/pq-review-table-new/request-investigation-dialog.tsx new file mode 100644 index 00000000..d5588be4 --- /dev/null +++ b/lib/pq/pq-review-table-new/request-investigation-dialog.tsx @@ -0,0 +1,331 @@ +"use client" + +import * as React from "react" +import { CalendarIcon } from "lucide-react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { format } from "date-fns" +import { z } from "zod" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Calendar } from "@/components/ui/calendar" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { UserCombobox } from "./user-combobox" +import { getQMManagers } from "@/lib/pq/service" + +// QM 사용자 타입 +interface QMUser { + id: number + name: string + email: string + department?: string +} + +const requestInvestigationFormSchema = z.object({ + evaluationType: z.enum(["SITE_AUDIT", "QM_SELF_AUDIT"], { + required_error: "평가 유형을 선택해주세요.", + }), + qmManagerId: z.number({ + required_error: "QM 담당자를 선택해주세요.", + }), + forecastedAt: z.date({ + required_error: "실사 예정일을 선택해주세요.", + }), + investigationAddress: z.string().min(1, "실사 장소를 입력해주세요."), + investigationMethod: z.string().optional(), + investigationNotes: z.string().optional(), +}) + +type RequestInvestigationFormValues = z.infer<typeof requestInvestigationFormSchema> + +interface RequestInvestigationDialogProps { + isOpen: boolean + onClose: () => void + onSubmit: (data: { + evaluationType: "SITE_AUDIT" | "QM_SELF_AUDIT", + qmManagerId: number, + forecastedAt: Date, + investigationAddress: string, + investigationMethod?: string, + investigationNotes?: string + }) => Promise<void> + selectedCount: number + // 선택된 행에서 가져온 초기값 + initialData?: { + evaluationType?: "SITE_AUDIT" | "QM_SELF_AUDIT", + qmManagerId?: number, + forecastedAt?: Date, + investigationAddress?: string, + investigationMethod?: string, + investigationNotes?: string + } +} + +export function RequestInvestigationDialog({ + isOpen, + onClose, + onSubmit, + selectedCount, + initialData, +}: RequestInvestigationDialogProps) { + const [isPending, setIsPending] = React.useState(false) + const [qmManagers, setQMManagers] = React.useState<QMUser[]>([]) + const [isLoadingManagers, setIsLoadingManagers] = React.useState(false) + + // form 객체 생성 시 initialData 활용 + const form = useForm<RequestInvestigationFormValues>({ + resolver: zodResolver(requestInvestigationFormSchema), + defaultValues: { + evaluationType: initialData?.evaluationType || "SITE_AUDIT", + qmManagerId: initialData?.qmManagerId || undefined, + forecastedAt: initialData?.forecastedAt || undefined, + investigationAddress: initialData?.investigationAddress || "", + investigationMethod: initialData?.investigationMethod || "", + investigationNotes: initialData?.investigationNotes || "", + }, + }) + + // Dialog가 열릴 때마다 초기값으로 폼 재설정 + React.useEffect(() => { + if (isOpen) { + form.reset({ + evaluationType: initialData?.evaluationType || "SITE_AUDIT", + qmManagerId: initialData?.qmManagerId || undefined, + forecastedAt: initialData?.forecastedAt || undefined, + investigationAddress: initialData?.investigationAddress || "", + investigationMethod: initialData?.investigationMethod || "", + investigationNotes: initialData?.investigationNotes || "", + }); + } + }, [isOpen, initialData, form]); + + // Dialog가 열릴 때 QM 담당자 목록 로드 + React.useEffect(() => { + if (isOpen && qmManagers.length === 0) { + const loadQMManagers = async () => { + setIsLoadingManagers(true) + try { + const result = await getQMManagers() + if (result.success && result.data) { + setQMManagers(result.data) + } + } catch (error) { + console.error("QM 담당자 로드 오류:", error) + } finally { + setIsLoadingManagers(false) + } + } + + loadQMManagers() + } + }, [isOpen, qmManagers.length]) + + async function handleSubmit(data: RequestInvestigationFormValues) { + setIsPending(true) + try { + await onSubmit(data) + } finally { + setIsPending(false) + form.reset() + } + } + + return ( + <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}> + <DialogContent className="sm:max-w-[500px]"> + <DialogHeader> + <DialogTitle>실사 의뢰</DialogTitle> + <DialogDescription> + {selectedCount}개 협력업체에 대한 실사를 의뢰합니다. 실사 관련 정보를 입력해주세요. + </DialogDescription> + </DialogHeader> + <Form {...form}> + <form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4"> + <FormField + control={form.control} + name="evaluationType" + render={({ field }) => ( + <FormItem> + <FormLabel>평가 유형</FormLabel> + <Select + onValueChange={field.onChange} + defaultValue={field.value} + disabled={isPending} + > + <FormControl> + <SelectTrigger> + <SelectValue placeholder="평가 유형을 선택하세요" /> + </SelectTrigger> + </FormControl> + <SelectContent> + <SelectItem value="SITE_AUDIT">실사의뢰평가</SelectItem> + <SelectItem value="QM_SELF_AUDIT">QM자체평가</SelectItem> + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="qmManagerId" + render={({ field }) => ( + <FormItem> + <FormLabel>QM 담당자</FormLabel> + <FormControl> + <UserCombobox + users={qmManagers} + value={field.value} + onChange={field.onChange} + placeholder={isLoadingManagers ? "담당자 로딩 중..." : "담당자 선택..."} + disabled={isPending || isLoadingManagers} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="forecastedAt" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel>실사 예정일</FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant={"outline"} + className={`w-full pl-3 text-left font-normal ${!field.value && "text-muted-foreground"}`} + disabled={isPending} + > + {field.value ? ( + format(field.value, "yyyy년 MM월 dd일") + ) : ( + <span>실사 예정일을 선택하세요</span> + )} + <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <Calendar + mode="single" + selected={field.value} + onSelect={field.onChange} + disabled={(date) => date < new Date()} + initialFocus + /> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="investigationAddress" + render={({ field }) => ( + <FormItem> + <FormLabel>실사 장소</FormLabel> + <FormControl> + <Textarea + placeholder="실사가 진행될 주소를 입력하세요" + {...field} + disabled={isPending} + className="min-h-[60px]" + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="investigationMethod" + render={({ field }) => ( + <FormItem> + <FormLabel>실사 방법 (선택사항)</FormLabel> + <FormControl> + <Input + placeholder="실사 방법을 입력하세요" + {...field} + disabled={isPending} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="investigationNotes" + render={({ field }) => ( + <FormItem> + <FormLabel>특이사항 (선택사항)</FormLabel> + <FormControl> + <Textarea + placeholder="실사 관련 특이사항을 입력하세요" + className="resize-none min-h-[60px]" + {...field} + disabled={isPending} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={onClose} + disabled={isPending} + > + 취소 + </Button> + <Button type="submit" disabled={isPending || isLoadingManagers}> + {isPending ? "처리 중..." : "실사 의뢰"} + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/pq/pq-review-table-new/send-results-dialog.tsx b/lib/pq/pq-review-table-new/send-results-dialog.tsx new file mode 100644 index 00000000..0a423f7f --- /dev/null +++ b/lib/pq/pq-review-table-new/send-results-dialog.tsx @@ -0,0 +1,69 @@ +"use client" + +import * as React from "react" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" + +interface SendResultsDialogProps { + isOpen: boolean + onClose: () => void + onConfirm: () => Promise<void> + selectedCount: number +} + +export function SendResultsDialog({ + isOpen, + onClose, + onConfirm, + selectedCount, +}: SendResultsDialogProps) { + const [isPending, setIsPending] = React.useState(false) + + async function handleConfirm() { + setIsPending(true) + try { + await onConfirm() + } finally { + setIsPending(false) + } + } + + return ( + <Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}> + <DialogContent> + <DialogHeader> + <DialogTitle>실사 결과 발송</DialogTitle> + <DialogDescription> + 선택한 {selectedCount}개 협력업체의 실사 결과를 발송하시겠습니까? + 완료된 실사만 결과를 발송할 수 있습니다. + </DialogDescription> + </DialogHeader> + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={onClose} + disabled={isPending} + > + 취소 + </Button> + <Button + type="button" + onClick={handleConfirm} + disabled={isPending} + > + {isPending ? "처리 중..." : "결과 발송"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/pq/pq-review-table-new/user-combobox.tsx b/lib/pq/pq-review-table-new/user-combobox.tsx new file mode 100644 index 00000000..0fb0e4c8 --- /dev/null +++ b/lib/pq/pq-review-table-new/user-combobox.tsx @@ -0,0 +1,122 @@ +"use client" + +import * as React from "react" +import { Check, ChevronsUpDown } from "lucide-react" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from "@/components/ui/command" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" + +interface User { + id: number + name: string + email: string + department?: string +} + +interface UserComboboxProps { + users: User[] + value: number | null + onChange: (value: number) => void + placeholder?: string + disabled?: boolean +} + +export function UserCombobox({ + users, + value, + onChange, + placeholder = "담당자 선택...", + disabled = false +}: UserComboboxProps) { + const [open, setOpen] = React.useState(false) + const [inputValue, setInputValue] = React.useState("") + + const selectedUser = React.useMemo(() => { + return users.find(user => user.id === value) + }, [users, value]) + + return ( + <Popover open={open} onOpenChange={setOpen}> + <PopoverTrigger asChild> + <Button + variant="outline" + role="combobox" + aria-expanded={open} + className={cn( + "w-full justify-between", + !value && "text-muted-foreground" + )} + disabled={disabled} + > + {selectedUser ? ( + <span className="flex items-center"> + <span className="font-medium">{selectedUser.name}</span> + {selectedUser.department && ( + <span className="ml-2 text-xs text-muted-foreground"> + ({selectedUser.department}) + </span> + )} + </span> + ) : ( + placeholder + )} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </PopoverTrigger> + <PopoverContent className="w-[300px] p-0"> + <Command> + <CommandInput + placeholder="담당자 검색..." + value={inputValue} + onValueChange={setInputValue} + /> + <CommandEmpty>검색 결과가 없습니다.</CommandEmpty> + <CommandGroup className="max-h-[200px] overflow-y-auto"> + {users.map((user) => ( + <CommandItem + key={user.id} + value={user.email} // 이메일을 value로 사용 + onSelect={() => { + onChange(user.id) + setOpen(false) + }} + > + <Check + className={cn( + "mr-2 h-4 w-4", + value === user.id ? "opacity-100" : "opacity-0" + )} + /> + <div className="flex flex-col truncate"> + <div className="flex items-center"> + <span className="font-medium">{user.name}</span> + {user.department && ( + <span className="ml-2 text-xs text-muted-foreground"> + ({user.department}) + </span> + )} + </div> + <span className="text-xs text-muted-foreground truncate"> + {user.email} + </span> + </div> + </CommandItem> + ))} + </CommandGroup> + </Command> + </PopoverContent> + </Popover> + ) +}
\ No newline at end of file diff --git a/lib/pq/pq-review-table-new/vendors-table-columns.tsx b/lib/pq/pq-review-table-new/vendors-table-columns.tsx new file mode 100644 index 00000000..0491f1dc --- /dev/null +++ b/lib/pq/pq-review-table-new/vendors-table-columns.tsx @@ -0,0 +1,640 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Ellipsis, Eye, PaperclipIcon, FileEdit } from "lucide-react" + +import { formatDate } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header" +import { useRouter } from "next/navigation" + +// PQ 제출 타입 정의 +export interface PQSubmission { + id: number + pqNumber: string + type: string + status: string + requesterName: string | null // 요청자 이름 + createdAt: Date + updatedAt: Date + submittedAt: Date | null + approvedAt: Date | null + rejectedAt: Date | null + rejectReason: string | null + vendorId: number + vendorName: string + vendorCode: string + taxId: string + vendorStatus: string + projectId: number | null + projectName: string | null + projectCode: string | null + answerCount: number + attachmentCount: number + pqStatus: string + pqTypeLabel: string + investigation: { + id: number + investigationStatus: string + requesterName: string | null // 실사 요청자 이름 + evaluationType: "SITE_AUDIT" | "QM_SELF_AUDIT" | null + qmManagerId: number | null + qmManagerName: string | null // QM 담당자 이름 + qmManagerEmail: string | null // QM 담당자 이메일 + investigationAddress: string | null + investigationMethod: string | null + scheduledStartAt: Date | null + scheduledEndAt: Date | null + requestedAt: Date | null + confirmedAt: Date | null + completedAt: Date | null + forecastedAt: Date | null + evaluationScore: number | null + evaluationResult: "APPROVED" | "SUPPLEMENT" | "REJECTED" | null + investigationNotes: string | null + } | null + // 통합 상태를 위한 새 필드 + combinedStatus: { + status: string + label: string + variant: "default" | "outline" | "secondary" | "destructive" | "success" + } +} + +type NextRouter = ReturnType<typeof useRouter>; + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<PQSubmission> | null>>; + router: NextRouter; +} + +// 상태에 따른 Badge 변형 결정 함수 +function getStatusBadge(status: string) { + switch (status) { + case "REQUESTED": + return <Badge variant="outline">요청됨</Badge> + case "IN_PROGRESS": + return <Badge variant="secondary">진행 중</Badge> + case "SUBMITTED": + return <Badge>제출됨</Badge> + case "APPROVED": + return <Badge variant="success">승인됨</Badge> + case "REJECTED": + return <Badge variant="destructive">거부됨</Badge> + default: + return <Badge variant="outline">{status}</Badge> + } +} + +/** + * tanstack table 컬럼 정의 + */ +export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef<PQSubmission>[] { + // ---------------------------------------------------------------- + // 1) select 컬럼 (체크박스) + // ---------------------------------------------------------------- + const selectColumn: ColumnDef<PQSubmission> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + } + + // ---------------------------------------------------------------- + // 2) 일반 컬럼들 + // -------------------------- + // -------------------------------------- + + const pqNoColumn: ColumnDef<PQSubmission> = { + accessorKey: "pqNumber", + header: ({ column }) => ( + <DataTableColumnHeader column={column} title="PQ No." /> + ), + cell: ({ row }) => ( + <div className="flex flex-col"> + <span className="font-medium">{row.getValue("pqNumber")}</span> + </div> + ), + } + + // 협력업체 컬럼 + const vendorColumn: ColumnDef<PQSubmission> = { + accessorKey: "vendorName", + header: ({ column }) => ( + <DataTableColumnHeader column={column} title="협력업체" /> + ), + cell: ({ row }) => ( + <div className="flex flex-col"> + <span className="font-medium">{row.getValue("vendorName")}</span> + <span className="text-xs text-muted-foreground">{row.original.vendorCode ? row.original.vendorCode : "-"}/{row.original.taxId}</span> + </div> + ), + } + + // PQ 유형 컬럼 + const typeColumn: ColumnDef<PQSubmission> = { + accessorKey: "type", + header: ({ column }) => ( + <DataTableColumnHeader column={column} title="PQ 유형" /> + ), + cell: ({ row }) => { + return ( + <div className="flex items-center"> + <Badge variant={row.original.type === "PROJECT" ? "default" : "outline"}> + {row.original.pqTypeLabel} + </Badge> + </div> + ) + }, + filterFn: (row, id, value) => { + return value.includes(row.getValue(id)) + }, + } + + // 프로젝트 컬럼 + const projectColumn: ColumnDef<PQSubmission> = { + accessorKey: "projectName", + header: ({ column }) => ( + <DataTableColumnHeader column={column} title="프로젝트" /> + ), + cell: ({ row }) => { + const projectName = row.original.projectName + const projectCode = row.original.projectCode + + if (!projectName) { + return <span className="text-muted-foreground">-</span> + } + + return ( + <div className="flex flex-col"> + <span>{projectName}</span> + {projectCode && ( + <span className="text-xs text-muted-foreground">{projectCode}</span> + )} + </div> + ) + }, + } + + // 상태 컬럼 + const statusColumn: ColumnDef<PQSubmission> = { + accessorKey: "combinedStatus", + header: ({ column }) => ( + <DataTableColumnHeader column={column} title="진행현황" /> + ), + cell: ({ row }) => { + const combinedStatus = getCombinedStatus(row.original); + return <Badge variant={combinedStatus.variant}>{combinedStatus.label}</Badge>; + }, + filterFn: (row, id, value) => { + const combinedStatus = getCombinedStatus(row.original); + return value.includes(combinedStatus.status); + }, + }; + + // PQ 상태와 실사 상태를 결합하는 헬퍼 함수 + function getCombinedStatus(submission: PQSubmission) { + // PQ가 승인되지 않은 경우, PQ 상태를 우선 표시 + if (submission.status !== "APPROVED") { + switch (submission.status) { + case "REQUESTED": + return { status: "PQ_REQUESTED", label: "PQ 요청됨", variant: "outline" as const }; + case "IN_PROGRESS": + return { status: "PQ_IN_PROGRESS", label: "PQ 진행 중", variant: "secondary" as const }; + case "SUBMITTED": + return { status: "PQ_SUBMITTED", label: "PQ 제출됨", variant: "default" as const }; + case "REJECTED": + return { status: "PQ_REJECTED", label: "PQ 거부됨", variant: "destructive" as const }; + default: + return { status: submission.status, label: submission.status, variant: "outline" as const }; + } + } + + // PQ가 승인되었지만 실사가 없는 경우 + if (!submission.investigation) { + return { status: "PQ_APPROVED", label: "PQ 승인됨", variant: "success" as const }; + } + + // PQ가 승인되고 실사가 있는 경우 + switch (submission.investigation.investigationStatus) { + case "PLANNED": + return { status: "INVESTIGATION_PLANNED", label: "실사 계획됨", variant: "outline" as const }; + case "IN_PROGRESS": + return { status: "INVESTIGATION_IN_PROGRESS", label: "실사 진행 중", variant: "secondary" as const }; + case "COMPLETED": + // 실사 완료 후 평가 결과에 따라 다른 상태 표시 + if (submission.investigation.evaluationResult) { + switch (submission.investigation.evaluationResult) { + case "APPROVED": + return { status: "INVESTIGATION_APPROVED", label: "실사 승인", variant: "success" as const }; + case "SUPPLEMENT": + return { status: "INVESTIGATION_SUPPLEMENT", label: "실사 보완필요", variant: "secondary" as const }; + case "REJECTED": + return { status: "INVESTIGATION_REJECTED", label: "실사 불가", variant: "destructive" as const }; + default: + return { status: "INVESTIGATION_COMPLETED", label: "실사 완료", variant: "default" as const }; + } + } + return { status: "INVESTIGATION_COMPLETED", label: "실사 완료", variant: "default" as const }; + case "CANCELED": + return { status: "INVESTIGATION_CANCELED", label: "실사 취소됨", variant: "destructive" as const }; + default: + return { + status: `INVESTIGATION_${submission.investigation.investigationStatus}`, + label: `실사 ${submission.investigation.investigationStatus}`, + variant: "outline" as const + }; + } + } + + const evaluationTypeColumn: ColumnDef<PQSubmission> = { + accessorKey: "evaluationType", + header: ({ column }) => ( + <DataTableColumnHeader column={column} title="평가 유형" /> + ), + cell: ({ row }) => { + const investigation = row.original.investigation; + + if (!investigation || !investigation.evaluationType) { + return <span className="text-muted-foreground">-</span>; + } + + switch (investigation.evaluationType) { + case "SITE_AUDIT": + return <Badge variant="outline">실사의뢰평가</Badge>; + case "QM_SELF_AUDIT": + return <Badge variant="secondary">QM자체평가</Badge>; + default: + return <span>{investigation.evaluationType}</span>; + } + }, + filterFn: (row, id, value) => { + const investigation = row.original.investigation; + if (!investigation || !investigation.evaluationType) return value.includes("null"); + return value.includes(investigation.evaluationType); + }, + }; + + + const evaluationResultColumn: ColumnDef<PQSubmission> = { + accessorKey: "evaluationResult", + header: ({ column }) => ( + <DataTableColumnHeader column={column} title="평가 결과" /> + ), + cell: ({ row }) => { + const investigation = row.original.investigation; + + if (!investigation || !investigation.evaluationResult) { + return <span className="text-muted-foreground">-</span>; + } + + switch (investigation.evaluationResult) { + case "APPROVED": + return <Badge variant="success">승인</Badge>; + case "SUPPLEMENT": + return <Badge variant="secondary">보완</Badge>; + case "REJECTED": + return <Badge variant="destructive">불가</Badge>; + default: + return <span>{investigation.evaluationResult}</span>; + } + }, + filterFn: (row, id, value) => { + const investigation = row.original.investigation; + if (!investigation || !investigation.evaluationResult) return value.includes("null"); + return value.includes(investigation.evaluationResult); + }, + }; + + // 답변 수 컬럼 + const answerCountColumn: ColumnDef<PQSubmission> = { + accessorKey: "answerCount", + header: ({ column }) => ( + <DataTableColumnHeader column={column} title="답변 수" /> + ), + cell: ({ row }) => { + return ( + <div className="flex items-center gap-2"> + <span>{row.original.answerCount}</span> + </div> + ) + }, + } + + const investigationAddressColumn: ColumnDef<PQSubmission> = { + accessorKey: "investigationAddress", + header: ({ column }) => ( + <DataTableColumnHeader column={column} title="실사 주소" /> + ), + cell: ({ row }) => { + const investigation = row.original.investigation; + + if (!investigation || !investigation.evaluationType) { + return <span className="text-muted-foreground">-</span>; + } + + return ( + <div className="flex items-center gap-2"> + <span>{investigation.investigationAddress}</span> + </div> + ) + }, + } + + const investigationNotesColumn: ColumnDef<PQSubmission> = { + accessorKey: "investigationNotes", + header: ({ column }) => ( + <DataTableColumnHeader column={column} title="QM 의견" /> + ), + cell: ({ row }) => { + const investigation = row.original.investigation; + + if (!investigation || !investigation.investigationNotes) { + return <span className="text-muted-foreground">-</span>; + } + + return ( + <div className="flex items-center gap-2"> + <span>{investigation.investigationNotes}</span> + </div> + ) + }, + } + + + const investigationRequestedAtColumn: ColumnDef<PQSubmission> = { + accessorKey: "investigationRequestedAt", + header: ({ column }) => ( + <DataTableColumnHeader column={column} title="실사 의뢰일" /> + ), + cell: ({ row }) => { + const investigation = row.original.investigation; + + if (!investigation || !investigation.requestedAt) { + return <span className="text-muted-foreground">-</span>; + } + const dateVal = investigation.requestedAt + + return ( + <div className="flex items-center gap-2"> + <span>{dateVal ? formatDate(dateVal, 'KR') : "-"}</span> + </div> + ) + }, + } + + + const investigationForecastedAtColumn: ColumnDef<PQSubmission> = { + accessorKey: "investigationForecastedAt", + header: ({ column }) => ( + <DataTableColumnHeader column={column} title="실사 예정일" /> + ), + cell: ({ row }) => { + const investigation = row.original.investigation; + + if (!investigation || !investigation.forecastedAt) { + return <span className="text-muted-foreground">-</span>; + } + const dateVal = investigation.forecastedAt + + return ( + <div className="flex items-center gap-2"> + <span>{dateVal ? formatDate(dateVal, 'KR') : "-"}</span> + </div> + ) + }, + } + + const investigationConfirmedAtColumn: ColumnDef<PQSubmission> = { + accessorKey: "investigationConfirmedAt", + header: ({ column }) => ( + <DataTableColumnHeader column={column} title="실사 확정일" /> + ), + cell: ({ row }) => { + const investigation = row.original.investigation; + + if (!investigation || !investigation.confirmedAt) { + return <span className="text-muted-foreground">-</span>; + } + const dateVal = investigation.confirmedAt + + return ( + <div className="flex items-center gap-2"> + <span>{dateVal ? formatDate(dateVal, 'KR') : "-"}</span> + </div> + ) + }, + } + + const investigationCompletedAtColumn: ColumnDef<PQSubmission> = { + accessorKey: "investigationCompletedAt", + header: ({ column }) => ( + <DataTableColumnHeader column={column} title="실제 실사일" /> + ), + cell: ({ row }) => { + const investigation = row.original.investigation; + + if (!investigation || !investigation.completedAt) { + return <span className="text-muted-foreground">-</span>; + } + const dateVal = investigation.completedAt + + return ( + <div className="flex items-center gap-2"> + <span>{dateVal ? formatDate(dateVal, 'KR') : "-"}</span> + </div> + ) + }, + } + + // 제출일 컬럼 + const createdAtColumn: ColumnDef<PQSubmission> = { + accessorKey: "createdAt", + header: ({ column }) => ( + <DataTableColumnHeader column={column} title="PQ 전송일" /> + ), + cell: ({ row }) => { + const dateVal = row.original.createdAt as Date + return formatDate(dateVal, 'KR') + }, + } + + // 제출일 컬럼 + const submittedAtColumn: ColumnDef<PQSubmission> = { + accessorKey: "submittedAt", + header: ({ column }) => ( + <DataTableColumnHeader column={column} title="PQ 회신일" /> + ), + cell: ({ row }) => { + const dateVal = row.original.submittedAt as Date + return dateVal ? formatDate(dateVal, 'KR') : "-" + }, + } + + // 승인/거부일 컬럼 + const approvalDateColumn: ColumnDef<PQSubmission> = { + accessorKey: "approvedAt", + header: ({ column }) => ( + <DataTableColumnHeader column={column} title="PQ 승인/거부일" /> + ), + cell: ({ row }) => { + if (row.original.approvedAt) { + return <span className="text-green-600">{formatDate(row.original.approvedAt)}</span> + } + if (row.original.rejectedAt) { + return <span className="text-red-600">{formatDate(row.original.rejectedAt)}</span> + } + return "-" + }, + } + + // ---------------------------------------------------------------- + // 3) actions 컬럼 (Dropdown 메뉴) + // ---------------------------------------------------------------- + const actionsColumn: ColumnDef<PQSubmission> = { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const pq = row.original + const isSubmitted = pq.status === "SUBMITTED" + const reviewUrl = `/evcp/pq_new/${pq.vendorId}/${pq.id}` + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + <DropdownMenuItem + onSelect={() => { + router.push(reviewUrl); + }} + > + {isSubmitted ? ( + <> + <FileEdit className="mr-2 h-4 w-4" /> + 검토 + </> + ) : ( + <> + <Eye className="mr-2 h-4 w-4" /> + 보기 + </> + )} + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, + } + + // 요청자 컬럼 추가 +const requesterColumn: ColumnDef<PQSubmission> = { + accessorKey: "requesterName", + header: ({ column }) => ( + <DataTableColumnHeader column={column} title="PQ/실사 요청자" /> + ), + cell: ({ row }) => { + // PQ 요청자와 실사 요청자를 모두 표시 + const pqRequesterName = row.original.requesterName; + const investigationRequesterName = row.original.investigation?.requesterName; + + // 상태에 따라 적절한 요청자 표시 + const status = getCombinedStatus(row.original).status; + + if (status.startsWith('INVESTIGATION_') && investigationRequesterName) { + return <span>{investigationRequesterName}</span>; + } + + return pqRequesterName + ? <span>{pqRequesterName}</span> + : <span className="text-muted-foreground">-</span>; + }, +}; +const qmManagerColumn: ColumnDef<PQSubmission> = { + accessorKey: "qmManager", + header: ({ column }) => ( + <DataTableColumnHeader column={column} title="QM 담당자" /> + ), + cell: ({ row }) => { + const investigation = row.original.investigation; + + if (!investigation || !investigation.qmManagerName) { + return <span className="text-muted-foreground">-</span>; + } + + return ( + <div className="flex flex-col"> + <span>{investigation.qmManagerName}</span> + {investigation.qmManagerEmail && ( + <span className="text-xs text-muted-foreground">{investigation.qmManagerEmail}</span> + )} + </div> + ); + }, +}; + + + // ---------------------------------------------------------------- + // 4) 최종 컬럼 배열 + // ---------------------------------------------------------------- + return [ + selectColumn, + statusColumn, // 통합된 진행현황 컬럼 + pqNoColumn, + vendorColumn, + investigationAddressColumn, + typeColumn, + projectColumn, + createdAtColumn, + submittedAtColumn, + approvalDateColumn, + answerCountColumn, + evaluationTypeColumn, // 평가 유형 컬럼 + investigationForecastedAtColumn, + investigationRequestedAtColumn, + investigationConfirmedAtColumn, + investigationCompletedAtColumn, + evaluationResultColumn, // 평가 결과 컬럼 + requesterColumn, + qmManagerColumn, + investigationNotesColumn, + actionsColumn, + ]; +}
\ No newline at end of file diff --git a/lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx b/lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx new file mode 100644 index 00000000..abba72d1 --- /dev/null +++ b/lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx @@ -0,0 +1,351 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, ClipboardCheck, X, Send } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { PQSubmission } from "./vendors-table-columns" +import { + requestInvestigationAction, + cancelInvestigationAction, + sendInvestigationResultsAction, + getFactoryLocationAnswer +} from "@/lib/pq/service" +import { RequestInvestigationDialog } from "./request-investigation-dialog" +import { CancelInvestigationDialog } from "./cancel-investigation-dialog" +import { SendResultsDialog } from "./send-results-dialog" + +interface VendorsTableToolbarActionsProps { + table: Table<PQSubmission> +} + +interface InvestigationInitialData { + evaluationType?: "SITE_AUDIT" | "QM_SELF_AUDIT"; + qmManagerId?: number; + forecastedAt?: Date; + createdAt?: Date; + investigationAddress?: string; + investigationNotes?: string; +} + +export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActionsProps) { + const selectedRows = table.getFilteredSelectedRowModel().rows + const [isLoading, setIsLoading] = React.useState(false) + + // Dialog 상태 관리 + const [isRequestDialogOpen, setIsRequestDialogOpen] = React.useState(false) + const [isCancelDialogOpen, setIsCancelDialogOpen] = React.useState(false) + const [isSendResultsDialogOpen, setIsSendResultsDialogOpen] = React.useState(false) + + // 초기 데이터 상태 + const [dialogInitialData, setDialogInitialData] = React.useState<InvestigationInitialData | undefined>(undefined) + + // 실사 의뢰 대화상자 열기 핸들러 +// 실사 의뢰 대화상자 열기 핸들러 +const handleOpenRequestDialog = async () => { + setIsLoading(true); + const initialData: InvestigationInitialData = {}; + + try { + // 선택된 행이 정확히 1개인 경우에만 초기값 설정 + if (selectedRows.length === 1) { + const row = selectedRows[0].original; + + // 승인된 PQ이고 아직 실사가 없는 경우 + if (row.status === "APPROVED" && !row.investigation) { + // Factory Location 정보 가져오기 + const locationResponse = await getFactoryLocationAnswer( + row.vendorId, + row.projectId + ); + + // 기본 주소 설정 - Factory Location 응답 또는 fallback + let defaultAddress = ""; + if (locationResponse.success && locationResponse.factoryLocation) { + defaultAddress = locationResponse.factoryLocation; + } else { + // Factory Location을 찾지 못한 경우 fallback + defaultAddress = row.taxId ? + `${row.vendorName} 사업장 (${row.taxId})` : + `${row.vendorName} 사업장`; + } + + // 이미 같은 회사에 대한 다른 실사가 있는지 확인 + const existingInvestigations = table.getFilteredRowModel().rows + .map(r => r.original) + .filter(r => + r.vendorId === row.vendorId && + r.investigation !== null + ); + + // 같은 업체의 이전 실사 기록이 있다면 참고하되, 주소는 Factory Location 사용 + if (existingInvestigations.length > 0) { + // 날짜 기준으로 정렬하여 가장 최근 것을 가져옴 + const latestInvestigation = existingInvestigations.sort((a, b) => { + const dateA = a.investigation?.createdAt || new Date(0); + const dateB = b.investigation?.createdAt || new Date(0); + return (dateB as Date).getTime() - (dateA as Date).getTime(); + })[0].investigation; + + if (latestInvestigation) { + initialData.evaluationType = latestInvestigation.evaluationType || "SITE_AUDIT"; + initialData.qmManagerId = latestInvestigation.qmManagerId || undefined; + initialData.investigationAddress = defaultAddress; // Factory Location 사용 + + // 날짜는 미래로 설정 + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 14); // 기본값으로 2주 후 + initialData.forecastedAt = futureDate; + } + } else { + // 기본값 설정 + initialData.evaluationType = "SITE_AUDIT"; + const futureDate = new Date(); + futureDate.setDate(futureDate.getDate() + 14); // 기본값으로 2주 후 + initialData.forecastedAt = futureDate; + initialData.investigationAddress = defaultAddress; // Factory Location 사용 + } + } + // 실사가 이미 있고 수정하는 경우 + else if (row.investigation) { + initialData.evaluationType = row.investigation.evaluationType || "SITE_AUDIT"; + initialData.qmManagerId = row.investigation.qmManagerId !== null ? + row.investigation.qmManagerId : undefined; + initialData.forecastedAt = row.investigation.forecastedAt || new Date(); + initialData.investigationAddress = row.investigation.investigationAddress || ""; + initialData.investigationNotes = row.investigation.investigationNotes || ""; + } + } + } catch (error) { + console.error("초기 데이터 로드 중 오류:", error); + toast.error("초기 데이터 로드 중 오류가 발생했습니다."); + } finally { + setIsLoading(false); + + // 초기 데이터 설정 및 대화상자 열기 + setDialogInitialData(Object.keys(initialData).length > 0 ? initialData : undefined); + setIsRequestDialogOpen(true); + } +}; + // 실사 의뢰 요청 처리 + const handleRequestInvestigation = async (formData: { + evaluationType: "SITE_AUDIT" | "QM_SELF_AUDIT", + qmManagerId: number, + forecastedAt: Date, + investigationAddress: string, + investigationNotes?: string + }) => { + setIsLoading(true) + try { + // 승인된 PQ 제출만 필터링 + const approvedPQs = selectedRows.filter(row => + row.original.status === "APPROVED" && !row.original.investigation + ) + + if (approvedPQs.length === 0) { + toast.error("실사를 의뢰할 수 있는 업체가 없습니다. 승인된 PQ 제출만 실사 의뢰가 가능합니다.") + return + } + + // 서버 액션 호출 + const result = await requestInvestigationAction( + approvedPQs.map(row => row.original.id), + formData + ) + + if (result.success) { + toast.success(`${result.count}개 업체에 대한 ${formData.evaluationType === "SITE_AUDIT" ? "실사의뢰평가" : "QM자체평가"}가 의뢰되었습니다.`) + window.location.reload() + } else { + toast.error(result.error || "실사 의뢰 처리 중 오류가 발생했습니다.") + } + } catch (error) { + console.error("실사 의뢰 중 오류 발생:", error) + toast.error("실사 의뢰 중 오류가 발생했습니다.") + } finally { + setIsLoading(false) + setIsRequestDialogOpen(false) + setDialogInitialData(undefined); // 초기 데이터 초기화 + } + } + + const handleCloseRequestDialog = () => { + setIsRequestDialogOpen(false); + setDialogInitialData(undefined); + }; + + + // 실사 의뢰 취소 처리 + const handleCancelInvestigation = async () => { + setIsLoading(true) + try { + // 실사가 계획됨 상태인 PQ만 필터링 + const plannedInvestigations = selectedRows.filter(row => + row.original.investigation && + row.original.investigation.investigationStatus === "PLANNED" + ) + + if (plannedInvestigations.length === 0) { + toast.error("취소할 수 있는 실사 의뢰가 없습니다. 계획 상태의 실사만 취소할 수 있습니다.") + return + } + + // 서버 액션 호출 + const result = await cancelInvestigationAction( + plannedInvestigations.map(row => row.original.investigation!.id) + ) + + if (result.success) { + toast.success(`${result.count}개 업체에 대한 실사 의뢰가 취소되었습니다.`) + window.location.reload() + } else { + toast.error(result.error || "실사 취소 처리 중 오류가 발생했습니다.") + } + } catch (error) { + console.error("실사 의뢰 취소 중 오류 발생:", error) + toast.error("실사 의뢰 취소 중 오류가 발생했습니다.") + } finally { + setIsLoading(false) + setIsCancelDialogOpen(false) + } + } + + // 실사 결과 발송 처리 + const handleSendInvestigationResults = async () => { + setIsLoading(true) + try { + // 완료된 실사만 필터링 + const completedInvestigations = selectedRows.filter(row => + row.original.investigation && + row.original.investigation.investigationStatus === "COMPLETED" + ) + + if (completedInvestigations.length === 0) { + toast.error("발송할 실사 결과가 없습니다. 완료된 실사만 결과를 발송할 수 있습니다.") + return + } + + // 서버 액션 호출 + const result = await sendInvestigationResultsAction( + completedInvestigations.map(row => row.original.investigation!.id) + ) + + if (result.success) { + toast.success(`${result.count}개 업체에 대한 실사 결과가 발송되었습니다.`) + window.location.reload() + } else { + toast.error(result.error || "실사 결과 발송 처리 중 오류가 발생했습니다.") + } + } catch (error) { + console.error("실사 결과 발송 중 오류 발생:", error) + toast.error("실사 결과 발송 중 오류가 발생했습니다.") + } finally { + setIsLoading(false) + setIsSendResultsDialogOpen(false) + } + } + + // 승인된 업체 수 확인 + const approvedPQsCount = selectedRows.filter(row => + row.original.status === "APPROVED" && !row.original.investigation + ).length + + // 계획 상태 실사 수 확인 + const plannedInvestigationsCount = selectedRows.filter(row => + row.original.investigation && + row.original.investigation.investigationStatus === "PLANNED" + ).length + + // 완료된 실사 수 확인 + const completedInvestigationsCount = selectedRows.filter(row => + row.original.investigation && + row.original.investigation.investigationStatus === "COMPLETED" + ).length + + return ( + <> + <div className="flex items-center gap-2"> + {/* 실사 의뢰 버튼 */} + <Button + variant="outline" + size="sm" + onClick={handleOpenRequestDialog} // 여기를 수정: 새로운 핸들러 함수 사용 + disabled={isLoading || selectedRows.length === 0} + className="gap-2" + > + <ClipboardCheck className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">실사 의뢰</span> + </Button> + + {/* 실사 의뢰 취소 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => setIsCancelDialogOpen(true)} + disabled={isLoading || selectedRows.length === 0} + className="gap-2" + > + <X className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">실사 취소</span> + </Button> + + {/* 실사 결과 발송 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => setIsSendResultsDialogOpen(true)} + disabled={isLoading || selectedRows.length === 0} + className="gap-2" + > + <Send className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">결과 발송</span> + </Button> + + {/** Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "vendors-pq-submissions", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + </div> + + {/* 실사 의뢰 Dialog */} + <RequestInvestigationDialog + isOpen={isRequestDialogOpen} + onClose={handleCloseRequestDialog} // 새로운 핸들러로 변경 + onSubmit={handleRequestInvestigation} + selectedCount={approvedPQsCount} + initialData={dialogInitialData} // 초기 데이터 전달 + /> + + + {/* 실사 취소 Dialog */} + <CancelInvestigationDialog + isOpen={isCancelDialogOpen} + onClose={() => setIsCancelDialogOpen(false)} + onConfirm={handleCancelInvestigation} + selectedCount={plannedInvestigationsCount} + /> + + {/* 결과 발송 Dialog */} + <SendResultsDialog + isOpen={isSendResultsDialogOpen} + onClose={() => setIsSendResultsDialogOpen(false)} + onConfirm={handleSendInvestigationResults} + selectedCount={completedInvestigationsCount} + /> + </> + ) +}
\ No newline at end of file diff --git a/lib/pq/pq-review-table-new/vendors-table.tsx b/lib/pq/pq-review-table-new/vendors-table.tsx new file mode 100644 index 00000000..e1c4cefe --- /dev/null +++ b/lib/pq/pq-review-table-new/vendors-table.tsx @@ -0,0 +1,308 @@ +"use client" + +import * as React from "react" +import { useRouter, useSearchParams } from "next/navigation" +import { Button } from "@/components/ui/button" +import { PanelLeftClose, PanelLeftOpen } from "lucide-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 { getPQSubmissions } from "../service" +import { getColumns, PQSubmission } from "./vendors-table-columns" +import { VendorsTableToolbarActions } from "./vendors-table-toolbar-actions" +import { PQFilterSheet } from "./pq-filter-sheet" +import { cn } from "@/lib/utils" +// TablePresetManager 관련 import 추가 +import { useTablePresets } from "@/components/data-table/use-table-presets" +import { TablePresetManager } from "@/components/data-table/data-table-preset" +import { useMemo } from "react" + +interface PQSubmissionsTableProps { + promises: Promise<[Awaited<ReturnType<typeof getPQSubmissions>>]> + className?: string +} + +export function PQSubmissionsTable({ promises, className }: PQSubmissionsTableProps) { + const [rowAction, setRowAction] = React.useState<DataTableRowAction<PQSubmission> | null>(null) + const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false) + + const router = useRouter() + const searchParams = useSearchParams() + + // Container wrapper의 위치를 측정하기 위한 ref + const containerRef = React.useRef<HTMLDivElement>(null) + const [containerTop, setContainerTop] = React.useState(0) + + // Container 위치 측정 함수 - top만 측정 + const updateContainerBounds = React.useCallback(() => { + if (containerRef.current) { + const rect = containerRef.current.getBoundingClientRect() + setContainerTop(rect.top) + } + }, []) + + // 컴포넌트 마운트 시와 윈도우 리사이즈 시 위치 업데이트 + React.useEffect(() => { + updateContainerBounds() + + const handleResize = () => { + updateContainerBounds() + } + + window.addEventListener('resize', handleResize) + window.addEventListener('scroll', updateContainerBounds) + + return () => { + window.removeEventListener('resize', handleResize) + window.removeEventListener('scroll', updateContainerBounds) + } + }, [updateContainerBounds]) + + // Suspense 방식으로 데이터 처리 + const [promiseData] = React.use(promises) + const tableData = promiseData + + // 디버깅용 로그 + console.log("PQ Table Data:", { + dataLength: tableData.data?.length, + pageCount: tableData.pageCount, + sampleData: tableData.data?.[0] + }) + + // 초기 설정 정의 (RFQ와 동일한 패턴) + const initialSettings = React.useMemo(() => ({ + page: parseInt(searchParams.get('page') || '1'), + perPage: parseInt(searchParams.get('perPage') || '10'), + sort: searchParams.get('sort') ? JSON.parse(searchParams.get('sort')!) : [{ id: "updatedAt", desc: true }], + filters: searchParams.get('filters') ? JSON.parse(searchParams.get('filters')!) : [], + joinOperator: (searchParams.get('joinOperator') as "and" | "or") || "and", + basicFilters: searchParams.get('basicFilters') || searchParams.get('pqBasicFilters') ? + JSON.parse(searchParams.get('basicFilters') || searchParams.get('pqBasicFilters')!) : [], + basicJoinOperator: (searchParams.get('basicJoinOperator') as "and" | "or") || "and", + search: searchParams.get('search') || '', + from: searchParams.get('from') || undefined, + to: searchParams.get('to') || undefined, + columnVisibility: {}, + columnOrder: [], + pinnedColumns: { left: [], right: ["actions"] }, // PQ는 actions를 오른쪽에 고정 + groupBy: [], + expandedRows: [] + }), [searchParams]) + + // DB 기반 프리셋 훅 사용 + const { + presets, + activePresetId, + hasUnsavedChanges, + isLoading: presetsLoading, + createPreset, + applyPreset, + updatePreset, + deletePreset, + setDefaultPreset, + renamePreset, + updateClientState, + getCurrentSettings, + } = useTablePresets<PQSubmission>('pq-submissions-table', initialSettings) + + const columns = React.useMemo( + () => getColumns({ setRowAction, router }), + [setRowAction, router] + ) + + // PQ 제출 필터링을 위한 필드 정의 + const filterFields: DataTableFilterField<PQSubmission>[] = [ + { id: "vendorName", label: "협력업체" }, + { id: "projectName", label: "프로젝트" }, + { id: "status", label: "상태" }, + ] + + // 고급 필터 필드 정의 + const advancedFilterFields: DataTableAdvancedFilterField<PQSubmission>[] = [ + { id: "requesterName", label: "요청자명", type: "text" }, + { id: "pqNumber", label: "PQ 번호", type: "text" }, + { id: "vendorName", label: "협력업체명", type: "text" }, + { id: "vendorCode", label: "협력업체 코드", type: "text" }, + { id: "type", label: "PQ 유형", type: "select", options: [ + { label: "일반 PQ", value: "GENERAL" }, + { label: "프로젝트 PQ", value: "PROJECT" }, + ]}, + { id: "projectName", label: "프로젝트명", type: "text" }, + { id: "status", label: "PQ 상태", type: "select", options: [ + { label: "요청됨", value: "REQUESTED" }, + { label: "진행 중", value: "IN_PROGRESS" }, + { label: "제출됨", value: "SUBMITTED" }, + { label: "승인됨", value: "APPROVED" }, + { label: "거부됨", value: "REJECTED" }, + ]}, + { id: "evaluationResult", label: "평가 결과", type: "select", options: [ + { label: "승인", value: "APPROVED" }, + { label: "보완", value: "SUPPLEMENT" }, + { label: "불가", value: "REJECTED" }, + ]}, + { id: "createdAt", label: "생성일", type: "date" }, + { id: "submittedAt", label: "제출일", type: "date" }, + { id: "approvedAt", label: "승인일", type: "date" }, + { id: "rejectedAt", label: "거부일", type: "date" }, + ] + + // 현재 설정 가져오기 + const currentSettings = useMemo(() => { + return getCurrentSettings() + }, [getCurrentSettings]) + + // useDataTable 초기 상태 설정 (RFQ와 동일한 패턴) + const initialState = useMemo(() => { + return { + sorting: initialSettings.sort.filter(sortItem => { + const columnExists = columns.some(col => col.accessorKey === sortItem.id) + return columnExists + }) as any, + columnVisibility: currentSettings.columnVisibility, + columnPinning: currentSettings.pinnedColumns, + } + }, [currentSettings, initialSettings.sort, columns]) + + const { table } = useDataTable({ + data: tableData.data, + columns, + pageCount: tableData.pageCount, + rowCount: tableData.total || tableData.data.length, // total 추가 + filterFields, // RFQ와 달리 빈 배열이 아닌 실제 필터 필드 사용 + enablePinning: true, + enableAdvancedFilter: true, + initialState, + getRowId: (originalRow) => String(originalRow.id), + shallow: false, + clearOnDefault: true, + }) + + // 조회 버튼 클릭 핸들러 - PQFilterSheet에 전달 + const handleSearch = () => { + // Close the panel after search + setIsFilterPanelOpen(false) + } + + // Get active basic filter count + const getActiveBasicFilterCount = () => { + try { + const basicFilters = searchParams.get('basicFilters') || searchParams.get('pqBasicFilters') + return basicFilters ? JSON.parse(basicFilters).length : 0 + } catch (e) { + return 0 + } + } + + // Filter panel width + const FILTER_PANEL_WIDTH = 400; + + return ( + <> + {/* Filter Panel - fixed positioning으로 화면 최대 좌측에서 시작 */} + <div + className={cn( + "fixed left-0 bg-background border-r z-50 flex flex-col transition-all duration-300 ease-in-out overflow-hidden", + isFilterPanelOpen ? "border-r shadow-lg" : "border-r-0" + )} + style={{ + width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px', + top: `${containerTop}px`, + height: `calc(100vh - ${containerTop}px)` + }} + > + {/* Filter Content */} + <div className="h-full"> + <PQFilterSheet + isOpen={isFilterPanelOpen} + onClose={() => setIsFilterPanelOpen(false)} + onSearch={handleSearch} + isLoading={false} + /> + </div> + </div> + + {/* Main Content Container */} + <div + ref={containerRef} + className={cn("relative w-full overflow-hidden", className)} + > + <div className="flex w-full h-full"> + {/* Main Content - 너비 조정으로 필터 패널 공간 확보 */} + <div + className="flex flex-col min-w-0 overflow-hidden transition-all duration-300 ease-in-out" + style={{ + width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : '100%', + marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px' + }} + > + {/* Header Bar */} + <div className="flex items-center justify-between p-4 bg-background shrink-0"> + <div className="flex items-center gap-3"> + <Button + variant="outline" + size="sm" + type='button' + onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)} + className="flex items-center shadow-sm" + > + {isFilterPanelOpen ? <PanelLeftClose className="size-4"/> : <PanelLeftOpen className="size-4"/>} + {getActiveBasicFilterCount() > 0 && ( + <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs"> + {getActiveBasicFilterCount()} + </span> + )} + </Button> + </div> + + {/* Right side info */} + <div className="text-sm text-muted-foreground"> + {tableData && ( + <span>총 {tableData.total || tableData.data.length}건</span> + )} + </div> + </div> + + {/* Table Content Area */} + <div className="flex-1 overflow-hidden" style={{ height: 'calc(100vh - 380px)' }}> + <div className="h-full w-full"> + <DataTable table={table} className="h-full"> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <div className="flex items-center gap-2"> + {/* DB 기반 테이블 프리셋 매니저 추가 */} + <TablePresetManager<PQSubmission> + presets={presets} + activePresetId={activePresetId} + currentSettings={currentSettings} + hasUnsavedChanges={hasUnsavedChanges} + isLoading={presetsLoading} + onCreatePreset={createPreset} + onUpdatePreset={updatePreset} + onDeletePreset={deletePreset} + onApplyPreset={applyPreset} + onSetDefaultPreset={setDefaultPreset} + onRenamePreset={renamePreset} + /> + + {/* 기존 툴바 액션들 */} + <VendorsTableToolbarActions table={table} /> + </div> + </DataTableAdvancedToolbar> + </DataTable> + </div> + </div> + </div> + </div> + </div> + </> + ) +}
\ No newline at end of file diff --git a/lib/pq/service.ts b/lib/pq/service.ts index 6159a307..18d1a5d3 100644 --- a/lib/pq/service.ts +++ b/lib/pq/service.ts @@ -1,14 +1,14 @@ "use server" import db from "@/db/db" -import { GetPQSchema } from "./validations" +import { GetPQSchema, GetPQSubmissionsSchema } from "./validations" import { unstable_cache } from "@/lib/unstable-cache"; import { filterColumns } from "@/lib/filter-columns"; import { getErrorMessage } from "@/lib/handle-error"; import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq, count,isNull,SQL} from "drizzle-orm"; import { z } from "zod" import { revalidateTag, unstable_noStore, revalidatePath} from "next/cache"; -import { pqCriterias, pqCriteriasExtension, vendorCriteriaAttachments, vendorPqCriteriaAnswers, vendorPqReviewLogs, vendorProjectPQs } from "@/db/schema/pq" +import { pqCriterias, pqCriteriasExtension, vendorCriteriaAttachments, vendorInvestigations, vendorPQSubmissions, vendorPqCriteriaAnswers, vendorPqReviewLogs, vendorProjectPQs } from "@/db/schema/pq" import { countPqs, selectPqs } from "./repository"; import { sendEmail } from "../mail/sendEmail"; import { vendorAttachments, vendors } from "@/db/schema/vendors"; @@ -18,8 +18,12 @@ import { randomUUID } from 'crypto'; import { writeFile, mkdir } from 'fs/promises'; import { GetVendorsSchema } from "../vendors/validations"; import { countVendors, selectVendors } from "../vendors/repository"; -import { projects } from "@/db/schema"; +import { projects, users } from "@/db/schema"; import { headers } from 'next/headers'; +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" +import { alias } from 'drizzle-orm/pg-core'; +import { createPQFilterMapping, getPQJoinedTables } from "./helper"; /** * PQ 목록 조회 @@ -374,19 +378,19 @@ export interface ProjectPQ { export async function getPQProjectsByVendorId(vendorId: number): Promise<ProjectPQ[]> { const result = await db .select({ - id: vendorProjectPQs.id, - projectId: vendorProjectPQs.projectId, - status: vendorProjectPQs.status, - submittedAt: vendorProjectPQs.submittedAt, + id: vendorPQSubmissions.id, + projectId: vendorPQSubmissions.projectId, + status: vendorPQSubmissions.status, + submittedAt: vendorPQSubmissions.submittedAt, projectCode: projects.code, projectName: projects.name, }) - .from(vendorProjectPQs) + .from(vendorPQSubmissions) .innerJoin( projects, - eq(vendorProjectPQs.projectId, projects.id) + eq(vendorPQSubmissions.projectId, projects.id) ) - .where(eq(vendorProjectPQs.vendorId, vendorId)) + .where(eq(vendorPQSubmissions.vendorId, vendorId)) .orderBy(projects.code); return result; @@ -659,10 +663,12 @@ export async function savePQAnswersAction(input: SavePQInput) { */ export async function submitPQAction({ vendorId, - projectId + projectId, + pqSubmissionId }: { vendorId: number; projectId?: number; + pqSubmissionId?: number; // 특정 PQ 제출 ID가 있는 경우 사용 }) { unstable_noStore(); @@ -671,21 +677,21 @@ export async function submitPQAction({ const host = headersList.get('host') || 'localhost:3000'; // 1. 모든 PQ 항목에 대한 응답이 있는지 검증 - const queryConditions = [ + const answerQueryConditions = [ eq(vendorPqCriteriaAnswers.vendorId, vendorId) ]; // Add projectId condition when it exists if (projectId !== undefined) { - queryConditions.push(eq(vendorPqCriteriaAnswers.projectId, projectId)); + answerQueryConditions.push(eq(vendorPqCriteriaAnswers.projectId, projectId)); } else { - queryConditions.push(isNull(vendorPqCriteriaAnswers.projectId)); + answerQueryConditions.push(isNull(vendorPqCriteriaAnswers.projectId)); } const pqCriteriaCount = await db .select({ count: count() }) .from(vendorPqCriteriaAnswers) - .where(and(...queryConditions)); + .where(and(...answerQueryConditions)); const totalPqCriteriaCount = pqCriteriaCount[0]?.count || 0; @@ -724,86 +730,116 @@ export async function submitPQAction({ projectName = projectData?.projectName || 'Unknown Project'; } - // 3. 상태 업데이트 + // 3. 현재 PQ 제출 상태 확인 및 업데이트 const currentDate = new Date(); + let existingSubmission; - if (projectId) { - // 프로젝트별 PQ인 경우 vendorProjectPQs 테이블 업데이트 - const existingProjectPQ = await db - .select({ id: vendorProjectPQs.id, status: vendorProjectPQs.status }) - .from(vendorProjectPQs) + // 특정 PQ Submission ID가 있는 경우 + if (pqSubmissionId) { + existingSubmission = await db + .select({ + id: vendorPQSubmissions.id, + status: vendorPQSubmissions.status, + type: vendorPQSubmissions.type + }) + .from(vendorPQSubmissions) .where( and( - eq(vendorProjectPQs.vendorId, vendorId), - eq(vendorProjectPQs.projectId, projectId) + eq(vendorPQSubmissions.id, pqSubmissionId), + eq(vendorPQSubmissions.vendorId, vendorId) ) ) .then(rows => rows[0]); - if (existingProjectPQ) { - // 프로젝트 PQ 상태가 제출 가능한 상태인지 확인 - const allowedStatuses = ["REQUESTED", "IN_PROGRESS", "REJECTED"]; - - if (!allowedStatuses.includes(existingProjectPQ.status)) { - return { - ok: false, - error: `Cannot submit Project PQ in current status: ${existingProjectPQ.status}` - }; - } - - // Update existing project PQ status - await db - .update(vendorProjectPQs) - .set({ - status: "SUBMITTED", - submittedAt: currentDate, - updatedAt: currentDate, - }) - .where(eq(vendorProjectPQs.id, existingProjectPQ.id)); + if (!existingSubmission) { + return { ok: false, error: "PQ submission not found or access denied" }; + } + } + // ID가 없는 경우 vendorId와 projectId로 조회 + else { + const pqType = projectId ? "PROJECT" : "GENERAL"; + + const submissionQueryConditions = [ + eq(vendorPQSubmissions.vendorId, vendorId), + eq(vendorPQSubmissions.type, pqType) + ]; + + if (projectId) { + submissionQueryConditions.push(eq(vendorPQSubmissions.projectId, projectId)); } else { - // Project PQ entry doesn't exist, create one - await db - .insert(vendorProjectPQs) - .values({ - vendorId, - projectId, - status: "SUBMITTED", - submittedAt: currentDate, - createdAt: currentDate, - updatedAt: currentDate, - }); + submissionQueryConditions.push(isNull(vendorPQSubmissions.projectId)); } - } else { - // 일반 PQ인 경우 협력업체 상태 검증 및 업데이트 - const allowedStatuses = ["IN_PQ", "PENDING_REVIEW", "IN_REVIEW", "REJECTED", "PQ_FAILED"]; - if (!allowedStatuses.includes(vendor.status)) { + existingSubmission = await db + .select({ + id: vendorPQSubmissions.id, + status: vendorPQSubmissions.status, + type: vendorPQSubmissions.type + }) + .from(vendorPQSubmissions) + .where(and(...submissionQueryConditions)) + .then(rows => rows[0]); + } + + // 제출 가능한 상태 확인 + const allowedStatuses = ["REQUESTED", "IN_PROGRESS", "REJECTED"]; + + if (existingSubmission) { + if (!allowedStatuses.includes(existingSubmission.status)) { return { ok: false, - error: `Cannot submit PQ in current status: ${vendor.status}` + error: `Cannot submit PQ in current status: ${existingSubmission.status}` }; } - // Update vendor status + // 기존 제출 상태 업데이트 await db - .update(vendors) + .update(vendorPQSubmissions) .set({ - status: "PQ_SUBMITTED", + status: "SUBMITTED", + submittedAt: currentDate, updatedAt: currentDate, }) - .where(eq(vendors.id, vendorId)); + .where(eq(vendorPQSubmissions.id, existingSubmission.id)); + } else { + // PQ Submission ID가 없고 기존 submission도 없는 경우 새로운 제출 생성 + const pqType = projectId ? "PROJECT" : "GENERAL"; + await db + .insert(vendorPQSubmissions) + .values({ + vendorId, + projectId: projectId || null, + type: pqType, + status: "SUBMITTED", + submittedAt: currentDate, + createdAt: currentDate, + updatedAt: currentDate, + }); } + + // 4. 일반 PQ인 경우 벤더 상태도 업데이트 + if (!projectId) { + const allowedVendorStatuses = ["IN_PQ", "PENDING_REVIEW", "IN_REVIEW", "REJECTED", "PQ_FAILED"]; - // 4. 관리자에게 이메일 알림 발송 + if (allowedVendorStatuses.includes(vendor.status)) { + await db + .update(vendors) + .set({ + status: "PQ_SUBMITTED", + updatedAt: currentDate, + }) + .where(eq(vendors.id, vendorId)); + } + } + + // 5. 관리자에게 이메일 알림 발송 if (process.env.ADMIN_EMAIL) { try { const emailSubject = projectId ? `[eVCP] Project PQ Submitted: ${vendor.vendorName} for ${projectName}` - : `[eVCP] PQ Submitted: ${vendor.vendorName}`; + : `[eVCP] General PQ Submitted: ${vendor.vendorName}`; - const adminUrl = projectId - ? `http://${host}/evcp/pq/${vendorId}?projectId=${projectId}` - : `http://${host}/evcp/pq/${vendorId}`; + const adminUrl = `http://${host}/evcp/pq/${vendorId}/${existingSubmission?.id || ''}`; await sendEmail({ to: process.env.ADMIN_EMAIL, @@ -821,18 +857,17 @@ export async function submitPQAction({ }); } catch (emailError) { console.error("Failed to send admin notification:", emailError); - // 이메일 실패는 전체 프로세스를 중단하지 않음 } } - // 5. 벤더에게 확인 이메일 발송 + // 6. 벤더에게 확인 이메일 발송 if (vendor.email) { try { const emailSubject = projectId ? `[eVCP] Project PQ Submission Confirmation for ${projectName}` - : "[eVCP] PQ Submission Confirmation"; + : "[eVCP] General PQ Submission Confirmation"; - const portalUrl = `${host}/dashboard`; + const portalUrl = `${host}/partners/pq`; await sendEmail({ to: vendor.email, @@ -849,16 +884,16 @@ export async function submitPQAction({ }); } catch (emailError) { console.error("Failed to send vendor confirmation:", emailError); - // 이메일 실패는 전체 프로세스를 중단하지 않음 } } - // 6. 캐시 무효화 + // 7. 캐시 무효화 revalidateTag("vendors"); revalidateTag("vendor-status-counts"); + revalidateTag(`vendor-pq-submissions-${vendorId}`); if (projectId) { - revalidateTag(`vendor-project-pqs-${vendorId}`); + revalidateTag(`project-pq-submissions-${projectId}`); revalidateTag(`project-vendors-${projectId}`); revalidateTag(`project-pq-${projectId}`); } @@ -1702,4 +1737,1146 @@ export async function loadProjectPQAction(vendorId: number, projectId?: number): throw new Error("Project ID is required for loading project PQ data"); } return getPQDataByVendorId(vendorId, projectId); +} + + + +export async function getAllPQsByVendorId(vendorId: number) { + try { + const pqList = await db + .select({ + id: vendorPQSubmissions.id, + type: vendorPQSubmissions.type, + status: vendorPQSubmissions.status, + projectId: vendorPQSubmissions.projectId, + projectName: projects.name, + createdAt: vendorPQSubmissions.createdAt, + updatedAt: vendorPQSubmissions.updatedAt, + submittedAt: vendorPQSubmissions.submittedAt, + approvedAt: vendorPQSubmissions.approvedAt, + rejectedAt: vendorPQSubmissions.rejectedAt, + rejectReason: vendorPQSubmissions.rejectReason, + }) + .from(vendorPQSubmissions) + .leftJoin(projects, eq(vendorPQSubmissions.projectId, projects.id)) + .where(eq(vendorPQSubmissions.vendorId, vendorId)) + .orderBy(desc(vendorPQSubmissions.createdAt)); + + return pqList; + } catch (error) { + console.error("Error fetching PQ list:", error); + return []; + } +} + +// 특정 PQ의 상세 정보 조회 (개별 PQ 페이지용) +export async function getPQById(pqSubmissionId: number, vendorId: number) { + try { + const pq = await db + .select({ + id: vendorPQSubmissions.id, + vendorId: vendorPQSubmissions.vendorId, + projectId: vendorPQSubmissions.projectId, + type: vendorPQSubmissions.type, + status: vendorPQSubmissions.status, + createdAt: vendorPQSubmissions.createdAt, + submittedAt: vendorPQSubmissions.submittedAt, + approvedAt: vendorPQSubmissions.approvedAt, + rejectedAt: vendorPQSubmissions.rejectedAt, + rejectReason: vendorPQSubmissions.rejectReason, + + // 벤더 정보 (추가) + vendorName: vendors.vendorName, + vendorCode: vendors.vendorCode, + vendorStatus: vendors.status, + + // 프로젝트 정보 (조인) + projectName: projects.name, + projectCode: projects.code, + }) + .from(vendorPQSubmissions) + .leftJoin(vendors, eq(vendorPQSubmissions.vendorId, vendors.id)) + .leftJoin(projects, eq(vendorPQSubmissions.projectId, projects.id)) + .where( + and( + eq(vendorPQSubmissions.id, pqSubmissionId), + eq(vendorPQSubmissions.vendorId, vendorId) + ) + ) + .limit(1) + .then(rows => rows[0]); + + if (!pq) { + throw new Error("PQ not found or access denied"); + } + + return pq; + } catch (error) { + console.error("Error fetching PQ by ID:", error); + throw error; + } +} + +export async function getPQStatusCounts(vendorId: number) { + try { + // 모든 PQ 상태 조회 (일반 PQ + 프로젝트 PQ) + const pqStatuses = await db + .select({ + status: vendorPQSubmissions.status, + count: count(), + }) + .from(vendorPQSubmissions) + .where(eq(vendorPQSubmissions.vendorId, vendorId)) + .groupBy(vendorPQSubmissions.status); + + // 상태별 개수를 객체로 변환 + const statusCounts = { + REQUESTED: 0, + IN_PROGRESS: 0, + SUBMITTED: 0, + APPROVED: 0, + REJECTED: 0, + }; + + // 조회된 결과를 statusCounts 객체에 매핑 + pqStatuses.forEach((item) => { + if (item.status in statusCounts) { + statusCounts[item.status as keyof typeof statusCounts] = item.count; + } + }); + + return statusCounts; + } catch (error) { + console.error("Error fetching PQ status counts:", error); + return { + REQUESTED: 0, + IN_PROGRESS: 0, + SUBMITTED: 0, + APPROVED: 0, + REJECTED: 0, + }; + } +} + +// 상태 레이블 함수 +function getStatusLabel(status: string): string { + switch (status) { + case "REQUESTED": + return "요청됨"; + case "IN_PROGRESS": + return "진행 중"; + case "SUBMITTED": + return "제출됨"; + case "APPROVED": + return "승인됨"; + case "REJECTED": + return "거부됨"; + default: + return status; + } +} + +export async function getPQSubmissions(input: GetPQSubmissionsSchema) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + const pqFilterMapping = createPQFilterMapping(); + const joinedTables = getPQJoinedTables(); + + console.log(input, "input") + + // 1) 고급 필터 조건 (DataTableAdvancedToolbar에서) + let advancedWhere: SQL<unknown> | undefined = undefined; + if (input.filters && input.filters.length > 0) { + advancedWhere = filterColumns({ + table: vendorPQSubmissions, + filters: input.filters, + joinOperator: input.joinOperator || 'and', + joinedTables, + customColumnMapping: pqFilterMapping, + }); + console.log("advancedWhere:", advancedWhere); + } + + // 2) 기본 필터 조건 (PQFilterSheet에서) + let basicWhere: SQL<unknown> | undefined = undefined; + if (input.basicFilters && input.basicFilters.length > 0) { + basicWhere = filterColumns({ + table: vendorPQSubmissions, + filters: input.basicFilters, + joinOperator: input.basicJoinOperator || 'and', + joinedTables, + customColumnMapping: pqFilterMapping, + }); + console.log("basicWhere:", basicWhere); + } + + // 3) 글로벌 검색 조건 + let globalWhere: SQL<unknown> | undefined = undefined; + if (input.search) { + const s = `%${input.search}%`; + + const validSearchConditions: SQL<unknown>[] = []; + + // 기존 검색 조건들 + const nameCondition = ilike(vendors.vendorName, s); + if (nameCondition) validSearchConditions.push(nameCondition); + + const codeCondition = ilike(vendors.vendorCode, s); + if (codeCondition) validSearchConditions.push(codeCondition); + + const projectNameCondition = ilike(projects.name, s); + if (projectNameCondition) validSearchConditions.push(projectNameCondition); + + const projectCodeCondition = ilike(projects.code, s); + if (projectCodeCondition) validSearchConditions.push(projectCodeCondition); + + // 새로 추가된 검색 조건들 + const pqNumberCondition = ilike(vendorPQSubmissions.pqNumber, s); + if (pqNumberCondition) validSearchConditions.push(pqNumberCondition); + + const requesterCondition = ilike(users.name, s); + if (requesterCondition) validSearchConditions.push(requesterCondition); + + if (validSearchConditions.length > 0) { + globalWhere = or(...validSearchConditions); + } + } + + // 4) 날짜 조건 + let fromDateWhere: SQL<unknown> | undefined = undefined; + let toDateWhere: SQL<unknown> | undefined = undefined; + + if (input.submittedDateFrom) { + const fromDate = new Date(input.submittedDateFrom); + const condition = gte(vendorPQSubmissions.submittedAt, fromDate); + if (condition) fromDateWhere = condition; + } + + if (input.submittedDateTo) { + const toDate = new Date(input.submittedDateTo); + const condition = lte(vendorPQSubmissions.submittedAt, toDate); + if (condition) toDateWhere = condition; + } + + // 5) 최종 WHERE 조건 생성 - 각 그룹을 AND로 연결 + const whereConditions: SQL<unknown>[] = []; + + // 고급 필터 조건 추가 + if (advancedWhere) whereConditions.push(advancedWhere); + + // 기본 필터 조건 추가 + if (basicWhere) whereConditions.push(basicWhere); + + // 기타 조건들 추가 + if (globalWhere) whereConditions.push(globalWhere); + if (fromDateWhere) whereConditions.push(fromDateWhere); + if (toDateWhere) whereConditions.push(toDateWhere); + + // 모든 조건을 AND로 연결 + const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined; + + console.log("Final WHERE conditions:", { + advancedWhere: !!advancedWhere, + basicWhere: !!basicWhere, + globalWhere: !!globalWhere, + dateConditions: !!(fromDateWhere || toDateWhere), + totalConditions: whereConditions.length + }); + + // 6) 전체 데이터 수 조회 + const totalResult = await db + .select({ count: count() }) + .from(vendorPQSubmissions) + .leftJoin(vendors, eq(vendorPQSubmissions.vendorId, vendors.id)) + .leftJoin(projects, eq(vendorPQSubmissions.projectId, projects.id)) + .leftJoin(users, eq(vendorPQSubmissions.requesterId, users.id)) + .leftJoin(vendorInvestigations, eq(vendorInvestigations.pqSubmissionId, vendorPQSubmissions.id)) + .where(finalWhere); + + const total = totalResult[0]?.count || 0; + + if (total === 0) { + return { data: [], pageCount: 0 }; + } + + // 7) 정렬 및 페이징 처리된 데이터 조회 + const orderByColumns = input.sort.map((sort) => { + const column = sort.id as keyof typeof vendorPQSubmissions.$inferSelect; + return sort.desc ? desc(vendorPQSubmissions[column]) : asc(vendorPQSubmissions[column]); + }); + + if (orderByColumns.length === 0) { + orderByColumns.push(desc(vendorPQSubmissions.updatedAt)); + } + + const pqSubmissions = await db + .select({ + id: vendorPQSubmissions.id, + type: vendorPQSubmissions.type, + pqNumber: vendorPQSubmissions.pqNumber, + requesterId: vendorPQSubmissions.requesterId, + requesterName: users.name, + status: vendorPQSubmissions.status, + createdAt: vendorPQSubmissions.createdAt, + updatedAt: vendorPQSubmissions.updatedAt, + submittedAt: vendorPQSubmissions.submittedAt, + approvedAt: vendorPQSubmissions.approvedAt, + rejectedAt: vendorPQSubmissions.rejectedAt, + rejectReason: vendorPQSubmissions.rejectReason, + // Vendor 정보 + vendorId: vendors.id, + vendorName: vendors.vendorName, + vendorCode: vendors.vendorCode, + taxId: vendors.taxId, + vendorStatus: vendors.status, + // Project 정보 (프로젝트 PQ인 경우) + projectId: projects.id, + projectName: projects.name, + projectCode: projects.code, + }) + .from(vendorPQSubmissions) + .leftJoin(vendors, eq(vendorPQSubmissions.vendorId, vendors.id)) + .leftJoin(projects, eq(vendorPQSubmissions.projectId, projects.id)) + .leftJoin(users, eq(vendorPQSubmissions.requesterId, users.id)) + .where(finalWhere) + .orderBy(...orderByColumns) + .limit(input.perPage) + .offset(offset); + + // 8) 각 PQ 제출에 대한 추가 정보 조회 (기존과 동일) + const pqSubmissionsWithDetails = await Promise.all( + pqSubmissions.map(async (submission) => { + // 기본 반환 객체 + const baseResult = { + ...submission, + answerCount: 0, + attachmentCount: 0, + pqStatus: getStatusLabel(submission.status), + pqTypeLabel: submission.type === "GENERAL" ? "일반 PQ" : "프로젝트 PQ", + }; + + // vendorId가 null이면 기본 정보만 반환 + if (submission.vendorId === null) { + return baseResult; + } + + try { + // 답변 수 조회 + const vendorId = submission.vendorId; + + const answerWhereConditions: SQL<unknown>[] = []; + + const vendorCondition = eq(vendorPqCriteriaAnswers.vendorId, vendorId); + if (vendorCondition) answerWhereConditions.push(vendorCondition); + + let projectCondition: SQL<unknown> | undefined; + if (submission.projectId !== null) { + projectCondition = eq(vendorPqCriteriaAnswers.projectId, submission.projectId); + } else { + projectCondition = isNull(vendorPqCriteriaAnswers.projectId); + } + + if (projectCondition) answerWhereConditions.push(projectCondition); + + const answerWhere = and(...answerWhereConditions); + + const answersResult = await db + .select({ count: count() }) + .from(vendorPqCriteriaAnswers) + .where(answerWhere); + + const answerCount = answersResult[0]?.count || 0; + + // 첨부 파일 수 조회 + const attachmentsResult = await db + .select({ count: count() }) + .from(vendorPqCriteriaAnswers) + .leftJoin( + vendorCriteriaAttachments, + eq(vendorCriteriaAttachments.vendorCriteriaAnswerId, vendorPqCriteriaAnswers.id) + ) + .where(answerWhere); + + const attachmentCount = attachmentsResult[0]?.count || 0; + + const requesters = alias(users, 'requesters'); + const qmManagers = alias(users, 'qmManagers'); + + const investigationResult = await db + .select({ + id: vendorInvestigations.id, + investigationStatus: vendorInvestigations.investigationStatus, + evaluationType: vendorInvestigations.evaluationType, + investigationAddress: vendorInvestigations.investigationAddress, + investigationMethod: vendorInvestigations.investigationMethod, + scheduledStartAt: vendorInvestigations.scheduledStartAt, + scheduledEndAt: vendorInvestigations.scheduledEndAt, + requestedAt: vendorInvestigations.requestedAt, + confirmedAt: vendorInvestigations.confirmedAt, + completedAt: vendorInvestigations.completedAt, + forecastedAt: vendorInvestigations.forecastedAt, + evaluationScore: vendorInvestigations.evaluationScore, + evaluationResult: vendorInvestigations.evaluationResult, + investigationNotes: vendorInvestigations.investigationNotes, + requesterId: vendorInvestigations.requesterId, + requesterName: requesters.name, + qmManagerId: vendorInvestigations.qmManagerId, + qmManagerName: qmManagers.name, + qmManagerEmail: qmManagers.email, + }) + .from(vendorInvestigations) + .leftJoin(requesters, eq(vendorInvestigations.requesterId, requesters.id)) + .leftJoin(qmManagers, eq(vendorInvestigations.qmManagerId, qmManagers.id)) + .where(and( + eq(vendorInvestigations.vendorId, submission.vendorId), + eq(vendorInvestigations.pqSubmissionId, submission.id) + )) + .orderBy(desc(vendorInvestigations.createdAt)) + .limit(1); + + const investigation = investigationResult[0] || null; + + return { + ...baseResult, + answerCount, + attachmentCount, + investigation + }; + } catch (error) { + console.error("Error fetching PQ details:", error); + return baseResult; + } + }) + ); + + const pageCount = Math.ceil(total / input.perPage); + + return { data: pqSubmissionsWithDetails, pageCount }; + } catch (err) { + console.error("Error in getPQSubmissions:", err); + return { data: [], pageCount: 0 }; + } + }, + [JSON.stringify(input)], // 캐싱 키 + { + revalidate: 3600, + tags: ["pq-submissions"], // revalidateTag 호출 시 무효화 + } + )(); +} + +export async function getPQStatusCountsAll() { + try { + // 모든 PQ 상태별 개수 조회 (벤더 제한 없음) + const pqStatuses = await db + .select({ + status: vendorPQSubmissions.status, + count: count(), + }) + .from(vendorPQSubmissions) + .groupBy(vendorPQSubmissions.status); + + // 상태별 개수를 객체로 변환 + const statusCounts = { + REQUESTED: 0, + IN_PROGRESS: 0, + SUBMITTED: 0, + APPROVED: 0, + REJECTED: 0, + }; + + // 조회된 결과를 statusCounts 객체에 매핑 + pqStatuses.forEach((item) => { + if (item.status in statusCounts) { + statusCounts[item.status as keyof typeof statusCounts] = item.count; + } + }); + + return statusCounts; + } catch (error) { + console.error("Error fetching PQ status counts:", error); + return { + REQUESTED: 0, + IN_PROGRESS: 0, + SUBMITTED: 0, + APPROVED: 0, + REJECTED: 0, + }; + } +} + +// PQ 타입별, 상태별 개수 집계 함수 (추가 옵션) +export async function getPQDetailedStatusCounts() { + try { + // 타입별, 상태별 개수 조회 + const pqStatuses = await db + .select({ + type: vendorPQSubmissions.type, + status: vendorPQSubmissions.status, + count: count(), + }) + .from(vendorPQSubmissions) + .groupBy(vendorPQSubmissions.type, vendorPQSubmissions.status); + + // 결과를 저장할 객체 초기화 + const result = { + GENERAL: { + REQUESTED: 0, + IN_PROGRESS: 0, + SUBMITTED: 0, + APPROVED: 0, + REJECTED: 0, + total: 0 + }, + PROJECT: { + REQUESTED: 0, + IN_PROGRESS: 0, + SUBMITTED: 0, + APPROVED: 0, + REJECTED: 0, + total: 0 + }, + total: { + REQUESTED: 0, + IN_PROGRESS: 0, + SUBMITTED: 0, + APPROVED: 0, + REJECTED: 0, + total: 0 + } + }; + + // 결과 매핑 + pqStatuses.forEach((item) => { + if (item.type && item.status) { + const type = item.type as keyof typeof result; + const status = item.status as keyof typeof result.GENERAL; + + if (type in result && status in result[type]) { + // 타입별 상태 카운트 업데이트 + result[type][status] = item.count; + + // 타입별 합계 업데이트 + result[type].total += item.count; + + // 전체 상태별 카운트 업데이트 + result.total[status] += item.count; + + // 전체 합계 업데이트 + result.total.total += item.count; + } + } + }); + + return result; + } catch (error) { + console.error("Error fetching detailed PQ status counts:", error); + return { + GENERAL: { + REQUESTED: 0, + IN_PROGRESS: 0, + SUBMITTED: 0, + APPROVED: 0, + REJECTED: 0, + total: 0 + }, + PROJECT: { + REQUESTED: 0, + IN_PROGRESS: 0, + SUBMITTED: 0, + APPROVED: 0, + REJECTED: 0, + total: 0 + }, + total: { + REQUESTED: 0, + IN_PROGRESS: 0, + SUBMITTED: 0, + APPROVED: 0, + REJECTED: 0, + total: 0 + } + }; + } +} + +// PQ 승인 액션 +export async function approvePQAction({ + pqSubmissionId, + vendorId, +}: { + pqSubmissionId: number; + vendorId: number; +}) { + unstable_noStore(); + + try { + const headersList = await headers(); + const host = headersList.get('host') || 'localhost:3000'; + const currentDate = new Date(); + + // 1. PQ 제출 정보 조회 + const pqSubmission = await db + .select({ + id: vendorPQSubmissions.id, + vendorId: vendorPQSubmissions.vendorId, + projectId: vendorPQSubmissions.projectId, + type: vendorPQSubmissions.type, + status: vendorPQSubmissions.status, + }) + .from(vendorPQSubmissions) + .where( + and( + eq(vendorPQSubmissions.id, pqSubmissionId), + eq(vendorPQSubmissions.vendorId, vendorId) + ) + ) + .then(rows => rows[0]); + + if (!pqSubmission) { + return { ok: false, error: "PQ submission not found" }; + } + + // 2. 상태 확인 (SUBMITTED 상태만 승인 가능) + if (pqSubmission.status !== "SUBMITTED") { + return { + ok: false, + error: `Cannot approve PQ in current status: ${pqSubmission.status}` + }; + } + + // 3. 벤더 정보 조회 + const vendor = await db + .select({ + id: vendors.id, + vendorName: vendors.vendorName, + email: vendors.email, + status: vendors.status, + }) + .from(vendors) + .where(eq(vendors.id, vendorId)) + .then(rows => rows[0]); + + if (!vendor) { + return { ok: false, error: "Vendor not found" }; + } + + // 4. 프로젝트 정보 (프로젝트 PQ인 경우) + let projectName = ''; + if (pqSubmission.projectId) { + const projectData = await db + .select({ + id: projects.id, + name: projects.name, + }) + .from(projects) + .where(eq(projects.id, pqSubmission.projectId)) + .then(rows => rows[0]); + + projectName = projectData?.name || 'Unknown Project'; + } + + // 5. PQ 상태 업데이트 + await db + .update(vendorPQSubmissions) + .set({ + status: "APPROVED", + approvedAt: currentDate, + updatedAt: currentDate, + }) + .where(eq(vendorPQSubmissions.id, pqSubmissionId)); + + // 6. 일반 PQ인 경우 벤더 상태 업데이트 (선택사항) + if (pqSubmission.type === "GENERAL") { + await db + .update(vendors) + .set({ + status: "PQ_APPROVED", + updatedAt: currentDate, + }) + .where(eq(vendors.id, vendorId)); + } + + // 7. 벤더에게 이메일 알림 발송 + if (vendor.email) { + try { + const emailSubject = pqSubmission.projectId + ? `[eVCP] Project PQ Approved for ${projectName}` + : "[eVCP] General PQ Approved"; + + const portalUrl = `${host}/partners/pq`; + + await sendEmail({ + to: vendor.email, + subject: emailSubject, + template: "pq-approved-vendor", + context: { + vendorName: vendor.vendorName, + projectId: pqSubmission.projectId, + projectName: projectName, + isProjectPQ: !!pqSubmission.projectId, + approvedDate: currentDate.toLocaleString(), + portalUrl, + } + }); + } catch (emailError) { + console.error("Failed to send vendor notification:", emailError); + // 이메일 발송 실패가 전체 프로세스를 중단하지 않음 + } + } + + // 8. 캐시 무효화 + revalidateTag("vendors"); + revalidateTag("vendor-status-counts"); + revalidateTag("pq-submissions"); + revalidateTag(`vendor-pq-submissions-${vendorId}`); + + if (pqSubmission.projectId) { + revalidateTag(`project-pq-submissions-${pqSubmission.projectId}`); + revalidateTag(`project-vendors-${pqSubmission.projectId}`); + } + + return { ok: true }; + } catch (error) { + console.error("PQ approve error:", error); + return { ok: false, error: getErrorMessage(error) }; + } +} + +// PQ 거부 액션 +export async function rejectPQAction({ + pqSubmissionId, + vendorId, + rejectReason +}: { + pqSubmissionId: number; + vendorId: number; + rejectReason: string; +}) { + unstable_noStore(); + + try { + const headersList = await headers(); + const host = headersList.get('host') || 'localhost:3000'; + const currentDate = new Date(); + + // 1. PQ 제출 정보 조회 + const pqSubmission = await db + .select({ + id: vendorPQSubmissions.id, + vendorId: vendorPQSubmissions.vendorId, + projectId: vendorPQSubmissions.projectId, + type: vendorPQSubmissions.type, + status: vendorPQSubmissions.status, + }) + .from(vendorPQSubmissions) + .where( + and( + eq(vendorPQSubmissions.id, pqSubmissionId), + eq(vendorPQSubmissions.vendorId, vendorId) + ) + ) + .then(rows => rows[0]); + + if (!pqSubmission) { + return { ok: false, error: "PQ submission not found" }; + } + + // 2. 상태 확인 (SUBMITTED 상태만 거부 가능) + if (pqSubmission.status !== "SUBMITTED") { + return { + ok: false, + error: `Cannot reject PQ in current status: ${pqSubmission.status}` + }; + } + + // 3. 벤더 정보 조회 + const vendor = await db + .select({ + id: vendors.id, + vendorName: vendors.vendorName, + email: vendors.email, + status: vendors.status, + }) + .from(vendors) + .where(eq(vendors.id, vendorId)) + .then(rows => rows[0]); + + if (!vendor) { + return { ok: false, error: "Vendor not found" }; + } + + // 4. 프로젝트 정보 (프로젝트 PQ인 경우) + let projectName = ''; + if (pqSubmission.projectId) { + const projectData = await db + .select({ + id: projects.id, + name: projects.name, + }) + .from(projects) + .where(eq(projects.id, pqSubmission.projectId)) + .then(rows => rows[0]); + + projectName = projectData?.name || 'Unknown Project'; + } + + // 5. PQ 상태 업데이트 + await db + .update(vendorPQSubmissions) + .set({ + status: "REJECTED", + rejectedAt: currentDate, + rejectReason: rejectReason, + updatedAt: currentDate, + }) + .where(eq(vendorPQSubmissions.id, pqSubmissionId)); + + // 6. 일반 PQ인 경우 벤더 상태 업데이트 (선택사항) + if (pqSubmission.type === "GENERAL") { + await db + .update(vendors) + .set({ + status: "PQ_FAILED", + updatedAt: currentDate, + }) + .where(eq(vendors.id, vendorId)); + } + + // 7. 벤더에게 이메일 알림 발송 + if (vendor.email) { + try { + const emailSubject = pqSubmission.projectId + ? `[eVCP] Project PQ Rejected for ${projectName}` + : "[eVCP] General PQ Rejected"; + + const portalUrl = `${host}/partners/pq`; + + await sendEmail({ + to: vendor.email, + subject: emailSubject, + template: "pq-rejected-vendor", + context: { + vendorName: vendor.vendorName, + projectId: pqSubmission.projectId, + projectName: projectName, + isProjectPQ: !!pqSubmission.projectId, + rejectedDate: currentDate.toLocaleString(), + rejectReason: rejectReason, + portalUrl, + } + }); + } catch (emailError) { + console.error("Failed to send vendor notification:", emailError); + // 이메일 발송 실패가 전체 프로세스를 중단하지 않음 + } + } + + // 8. 캐시 무효화 + revalidateTag("vendors"); + revalidateTag("vendor-status-counts"); + revalidateTag("pq-submissions"); + revalidateTag(`vendor-pq-submissions-${vendorId}`); + + if (pqSubmission.projectId) { + revalidateTag(`project-pq-submissions-${pqSubmission.projectId}`); + revalidateTag(`project-vendors-${pqSubmission.projectId}`); + } + + return { ok: true }; + } catch (error) { + console.error("PQ reject error:", error); + return { ok: false, error: getErrorMessage(error) }; + } +} + + +// 실사 의뢰 생성 서버 액션 +export async function requestInvestigationAction( + pqSubmissionIds: number[], + data: { + evaluationType: "SITE_AUDIT" | "QM_SELF_AUDIT", + qmManagerId: number, + forecastedAt: Date, + investigationAddress: string, + investigationNotes?: string + } +) { + try { + // 세션에서 요청자 정보 가져오기 + const session = await getServerSession(authOptions); + const requesterId = session?.user?.id ? Number(session.user.id) : null; + + if (!requesterId) { + return { success: false, error: "인증된 사용자만 실사를 의뢰할 수 있습니다." }; + } + + const result = await db.transaction(async (tx) => { + // PQ 제출 정보 조회 + const pqSubmissions = await tx + .select({ + id: vendorPQSubmissions.id, + vendorId: vendorPQSubmissions.vendorId, + }) + .from(vendorPQSubmissions) + .where( + and( + inArray(vendorPQSubmissions.id, pqSubmissionIds), + eq(vendorPQSubmissions.status, "APPROVED") + ) + ); + + if (pqSubmissions.length === 0) { + throw new Error("승인된 PQ 제출 항목이 없습니다."); + } + + const now = new Date(); + + // 각 PQ에 대한 실사 요청 생성 - 타입이 정확히 맞는지 확인 + const investigations = pqSubmissions.map((pq) => { + return { + vendorId: pq.vendorId, + pqSubmissionId: pq.id, + investigationStatus: "PLANNED" as const, // enum 타입으로 명시적 지정 + evaluationType: data.evaluationType, + qmManagerId: data.qmManagerId, + forecastedAt: data.forecastedAt, + investigationAddress: data.investigationAddress, + investigationNotes: data.investigationNotes || null, + requesterId: requesterId, + requestedAt: now, + createdAt: now, + updatedAt: now, + }; + }); + + // 실사 요청 저장 + const created = await tx + .insert(vendorInvestigations) + .values(investigations) + .returning(); + + return created; + }); + + // 캐시 무효화 + revalidateTag("vendor-investigations"); + revalidateTag("pq-submissions"); + + return { + success: true, + count: result.length, + data: result + }; + } catch (err) { + console.error("실사 의뢰 중 오류 발생:", err); + return { + success: false, + error: err instanceof Error ? err.message : "알 수 없는 오류가 발생했습니다." + }; + } +} + +// 실사 의뢰 취소 서버 액션 +export async function cancelInvestigationAction(investigationIds: number[]) { + try { + const session = await getServerSession(authOptions) + const userId = session?.user?.id ? Number(session.user.id) : null + + if (!userId) { + return { success: false, error: "인증된 사용자만 실사를 취소할 수 있습니다." } + } + + const result = await db.transaction(async (tx) => { + // PLANNED 상태인 실사만 취소 가능 + const updatedInvestigations = await tx + .update(vendorInvestigations) + .set({ + investigationStatus: "CANCELED", + updatedAt: new Date(), + }) + .where( + and( + inArray(vendorInvestigations.id, investigationIds), + eq(vendorInvestigations.investigationStatus, "PLANNED") + ) + ) + .returning() + + return updatedInvestigations + }) + + // 캐시 무효화 + revalidateTag("vendor-investigations") + revalidateTag("pq-submissions") + + return { + success: true, + count: result.length, + data: result + } + } catch (err) { + console.error("실사 취소 중 오류 발생:", err) + return { + success: false, + error: err instanceof Error ? err.message : "알 수 없는 오류가 발생했습니다." + } + } +} + +// 실사 결과 발송 서버 액션 +export async function sendInvestigationResultsAction(investigationIds: number[]) { + try { + const session = await getServerSession(authOptions) + const userId = session?.user?.id ? Number(session.user.id) : null + + if (!userId) { + return { success: false, error: "인증된 사용자만 실사 결과를 발송할 수 있습니다." } + } + + // 여기서는 실사 상태를 업데이트하고, 필요하다면 이메일도 발송할 수 있습니다 + // 이메일 발송 로직은 서버 액션 내에서 구현할 수 있습니다 + const result = await db.transaction(async (tx) => { + // 완료된 실사만 결과 발송 가능 + const investigations = await tx + .select() + .from(vendorInvestigations) + .where( + and( + inArray(vendorInvestigations.id, investigationIds), + eq(vendorInvestigations.investigationStatus, "COMPLETED") + ) + ) + + if (investigations.length === 0) { + throw new Error("발송할 수 있는 완료된 실사가 없습니다.") + } + + // 여기에 이메일 발송 로직 추가 + // 예: await sendInvestigationResultEmails(investigations) + + // 필요하다면 상태 업데이트 (예: 결과 발송됨 상태 추가) + const updatedInvestigations = await tx + .update(vendorInvestigations) + .set({ + // 예시: 결과 발송 표시를 위한 필드 업데이트 + // resultSent: true, + // resultSentAt: new Date(), + updatedAt: new Date(), + }) + .where( + inArray(vendorInvestigations.id, investigationIds) + ) + .returning() + + return updatedInvestigations + }) + + // 캐시 무효화 + revalidateTag("vendor-investigations") + revalidateTag("pq-submissions") + + return { + success: true, + count: result.length, + data: result + } + } catch (err) { + console.error("실사 결과 발송 중 오류 발생:", err) + return { + success: false, + error: err instanceof Error ? err.message : "알 수 없는 오류가 발생했습니다." + } + } +} + + +export async function getQMManagers() { + try { + // QM 부서 사용자만 필터링 (department 필드가 있다고 가정) + // 또는 QM 역할을 가진 사용자만 필터링 (role 필드가 있다고 가정) + const qmUsers = await db + .select({ + id: users.id, + name: users.name, + email: users.email, + }) + .from(users) + // .where( + // // 필요에 따라 조건 조정 (예: QM 부서 또는 특정 역할만) + // // eq(users.department, "QM") 또는 + // // eq(users.role, "QM_MANAGER") + // // 테스트를 위해 모든 사용자 반환도 가능 + // eq(users.active, true) + // ) + .orderBy(users.name) + + return { + data: qmUsers, + success: true + } + } catch (error) { + console.error("QM 담당자 목록 조회 오류:", error) + return { + data: [], + success: false, + error: error instanceof Error ? error.message : "QM 담당자 목록을 가져오는 중 오류가 발생했습니다." + } + } +} + +export async function getFactoryLocationAnswer(vendorId: number, projectId: number | null = null) { + try { + // 1. "Location of Factory" 체크포인트를 가진 criteria 찾기 + const criteria = await db + .select({ + id: pqCriterias.id + }) + .from(pqCriterias) + .where(ilike(pqCriterias.checkPoint, "%Location of Factory%")) + .limit(1); + + if (!criteria.length) { + return { success: false, message: "Factory Location 질문을 찾을 수 없습니다." }; + } + + const criteriaId = criteria[0].id; + + // 2. 해당 criteria에 대한 벤더의 응답 조회 + const answerQuery = db + .select({ + answer: vendorPqCriteriaAnswers.answer + }) + .from(vendorPqCriteriaAnswers) + .where( + and( + eq(vendorPqCriteriaAnswers.vendorId, vendorId), + eq(vendorPqCriteriaAnswers.criteriaId, criteriaId) + ) + ); + + // 프로젝트 ID가 있으면 추가 조건 + if (projectId !== null) { + answerQuery.where(eq(vendorPqCriteriaAnswers.projectId, projectId)); + } else { + answerQuery.where(eq(vendorPqCriteriaAnswers.projectId, null)); + } + + const answers = await answerQuery.limit(1); + + if (!answers.length || !answers[0].answer) { + return { success: false, message: "공장 위치 정보를 찾을 수 없습니다." }; + } + + return { + success: true, + factoryLocation: answers[0].answer + }; + } catch (error) { + console.error("Factory location 조회 오류:", error); + return { success: false, message: "오류가 발생했습니다." }; + } }
\ No newline at end of file diff --git a/lib/pq/validations.ts b/lib/pq/validations.ts index 27e065ba..cf512d63 100644 --- a/lib/pq/validations.ts +++ b/lib/pq/validations.ts @@ -8,7 +8,7 @@ import { import * as z from "zod" import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" -import { PqCriterias } from "@/db/schema/pq" +import { PqCriterias, vendorPQSubmissions } from "@/db/schema/pq" export const searchParamsCache = createSearchParamsCache({ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault( @@ -34,3 +34,41 @@ export const searchParamsCache = createSearchParamsCache({ export type GetPQSchema = Awaited<ReturnType<typeof searchParamsCache.parse>> + + +export const searchParamsPQReviewCache = createSearchParamsCache({ + // 공통 플래그 + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + + // 페이징 + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // 정렬 + sort: getSortingStateParser<typeof vendorPQSubmissions.$inferSelect>().withDefault([ + { id: "updatedAt", desc: true }, + ]), + + // 고급 필터 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 기본 필터 (새로 추가) + pqBasicFilters: getFiltersStateParser().withDefault([]), + pqBasicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + basicFilters: getFiltersStateParser().withDefault([]), + basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + // 검색 키워드 + search: parseAsString.withDefault(""), + + // PQ 특화 필터 (기존 유지) + vendorName: parseAsString.withDefault(""), + vendorCode: parseAsString.withDefault(""), + projectName: parseAsString.withDefault(""), + type: parseAsStringEnum(["GENERAL", "PROJECT"]), + status: parseAsStringEnum(["REQUESTED", "IN_PROGRESS", "SUBMITTED", "APPROVED", "REJECTED"]), + submittedDateFrom: parseAsString.withDefault(""), + submittedDateTo: parseAsString.withDefault(""), +}); + +export type GetPQSubmissionsSchema = Awaited<ReturnType<typeof searchParamsPQReviewCache.parse>>
\ No newline at end of file diff --git a/lib/procurement-rfqs/services.ts b/lib/procurement-rfqs/services.ts index facdc9c9..32048768 100644 --- a/lib/procurement-rfqs/services.ts +++ b/lib/procurement-rfqs/services.ts @@ -2056,3 +2056,4 @@ export async function fetchQuotationItems(quotationIds: number[]) { return { success: false, error: "견적 아이템을 조회하는 중 오류가 발생했습니다" } } } + diff --git a/lib/procurement-rfqs/table/detail-table/add-vendor-dialog.tsx b/lib/procurement-rfqs/table/detail-table/add-vendor-dialog.tsx new file mode 100644 index 00000000..79524f58 --- /dev/null +++ b/lib/procurement-rfqs/table/detail-table/add-vendor-dialog.tsx @@ -0,0 +1,512 @@ +"use client" + +import * as React from "react" +import { useState } from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { toast } from "sonner" +import { Check, ChevronsUpDown, File, Upload, X } from "lucide-react" + +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form" +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" +import { ProcurementRfqsView } from "@/db/schema" +import { addVendorToRfq } from "@/lib/procurement-rfqs/services" +import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" +import { cn } from "@/lib/utils" +import { ScrollArea } from "@/components/ui/scroll-area" + +// 필수 필드를 위한 커스텀 레이블 컴포넌트 +const RequiredLabel = ({ children }: { children: React.ReactNode }) => ( + <FormLabel> + {children} <span className="text-red-500">*</span> + </FormLabel> +); + +// 폼 유효성 검증 스키마 +const vendorFormSchema = z.object({ + vendorId: z.string().min(1, "벤더를 선택해주세요"), + currency: z.string().min(1, "통화를 선택해주세요"), + paymentTermsCode: z.string().min(1, "지불 조건을 선택해주세요"), + incotermsCode: z.string().min(1, "인코텀즈를 선택해주세요"), + incotermsDetail: z.string().optional(), + deliveryDate: z.string().optional(), + taxCode: z.string().optional(), + placeOfShipping: z.string().optional(), + placeOfDestination: z.string().optional(), + materialPriceRelatedYn: z.boolean().default(false), +}) + +type VendorFormValues = z.infer<typeof vendorFormSchema> + +interface AddVendorDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + selectedRfq: ProcurementRfqsView | null + // 벤더 및 기타 옵션 데이터를 prop으로 받음 + vendors?: { id: number; vendorName: string; vendorCode: string }[] + currencies?: { code: string; name: string }[] + paymentTerms?: { code: string; description: string }[] + incoterms?: { code: string; description: string }[] + onSuccess?: () => void + existingVendorIds?: number[] + +} + +export function AddVendorDialog({ + open, + onOpenChange, + selectedRfq, + vendors = [], + currencies = [], + paymentTerms = [], + incoterms = [], + onSuccess, + existingVendorIds = [], // 기본값 빈 배열 +}: AddVendorDialogProps) { + + + const availableVendors = React.useMemo(() => { + return vendors.filter(vendor => !existingVendorIds.includes(vendor.id)); + }, [vendors, existingVendorIds]); + + + // 파일 업로드 상태 관리 + const [attachments, setAttachments] = useState<File[]>([]) + const [isSubmitting, setIsSubmitting] = useState(false) + + // 벤더 선택을 위한 팝오버 상태 + const [vendorOpen, setVendorOpen] = useState(false) + + const form = useForm<VendorFormValues>({ + resolver: zodResolver(vendorFormSchema), + defaultValues: { + vendorId: "", + currency: "", + paymentTermsCode: "", + incotermsCode: "", + incotermsDetail: "", + deliveryDate: "", + taxCode: "", + placeOfShipping: "", + placeOfDestination: "", + materialPriceRelatedYn: false, + }, + }) + + // 폼 제출 핸들러 + async function onSubmit(values: VendorFormValues) { + if (!selectedRfq) { + toast.error("선택된 RFQ가 없습니다") + return + } + + try { + setIsSubmitting(true) + + // FormData 생성 + const formData = new FormData() + formData.append("rfqId", selectedRfq.id.toString()) + + // 폼 데이터 추가 + Object.entries(values).forEach(([key, value]) => { + formData.append(key, value.toString()) + }) + + // 첨부파일 추가 + attachments.forEach((file, index) => { + formData.append(`attachment-${index}`, file) + }) + + // 서버 액션 호출 + const result = await addVendorToRfq(formData) + + if (result.success) { + toast.success("벤더가 성공적으로 추가되었습니다") + onOpenChange(false) + form.reset() + setAttachments([]) + onSuccess?.() + } else { + toast.error(result.message || "벤더 추가 중 오류가 발생했습니다") + } + } catch (error) { + console.error("벤더 추가 오류:", error) + toast.error("벤더 추가 중 오류가 발생했습니다") + } finally { + setIsSubmitting(false) + } + } + + // 파일 업로드 핸들러 + const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => { + if (event.target.files && event.target.files.length > 0) { + const newFiles = Array.from(event.target.files) + setAttachments((prev) => [...prev, ...newFiles]) + } + } + + // 파일 삭제 핸들러 + const handleRemoveFile = (index: number) => { + setAttachments((prev) => prev.filter((_, i) => i !== index)) + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + {/* 커스텀 DialogContent - 고정 헤더, 스크롤 가능한 콘텐츠, 고정 푸터 */} + <DialogContent className="sm:max-w-[600px] p-0 h-[85vh] flex flex-col overflow-hidden" style={{maxHeight:'85vh'}}> + {/* 고정 헤더 */} + <div className="p-6 border-b"> + <DialogHeader> + <DialogTitle>벤더 추가</DialogTitle> + <DialogDescription> + {selectedRfq ? ( + <> + <span className="font-medium">{selectedRfq.rfqCode}</span> RFQ에 벤더를 추가합니다. + </> + ) : ( + "RFQ에 벤더를 추가합니다." + )} + </DialogDescription> + </DialogHeader> + </div> + + {/* 스크롤 가능한 콘텐츠 영역 */} + <div className="flex-1 overflow-y-auto p-6"> + <Form {...form}> + <form id="vendor-form" onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + {/* 검색 가능한 벤더 선택 필드 */} + <FormField + control={form.control} + name="vendorId" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <RequiredLabel>벤더</RequiredLabel> + <Popover open={vendorOpen} onOpenChange={setVendorOpen}> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + role="combobox" + aria-expanded={vendorOpen} + className={cn( + "w-full justify-between", + !field.value && "text-muted-foreground" + )} + > + {field.value + ? vendors.find((vendor) => String(vendor.id) === field.value) + ? `${vendors.find((vendor) => String(vendor.id) === field.value)?.vendorName} (${vendors.find((vendor) => String(vendor.id) === field.value)?.vendorCode})` + : "벤더를 선택하세요" + : "벤더를 선택하세요"} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-[400px] p-0"> + <Command> + <CommandInput placeholder="벤더 검색..." /> + <CommandEmpty>검색 결과가 없습니다</CommandEmpty> + <CommandList> + <ScrollArea className="h-60"> + <CommandGroup> + {availableVendors.length > 0 ? ( + availableVendors.map((vendor) => ( + <CommandItem + key={vendor.id} + value={`${vendor.vendorName} ${vendor.vendorCode}`} + onSelect={() => { + form.setValue("vendorId", String(vendor.id), { + shouldValidate: true, + }) + setVendorOpen(false) + }} + > + <Check + className={cn( + "mr-2 h-4 w-4", + String(vendor.id) === field.value + ? "opacity-100" + : "opacity-0" + )} + /> + {vendor.vendorName} ({vendor.vendorCode}) + </CommandItem> + )) + ) : ( + <CommandItem disabled>추가 가능한 벤더가 없습니다</CommandItem> + )} + </CommandGroup> + </ScrollArea> + </CommandList> + </Command> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="currency" + render={({ field }) => ( + <FormItem> + <RequiredLabel>통화</RequiredLabel> + <Select onValueChange={field.onChange} defaultValue={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="통화를 선택하세요" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {currencies.map((currency) => ( + <SelectItem key={currency.code} value={currency.code}> + {currency.name} ({currency.code}) + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + <div className="grid grid-cols-2 gap-4"> + <FormField + control={form.control} + name="paymentTermsCode" + render={({ field }) => ( + <FormItem> + <RequiredLabel>지불 조건</RequiredLabel> + <Select onValueChange={field.onChange} defaultValue={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="지불 조건 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {paymentTerms.map((term) => ( + <SelectItem key={term.code} value={term.code}> + {term.description} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="incotermsCode" + render={({ field }) => ( + <FormItem> + <RequiredLabel>인코텀즈</RequiredLabel> + <Select onValueChange={field.onChange} defaultValue={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="인코텀즈 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {incoterms.map((incoterm) => ( + <SelectItem key={incoterm.code} value={incoterm.code}> + {incoterm.description} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + </div> + + {/* 나머지 필드들은 동일하게 유지 */} + <FormField + control={form.control} + name="incotermsDetail" + render={({ field }) => ( + <FormItem> + <FormLabel>인코텀즈 세부사항</FormLabel> + <FormControl> + <Input {...field} placeholder="인코텀즈 세부사항" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <div className="grid grid-cols-2 gap-4"> + <FormField + control={form.control} + name="deliveryDate" + render={({ field }) => ( + <FormItem> + <FormLabel>납품 예정일</FormLabel> + <FormControl> + <Input {...field} type="date" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="taxCode" + render={({ field }) => ( + <FormItem> + <FormLabel>세금 코드</FormLabel> + <FormControl> + <Input {...field} placeholder="세금 코드" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <div className="grid grid-cols-2 gap-4"> + <FormField + control={form.control} + name="placeOfShipping" + render={({ field }) => ( + <FormItem> + <FormLabel>선적지</FormLabel> + <FormControl> + <Input {...field} placeholder="선적지" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="placeOfDestination" + render={({ field }) => ( + <FormItem> + <FormLabel>도착지</FormLabel> + <FormControl> + <Input {...field} placeholder="도착지" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <FormField + control={form.control} + name="materialPriceRelatedYn" + render={({ field }) => ( + <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4"> + <FormControl> + <input + type="checkbox" + checked={field.value} + onChange={field.onChange} + className="h-4 w-4 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500" + /> + </FormControl> + <div className="space-y-1 leading-none"> + <FormLabel>하도급대금 연동제 여부</FormLabel> + </div> + </FormItem> + )} + /> + + {/* 파일 업로드 섹션 */} + <div className="space-y-2"> + <Label>첨부 파일</Label> + <div className="border rounded-md p-4"> + <div className="flex items-center justify-center w-full"> + <label + htmlFor="file-upload" + className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed rounded-lg cursor-pointer bg-gray-50 hover:bg-gray-100" + > + <div className="flex flex-col items-center justify-center pt-5 pb-6"> + <Upload className="w-8 h-8 mb-2 text-gray-500" /> + <p className="mb-2 text-sm text-gray-500"> + <span className="font-semibold">클릭하여 파일 업로드</span> 또는 파일을 끌어 놓으세요 + </p> + <p className="text-xs text-gray-500">PDF, DOCX, XLSX, JPG, PNG (최대 10MB)</p> + </div> + <input + id="file-upload" + type="file" + className="hidden" + multiple + onChange={handleFileUpload} + /> + </label> + </div> + + {/* 업로드된 파일 목록 */} + {attachments.length > 0 && ( + <div className="mt-4 space-y-2"> + <h4 className="text-sm font-medium">업로드된 파일</h4> + <ul className="space-y-2"> + {attachments.map((file, index) => ( + <li + key={index} + className="flex items-center justify-between p-2 text-sm bg-gray-50 rounded-md" + > + <div className="flex items-center space-x-2"> + <File className="w-4 h-4 text-gray-500" /> + <span className="truncate max-w-[250px]">{file.name}</span> + <span className="text-gray-500 text-xs"> + ({(file.size / 1024).toFixed(1)} KB) + </span> + </div> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => handleRemoveFile(index)} + > + <X className="w-4 h-4 text-gray-500" /> + </Button> + </li> + ))} + </ul> + </div> + )} + </div> + </div> + </form> + </Form> + </div> + + {/* 고정 푸터 */} + <div className="p-6 border-t"> + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isSubmitting} + > + 취소 + </Button> + <Button + type="submit" + form="vendor-form" + disabled={isSubmitting} + > + {isSubmitting ? "처리 중..." : "벤더 추가"} + </Button> + </DialogFooter> + </div> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/procurement-rfqs/table/detail-table/delete-vendor-dialog.tsx b/lib/procurement-rfqs/table/detail-table/delete-vendor-dialog.tsx new file mode 100644 index 00000000..49d982e1 --- /dev/null +++ b/lib/procurement-rfqs/table/detail-table/delete-vendor-dialog.tsx @@ -0,0 +1,150 @@ +"use client" + +import * as React from "react" +import { type RfqDetailView } from "./rfq-detail-column" +import { type Row } from "@tanstack/react-table" +import { Loader, Trash } from "lucide-react" +import { toast } from "sonner" + +import { useMediaQuery } from "@/hooks/use-media-query" +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogClose, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer" +import { deleteRfqDetail } from "@/lib/procurement-rfqs/services" + + +interface DeleteRfqDetailDialogProps + extends React.ComponentPropsWithoutRef<typeof Dialog> { + detail: RfqDetailView | null + showTrigger?: boolean + onSuccess?: () => void +} + +export function DeleteRfqDetailDialog({ + detail, + showTrigger = true, + onSuccess, + ...props +}: DeleteRfqDetailDialogProps) { + const [isDeletePending, startDeleteTransition] = React.useTransition() + const isDesktop = useMediaQuery("(min-width: 640px)") + + function onDelete() { + if (!detail) return + + startDeleteTransition(async () => { + try { + const result = await deleteRfqDetail(detail.detailId) + + if (!result.success) { + toast.error(result.message || "삭제 중 오류가 발생했습니다") + return + } + + props.onOpenChange?.(false) + toast.success("RFQ 벤더 정보가 삭제되었습니다") + onSuccess?.() + } catch (error) { + console.error("RFQ 벤더 삭제 오류:", error) + toast.error("삭제 중 오류가 발생했습니다") + } + }) + } + + if (isDesktop) { + return ( + <Dialog {...props}> + {showTrigger ? ( + <DialogTrigger asChild> + <Button variant="destructive" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + 삭제 + </Button> + </DialogTrigger> + ) : null} + <DialogContent> + <DialogHeader> + <DialogTitle>정말로 삭제하시겠습니까?</DialogTitle> + <DialogDescription> + 이 작업은 되돌릴 수 없습니다. 벤더 "{detail?.vendorName}"({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다. + </DialogDescription> + </DialogHeader> + <DialogFooter className="gap-2 sm:space-x-0"> + <DialogClose asChild> + <Button variant="outline">취소</Button> + </DialogClose> + <Button + aria-label="선택한 RFQ 벤더 정보 삭제" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + 삭제 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) + } + + return ( + <Drawer {...props}> + {showTrigger ? ( + <DrawerTrigger asChild> + <Button variant="destructive" size="sm"> + <Trash className="mr-2 size-4" aria-hidden="true" /> + 삭제 + </Button> + </DrawerTrigger> + ) : null} + <DrawerContent> + <DrawerHeader> + <DrawerTitle>정말로 삭제하시겠습니까?</DrawerTitle> + <DrawerDescription> + 이 작업은 되돌릴 수 없습니다. 벤더 "{detail?.vendorName}"({detail?.vendorCode})의 RFQ 정보가 영구적으로 삭제됩니다. + </DrawerDescription> + </DrawerHeader> + <DrawerFooter className="gap-2 sm:space-x-0"> + <DrawerClose asChild> + <Button variant="outline">취소</Button> + </DrawerClose> + <Button + aria-label="선택한 RFQ 벤더 정보 삭제" + variant="destructive" + onClick={onDelete} + disabled={isDeletePending} + > + {isDeletePending && ( + <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" /> + )} + 삭제 + </Button> + </DrawerFooter> + </DrawerContent> + </Drawer> + ) +}
\ No newline at end of file diff --git a/lib/procurement-rfqs/table/detail-table/rfq-detail-column.tsx b/lib/procurement-rfqs/table/detail-table/rfq-detail-column.tsx new file mode 100644 index 00000000..ce5e7767 --- /dev/null +++ b/lib/procurement-rfqs/table/detail-table/rfq-detail-column.tsx @@ -0,0 +1,369 @@ +"use client" + +import * as React from "react" +import type { ColumnDef, Row } from "@tanstack/react-table"; +import { formatDate, formatDateTime } from "@/lib/utils" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuRadioGroup, + DropdownMenuRadioItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuSub, + DropdownMenuSubContent, + DropdownMenuSubTrigger, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Ellipsis, MessageCircle } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Badge } from "@/components/ui/badge"; + +export interface DataTableRowAction<TData> { + row: Row<TData>; + type: "delete" | "update" | "communicate"; // communicate 타입 추가 +} + +// procurementRfqDetailsView 타입 정의 (DB 스키마에 맞게 조정 필요) +export interface RfqDetailView { + detailId: number + rfqId: number + rfqCode: string + vendorId?: number | null // 벤더 ID 필드 추가 + projectCode: string | null + projectName: string | null + vendorCountry: string | null + itemCode: string | null + itemName: string | null + vendorName: string | null + vendorCode: string | null + currency: string | null + paymentTermsCode: string | null + paymentTermsDescription: string | null + incotermsCode: string | null + incotermsDescription: string | null + incotermsDetail: string | null + deliveryDate: Date | null + taxCode: string | null + placeOfShipping: string | null + placeOfDestination: string | null + materialPriceRelatedYn: boolean | null + hasQuotation: boolean | null + updatedByUserName: string | null + quotationStatus: string | null + updatedAt: Date | null + prItemsCount: number + majorItemsCount: number + quotationVersion:number | null + // 커뮤니케이션 관련 필드 추가 + commentCount?: number // 전체 코멘트 수 + unreadCount?: number // 읽지 않은 코멘트 수 + lastCommentDate?: Date // 마지막 코멘트 날짜 +} + +interface GetColumnsProps<TData> { + setRowAction: React.Dispatch< + React.SetStateAction<DataTableRowAction<TData> | null> + >; + unreadMessages?: Record<number, number>; // 벤더 ID별 읽지 않은 메시지 수 +} + +export function getRfqDetailColumns({ + setRowAction, + unreadMessages = {}, +}: GetColumnsProps<RfqDetailView>): ColumnDef<RfqDetailView>[] { + return [ + { + accessorKey: "quotationStatus", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="견적 상태" /> + ), + cell: ({ row }) => <div>{row.getValue("quotationStatus")}</div>, + meta: { + excelHeader: "견적 상태" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "quotationVersion", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="견적 버전" /> + ), + cell: ({ row }) => <div>{row.getValue("quotationVersion")}</div>, + meta: { + excelHeader: "견적 버전" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "vendorCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="벤더 코드" /> + ), + cell: ({ row }) => <div>{row.getValue("vendorCode")}</div>, + meta: { + excelHeader: "벤더 코드" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "vendorName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="벤더명" /> + ), + cell: ({ row }) => <div>{row.getValue("vendorName")}</div>, + meta: { + excelHeader: "벤더명" + }, + enableResizing: true, + size: 160, + }, + { + accessorKey: "vendorType", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="내외자" /> + ), + cell: ({ row }) => <div>{row.original.vendorCountry === "KR"?"D":"F"}</div>, + meta: { + excelHeader: "내외자" + }, + enableResizing: true, + size: 80, + }, + { + accessorKey: "currency", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="통화" /> + ), + cell: ({ row }) => <div>{row.getValue("currency")}</div>, + meta: { + excelHeader: "통화" + }, + enableResizing: true, + size: 80, + }, + { + accessorKey: "paymentTermsCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="지불 조건 코드" /> + ), + cell: ({ row }) => <div>{row.getValue("paymentTermsCode")}</div>, + meta: { + excelHeader: "지불 조건 코드" + }, + enableResizing: true, + size: 140, + }, + { + accessorKey: "paymentTermsDescription", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="지불 조건" /> + ), + cell: ({ row }) => <div>{row.getValue("paymentTermsDescription")}</div>, + meta: { + excelHeader: "지불 조건" + }, + enableResizing: true, + size: 160, + }, + { + accessorKey: "incotermsCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="인코텀스 코드" /> + ), + cell: ({ row }) => <div>{row.getValue("incotermsCode")}</div>, + meta: { + excelHeader: "인코텀스 코드" + }, + enableResizing: true, + size: 140, + }, + { + accessorKey: "incotermsDescription", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="인코텀스" /> + ), + cell: ({ row }) => <div>{row.getValue("incotermsDescription")}</div>, + meta: { + excelHeader: "인코텀스" + }, + enableResizing: true, + size: 160, + }, + { + accessorKey: "incotermsDetail", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="인코텀스 상세" /> + ), + cell: ({ row }) => <div>{row.getValue("incotermsDetail")}</div>, + meta: { + excelHeader: "인코텀스 상세" + }, + enableResizing: true, + size: 160, + }, + { + accessorKey: "deliveryDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="납품일" /> + ), + cell: ({ cell }) => { + const value = cell.getValue(); + return value ? formatDate(value as Date, "KR") : ""; + }, + meta: { + excelHeader: "납품일" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "taxCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="세금 코드" /> + ), + cell: ({ row }) => <div>{row.getValue("taxCode")}</div>, + meta: { + excelHeader: "세금 코드" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "placeOfShipping", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="선적지" /> + ), + cell: ({ row }) => <div>{row.getValue("placeOfShipping")}</div>, + meta: { + excelHeader: "선적지" + }, + enableResizing: true, + size: 140, + }, + { + accessorKey: "placeOfDestination", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="도착지" /> + ), + cell: ({ row }) => <div>{row.getValue("placeOfDestination")}</div>, + meta: { + excelHeader: "도착지" + }, + enableResizing: true, + size: 140, + }, + { + accessorKey: "materialPriceRelatedYn", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="하도급대금 연동" /> + ), + cell: ({ row }) => <div>{row.getValue("materialPriceRelatedYn") ? "Y" : "N"}</div>, + meta: { + excelHeader: "하도급대금 연동" + }, + enableResizing: true, + size: 140, + }, + { + accessorKey: "updatedByUserName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="수정자" /> + ), + cell: ({ row }) => <div>{row.getValue("updatedByUserName")}</div>, + meta: { + excelHeader: "수정자" + }, + enableResizing: true, + size: 120, + }, + { + accessorKey: "updatedAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="수정일시" /> + ), + cell: ({ cell }) => { + const value = cell.getValue(); + return value ? formatDateTime(value as Date, "KR") : ""; + }, + meta: { + excelHeader: "수정일시" + }, + enableResizing: true, + size: 140, + }, + // 커뮤니케이션 컬럼 추가 + { + id: "communication", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="커뮤니케이션" /> + ), + cell: ({ row }) => { + const vendorId = row.original.vendorId || 0; + const unreadCount = unreadMessages[vendorId] || 0; + + return ( + <Button + variant="ghost" + size="sm" + className="relative p-0 h-8 w-8 flex items-center justify-center" + onClick={() => setRowAction({ row, type: "communicate" })} + > + <MessageCircle className="h-4 w-4" /> + {unreadCount > 0 && ( + <Badge + variant="destructive" + className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 text-xs" + > + {unreadCount} + </Badge> + )} + </Button> + ); + }, + enableResizing: false, + size: 80, + }, + { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-7 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-40"> + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "update" })} + > + Edit + </DropdownMenuItem> + + <DropdownMenuItem + onSelect={() => setRowAction({ row, type: "delete" })} + > + Delete + <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut> + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, + } + ] +}
\ No newline at end of file diff --git a/lib/procurement-rfqs/table/detail-table/rfq-detail-table.tsx b/lib/procurement-rfqs/table/detail-table/rfq-detail-table.tsx new file mode 100644 index 00000000..ad9a19e7 --- /dev/null +++ b/lib/procurement-rfqs/table/detail-table/rfq-detail-table.tsx @@ -0,0 +1,521 @@ +"use client" + +import * as React from "react" +import { useEffect, useState } from "react" +import { + DataTableRowAction, + getRfqDetailColumns, + RfqDetailView +} from "./rfq-detail-column" +import { toast } from "sonner" + +import { Skeleton } from "@/components/ui/skeleton" +import { Card, CardContent } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { ProcurementRfqsView } from "@/db/schema" +import { + fetchCurrencies, + fetchIncoterms, + fetchPaymentTerms, + fetchRfqDetails, + fetchVendors, + fetchUnreadMessages +} from "@/lib/procurement-rfqs/services" +import { ClientDataTable } from "@/components/client-data-table/data-table" +import { AddVendorDialog } from "./add-vendor-dialog" +import { Button } from "@/components/ui/button" +import { Loader2, UserPlus, BarChart2 } from "lucide-react" // 아이콘 추가 +import { DeleteRfqDetailDialog } from "./delete-vendor-dialog" +import { UpdateRfqDetailSheet } from "./update-vendor-sheet" +import { VendorCommunicationDrawer } from "./vendor-communication-drawer" +import { VendorQuotationComparisonDialog } from "./vendor-quotation-comparison-dialog" // 새로운 컴포넌트 임포트 + +// 프로퍼티 정의 +interface RfqDetailTablesProps { + selectedRfq: ProcurementRfqsView | null + maxHeight?: string | number +} + +// 데이터 타입 정의 +interface Vendor { + id: number; + vendorName: string; + vendorCode: string | null; // Update this to allow null + // 기타 필요한 벤더 속성들 +} + +interface Currency { + code: string; + name: string; +} + +interface PaymentTerm { + code: string; + description: string; +} + +interface Incoterm { + code: string; + description: string; +} + +export function RfqDetailTables({ selectedRfq , maxHeight}: RfqDetailTablesProps) { + + console.log("selectedRfq", selectedRfq) + // 상태 관리 + const [isLoading, setIsLoading] = useState(false) + const [isRefreshing, setIsRefreshing] = useState(false) + const [details, setDetails] = useState<RfqDetailView[]>([]) + const [vendorDialogOpen, setVendorDialogOpen] = React.useState(false) + const [updateSheetOpen, setUpdateSheetOpen] = React.useState(false) + const [deleteDialogOpen, setDeleteDialogOpen] = React.useState(false) + const [selectedDetail, setSelectedDetail] = React.useState<RfqDetailView | null>(null) + + const [vendors, setVendors] = React.useState<Vendor[]>([]) + const [currencies, setCurrencies] = React.useState<Currency[]>([]) + const [paymentTerms, setPaymentTerms] = React.useState<PaymentTerm[]>([]) + const [incoterms, setIncoterms] = React.useState<Incoterm[]>([]) + const [isAdddialogLoading, setIsAdddialogLoading] = useState(false) + + const [rowAction, setRowAction] = React.useState<DataTableRowAction<RfqDetailView> | null>(null) + + // 벤더 커뮤니케이션 상태 관리 + const [communicationDrawerOpen, setCommunicationDrawerOpen] = useState(false) + const [selectedVendor, setSelectedVendor] = useState<RfqDetailView | null>(null) + + // 읽지 않은 메시지 개수 + const [unreadMessages, setUnreadMessages] = useState<Record<number, number>>({}) + const [isUnreadLoading, setIsUnreadLoading] = useState(false) + + // 견적 비교 다이얼로그 상태 관리 (추가) + const [comparisonDialogOpen, setComparisonDialogOpen] = useState(false) + + const existingVendorIds = React.useMemo(() => { + return details.map(detail => Number(detail.vendorId)).filter(Boolean); + }, [details]); + + const handleAddVendor = async () => { + try { + setIsAdddialogLoading(true) + + // 필요한 데이터 로드 (벤더, 통화, 지불조건, 인코텀즈) + const [vendorsData, currenciesData, paymentTermsData, incotermsData] = await Promise.all([ + fetchVendors(), + fetchCurrencies(), + fetchPaymentTerms(), + fetchIncoterms() + ]) + + setVendors(vendorsData.data || []) + setCurrencies(currenciesData.data || []) + setPaymentTerms(paymentTermsData.data || []) + setIncoterms(incotermsData.data || []) + + setVendorDialogOpen(true) + } catch (error) { + console.error("데이터 로드 오류:", error) + toast.error("벤더 정보를 불러오는 중 오류가 발생했습니다") + } finally { + setIsAdddialogLoading(false) + } + } + + // 견적 비교 다이얼로그 열기 핸들러 (추가) + const handleOpenComparisonDialog = () => { + // 제출된 견적이 있는 벤더가 최소 1개 이상 있는지 확인 + const hasSubmittedQuotations = details.some(detail => + detail.hasQuotation && detail.quotationStatus === "Submitted" + ); + + if (!hasSubmittedQuotations) { + toast.warning("제출된 견적이 없습니다."); + return; + } + + setComparisonDialogOpen(true); + } + + // 읽지 않은 메시지 로드 + const loadUnreadMessages = async () => { + if (!selectedRfq || !selectedRfq.id) return; + + try { + setIsUnreadLoading(true); + + // 읽지 않은 메시지 수 가져오기 + const unreadData = await fetchUnreadMessages(selectedRfq.id); + setUnreadMessages(unreadData); + } catch (error) { + console.error("읽지 않은 메시지 로드 오류:", error); + // 조용히 실패 - 사용자에게 알림 표시하지 않음 + } finally { + setIsUnreadLoading(false); + } + }; + + // 칼럼 정의 - unreadMessages 상태 전달 + const columns = React.useMemo(() => + getRfqDetailColumns({ + setRowAction, + unreadMessages + }), [unreadMessages]) + + // 필터 필드 정의 (필터 사용 시) + const advancedFilterFields = React.useMemo( + () => [ + { + id: "vendorName", + label: "벤더명", + type: "text", + }, + { + id: "vendorCode", + label: "벤더 코드", + type: "text", + }, + { + id: "currency", + label: "통화", + type: "text", + }, + ], + [] + ) + + // RFQ ID가 변경될 때 데이터 로드 + useEffect(() => { + async function loadRfqDetails() { + if (!selectedRfq || !selectedRfq.id) { + setDetails([]) + return + } + + try { + setIsLoading(true) + const transformRfqDetails = (data: any[]): RfqDetailView[] => { + return data.map(item => ({ + ...item, + // Convert vendorId from string|null to number|undefined + vendorId: item.vendorId ? Number(item.vendorId) : undefined, + // Transform any other fields that need type conversion + })); + }; + + // Then in your useEffect: + const result = await fetchRfqDetails(selectedRfq.id); + setDetails(transformRfqDetails(result.data)); + + // 읽지 않은 메시지 개수 로드 + await loadUnreadMessages(); + } catch (error) { + console.error("RFQ 디테일 로드 오류:", error) + setDetails([]) + toast.error("RFQ 세부정보를 불러오는 중 오류가 발생했습니다") + } finally { + setIsLoading(false) + } + } + + loadRfqDetails() + }, [selectedRfq]) + + // 주기적으로 읽지 않은 메시지 갱신 (60초마다) + useEffect(() => { + if (!selectedRfq || !selectedRfq.id) return; + + const intervalId = setInterval(() => { + loadUnreadMessages(); + }, 60000); // 60초마다 갱신 + + return () => clearInterval(intervalId); + }, [selectedRfq]); + + // rowAction 처리 + useEffect(() => { + if (!rowAction) return + + const handleRowAction = async () => { + try { + // 통신 액션인 경우 드로어 열기 + if (rowAction.type === "communicate") { + setSelectedVendor(rowAction.row.original); + setCommunicationDrawerOpen(true); + + // 해당 벤더의 읽지 않은 메시지를 0으로 설정 (메시지를 읽은 것으로 간주) + const vendorId = rowAction.row.original.vendorId; + if (vendorId) { + setUnreadMessages(prev => ({ + ...prev, + [vendorId]: 0 + })); + } + + // rowAction 초기화 + setRowAction(null); + return; + } + + // 다른 액션들은 기존과 동일하게 처리 + setIsAdddialogLoading(true); + + // 필요한 데이터 로드 (벤더, 통화, 지불조건, 인코텀즈) + const [vendorsData, currenciesData, paymentTermsData, incotermsData] = await Promise.all([ + fetchVendors(), + fetchCurrencies(), + fetchPaymentTerms(), + fetchIncoterms() + ]); + + setVendors(vendorsData.data || []); + setCurrencies(currenciesData.data || []); + setPaymentTerms(paymentTermsData.data || []); + setIncoterms(incotermsData.data || []); + + // 이제 데이터가 로드되었으므로 필요한 작업 수행 + if (rowAction.type === "update") { + setSelectedDetail(rowAction.row.original); + setUpdateSheetOpen(true); + } else if (rowAction.type === "delete") { + setSelectedDetail(rowAction.row.original); + setDeleteDialogOpen(true); + } + } catch (error) { + console.error("데이터 로드 오류:", error); + toast.error("데이터를 불러오는 중 오류가 발생했습니다"); + } finally { + // communicate 타입이 아닌 경우에만 로딩 상태 변경 + if (rowAction && rowAction.type !== "communicate") { + setIsAdddialogLoading(false); + } + } + }; + + handleRowAction(); + }, [rowAction]) + + // RFQ가 선택되지 않은 경우 + if (!selectedRfq) { + return ( + <div className="flex h-full items-center justify-center text-muted-foreground"> + RFQ를 선택하세요 + </div> + ) + } + + // 로딩 중인 경우 + if (isLoading) { + return ( + <div className="p-4 space-y-4"> + <Skeleton className="h-8 w-1/2" /> + <Skeleton className="h-24 w-full" /> + <Skeleton className="h-48 w-full" /> + </div> + ) + } + + const handleRefreshData = async () => { + if (!selectedRfq || !selectedRfq.id) return + + try { + setIsRefreshing(true) + + const transformRfqDetails = (data: any[]): RfqDetailView[] => { + return data.map(item => ({ + ...item, + // Convert vendorId from string|null to number|undefined + vendorId: item.vendorId ? Number(item.vendorId) : undefined, + // Transform any other fields that need type conversion + })); + }; + + // Then in your useEffect: + const result = await fetchRfqDetails(selectedRfq.id); + setDetails(transformRfqDetails(result.data)); + + // 읽지 않은 메시지 개수 업데이트 + await loadUnreadMessages(); + + toast.success("데이터가 새로고침되었습니다") + } catch (error) { + console.error("RFQ 디테일 로드 오류:", error) + toast.error("데이터 새로고침 중 오류가 발생했습니다") + } finally { + setIsRefreshing(false) + } + } + + // 전체 읽지 않은 메시지 수 계산 + const totalUnreadMessages = Object.values(unreadMessages).reduce((sum, count) => sum + count, 0); + + // 견적이 있는 벤더 수 계산 + const vendorsWithQuotations = details.filter(detail => detail.hasQuotation && detail.quotationStatus === "Submitted").length; + + return ( + <div className="h-full overflow-hidden pt-4"> + + {/* 메시지 및 새로고침 영역 */} + + + {/* 테이블 또는 빈 상태 표시 */} + {details.length > 0 ? ( + + <ClientDataTable + columns={columns} + data={details} + advancedFilterFields={advancedFilterFields} + maxHeight={maxHeight} + > + + <div className="flex justify-between items-center"> + <div className="flex items-center gap-2 mr-2"> + {totalUnreadMessages > 0 && ( + <Badge variant="destructive" className="h-6"> + 읽지 않은 메시지: {totalUnreadMessages}건 + </Badge> + )} + {vendorsWithQuotations > 0 && ( + <Badge variant="outline" className="h-6"> + 견적 제출: {vendorsWithQuotations}개 벤더 + </Badge> + )} + </div> + <div className="flex gap-2"> + {/* 견적 비교 버튼 추가 */} + <Button + variant="outline" + size="sm" + onClick={handleOpenComparisonDialog} + className="gap-2" + disabled={ + !selectedRfq || + details.length === 0 || + (!!selectedRfq.rfqSealedYn && selectedRfq.dueDate && new Date() < new Date(selectedRfq.dueDate)) + } + > + <BarChart2 className="size-4" aria-hidden="true" /> + <span>견적 비교</span> + </Button> + <Button + variant="outline" + size="sm" + onClick={handleRefreshData} + disabled={isRefreshing} + > + {isRefreshing ? ( + <> + <Loader2 className="h-4 w-4 mr-2 animate-spin" /> + 새로고침 중... + </> + ) : ( + '새로고침' + )} + </Button> + </div> + </div> + <Button + variant="outline" + size="sm" + onClick={handleAddVendor} + className="gap-2" + disabled={!selectedRfq || isAdddialogLoading} + > + {isAdddialogLoading ? ( + <> + <Loader2 className="size-4 animate-spin" aria-hidden="true" /> + <span>로딩 중...</span> + </> + ) : ( + <> + <UserPlus className="size-4" aria-hidden="true" /> + <span>벤더 추가</span> + </> + )} + </Button> + </ClientDataTable> + + ) : ( + <div className="flex h-48 items-center justify-center text-muted-foreground border rounded-md p-4"> + <div className="flex flex-col items-center gap-4"> + <p>해당 RFQ에 대한 협력업체가 정해지지 않았습니다. 아래 버튼을 이용하여 추가하시기 바랍니다.</p> + <Button + variant="outline" + size="sm" + onClick={handleAddVendor} + className="gap-2" + disabled={!selectedRfq || isAdddialogLoading} + > + {isAdddialogLoading ? ( + <> + <Loader2 className="size-4 animate-spin" aria-hidden="true" /> + <span>로딩 중...</span> + </> + ) : ( + <> + <UserPlus className="size-4" aria-hidden="true" /> + <span>협력업체 추가</span> + </> + )} + </Button> + </div> + </div> + )} + + {/* 벤더 추가 다이얼로그 */} + <AddVendorDialog + open={vendorDialogOpen} + onOpenChange={(open) => { + setVendorDialogOpen(open); + if (!open) setIsAdddialogLoading(false); + }} + selectedRfq={selectedRfq} + vendors={vendors} + currencies={currencies} + paymentTerms={paymentTerms} + incoterms={incoterms} + onSuccess={handleRefreshData} + existingVendorIds={existingVendorIds} + /> + + {/* 벤더 정보 수정 시트 */} + <UpdateRfqDetailSheet + open={updateSheetOpen} + onOpenChange={setUpdateSheetOpen} + detail={selectedDetail} + vendors={vendors} + currencies={currencies} + paymentTerms={paymentTerms} + incoterms={incoterms} + onSuccess={handleRefreshData} + /> + + {/* 벤더 정보 삭제 다이얼로그 */} + <DeleteRfqDetailDialog + open={deleteDialogOpen} + onOpenChange={setDeleteDialogOpen} + detail={selectedDetail} + showTrigger={false} + onSuccess={handleRefreshData} + /> + + {/* 벤더 커뮤니케이션 드로어 */} + <VendorCommunicationDrawer + open={communicationDrawerOpen} + onOpenChange={(open) => { + setCommunicationDrawerOpen(open); + // 드로어가 닫힐 때 읽지 않은 메시지 개수 갱신 + if (!open) loadUnreadMessages(); + }} + selectedRfq={selectedRfq} + selectedVendor={selectedVendor} + onSuccess={handleRefreshData} + /> + + {/* 견적 비교 다이얼로그 추가 */} + <VendorQuotationComparisonDialog + open={comparisonDialogOpen} + onOpenChange={setComparisonDialogOpen} + selectedRfq={selectedRfq} + /> + </div> + ) +}
\ No newline at end of file diff --git a/lib/procurement-rfqs/table/detail-table/update-vendor-sheet.tsx b/lib/procurement-rfqs/table/detail-table/update-vendor-sheet.tsx new file mode 100644 index 00000000..45e4a602 --- /dev/null +++ b/lib/procurement-rfqs/table/detail-table/update-vendor-sheet.tsx @@ -0,0 +1,449 @@ +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { Check, ChevronsUpDown, Loader } from "lucide-react" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import { z } from "zod" + +import { cn } from "@/lib/utils" +import { Button } from "@/components/ui/button" +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, +} from "@/components/ui/command" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { Checkbox } from "@/components/ui/checkbox" +import { ScrollArea } from "@/components/ui/scroll-area" + +import { RfqDetailView } from "./rfq-detail-column" +import { updateRfqDetail } from "@/lib/procurement-rfqs/services" + +// 폼 유효성 검증 스키마 +const updateRfqDetailSchema = z.object({ + vendorId: z.string().min(1, "벤더를 선택해주세요"), + currency: z.string().min(1, "통화를 선택해주세요"), + paymentTermsCode: z.string().min(1, "지불 조건을 선택해주세요"), + incotermsCode: z.string().min(1, "인코텀즈를 선택해주세요"), + incotermsDetail: z.string().optional(), + deliveryDate: z.string().optional(), + taxCode: z.string().optional(), + placeOfShipping: z.string().optional(), + placeOfDestination: z.string().optional(), + materialPriceRelatedYn: z.boolean().default(false), +}) + +type UpdateRfqDetailFormValues = z.infer<typeof updateRfqDetailSchema> + +// 데이터 타입 정의 +interface Vendor { + id: number; + vendorName: string; + vendorCode: string; +} + +interface Currency { + code: string; + name: string; +} + +interface PaymentTerm { + code: string; + description: string; +} + +interface Incoterm { + code: string; + description: string; +} + +interface UpdateRfqDetailSheetProps + extends React.ComponentPropsWithRef<typeof Sheet> { + detail: RfqDetailView | null; + vendors: Vendor[]; + currencies: Currency[]; + paymentTerms: PaymentTerm[]; + incoterms: Incoterm[]; + onSuccess?: () => void; +} + +export function UpdateRfqDetailSheet({ + detail, + vendors, + currencies, + paymentTerms, + incoterms, + onSuccess, + ...props +}: UpdateRfqDetailSheetProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + const [vendorOpen, setVendorOpen] = React.useState(false) + + const form = useForm<UpdateRfqDetailFormValues>({ + resolver: zodResolver(updateRfqDetailSchema), + defaultValues: { + vendorId: detail?.vendorName ? String(vendors.find(v => v.vendorName === detail.vendorName)?.id || "") : "", + currency: detail?.currency || "", + paymentTermsCode: detail?.paymentTermsCode || "", + incotermsCode: detail?.incotermsCode || "", + incotermsDetail: detail?.incotermsDetail || "", + deliveryDate: detail?.deliveryDate ? new Date(detail.deliveryDate).toISOString().split('T')[0] : "", + taxCode: detail?.taxCode || "", + placeOfShipping: detail?.placeOfShipping || "", + placeOfDestination: detail?.placeOfDestination || "", + materialPriceRelatedYn: detail?.materialPriceRelatedYn || false, + }, + }) + + // detail이 변경될 때 form 값 업데이트 + React.useEffect(() => { + if (detail) { + const vendorId = vendors.find(v => v.vendorName === detail.vendorName)?.id + + form.reset({ + vendorId: vendorId ? String(vendorId) : "", + currency: detail.currency || "", + paymentTermsCode: detail.paymentTermsCode || "", + incotermsCode: detail.incotermsCode || "", + incotermsDetail: detail.incotermsDetail || "", + deliveryDate: detail.deliveryDate ? new Date(detail.deliveryDate).toISOString().split('T')[0] : "", + taxCode: detail.taxCode || "", + placeOfShipping: detail.placeOfShipping || "", + placeOfDestination: detail.placeOfDestination || "", + materialPriceRelatedYn: detail.materialPriceRelatedYn || false, + }) + } + }, [detail, form, vendors]) + + function onSubmit(values: UpdateRfqDetailFormValues) { + if (!detail) return + + startUpdateTransition(async () => { + try { + const result = await updateRfqDetail(detail.detailId, values) + + if (!result.success) { + toast.error(result.message || "수정 중 오류가 발생했습니다") + return + } + + props.onOpenChange?.(false) + toast.success("RFQ 벤더 정보가 수정되었습니다") + onSuccess?.() + } catch (error) { + console.error("RFQ 벤더 수정 오류:", error) + toast.error("수정 중 오류가 발생했습니다") + } + }) + } + + return ( + <Sheet {...props}> + <SheetContent className="flex w-full flex-col gap-6 sm:max-w-xl"> + <SheetHeader className="text-left"> + <SheetTitle>RFQ 벤더 정보 수정</SheetTitle> + <SheetDescription> + 벤더 정보를 수정하고 저장하세요 + </SheetDescription> + </SheetHeader> + <ScrollArea className="flex-1 pr-4"> + <Form {...form}> + <form + id="update-rfq-detail-form" + onSubmit={form.handleSubmit(onSubmit)} + className="flex flex-col gap-4" + > + {/* 검색 가능한 벤더 선택 필드 */} + <FormField + control={form.control} + name="vendorId" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel>벤더 <span className="text-red-500">*</span></FormLabel> + <Popover open={vendorOpen} onOpenChange={setVendorOpen}> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + role="combobox" + aria-expanded={vendorOpen} + className={cn( + "w-full justify-between", + !field.value && "text-muted-foreground" + )} + > + {field.value + ? vendors.find((vendor) => String(vendor.id) === field.value) + ? `${vendors.find((vendor) => String(vendor.id) === field.value)?.vendorName} (${vendors.find((vendor) => String(vendor.id) === field.value)?.vendorCode})` + : "벤더를 선택하세요" + : "벤더를 선택하세요"} + <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-[400px] p-0"> + <Command> + <CommandInput placeholder="벤더 검색..." /> + <CommandEmpty>검색 결과가 없습니다</CommandEmpty> + <ScrollArea className="h-60"> + <CommandGroup> + {vendors.map((vendor) => ( + <CommandItem + key={vendor.id} + value={`${vendor.vendorName} ${vendor.vendorCode}`} + onSelect={() => { + form.setValue("vendorId", String(vendor.id), { + shouldValidate: true, + }) + setVendorOpen(false) + }} + > + <Check + className={cn( + "mr-2 h-4 w-4", + String(vendor.id) === field.value + ? "opacity-100" + : "opacity-0" + )} + /> + {vendor.vendorName} ({vendor.vendorCode}) + </CommandItem> + ))} + </CommandGroup> + </ScrollArea> + </Command> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="currency" + render={({ field }) => ( + <FormItem> + <FormLabel>통화 <span className="text-red-500">*</span></FormLabel> + <Select onValueChange={field.onChange} value={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="통화를 선택하세요" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {currencies.map((currency) => ( + <SelectItem key={currency.code} value={currency.code}> + {currency.name} ({currency.code}) + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + <div className="grid grid-cols-2 gap-4"> + <FormField + control={form.control} + name="paymentTermsCode" + render={({ field }) => ( + <FormItem> + <FormLabel>지불 조건 <span className="text-red-500">*</span></FormLabel> + <Select onValueChange={field.onChange} value={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="지불 조건 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {paymentTerms.map((term) => ( + <SelectItem key={term.code} value={term.code}> + {term.description} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="incotermsCode" + render={({ field }) => ( + <FormItem> + <FormLabel>인코텀즈 <span className="text-red-500">*</span></FormLabel> + <Select onValueChange={field.onChange} value={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="인코텀즈 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {incoterms.map((incoterm) => ( + <SelectItem key={incoterm.code} value={incoterm.code}> + {incoterm.description} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <FormField + control={form.control} + name="incotermsDetail" + render={({ field }) => ( + <FormItem> + <FormLabel>인코텀즈 세부사항</FormLabel> + <FormControl> + <Input {...field} placeholder="인코텀즈 세부사항" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <div className="grid grid-cols-2 gap-4"> + <FormField + control={form.control} + name="deliveryDate" + render={({ field }) => ( + <FormItem> + <FormLabel>납품 예정일</FormLabel> + <FormControl> + <Input {...field} type="date" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="taxCode" + render={({ field }) => ( + <FormItem> + <FormLabel>세금 코드</FormLabel> + <FormControl> + <Input {...field} placeholder="세금 코드" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <div className="grid grid-cols-2 gap-4"> + <FormField + control={form.control} + name="placeOfShipping" + render={({ field }) => ( + <FormItem> + <FormLabel>선적지</FormLabel> + <FormControl> + <Input {...field} placeholder="선적지" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="placeOfDestination" + render={({ field }) => ( + <FormItem> + <FormLabel>도착지</FormLabel> + <FormControl> + <Input {...field} placeholder="도착지" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <FormField + control={form.control} + name="materialPriceRelatedYn" + render={({ field }) => ( + <FormItem className="flex flex-row items-start space-x-3 space-y-0 rounded-md border p-4"> + <FormControl> + <Checkbox + checked={field.value} + onCheckedChange={field.onChange} + /> + </FormControl> + <div className="space-y-1 leading-none"> + <FormLabel>자재 가격 관련 여부</FormLabel> + </div> + </FormItem> + )} + /> + </form> + </Form> + </ScrollArea> + <SheetFooter className="gap-2 pt-2 sm:space-x-0"> + <SheetClose asChild> + <Button type="button" variant="outline"> + 취소 + </Button> + </SheetClose> + <Button + type="submit" + form="update-rfq-detail-form" + disabled={isUpdatePending} + > + {isUpdatePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + 저장 + </Button> + </SheetFooter> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/procurement-rfqs/table/detail-table/vendor-communication-drawer.tsx b/lib/procurement-rfqs/table/detail-table/vendor-communication-drawer.tsx new file mode 100644 index 00000000..34efdfc2 --- /dev/null +++ b/lib/procurement-rfqs/table/detail-table/vendor-communication-drawer.tsx @@ -0,0 +1,518 @@ +"use client" + +import * as React from "react" +import { useState, useEffect, useRef } from "react" +import { ProcurementRfqsView } from "@/db/schema" +import { RfqDetailView } from "./rfq-detail-column" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { Textarea } from "@/components/ui/textarea" +import { Avatar, AvatarFallback } from "@/components/ui/avatar" +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, +} from "@/components/ui/drawer" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Badge } from "@/components/ui/badge" +import { toast } from "sonner" +import { + Send, + Paperclip, + DownloadCloud, + File, + FileText, + Image as ImageIcon, + AlertCircle, + X +} from "lucide-react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { formatDateTime } from "@/lib/utils" +import { formatFileSize } from "@/lib/utils" // formatFileSize 유틸리티 임포트 +import { fetchVendorComments, markMessagesAsRead } from "@/lib/procurement-rfqs/services" + +// 타입 정의 +interface Comment { + id: number; + rfqId: number; + vendorId: number | null // null 허용으로 변경 + userId?: number | null // null 허용으로 변경 + content: string; + isVendorComment: boolean | null; // null 허용으로 변경 + createdAt: Date; + updatedAt: Date; + userName?: string | null // null 허용으로 변경 + vendorName?: string | null // null 허용으로 변경 + attachments: Attachment[]; + isRead: boolean | null // null 허용으로 변경 +} + +interface Attachment { + id: number; + fileName: string; + fileSize: number; + fileType: string; + filePath: string; + uploadedAt: Date; +} + +// 프롭스 정의 +interface VendorCommunicationDrawerProps { + open: boolean; + onOpenChange: (open: boolean) => void; + selectedRfq: ProcurementRfqsView | null; + selectedVendor: RfqDetailView | null; + onSuccess?: () => void; +} + +async function sendComment(params: { + rfqId: number; + vendorId: number; + content: string; + attachments?: File[]; +}): Promise<Comment> { + try { + // 폼 데이터 생성 (파일 첨부를 위해) + const formData = new FormData(); + formData.append('rfqId', params.rfqId.toString()); + formData.append('vendorId', params.vendorId.toString()); + formData.append('content', params.content); + formData.append('isVendorComment', 'false'); + + // 첨부파일 추가 + if (params.attachments && params.attachments.length > 0) { + params.attachments.forEach((file) => { + formData.append(`attachments`, file); + }); + } + + // API 엔드포인트 구성 + const url = `/api/procurement-rfqs/${params.rfqId}/vendors/${params.vendorId}/comments`; + + // API 호출 + const response = await fetch(url, { + method: 'POST', + body: formData, // multipart/form-data 형식 사용 + }); + + if (!response.ok) { + const errorText = await response.text(); + throw new Error(`API 요청 실패: ${response.status} ${errorText}`); + } + + // 응답 데이터 파싱 + const result = await response.json(); + + if (!result.success || !result.data) { + throw new Error(result.message || '코멘트 전송 중 오류가 발생했습니다'); + } + + return result.data.comment; + } catch (error) { + console.error('코멘트 전송 오류:', error); + throw error; + } +} + +export function VendorCommunicationDrawer({ + open, + onOpenChange, + selectedRfq, + selectedVendor, + onSuccess +}: VendorCommunicationDrawerProps) { + // 상태 관리 + const [comments, setComments] = useState<Comment[]>([]); + const [newComment, setNewComment] = useState(""); + const [attachments, setAttachments] = useState<File[]>([]); + const [isLoading, setIsLoading] = useState(false); + const [isSubmitting, setIsSubmitting] = useState(false); + const fileInputRef = useRef<HTMLInputElement>(null); + const messagesEndRef = useRef<HTMLDivElement>(null); + + // 첨부파일 관련 상태 + const [previewDialogOpen, setPreviewDialogOpen] = useState(false); + const [selectedAttachment, setSelectedAttachment] = useState<Attachment | null>(null); + + // 드로어가 열릴 때 데이터 로드 + useEffect(() => { + if (open && selectedRfq && selectedVendor) { + loadComments(); + } + }, [open, selectedRfq, selectedVendor]); + + // 스크롤 최하단으로 이동 + useEffect(() => { + if (messagesEndRef.current) { + messagesEndRef.current.scrollIntoView({ behavior: "smooth" }); + } + }, [comments]); + + // 코멘트 로드 함수 + const loadComments = async () => { + if (!selectedRfq || !selectedVendor) return; + + try { + setIsLoading(true); + + // Server Action을 사용하여 코멘트 데이터 가져오기 + const commentsData = await fetchVendorComments(selectedRfq.id, selectedVendor.vendorId); + setComments(commentsData); + + // Server Action을 사용하여 읽지 않은 메시지를 읽음 상태로 변경 + await markMessagesAsRead(selectedRfq.id, selectedVendor.vendorId); + } catch (error) { + console.error("코멘트 로드 오류:", error); + toast.error("메시지를 불러오는 중 오류가 발생했습니다"); + } finally { + setIsLoading(false); + } + }; + + // 파일 선택 핸들러 + const handleFileSelect = () => { + fileInputRef.current?.click(); + }; + + // 파일 변경 핸들러 + const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => { + if (e.target.files && e.target.files.length > 0) { + const newFiles = Array.from(e.target.files); + setAttachments(prev => [...prev, ...newFiles]); + } + }; + + // 파일 제거 핸들러 + const handleRemoveFile = (index: number) => { + setAttachments(prev => prev.filter((_, i) => i !== index)); + }; + + console.log(newComment) + + // 코멘트 전송 핸들러 + const handleSubmitComment = async () => { + console.log("버튼 클릭1", selectedRfq,selectedVendor, selectedVendor?.vendorId ) + console.log(!newComment.trim() && attachments.length === 0) + + if (!newComment.trim() && attachments.length === 0) return; + if (!selectedRfq || !selectedVendor || !selectedVendor.vendorId) return; + + console.log("버튼 클릭") + + try { + setIsSubmitting(true); + + // API를 사용하여 새 코멘트 전송 (파일 업로드 때문에 FormData 사용) + const newCommentObj = await sendComment({ + rfqId: selectedRfq.id, + vendorId: selectedVendor.vendorId, + content: newComment, + attachments: attachments + }); + + // 상태 업데이트 + setComments(prev => [...prev, newCommentObj]); + setNewComment(""); + setAttachments([]); + + toast.success("메시지가 전송되었습니다"); + + // 데이터 새로고침 + if (onSuccess) { + onSuccess(); + } + } catch (error) { + console.error("코멘트 전송 오류:", error); + toast.error("메시지 전송 중 오류가 발생했습니다"); + } finally { + setIsSubmitting(false); + } + }; + + // 첨부파일 미리보기 + const handleAttachmentPreview = (attachment: Attachment) => { + setSelectedAttachment(attachment); + setPreviewDialogOpen(true); + }; + + // 첨부파일 다운로드 + const handleAttachmentDownload = (attachment: Attachment) => { + // TODO: 실제 다운로드 구현 + window.open(attachment.filePath, '_blank'); + }; + + // 파일 아이콘 선택 + const getFileIcon = (fileType: string) => { + if (fileType.startsWith("image/")) return <ImageIcon className="h-5 w-5 text-blue-500" />; + if (fileType.includes("pdf")) return <FileText className="h-5 w-5 text-red-500" />; + if (fileType.includes("spreadsheet") || fileType.includes("excel")) + return <FileText className="h-5 w-5 text-green-500" />; + if (fileType.includes("document") || fileType.includes("word")) + return <FileText className="h-5 w-5 text-blue-500" />; + return <File className="h-5 w-5 text-gray-500" />; + }; + + // 첨부파일 미리보기 다이얼로그 + const renderAttachmentPreviewDialog = () => { + if (!selectedAttachment) return null; + + const isImage = selectedAttachment.fileType.startsWith("image/"); + const isPdf = selectedAttachment.fileType.includes("pdf"); + + return ( + <Dialog open={previewDialogOpen} onOpenChange={setPreviewDialogOpen}> + <DialogContent className="max-w-3xl"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + {getFileIcon(selectedAttachment.fileType)} + {selectedAttachment.fileName} + </DialogTitle> + <DialogDescription> + {formatFileSize(selectedAttachment.fileSize)} • {formatDateTime(selectedAttachment.uploadedAt)} + </DialogDescription> + </DialogHeader> + + <div className="min-h-[300px] flex items-center justify-center p-4"> + {isImage ? ( + <img + src={selectedAttachment.filePath} + alt={selectedAttachment.fileName} + className="max-h-[500px] max-w-full object-contain" + /> + ) : isPdf ? ( + <iframe + src={`${selectedAttachment.filePath}#toolbar=0`} + className="w-full h-[500px]" + title={selectedAttachment.fileName} + /> + ) : ( + <div className="flex flex-col items-center gap-4 p-8"> + {getFileIcon(selectedAttachment.fileType)} + <p className="text-muted-foreground text-sm">미리보기를 지원하지 않는 파일 형식입니다.</p> + <Button + variant="outline" + onClick={() => handleAttachmentDownload(selectedAttachment)} + > + <DownloadCloud className="h-4 w-4 mr-2" /> + 다운로드 + </Button> + </div> + )} + </div> + </DialogContent> + </Dialog> + ); + }; + + if (!selectedRfq || !selectedVendor) { + return null; + } + + return ( + <Drawer open={open} onOpenChange={onOpenChange}> + <DrawerContent className="max-h-[85vh]"> + <DrawerHeader className="border-b"> + <DrawerTitle className="flex items-center gap-2"> + <Avatar className="h-8 w-8"> + <AvatarFallback className="bg-primary/10"> + {selectedVendor.vendorName?.[0] || 'V'} + </AvatarFallback> + </Avatar> + <div> + <span>{selectedVendor.vendorName}</span> + <Badge variant="outline" className="ml-2">{selectedVendor.vendorCode}</Badge> + </div> + </DrawerTitle> + <DrawerDescription> + RFQ: {selectedRfq.rfqCode} • 프로젝트: {selectedRfq.projectName} + </DrawerDescription> + </DrawerHeader> + + <div className="p-0 flex flex-col h-[60vh]"> + {/* 메시지 목록 */} + <ScrollArea className="flex-1 p-4"> + {isLoading ? ( + <div className="flex h-full items-center justify-center"> + <p className="text-muted-foreground">메시지 로딩 중...</p> + </div> + ) : comments.length === 0 ? ( + <div className="flex h-full items-center justify-center"> + <div className="flex flex-col items-center gap-2"> + <AlertCircle className="h-6 w-6 text-muted-foreground" /> + <p className="text-muted-foreground">아직 메시지가 없습니다</p> + </div> + </div> + ) : ( + <div className="space-y-4"> + {comments.map(comment => ( + <div + key={comment.id} + className={`flex gap-3 ${comment.isVendorComment ? 'justify-start' : 'justify-end'}`} + > + {comment.isVendorComment && ( + <Avatar className="h-8 w-8 mt-1"> + <AvatarFallback className="bg-primary/10"> + {comment.vendorName?.[0] || 'V'} + </AvatarFallback> + </Avatar> + )} + + <div className={`rounded-lg p-3 max-w-[80%] ${ + comment.isVendorComment + ? 'bg-muted' + : 'bg-primary text-primary-foreground' + }`}> + <div className="text-sm font-medium mb-1"> + {comment.isVendorComment ? comment.vendorName : comment.userName} + </div> + + {comment.content && ( + <div className="text-sm whitespace-pre-wrap break-words"> + {comment.content} + </div> + )} + + {/* 첨부파일 표시 */} + {comment.attachments.length > 0 && ( + <div className={`mt-2 pt-2 ${ + comment.isVendorComment + ? 'border-t border-t-border/30' + : 'border-t border-t-primary-foreground/20' + }`}> + {comment.attachments.map(attachment => ( + <div + key={attachment.id} + className="flex items-center text-xs gap-2 mb-1 p-1 rounded hover:bg-black/5 cursor-pointer" + onClick={() => handleAttachmentPreview(attachment)} + > + {getFileIcon(attachment.fileType)} + <span className="flex-1 truncate">{attachment.fileName}</span> + <span className="text-xs opacity-70"> + {formatFileSize(attachment.fileSize)} + </span> + <Button + variant="ghost" + size="icon" + className="h-6 w-6 rounded-full" + onClick={(e) => { + e.stopPropagation(); + handleAttachmentDownload(attachment); + }} + > + <DownloadCloud className="h-3 w-3" /> + </Button> + </div> + ))} + </div> + )} + + <div className="text-xs mt-1 opacity-70 flex items-center gap-1 justify-end"> + {formatDateTime(comment.createdAt)} + </div> + </div> + + {!comment.isVendorComment && ( + <Avatar className="h-8 w-8 mt-1"> + <AvatarFallback className="bg-primary/20"> + {comment.userName?.[0] || 'U'} + </AvatarFallback> + </Avatar> + )} + </div> + ))} + <div ref={messagesEndRef} /> + </div> + )} + </ScrollArea> + + {/* 선택된 첨부파일 표시 */} + {attachments.length > 0 && ( + <div className="p-2 bg-muted mx-4 rounded-md mb-2"> + <div className="text-xs font-medium mb-1">첨부파일</div> + <div className="flex flex-wrap gap-2"> + {attachments.map((file, index) => ( + <div key={index} className="flex items-center bg-background rounded-md p-1 pr-2 text-xs"> + {file.type.startsWith("image/") ? ( + <ImageIcon className="h-4 w-4 mr-1 text-blue-500" /> + ) : ( + <File className="h-4 w-4 mr-1 text-gray-500" /> + )} + <span className="truncate max-w-[100px]">{file.name}</span> + <Button + variant="ghost" + size="icon" + className="h-4 w-4 ml-1 p-0" + onClick={() => handleRemoveFile(index)} + > + <X className="h-3 w-3" /> + </Button> + </div> + ))} + </div> + </div> + )} + + {/* 메시지 입력 영역 */} + <div className="p-4 border-t"> + <div className="flex gap-2 items-end"> + <div className="flex-1"> + <Textarea + placeholder="메시지를 입력하세요..." + className="min-h-[80px] resize-none" + value={newComment} + onChange={(e) => setNewComment(e.target.value)} + /> + </div> + <div className="flex flex-col gap-2"> + <input + type="file" + ref={fileInputRef} + className="hidden" + multiple + onChange={handleFileChange} + /> + <Button + variant="outline" + size="icon" + onClick={handleFileSelect} + title="파일 첨부" + > + <Paperclip className="h-4 w-4" /> + </Button> + <Button + onClick={handleSubmitComment} + disabled={(!newComment.trim() && attachments.length === 0) || isSubmitting} + > + <Send className="h-4 w-4" /> + </Button> + </div> + </div> + </div> + </div> + + <DrawerFooter className="border-t"> + <div className="flex justify-between"> + <Button variant="outline" onClick={() => loadComments()}> + 새로고침 + </Button> + <DrawerClose asChild> + <Button variant="outline">닫기</Button> + </DrawerClose> + </div> + </DrawerFooter> + </DrawerContent> + + {renderAttachmentPreviewDialog()} + </Drawer> + ); +}
\ No newline at end of file diff --git a/lib/procurement-rfqs/table/detail-table/vendor-quotation-comparison-dialog.tsx b/lib/procurement-rfqs/table/detail-table/vendor-quotation-comparison-dialog.tsx new file mode 100644 index 00000000..72cf187c --- /dev/null +++ b/lib/procurement-rfqs/table/detail-table/vendor-quotation-comparison-dialog.tsx @@ -0,0 +1,665 @@ +"use client" + +import * as React from "react" +import { useEffect, useState } from "react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Skeleton } from "@/components/ui/skeleton" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { toast } from "sonner" + +// Lucide 아이콘 +import { Plus, Minus } from "lucide-react" + +import { ProcurementRfqsView } from "@/db/schema" +import { fetchVendorQuotations, fetchQuotationItems } from "@/lib/procurement-rfqs/services" +import { formatCurrency, formatDate } from "@/lib/utils" + +// 견적 정보 타입 +interface VendorQuotation { + id: number + rfqId: number + vendorId: number + vendorName?: string | null + quotationCode: string + quotationVersion: number + totalItemsCount: number + subTotal: string + taxTotal: string + discountTotal: string + totalPrice: string + currency: string + validUntil: string | Date // 수정: string | Date 허용 + estimatedDeliveryDate: string | Date // 수정: string | Date 허용 + paymentTermsCode: string + paymentTermsDescription?: string | null + incotermsCode: string + incotermsDescription?: string | null + incotermsDetail: string + status: string + remark: string + rejectionReason: string + submittedAt: string | Date // 수정: string | Date 허용 + acceptedAt: string | Date // 수정: string | Date 허용 + createdAt: string | Date // 수정: string | Date 허용 + updatedAt: string | Date // 수정: string | Date 허용 +} + +// 견적 아이템 타입 +interface QuotationItem { + id: number + quotationId: number + prItemId: number + materialCode: string | null // Changed from string to string | null + materialDescription: string | null // Changed from string to string | null + quantity: string + uom: string | null // Changed assuming this might be null + unitPrice: string + totalPrice: string + currency: string | null // Changed from string to string | null + vendorMaterialCode: string | null // Changed from string to string | null + vendorMaterialDescription: string | null // Changed from string to string | null + deliveryDate: Date | null // Changed from string to string | null + leadTimeInDays: number | null // Changed from number to number | null + taxRate: string | null // Changed from string to string | null + taxAmount: string | null // Changed from string to string | null + discountRate: string | null // Changed from string to string | null + discountAmount: string | null // Changed from string to string | null + remark: string | null // Changed from string to string | null + isAlternative: boolean | null // Changed from boolean to boolean | null + isRecommended: boolean | null // Changed from boolean to boolean | null +} + +interface VendorQuotationComparisonDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + selectedRfq: ProcurementRfqsView | null +} + +export function VendorQuotationComparisonDialog({ + open, + onOpenChange, + selectedRfq, +}: VendorQuotationComparisonDialogProps) { + const [isLoading, setIsLoading] = useState(false) + const [quotations, setQuotations] = useState<VendorQuotation[]>([]) + const [quotationItems, setQuotationItems] = useState<Record<number, QuotationItem[]>>({}) + const [activeTab, setActiveTab] = useState("summary") + + // 벤더별 접힘 상태 (true=접힘, false=펼침), 기본값: 접힘 + const [collapsedVendors, setCollapsedVendors] = useState<Record<number, boolean>>({}) + + useEffect(() => { + async function loadQuotationData() { + if (!open || !selectedRfq?.id) return + + try { + setIsLoading(true) + // 1) 견적 목록 + const quotationsResult = await fetchVendorQuotations(selectedRfq.id) + const rawQuotationsData = quotationsResult.data || [] + + const quotationsData = rawQuotationsData.map((rawData): VendorQuotation => ({ + id: rawData.id, + rfqId: rawData.rfqId, + vendorId: rawData.vendorId, + vendorName: rawData.vendorName || null, + quotationCode: rawData.quotationCode || '', + quotationVersion: rawData.quotationVersion || 0, + totalItemsCount: rawData.totalItemsCount || 0, + subTotal: rawData.subTotal || '0', + taxTotal: rawData.taxTotal || '0', + discountTotal: rawData.discountTotal || '0', + totalPrice: rawData.totalPrice || '0', + currency: rawData.currency || 'KRW', + validUntil: rawData.validUntil || '', + estimatedDeliveryDate: rawData.estimatedDeliveryDate || '', + paymentTermsCode: rawData.paymentTermsCode || '', + paymentTermsDescription: rawData.paymentTermsDescription || null, + incotermsCode: rawData.incotermsCode || '', + incotermsDescription: rawData.incotermsDescription || null, + incotermsDetail: rawData.incotermsDetail || '', + status: rawData.status || '', + remark: rawData.remark || '', + rejectionReason: rawData.rejectionReason || '', + submittedAt: rawData.submittedAt || '', + acceptedAt: rawData.acceptedAt || '', + createdAt: rawData.createdAt || '', + updatedAt: rawData.updatedAt || '', + })); + + setQuotations(quotationsData); + + // 벤더별로 접힘 상태 기본값(true) 설정 + const collapsedInit: Record<number, boolean> = {} + quotationsData.forEach((q) => { + collapsedInit[q.id] = true + }) + setCollapsedVendors(collapsedInit) + + // 2) 견적 아이템 + const qIds = quotationsData.map((q) => q.id) + if (qIds.length > 0) { + const itemsResult = await fetchQuotationItems(qIds) + const itemsData = itemsResult.data || [] + + const itemsByQuotation: Record<number, QuotationItem[]> = {} + itemsData.forEach((item) => { + if (!itemsByQuotation[item.quotationId]) { + itemsByQuotation[item.quotationId] = [] + } + itemsByQuotation[item.quotationId].push(item) + }) + setQuotationItems(itemsByQuotation) + } + } catch (error) { + console.error("견적 데이터 로드 오류:", error) + toast.error("견적 데이터를 불러오는 데 실패했습니다") + } finally { + setIsLoading(false) + } + } + + loadQuotationData() + }, [open, selectedRfq]) + + // 견적 상태 -> 뱃지 색 + const getStatusBadgeVariant = (status: string) => { + switch (status) { + case "Submitted": + return "default" + case "Accepted": + return "default" + case "Rejected": + return "destructive" + case "Revised": + return "destructive" + default: + return "secondary" + } + } + + // 모든 prItemId 모음 + const allItemIds = React.useMemo(() => { + const itemSet = new Set<number>() + Object.values(quotationItems).forEach((items) => { + items.forEach((it) => itemSet.add(it.prItemId)) + }) + return Array.from(itemSet) + }, [quotationItems]) + + // 아이템 찾는 함수 + const findItemByQuotationId = (prItemId: number, qid: number) => { + const items = quotationItems[qid] || [] + return items.find((i) => i.prItemId === prItemId) + } + + // 접힘 상태 토글 + const toggleVendor = (qid: number) => { + setCollapsedVendors((prev) => ({ + ...prev, + [qid]: !prev[qid], + })) + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + {/* 다이얼로그 자체는 max-h, max-w 설정, 내부에 스크롤 컨테이너를 둠 */} + <DialogContent className="max-w-[90vw] lg:max-w-5xl max-h-[90vh]" style={{ maxWidth: '90vw', maxHeight: '90vh' }}> + <DialogHeader> + <DialogTitle>벤더 견적 비교</DialogTitle> + <DialogDescription> + {selectedRfq + ? `RFQ ${selectedRfq.rfqCode} - ${selectedRfq.itemName || ""}` + : ""} + </DialogDescription> + </DialogHeader> + + {isLoading ? ( + <div className="space-y-4"> + <Skeleton className="h-8 w-1/2" /> + <Skeleton className="h-48 w-full" /> + </div> + ) : quotations.length === 0 ? ( + <div className="py-8 text-center text-muted-foreground"> + 제출된(Submitted) 견적이 없습니다 + </div> + ) : ( + <Tabs value={activeTab} onValueChange={setActiveTab} className="w-full"> + <TabsList className="grid w-full grid-cols-2"> + <TabsTrigger value="summary">견적 요약 비교</TabsTrigger> + <TabsTrigger value="items">아이템별 비교</TabsTrigger> + </TabsList> + + {/* ======================== 요약 비교 탭 ======================== */} + <TabsContent value="summary" className="mt-4"> + {/* + table-fixed + 가로 너비를 크게 잡아줌 (예: w-[1200px]) + -> 컨테이너보다 넓으면 수평 스크롤 발생. + */} + <div className="border rounded-md max-h-[60vh] overflow-auto"> + <table className="table-fixed w-full border-collapse"> + <thead className="sticky top-0 bg-background z-10"> + <TableRow> + <TableHead + className="sticky left-0 top-0 z-20 bg-background p-2" + > + 항목 + </TableHead> + {quotations.map((q) => ( + <TableHead key={q.id} className="p-2 text-center whitespace-nowrap"> + {q.vendorName || `벤더 ID: ${q.vendorId}`} + </TableHead> + ))} + </TableRow> + </thead> + <tbody> + {/* 견적 상태 */} + <TableRow> + <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> + 견적 상태 + </TableCell> + {quotations.map((q) => ( + <TableCell key={`status-${q.id}`} className="p-2"> + <Badge variant={getStatusBadgeVariant(q.status)}> + {q.status} + </Badge> + </TableCell> + ))} + </TableRow> + + {/* 견적 버전 */} + <TableRow> + <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> + 견적 버전 + </TableCell> + {quotations.map((q) => ( + <TableCell key={`version-${q.id}`} className="p-2"> + v{q.quotationVersion} + </TableCell> + ))} + </TableRow> + + {/* 총 금액 */} + <TableRow> + <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> + 총 금액 + </TableCell> + {quotations.map((q) => ( + <TableCell key={`total-${q.id}`} className="p-2 font-semibold"> + {formatCurrency(Number(q.totalPrice), q.currency)} + </TableCell> + ))} + </TableRow> + + {/* 소계 */} + <TableRow> + <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> + 소계 + </TableCell> + {quotations.map((q) => ( + <TableCell key={`subtotal-${q.id}`} className="p-2"> + {formatCurrency(Number(q.subTotal), q.currency)} + </TableCell> + ))} + </TableRow> + + {/* 세금 */} + <TableRow> + <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> + 세금 + </TableCell> + {quotations.map((q) => ( + <TableCell key={`tax-${q.id}`} className="p-2"> + {formatCurrency(Number(q.taxTotal), q.currency)} + </TableCell> + ))} + </TableRow> + + {/* 할인 */} + <TableRow> + <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> + 할인 + </TableCell> + {quotations.map((q) => ( + <TableCell key={`discount-${q.id}`} className="p-2"> + {formatCurrency(Number(q.discountTotal), q.currency)} + </TableCell> + ))} + </TableRow> + + {/* 통화 */} + <TableRow> + <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> + 통화 + </TableCell> + {quotations.map((q) => ( + <TableCell key={`currency-${q.id}`} className="p-2"> + {q.currency} + </TableCell> + ))} + </TableRow> + + {/* 유효기간 */} + <TableRow> + <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> + 유효 기간 + </TableCell> + {quotations.map((q) => ( + <TableCell key={`valid-${q.id}`} className="p-2"> + {formatDate(q.validUntil, "KR")} + </TableCell> + ))} + </TableRow> + + {/* 예상 배송일 */} + <TableRow> + <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> + 예상 배송일 + </TableCell> + {quotations.map((q) => ( + <TableCell key={`delivery-${q.id}`} className="p-2"> + {formatDate(q.estimatedDeliveryDate, "KR")} + </TableCell> + ))} + </TableRow> + + {/* 지불 조건 */} + <TableRow> + <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> + 지불 조건 + </TableCell> + {quotations.map((q) => ( + <TableCell key={`payment-${q.id}`} className="p-2"> + {q.paymentTermsDescription || q.paymentTermsCode} + </TableCell> + ))} + </TableRow> + + {/* 인코텀즈 */} + <TableRow> + <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> + 인코텀즈 + </TableCell> + {quotations.map((q) => ( + <TableCell key={`incoterms-${q.id}`} className="p-2"> + {q.incotermsDescription || q.incotermsCode} + {q.incotermsDetail && ( + <div className="text-xs text-muted-foreground mt-1"> + {q.incotermsDetail} + </div> + )} + </TableCell> + ))} + </TableRow> + + {/* 제출일 */} + <TableRow> + <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> + 제출일 + </TableCell> + {quotations.map((q) => ( + <TableCell key={`submitted-${q.id}`} className="p-2"> + {formatDate(q.submittedAt, "KR")} + </TableCell> + ))} + </TableRow> + + {/* 비고 */} + <TableRow> + <TableCell className="sticky left-0 z-10 bg-muted/30 p-2 font-medium"> + 비고 + </TableCell> + {quotations.map((q) => ( + <TableCell + key={`remark-${q.id}`} + className="p-2 whitespace-pre-wrap" + > + {q.remark || "-"} + </TableCell> + ))} + </TableRow> + </tbody> + </table> + </div> + </TabsContent> + + {/* ====================== 아이템별 비교 탭 ====================== */} + <TabsContent value="items" className="mt-4"> + {/* 컨테이너에 테이블 관련 클래스 직접 적용 */} + <div className="border rounded-md max-h-[60vh] overflow-y-auto overflow-x-auto" > + <div className="min-w-full w-max" style={{ maxWidth: '70vw' }}> + <table className="w-full border-collapse"> + <thead className="sticky top-0 bg-background z-10"> + {/* 첫 번째 헤더 행 */} + <tr> + {/* 첫 행: 자재(코드) 컬럼 */} + <th + rowSpan={2} + className="sticky left-0 top-0 z-20 p-2 border border-gray-200 text-left" + style={{ + width: '250px', + minWidth: '250px', + backgroundColor: 'white', + }} + > + 자재 (코드) + </th> + + {/* 벤더 헤더 (접힘/펼침) */} + {quotations.map((q, index) => { + const collapsed = collapsedVendors[q.id] + // 접힌 상태면 1칸, 펼친 상태면 6칸 + return ( + <th + key={q.id} + className="p-2 text-center whitespace-nowrap border border-gray-200" + colSpan={collapsed ? 1 : 6} + style={{ + borderRight: index < quotations.length - 1 ? '1px solid #e5e7eb' : '', + backgroundColor: 'white', + }} + > + {/* + / - 버튼 */} + <div className="flex items-center gap-2 justify-center"> + <Button + variant="ghost" + size="sm" + className="h-7 w-7 p-1" + onClick={() => toggleVendor(q.id)} + > + {collapsed ? <Plus size={16} /> : <Minus size={16} />} + </Button> + <span>{q.vendorName || `벤더 ID: ${q.vendorId}`}</span> + </div> + </th> + ) + })} + </tr> + + {/* 두 번째 헤더 행 - 하위 컬럼들 */} + <tr className="border-b border-b-gray-200"> + {/* 펼쳐진 벤더의 하위 컬럼들만 표시 */} + {quotations.flatMap((q, qIndex) => { + // 접힌 상태면 추가 헤더 없음 + if (collapsedVendors[q.id]) { + return [ + <th + key={`${q.id}-collapsed`} + className="p-2 text-center whitespace-nowrap border border-gray-200" + style={{ backgroundColor: 'white' }} + > + 총액 + </th> + ]; + } + + // 펼친 상태면 6개 컬럼 표시 + const columns = [ + { key: 'unitprice', label: '단가' }, + { key: 'totalprice', label: '총액' }, + { key: 'tax', label: '세금' }, + { key: 'discount', label: '할인' }, + { key: 'leadtime', label: '리드타임' }, + { key: 'alternative', label: '대체품' }, + ]; + + return columns.map((col, colIndex) => { + const isFirstInGroup = colIndex === 0; + const isLastInGroup = colIndex === columns.length - 1; + + return ( + <th + key={`${q.id}-${col.key}`} + className={`p-2 text-center whitespace-nowrap border border-gray-200 ${ + isFirstInGroup ? 'border-l border-l-gray-200' : '' + } ${ + isLastInGroup ? 'border-r border-r-gray-200' : '' + }`} + style={{ backgroundColor: 'white' }} + > + {col.label} + </th> + ); + }); + })} + </tr> + </thead> + + {/* 테이블 바디 */} + <tbody> + {allItemIds.map((itemId) => { + // 자재 기본 정보는 첫 번째 벤더 아이템 기준 + const firstQid = quotations[0]?.id + const sampleItem = firstQid + ? findItemByQuotationId(itemId, firstQid) + : undefined + + return ( + <tr key={itemId} className="border-b border-gray-100"> + {/* 자재 (코드) 셀 */} + <td + className="sticky left-0 z-10 p-2 align-top border-r border-gray-100" + style={{ + width: '250px', + minWidth: '250px', + backgroundColor: 'white', + }} + > + {sampleItem?.materialDescription || sampleItem?.materialCode || ""} + {sampleItem && ( + <div className="text-xs text-muted-foreground mt-1"> + 코드: {sampleItem.materialCode} | 수량:{" "} + {sampleItem.quantity} {sampleItem.uom} + </div> + )} + </td> + + {/* 벤더별 아이템 데이터 */} + {quotations.flatMap((q, qIndex) => { + const collapsed = collapsedVendors[q.id] + const itemData = findItemByQuotationId(itemId, q.id) + + // 접힌 상태면 총액만 표시 + if (collapsed) { + return [ + <td + key={`${q.id}-collapsed`} + className="p-2 text-center text-sm font-medium whitespace-nowrap border-r border-gray-100" + > + {itemData + ? formatCurrency(Number(itemData.totalPrice), itemData.currency) + : "N/A"} + </td> + ]; + } + + // 펼친 상태 - 아이템 없음 + if (!itemData) { + return [ + <td + key={`${q.id}-empty`} + colSpan={6} + className="p-2 text-center text-sm border-r border-gray-100" + > + 없음 + </td> + ]; + } + + // 펼친 상태 - 모든 컬럼 표시 + const columns = [ + { key: 'unitprice', render: () => formatCurrency(Number(itemData.unitPrice), itemData.currency), align: 'right' }, + { key: 'totalprice', render: () => formatCurrency(Number(itemData.totalPrice), itemData.currency), align: 'right', bold: true }, + { key: 'tax', render: () => itemData.taxRate ? `${itemData.taxRate}% (${formatCurrency(Number(itemData.taxAmount), itemData.currency)})` : "-", align: 'right' }, + { key: 'discount', render: () => itemData.discountRate ? `${itemData.discountRate}% (${formatCurrency(Number(itemData.discountAmount), itemData.currency)})` : "-", align: 'right' }, + { key: 'leadtime', render: () => itemData.leadTimeInDays ? `${itemData.leadTimeInDays}일` : "-", align: 'center' }, + { key: 'alternative', render: () => itemData.isAlternative ? "대체품" : "표준품", align: 'center' }, + ]; + + return columns.map((col, colIndex) => { + const isFirstInGroup = colIndex === 0; + const isLastInGroup = colIndex === columns.length - 1; + + return ( + <td + key={`${q.id}-${col.key}`} + className={`p-2 text-${col.align} ${col.bold ? 'font-semibold' : ''} ${ + isFirstInGroup ? 'border-l border-l-gray-100' : '' + } ${ + isLastInGroup ? 'border-r border-r-gray-100' : 'border-r border-gray-100' + }`} + > + {col.render()} + </td> + ); + }); + })} + </tr> + ); + })} + + {/* 아이템이 전혀 없는 경우 */} + {allItemIds.length === 0 && ( + <tr> + <td + colSpan={100} // 충분히 큰 수로 설정해 모든 컬럼을 커버 + className="text-center p-4 border border-gray-100" + > + 아이템 정보가 없습니다 + </td> + </tr> + )} + </tbody> + </table> + </div> + </div> + </TabsContent> + </Tabs> + )} + + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + 닫기 + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +} diff --git a/lib/procurement-rfqs/table/rfq-filter-sheet.tsx b/lib/procurement-rfqs/table/rfq-filter-sheet.tsx new file mode 100644 index 00000000..a746603b --- /dev/null +++ b/lib/procurement-rfqs/table/rfq-filter-sheet.tsx @@ -0,0 +1,686 @@ +"use client" + +import { useEffect, useTransition, useState, useRef } from "react" +import { useRouter, useParams } from "next/navigation" +import { z } from "zod" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { CalendarIcon, ChevronRight, Search, X } from "lucide-react" +import { customAlphabet } from "nanoid" +import { parseAsStringEnum, useQueryState } from "nuqs" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { cn } from "@/lib/utils" +import { useTranslation } from '@/i18n/client' +import { getFiltersStateParser } from "@/lib/parsers" +import { DateRangePicker } from "@/components/date-range-picker" + +// nanoid 생성기 +const generateId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 6) + +// 필터 스키마 정의 (RFQ 관련 항목 유지) +const filterSchema = z.object({ + picCode: z.string().optional(), + projectCode: z.string().optional(), + rfqCode: z.string().optional(), + itemCode: z.string().optional(), + majorItemMaterialCode: z.string().optional(), + status: z.string().optional(), + dateRange: z.object({ + from: z.date().optional(), + to: z.date().optional(), + }).optional(), +}) + +// 상태 옵션 정의 +const statusOptions = [ + { value: "RFQ Created", label: "RFQ Created" }, + { value: "RFQ Vendor Assignned", label: "RFQ Vendor Assignned" }, + { value: "RFQ Sent", label: "RFQ Sent" }, + { value: "Quotation Analysis", label: "Quotation Analysis" }, + { value: "PO Transfer", label: "PO Transfer" }, + { value: "PO Create", label: "PO Create" }, +] + +type FilterFormValues = z.infer<typeof filterSchema> + +interface RFQFilterSheetProps { + isOpen: boolean; + onClose: () => void; + onSearch?: () => void; + isLoading?: boolean; +} + +// Updated component for inline use (not a sheet anymore) +export function RFQFilterSheet({ + isOpen, + onClose, + onSearch, + isLoading = false +}: RFQFilterSheetProps) { + const router = useRouter() + const params = useParams(); + const lng = params ? (params.lng as string) : 'ko'; + const { t } = useTranslation(lng); + + const [isPending, startTransition] = useTransition() + + // 초기화 상태 추가 - 폼 초기화 중에는 상태 변경을 방지 + const [isInitializing, setIsInitializing] = useState(false) + // 마지막으로 적용된 필터를 추적하기 위한 ref + const lastAppliedFilters = useRef<string>("") + + // nuqs로 URL 상태 관리 - 파라미터명을 'basicFilters'로 변경 + const [filters, setFilters] = useQueryState( + "basicFilters", + getFiltersStateParser().withDefault([]) + ) + + // joinOperator 설정 + const [joinOperator, setJoinOperator] = useQueryState( + "basicJoinOperator", + parseAsStringEnum(["and", "or"]).withDefault("and") + ) + + // 현재 URL의 페이지 파라미터도 가져옴 + const [page, setPage] = useQueryState("page", { defaultValue: "1" }) + + // 폼 상태 초기화 + const form = useForm<FilterFormValues>({ + resolver: zodResolver(filterSchema), + defaultValues: { + picCode: "", + projectCode: "", + rfqCode: "", + itemCode: "", + majorItemMaterialCode: "", + status: "", + dateRange: { + from: undefined, + to: undefined, + }, + }, + }) + + // URL 필터에서 초기 폼 상태 설정 - 개선된 버전 + useEffect(() => { + // 현재 필터를 문자열로 직렬화 + const currentFiltersString = JSON.stringify(filters); + + // 패널이 열렸고, 필터가 있고, 마지막에 적용된 필터와 다를 때만 업데이트 + if (isOpen && filters && filters.length > 0 && currentFiltersString !== lastAppliedFilters.current) { + setIsInitializing(true); + + const formValues = { ...form.getValues() }; + let formUpdated = false; + + filters.forEach(filter => { + if (filter.id === "rfqSendDate" && Array.isArray(filter.value) && filter.value.length > 0) { + formValues.dateRange = { + from: filter.value[0] ? new Date(filter.value[0]) : undefined, + to: filter.value[1] ? new Date(filter.value[1]) : undefined, + }; + formUpdated = true; + } else if (filter.id in formValues) { + // @ts-ignore - 동적 필드 접근 + formValues[filter.id] = filter.value; + formUpdated = true; + } + }); + + // 폼 값이 변경된 경우에만 reset으로 한 번에 업데이트 + if (formUpdated) { + form.reset(formValues); + lastAppliedFilters.current = currentFiltersString; + } + + setIsInitializing(false); + } + }, [filters, isOpen]) // form 의존성 제거 + + // 현재 적용된 필터 카운트 + const getActiveFilterCount = () => { + return filters?.length || 0 + } + + // 폼 제출 핸들러 - PQ 방식으로 수정 (수동 URL 업데이트 버전) + async function onSubmit(data: FilterFormValues) { + // 초기화 중이면 제출 방지 + if (isInitializing) return; + + startTransition(async () => { + try { + // 필터 배열 생성 + const newFilters = [] + + if (data.picCode?.trim()) { + newFilters.push({ + id: "picCode", + value: data.picCode.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + + if (data.projectCode?.trim()) { + newFilters.push({ + id: "projectCode", + value: data.projectCode.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + + if (data.rfqCode?.trim()) { + newFilters.push({ + id: "rfqCode", + value: data.rfqCode.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + + if (data.itemCode?.trim()) { + newFilters.push({ + id: "itemCode", + value: data.itemCode.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + + if (data.majorItemMaterialCode?.trim()) { + newFilters.push({ + id: "majorItemMaterialCode", + value: data.majorItemMaterialCode.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + + if (data.status?.trim()) { + newFilters.push({ + id: "status", + value: data.status.trim(), + type: "select", + operator: "eq", + rowId: generateId() + }) + } + + // Add date range to params if it exists + if (data.dateRange?.from) { + newFilters.push({ + id: "rfqSendDate", + value: [ + data.dateRange.from.toISOString().split('T')[0], + data.dateRange.to ? data.dateRange.to.toISOString().split('T')[0] : undefined + ].filter(Boolean), + type: "date", + operator: "isBetween", + rowId: generateId() + }) + } + + console.log("=== RFQ Filter Submit Debug ==="); + console.log("Generated filters:", newFilters); + console.log("Join operator:", joinOperator); + + // 🔑 PQ 방식: 수동으로 URL 업데이트 (nuqs 대신) + const currentUrl = new URL(window.location.href); + const params = new URLSearchParams(currentUrl.search); + + // 기존 필터 관련 파라미터 제거 + params.delete('basicFilters'); + params.delete('basicJoinOperator'); + params.delete('page'); + + // 새로운 필터 추가 + if (newFilters.length > 0) { + params.set('basicFilters', JSON.stringify(newFilters)); + params.set('basicJoinOperator', joinOperator); + } + + // 페이지를 1로 설정 + params.set('page', '1'); + + const newUrl = `${currentUrl.pathname}?${params.toString()}`; + console.log("New URL:", newUrl); + + // 🔑 PQ 방식: 페이지 완전 새로고침으로 서버 렌더링 강제 + window.location.href = newUrl; + + // 마지막 적용된 필터 업데이트 + lastAppliedFilters.current = JSON.stringify(newFilters); + + // 필터 업데이트 후 조회 핸들러 호출 (제공된 경우) + if (onSearch) { + console.log("Calling onSearch..."); + onSearch(); + } + + console.log("=== RFQ Filter Submit Complete ==="); + } catch (error) { + console.error("RFQ 필터 적용 오류:", error); + } + }) + } + + // 필터 초기화 핸들러 - PQ 방식으로 수정 + async function handleReset() { + try { + setIsInitializing(true); + + form.reset({ + picCode: "", + projectCode: "", + rfqCode: "", + itemCode: "", + majorItemMaterialCode: "", + status: "", + dateRange: { from: undefined, to: undefined }, + }); + + console.log("=== RFQ Filter Reset Debug ==="); + console.log("Current URL before reset:", window.location.href); + + // 🔑 PQ 방식: 수동으로 URL 초기화 + const currentUrl = new URL(window.location.href); + const params = new URLSearchParams(currentUrl.search); + + // 필터 관련 파라미터 제거 + params.delete('basicFilters'); + params.delete('basicJoinOperator'); + params.set('page', '1'); + + const newUrl = `${currentUrl.pathname}?${params.toString()}`; + console.log("Reset URL:", newUrl); + + // 🔑 PQ 방식: 페이지 완전 새로고침 + window.location.href = newUrl; + + // 마지막 적용된 필터 초기화 + lastAppliedFilters.current = ""; + + console.log("RFQ 필터 초기화 완료"); + setIsInitializing(false); + } catch (error) { + console.error("RFQ 필터 초기화 오류:", error); + setIsInitializing(false); + } + } + + // Don't render if not open (for side panel use) + if (!isOpen) { + return null; + } + + return ( + <div className="flex flex-col h-full max-h-full bg-[#F5F7FB] px-6 sm:px-8" style={{backgroundColor:"#F5F7FB", paddingLeft:"2rem", paddingRight:"2rem"}}> + {/* Filter Panel Header */} + <div className="flex items-center justify-between px-6 min-h-[60px] shrink-0"> + <h3 className="text-lg font-semibold whitespace-nowrap">검색 필터</h3> + <div className="flex items-center gap-2"> + {getActiveFilterCount() > 0 && ( + <Badge variant="secondary" className="px-2 py-1"> + {getActiveFilterCount()}개 필터 적용됨 + </Badge> + )} + </div> + </div> + + {/* Join Operator Selection */} + <div className="px-6 shrink-0"> + <label className="text-sm font-medium">조건 결합 방식</label> + <Select + value={joinOperator} + onValueChange={(value: "and" | "or") => setJoinOperator(value)} + disabled={isInitializing} + > + <SelectTrigger className="h-8 w-[180px] mt-2 bg-white"> + <SelectValue placeholder="조건 결합 방식" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="and">모든 조건 충족 (AND)</SelectItem> + <SelectItem value="or">하나라도 충족 (OR)</SelectItem> + </SelectContent> + </Select> + </div> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col h-full min-h-0"> + {/* Scrollable content area - 헤더와 버튼 사이에서 스크롤 */} + <div className="flex-1 min-h-0 overflow-y-auto px-6 pb-4"> + <div className="space-y-4 pt-2"> + {/* 발주 담당 */} + <FormField + control={form.control} + name="picCode" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("발주담당")}</FormLabel> + <FormControl> + <div className="relative"> + <Input + placeholder={t("발주담당 입력")} + {...field} + className={cn(field.value && "pr-8", "bg-white")} + disabled={isInitializing} + /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-0 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("picCode", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 프로젝트 코드 */} + <FormField + control={form.control} + name="projectCode" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("프로젝트 코드")}</FormLabel> + <FormControl> + <div className="relative"> + <Input + placeholder={t("프로젝트 코드 입력")} + {...field} + className={cn(field.value && "pr-8", "bg-white")} + disabled={isInitializing} + /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-0 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("projectCode", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* RFQ NO. */} + <FormField + control={form.control} + name="rfqCode" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("RFQ NO.")}</FormLabel> + <FormControl> + <div className="relative"> + <Input + placeholder={t("RFQ 번호 입력")} + {...field} + className={cn(field.value && "pr-8", "bg-white")} + disabled={isInitializing} + /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-0 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("rfqCode", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 자재그룹 */} + <FormField + control={form.control} + name="itemCode" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("자재그룹")}</FormLabel> + <FormControl> + <div className="relative"> + <Input + placeholder={t("자재그룹 입력")} + {...field} + className={cn(field.value && "pr-8", "bg-white")} + disabled={isInitializing} + /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-0 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("itemCode", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 자재코드 */} + <FormField + control={form.control} + name="majorItemMaterialCode" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("자재코드")}</FormLabel> + <FormControl> + <div className="relative"> + <Input + placeholder={t("자재코드 입력")} + {...field} + className={cn(field.value && "pr-8", "bg-white")} + disabled={isInitializing} + /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-0 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("majorItemMaterialCode", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* Status */} + <FormField + control={form.control} + name="status" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("Status")}</FormLabel> + <Select + value={field.value} + onValueChange={field.onChange} + disabled={isInitializing} + > + <FormControl> + <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> + <div className="flex justify-between w-full"> + <SelectValue placeholder={t("Select status")} /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="h-4 w-4 -mr-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("status", ""); + }} + disabled={isInitializing} + > + <X className="size-3" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </SelectTrigger> + </FormControl> + <SelectContent> + {statusOptions.map(option => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + {/* RFQ 전송일 */} + <FormField + control={form.control} + name="dateRange" + render={({ field }) => ( + <FormItem> + <FormLabel>{t("RFQ 전송일")}</FormLabel> + <FormControl> + <div className="relative"> + <DateRangePicker + triggerSize="default" + triggerClassName="w-full bg-white" + align="start" + showClearButton={true} + placeholder={t("RFQ 전송일 범위를 고르세요")} + value={field.value || undefined} + onChange={field.onChange} + disabled={isInitializing} + /> + {(field.value?.from || field.value?.to) && ( + <Button + type="button" + variant="ghost" + size="icon" + className="absolute right-10 top-0 h-full px-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("dateRange", { from: undefined, to: undefined }); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + </div> + + {/* Fixed buttons at bottom */} + <div className="p-4 shrink-0"> + <div className="flex gap-2 justify-end"> + <Button + type="button" + variant="outline" + onClick={handleReset} + disabled={isPending || getActiveFilterCount() === 0 || isInitializing} + className="px-4" + > + {t("초기화")} + </Button> + <Button + type="submit" + variant="samsung" + disabled={isPending || isLoading || isInitializing} + className="px-4" + > + <Search className="size-4 mr-2" /> + {isPending || isLoading ? t("조회 중...") : t("조회")} + </Button> + </div> + </div> + </form> + </Form> + </div> + ) +}
\ No newline at end of file diff --git a/lib/procurement-rfqs/table/rfq-table copy.tsx b/lib/procurement-rfqs/table/rfq-table copy.tsx deleted file mode 100644 index 510f474d..00000000 --- a/lib/procurement-rfqs/table/rfq-table copy.tsx +++ /dev/null @@ -1,209 +0,0 @@ -"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 { getColumns, EditingCellState } from "./rfq-table-column" -import { useEffect } from "react" -import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" -import { RFQTableToolbarActions } from "./rfq-table-toolbar-actions" -import { ProcurementRfqsView } from "@/db/schema" -import { getPORfqs } from "../services" -import { toast } from "sonner" -import { updateRfqRemark } from "@/lib/procurement-rfqs/services" // 구현 필요 - -interface RFQListTableProps { - data?: Awaited<ReturnType<typeof getPORfqs>>; - onSelectRFQ?: (rfq: ProcurementRfqsView | null) => void; - // 데이터 새로고침을 위한 콜백 추가 - onDataRefresh?: () => void; - maxHeight?: string | number; // Add this prop -} - -// 보다 유연한 타입 정의 -type LocalDataType = Awaited<ReturnType<typeof getPORfqs>>; - -export function RFQListTable({ - data, - onSelectRFQ, - onDataRefresh, - maxHeight -}: RFQListTableProps) { - const [rowAction, setRowAction] = React.useState<DataTableRowAction<ProcurementRfqsView> | null>(null) - // 인라인 에디팅을 위한 상태 추가 - const [editingCell, setEditingCell] = React.useState<EditingCellState | null>(null) - // 로컬 데이터를 관리하기 위한 상태 추가 - const [localData, setLocalData] = React.useState<LocalDataType>(data || { data: [], pageCount: 0, total: 0 }); - - // 데이터가 변경될 때 로컬 데이터도 업데이트 - useEffect(() => { - setLocalData(data || { data: [], pageCount: 0, total: 0 }) - }, [data]) - - - // 비고 업데이트 함수 - const updateRemark = async (rfqId: number, remark: string) => { - try { - // 낙관적 UI 업데이트 (로컬 데이터 먼저 갱신) - if (localData && localData.data) { - // 로컬 데이터에서 해당 행 찾기 - const rowIndex = localData.data.findIndex(row => row.id === rfqId); - if (rowIndex >= 0) { - // 불변성을 유지하면서 로컬 데이터 업데이트 - const newData = [...localData.data]; - newData[rowIndex] = { ...newData[rowIndex], remark }; - - // 전체 데이터 구조 복사하여 업데이트 - setLocalData({ ...localData, data: newData } as typeof localData); - } - } - - const result = await updateRfqRemark(rfqId, remark); - - if (result.success) { - toast.success("비고가 업데이트되었습니다"); - - // 서버 데이터 리프레시 호출 - if (onDataRefresh) { - onDataRefresh(); - } - } else { - toast.error(result.message || "업데이트 중 오류가 발생했습니다"); - } - } catch (error) { - console.error("비고 업데이트 오류:", error); - toast.error("업데이트 중 오류가 발생했습니다"); - } - } - - // 행 액션 처리 - useEffect(() => { - if (rowAction) { - // 액션 유형에 따라 처리 - switch (rowAction.type) { - case "select": - // 선택된 문서 처리 - if (onSelectRFQ) { - onSelectRFQ(rowAction.row.original) - } - break; - case "update": - // 업데이트 처리 로직 - console.log("Update rfq:", rowAction.row.original) - break; - case "delete": - // 삭제 처리 로직 - console.log("Delete rfq:", rowAction.row.original) - break; - } - - // 액션 처리 후 rowAction 초기화 - setRowAction(null) - } - }, [rowAction, onSelectRFQ]) - - const columns = React.useMemo( - () => getColumns({ - setRowAction, - editingCell, - setEditingCell, - updateRemark - }), - [setRowAction, editingCell, setEditingCell, updateRemark] - ) - - - // Filter fields - const filterFields: DataTableFilterField<ProcurementRfqsView>[] = [] - - const advancedFilterFields: DataTableAdvancedFilterField<ProcurementRfqsView>[] = [ - { - id: "rfqCode", - label: "RFQ No.", - type: "text", - }, - { - id: "projectCode", - label: "프로젝트", - type: "text", - }, - { - id: "itemCode", - label: "자재그룹", - type: "text", - }, - { - id: "itemName", - label: "자재명", - type: "text", - }, - - { - id: "rfqSealedYn", - label: "RFQ 밀봉여부", - type: "text", - }, - { - id: "majorItemMaterialCode", - label: "자재코드", - type: "text", - }, - { - id: "rfqSendDate", - label: "RFQ 전송일", - type: "date", - }, - { - id: "dueDate", - label: "RFQ 마감일", - type: "date", - }, - { - id: "createdByUserName", - label: "요청자", - type: "text", - }, - ] - - // useDataTable 훅으로 react-table 구성 - 로컬 데이터 사용하도록 수정 - const { table } = useDataTable({ - data: localData?.data || [], - columns, - pageCount: localData?.pageCount || 0, - rowCount: localData?.total || 0, // 총 레코드 수 추가 - filterFields, - enablePinning: true, - enableAdvancedFilter: true, - initialState: { - sorting: [{ id: "updatedAt", desc: true }], - }, - getRowId: (originalRow) => String(originalRow.id), - shallow: false, - clearOnDefault: true, - columnResizeMode: "onEnd", - }) - - return ( - <div className="w-full overflow-auto"> - <DataTable table={table} maxHeight={maxHeight}> - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - shallow={false} - > - <RFQTableToolbarActions - table={table} - localData={localData} - setLocalData={setLocalData} - onSuccess={onDataRefresh} - /> - </DataTableAdvancedToolbar> - </DataTable> - </div> - ) -}
\ No newline at end of file diff --git a/lib/procurement-rfqs/table/rfq-table.tsx b/lib/procurement-rfqs/table/rfq-table.tsx index 23cd66fa..ca976172 100644 --- a/lib/procurement-rfqs/table/rfq-table.tsx +++ b/lib/procurement-rfqs/table/rfq-table.tsx @@ -1,44 +1,86 @@ "use client" import * as React from "react" +import { useSearchParams } from "next/navigation" +import { Button } from "@/components/ui/button" +import { PanelLeftClose, PanelLeftOpen } from "lucide-react" import type { DataTableAdvancedFilterField, DataTableRowAction, } from "@/types/table" +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from "@/components/ui/resizable" + import { useDataTable } from "@/hooks/use-data-table" import { DataTable } from "@/components/data-table/data-table" import { getColumns, EditingCellState } from "./rfq-table-column" -import { useEffect, useCallback, useRef, useMemo } from "react" +import { useEffect, useCallback, useRef, useMemo, useLayoutEffect } from "react" import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" import { RFQTableToolbarActions } from "./rfq-table-toolbar-actions" import { ProcurementRfqsView } from "@/db/schema" import { getPORfqs } from "../services" import { toast } from "sonner" import { updateRfqRemark } from "@/lib/procurement-rfqs/services" -import { useSearchParams } from "next/navigation" import { useTablePresets } from "@/components/data-table/use-table-presets" import { TablePresetManager } from "@/components/data-table/data-table-preset" import { Loader2 } from "lucide-react" +import { RFQFilterSheet } from "./rfq-filter-sheet" +import { RfqDetailTables } from "./detail-table/rfq-detail-table" +import { cn } from "@/lib/utils" interface RFQListTableProps { - data?: Awaited<ReturnType<typeof getPORfqs>>; - onSelectRFQ?: (rfq: ProcurementRfqsView | null) => void; - onDataRefresh?: () => void; - maxHeight?: string | number; + promises: Promise<[Awaited<ReturnType<typeof getPORfqs>>]> + className?: string; + calculatedHeight?: string; // 계산된 높이 추가 } export function RFQListTable({ - data, - onSelectRFQ, - onDataRefresh, - maxHeight + promises, + className, + calculatedHeight }: RFQListTableProps) { const searchParams = useSearchParams() + + // 필터 패널 상태 + const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false) + + // 선택된 RFQ 상태 + const [selectedRfq, setSelectedRfq] = React.useState<ProcurementRfqsView | null>(null) + + // 패널 collapse 상태 + const [isTopCollapsed, setIsTopCollapsed] = React.useState(false) + const [panelHeight, setPanelHeight] = React.useState<number>(55) + + // refs + const headerRef = React.useRef<HTMLDivElement>(null) + + // 고정 높이 설정을 위한 상수 (실제 측정값으로 조정 필요) + const LAYOUT_HEADER_HEIGHT = 64 // Layout Header 높이 + const LAYOUT_FOOTER_HEIGHT = 60 // Layout Footer 높이 (있다면 실제 값) + const LOCAL_HEADER_HEIGHT = 72 // 로컬 헤더 바 높이 (p-4 + border) + const FILTER_PANEL_WIDTH = 400 // 필터 패널 너비 + + // 높이 계산 + // 필터 패널 높이 - Layout Header와 Footer 사이 + const FIXED_FILTER_HEIGHT = `calc(100vh - ${LAYOUT_HEADER_HEIGHT*2}px)` + + console.log(calculatedHeight) + + // 테이블 컨텐츠 높이 - 전달받은 높이에서 로컬 헤더 제외 + const FIXED_TABLE_HEIGHT = calculatedHeight + ? `calc(${calculatedHeight} - ${LOCAL_HEADER_HEIGHT}px)` + : `calc(100vh - ${LAYOUT_HEADER_HEIGHT + LAYOUT_FOOTER_HEIGHT + LOCAL_HEADER_HEIGHT+76}px)` // fallback + + // Suspense 방식으로 데이터 처리 + const [promiseData] = React.use(promises) + const tableData = promiseData + const [rowAction, setRowAction] = React.useState<DataTableRowAction<ProcurementRfqsView> | null>(null) const [editingCell, setEditingCell] = React.useState<EditingCellState | null>(null) - const [localData, setLocalData] = React.useState<typeof data>(data || { data: [], pageCount: 0, total: 0 }) - const [isMounted, setIsMounted] = React.useState(false) - + // 초기 설정 정의 const initialSettings = React.useMemo(() => ({ page: parseInt(searchParams.get('page') || '1'), @@ -70,39 +112,16 @@ export function RFQListTable({ deletePreset, setDefaultPreset, renamePreset, - updateClientState, getCurrentSettings, } = useTablePresets<ProcurementRfqsView>('rfq-list-table', initialSettings) - // 클라이언트 마운트 체크 - useEffect(() => { - setIsMounted(true) - }, []) - - // 데이터 변경 감지 - useEffect(() => { - setLocalData(data || { data: [], pageCount: 0, total: 0 }) - }, [data]) - // 비고 업데이트 함수 const updateRemark = async (rfqId: number, remark: string) => { try { - if (localData && localData.data) { - const rowIndex = localData.data.findIndex(row => row.id === rfqId); - if (rowIndex >= 0) { - const newData = [...localData.data]; - newData[rowIndex] = { ...newData[rowIndex], remark }; - setLocalData({ ...localData, data: newData }); - } - } - const result = await updateRfqRemark(rfqId, remark); if (result.success) { toast.success("비고가 업데이트되었습니다"); - if (onDataRefresh) { - onDataRefresh(); - } } else { toast.error(result.message || "업데이트 중 오류가 발생했습니다"); } @@ -117,9 +136,7 @@ export function RFQListTable({ if (rowAction) { switch (rowAction.type) { case "select": - if (onSelectRFQ) { - onSelectRFQ(rowAction.row.original) - } + setSelectedRfq(rowAction.row.original) break; case "update": console.log("Update rfq:", rowAction.row.original) @@ -130,7 +147,7 @@ export function RFQListTable({ } setRowAction(null) } - }, [rowAction, onSelectRFQ]) + }, [rowAction]) const columns = React.useMemo( () => getColumns({ @@ -198,7 +215,6 @@ export function RFQListTable({ // useDataTable 초기 상태 설정 const initialState = useMemo(() => { - console.log('Setting initial state:', currentSettings) return { sorting: initialSettings.sort.filter(sortItem => { const columnExists = columns.some(col => col.accessorKey === sortItem.id) @@ -209,167 +225,188 @@ export function RFQListTable({ } }, [currentSettings, initialSettings.sort, columns]) - // useDataTable 훅 설정 + // useDataTable 훅 설정 (PQ와 동일한 설정) const { table } = useDataTable({ - data: localData?.data || [], + data: tableData?.data || [], columns, - pageCount: localData?.pageCount || 0, - rowCount: localData?.total || 0, - filterFields: [], + pageCount: tableData?.pageCount || 0, + rowCount: tableData?.total || 0, + filterFields: [], // PQ와 동일하게 빈 배열 enablePinning: true, enableAdvancedFilter: true, initialState, getRowId: (originalRow) => String(originalRow.id), - shallow: false, + shallow: false, // PQ와 동일하게 false clearOnDefault: true, - columnResizeMode: "onEnd", }) - - // 테이블 상태 변경 감지 및 자동 저장 - const lastKnownStateRef = useRef<{ - columnVisibility: string - columnPinning: string - columnOrder: string[] - }>({ - columnVisibility: '{}', - columnPinning: '{"left":[],"right":[]}', - columnOrder: [] - }) - - const checkAndUpdateTableState = useCallback(() => { - if (!presetsLoading && !activePresetId) return - + + // 조회 버튼 클릭 핸들러 + const handleSearch = () => { + setIsFilterPanelOpen(false) + } + + // Get active basic filter count (PQ와 동일한 방식) + const getActiveBasicFilterCount = () => { try { - const currentVisibility = table.getState().columnVisibility - const currentPinning = table.getState().columnPinning - - // 컬럼 순서 가져오기 - const allColumns = table.getAllColumns() - const leftPinned = table.getLeftHeaderGroups()[0]?.headers.map(h => h.column.id) || [] - const rightPinned = table.getRightHeaderGroups()[0]?.headers.map(h => h.column.id) || [] - const center = table.getCenterHeaderGroups()[0]?.headers.map(h => h.column.id) || [] - const currentOrder = [...leftPinned, ...center, ...rightPinned] - - const visibilityString = JSON.stringify(currentVisibility) - const pinningString = JSON.stringify(currentPinning) - const orderString = JSON.stringify(currentOrder) - - // 실제 변경이 있을 때만 업데이트 - if ( - visibilityString !== lastKnownStateRef.current.columnVisibility || - pinningString !== lastKnownStateRef.current.columnPinning || - orderString !== JSON.stringify(lastKnownStateRef.current.columnOrder) - ) { - console.log('Table state changed, updating preset...') - - const newClientState = { - columnVisibility: currentVisibility, - columnOrder: currentOrder, - pinnedColumns: currentPinning, - } - - // 상태 업데이트 전에 기록 - lastKnownStateRef.current = { - columnVisibility: visibilityString, - columnPinning: pinningString, - columnOrder: currentOrder - } - - updateClientState(newClientState) - } - } catch (error) { - console.error('Error checking table state:', error) - } - }, [activePresetId, table, updateClientState, presetsLoading ]) - - // 주기적으로 테이블 상태 체크 - useEffect(() => { - if (!isMounted || !activePresetId) return - - console.log('Starting table state polling') - const intervalId = setInterval(checkAndUpdateTableState, 500) - - return () => { - clearInterval(intervalId) - console.log('Stopped table state polling') + const basicFilters = searchParams.get('basicFilters') + return basicFilters ? JSON.parse(basicFilters).length : 0 + } catch (e) { + return 0 } - }, [isMounted, activePresetId, checkAndUpdateTableState]) - - // 프리셋 적용 시 테이블 상태 업데이트 - useEffect(() => { - if (isMounted && activePresetId && currentSettings) { - const settings = currentSettings - console.log('Applying preset settings to table:', settings) - - const currentVisibility = table.getState().columnVisibility - const currentPinning = table.getState().columnPinning - - if ( - JSON.stringify(currentVisibility) !== JSON.stringify(settings.columnVisibility) || - JSON.stringify(currentPinning) !== JSON.stringify(settings.pinnedColumns) - ) { - console.log('Updating table state to match preset...') - - // 테이블 상태 업데이트 - table.setColumnVisibility(settings.columnVisibility) - table.setColumnPinning(settings.pinnedColumns) - - // 상태 저장소 업데이트 - lastKnownStateRef.current = { - columnVisibility: JSON.stringify(settings.columnVisibility), - columnPinning: JSON.stringify(settings.pinnedColumns), - columnOrder: settings.columnOrder || [] - } - } - } - }, [isMounted, activePresetId, currentSettings, table]) + } - // 로딩 중일 때는 스켈레톤 표시 - if (!isMounted) { - return ( - <div className="w-full h-96 flex items-center justify-center"> - <div className="flex flex-col items-center gap-2"> - <Loader2 className="h-8 w-8 animate-spin text-muted-foreground" /> - <span className="text-sm text-muted-foreground">테이블 설정을 로드하는 중...</span> + console.log(panelHeight) + + return ( + <div + className={cn("flex flex-col relative", className)} + style={{ height: calculatedHeight }} + > + {/* Filter Panel - 계산된 높이 적용 */} + <div + className={cn( + "fixed left-0 bg-background border-r z-30 flex flex-col transition-all duration-300 ease-in-out overflow-hidden", + isFilterPanelOpen ? "border-r shadow-lg" : "border-r-0" + )} + style={{ + width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px', + top: `${LAYOUT_HEADER_HEIGHT*2}px`, + height: FIXED_FILTER_HEIGHT + }} + > + {/* Filter Content */} + <div className="h-full"> + <RFQFilterSheet + isOpen={isFilterPanelOpen} + onClose={() => setIsFilterPanelOpen(false)} + onSearch={handleSearch} + isLoading={false} + /> </div> </div> - ) - } - - return ( - <div className="w-full overflow-auto"> - <DataTable table={table} maxHeight={maxHeight}> - <DataTableAdvancedToolbar - table={table} - filterFields={advancedFilterFields} - shallow={false} + + {/* Main Content */} + <div + className="flex flex-col transition-all duration-300 ease-in-out" + style={{ + width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : '100%', + marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px', + height: '100%' + }} + > + {/* Header Bar - 고정 높이 */} + <div + ref={headerRef} + className="flex items-center justify-between p-4 bg-background border-b" + style={{ + height: `${LOCAL_HEADER_HEIGHT}px`, + flexShrink: 0 + }} > - <div className="flex items-center gap-2"> - {/* DB 기반 테이블 프리셋 매니저 */} - <TablePresetManager<ProcurementRfqsView> - presets={presets} - activePresetId={activePresetId} - currentSettings={currentSettings} - hasUnsavedChanges={hasUnsavedChanges} - isLoading={presetsLoading} - onCreatePreset={createPreset} - onUpdatePreset={updatePreset} - onDeletePreset={deletePreset} - onApplyPreset={applyPreset} - onSetDefaultPreset={setDefaultPreset} - onRenamePreset={renamePreset} - /> - - {/* 기존 툴바 액션들 */} - <RFQTableToolbarActions - table={table} - localData={localData} - setLocalData={setLocalData} - onSuccess={onDataRefresh} - /> + <div className="flex items-center gap-3"> + <Button + variant="outline" + size="sm" + type='button' + onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)} + className="flex items-center shadow-sm" + > + {isFilterPanelOpen ? <PanelLeftClose className="size-4"/> : <PanelLeftOpen className="size-4"/>} + {getActiveBasicFilterCount() > 0 && ( + <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs"> + {getActiveBasicFilterCount()} + </span> + )} + </Button> + </div> + + {/* Right side info */} + <div className="text-sm text-muted-foreground"> + {tableData && ( + <span>총 {tableData.total || 0}건</span> + )} </div> - </DataTableAdvancedToolbar> - </DataTable> + </div> + + {/* Table Content Area - 계산된 높이 사용 */} + <div + className="relative bg-background" + style={{ + height: FIXED_TABLE_HEIGHT, + display: 'grid', + gridTemplateRows: '1fr', + gridTemplateColumns: '1fr' + }} + > + <ResizablePanelGroup + direction="vertical" + className="w-full h-full" + > + <ResizablePanel + defaultSize={60} + minSize={25} + maxSize={75} + collapsible={false} + onResize={(size) => { + setPanelHeight(size) + }} + className="flex flex-col overflow-hidden" + > + {/* 상단 테이블 영역 */} + <div className="flex-1 min-h-0 overflow-hidden"> + <DataTable + table={table} + // className="h-full" + maxHeight={`${panelHeight*0.5}vh`} + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <div className="flex items-center gap-2"> + <TablePresetManager<ProcurementRfqsView> + presets={presets} + activePresetId={activePresetId} + currentSettings={currentSettings} + hasUnsavedChanges={hasUnsavedChanges} + isLoading={presetsLoading} + onCreatePreset={createPreset} + onUpdatePreset={updatePreset} + onDeletePreset={deletePreset} + onApplyPreset={applyPreset} + onSetDefaultPreset={setDefaultPreset} + onRenamePreset={renamePreset} + /> + + <RFQTableToolbarActions + table={table} + localData={tableData} + setLocalData={() => {}} + onSuccess={() => {}} + /> + </div> + </DataTableAdvancedToolbar> + </DataTable> + </div> + </ResizablePanel> + + <ResizableHandle withHandle /> + + <ResizablePanel + minSize={25} + defaultSize={40} + collapsible={false} + className="flex flex-col overflow-hidden" + > + {/* 하단 상세 테이블 영역 */} + <div className="flex-1 min-h-0 overflow-hidden bg-background"> + <RfqDetailTables selectedRfq={selectedRfq} maxHeight={`${(100-panelHeight)*0.4}vh`}/> + </div> + </ResizablePanel> + </ResizablePanelGroup> + </div> + </div> </div> ) }
\ No newline at end of file diff --git a/lib/rfqs/validations.ts b/lib/rfqs/validations.ts index 59e9e362..7f39d0a6 100644 --- a/lib/rfqs/validations.ts +++ b/lib/rfqs/validations.ts @@ -13,7 +13,7 @@ import { Vendor, vendors } from "@/db/schema/vendors"; export const RfqType = { PURCHASE_BUDGETARY: "PURCHASE_BUDGETARY", PURCHASE: "PURCHASE", - BUDGETARY: "c" + BUDGETARY: "BUDGETARY" } as const; export type RfqType = typeof RfqType[keyof typeof RfqType]; diff --git a/lib/sedp/get-tags.ts b/lib/sedp/get-tags.ts index 7c5661c3..7021d7d2 100644 --- a/lib/sedp/get-tags.ts +++ b/lib/sedp/get-tags.ts @@ -1,16 +1,16 @@ -// lib/sedp/get-tag.ts -import db from "@/db/db"; -import { +import db from "@/db/db"; +import { contractItems, - tags, - forms, - items, - tagTypeClassFormMappings, + tags, + forms, + items, + tagTypeClassFormMappings, projects, tagTypes, - tagClasses + tagClasses, + contracts } from "@/db/schema"; -import { eq, and, like } from "drizzle-orm"; +import { eq, and, like, inArray } from "drizzle-orm"; import { getSEDPToken } from "./sedp-token"; /** @@ -21,10 +21,10 @@ import { getSEDPToken } from "./sedp-token"; * @param progressCallback 진행 상황을 보고하기 위한 콜백 함수 * @returns 처리 결과 정보 (처리된 태그 수, 오류 목록 등) */ -// 함수 반환 타입 업데이트 export async function importTagsFromSEDP( packageId: number, - progressCallback?: (progress: number) => void + progressCallback?: (progress: number) => void, + mode?: string ): Promise<{ processedCount: number; excludedCount: number; @@ -44,8 +44,17 @@ export async function importTagsFromSEDP( throw new Error(`Contract item with ID ${packageId} not found`); } - // 진행 상황 보고 - if (progressCallback) progressCallback(5); + // Step 1-2: 계약 아이템에서 계약 정보를 가져와 프로젝트 ID 획득 + const contract = await db.query.contracts.findFirst({ + where: eq(contracts.id, contractItem.contractId) + }); + + if (!contract) { + throw new Error(`Contract with ID ${contractItem.contractId} not found`); + } + + // 프로젝트 ID 획득 + const projectId = contract.projectId; // Step 1-2: Get the item using itemId from contractItem const item = await db.query.items.findFirst({ @@ -61,153 +70,340 @@ export async function importTagsFromSEDP( // 진행 상황 보고 if (progressCallback) progressCallback(10); - // Step 2: Find the mapping entry with the item code in remark field - // 더 유연한 검색 패턴 사용 (%itemCode%) - const mapping = await db.query.tagTypeClassFormMappings.findFirst({ - where: like(tagTypeClassFormMappings.remark, `%${itemCode}%`) + // 기본 매핑 검색 - 모든 모드에서 사용 + const baseMappings = await db.query.tagTypeClassFormMappings.findMany({ + where: and( + like(tagTypeClassFormMappings.remark, `%${itemCode}%`), + eq(tagTypeClassFormMappings.projectId, projectId) + ) }); - - if (!mapping) { + + if (baseMappings.length === 0) { throw new Error(`No mapping found for item code ${itemCode}`); } - // 진행 상황 보고 - if (progressCallback) progressCallback(15); - - // Step 3: Get the project code - const project = await db.query.projects.findFirst({ - where: eq(projects.id, mapping.projectId) - }); + // Step 2: Find the mapping entries - 모드에 따라 다른 조건 적용 + let mappings = []; + + if (mode === 'IM') { + // IM 모드일 때는 먼저 SEDP에서 태그 데이터를 가져와 TAG_TYPE_ID 리스트 확보 + + // 프로젝트 코드 가져오기 + const project = await db.query.projects.findFirst({ + where: eq(projects.id, projectId) + }); + + if (!project) { + throw new Error(`Project with ID ${projectId} not found`); + } + + // 각 매핑의 formCode에 대해 태그 데이터 조회 + const tagTypeIds = new Set<string>(); + + for (const mapping of baseMappings) { + try { + // SEDP에서 태그 데이터 가져오기 + const tagData = await fetchTagDataFromSEDP(project.code, mapping.formCode); + + // 첫 번째 키를 테이블 이름으로 사용 + const tableName = Object.keys(tagData)[0]; + const tagEntries = tagData[tableName]; + + if (Array.isArray(tagEntries)) { + // 모든 태그에서 TAG_TYPE_ID 수집 + for (const entry of tagEntries) { + if (entry.TAG_TYPE_ID && entry.TAG_TYPE_ID !== "") { + tagTypeIds.add(entry.TAG_TYPE_ID); + } + } + } + } catch (error) { + console.error(`Error fetching tag data for formCode ${mapping.formCode}:`, error); + } + } + + if (tagTypeIds.size === 0) { + throw new Error('No valid TAG_TYPE_ID found in SEDP tag data'); + } + + // 수집된 TAG_TYPE_ID로 tagTypes에서 정보 조회 + const tagTypeInfo = await db.query.tagTypes.findMany({ + where: and( + inArray(tagTypes.code, Array.from(tagTypeIds)), + eq(tagTypes.projectId, projectId) + ) + }); + + if (tagTypeInfo.length === 0) { + throw new Error('No matching tag types found for the collected TAG_TYPE_IDs'); + } + + // 태그 타입 설명 수집 + const tagLabels = tagTypeInfo.map(tt => tt.description); + + // IM 모드에 맞는 매핑 조회 - ep가 "IMEP"인 항목만 + mappings = await db.query.tagTypeClassFormMappings.findMany({ + where: and( + inArray(tagTypeClassFormMappings.tagTypeLabel, tagLabels), + eq(tagTypeClassFormMappings.projectId, projectId), + eq(tagTypeClassFormMappings.ep, "IMEP") + ) + }); + + } else { + // ENG 모드 또는 기본 모드일 때 - 기본 매핑 사용 + mappings = [...baseMappings]; + + // ENG 모드에서는 ep 필드가 "IMEP"가 아닌 매핑만 필터링 + if (mode === 'ENG') { + mappings = mappings.filter(mapping => mapping.ep !== "IMEP"); + } + } - if (!project) { - throw new Error(`Project with ID ${mapping.projectId} not found`); + // 매핑이 없는 경우 모드에 따라 다른 오류 메시지 사용 + if (mappings.length === 0) { + if (mode === 'IM') { + throw new Error('No suitable mappings found for IM mode'); + } else { + throw new Error(`No mapping found for item code ${itemCode}`); + } } + + // 진행 상황 보고 + if (progressCallback) progressCallback(15); - const projectCode = project.code; - const formCode = mapping.formCode; + // 결과 누적을 위한 변수들 초기화 + let totalProcessedCount = 0; + let totalExcludedCount = 0; + let totalEntriesCount = 0; + const allErrors: string[] = []; - // 진행 상황 보고 - if (progressCallback) progressCallback(20); + // 각 매핑에 대해 처리 + for (let mappingIndex = 0; mappingIndex < mappings.length; mappingIndex++) { + const mapping = mappings[mappingIndex]; - // Step 4: Find the form ID - const form = await db.query.forms.findFirst({ - where: and( - eq(forms.contractItemId, packageId), - eq(forms.formCode, formCode) - ) - }); + // Step 3: Get the project code + const project = await db.query.projects.findFirst({ + where: eq(projects.id, mapping.projectId) + }); - let formId = form?.id; + if (!project) { + allErrors.push(`Project with ID ${mapping.projectId} not found`); + continue; // 다음 매핑으로 진행 + } - // If form doesn't exist, create it - if (!form) { - const insertResult = await db.insert(forms).values({ - contractItemId: packageId, - formCode: formCode, - formName: mapping.formName - }).returning({ id: forms.id }); + const projectCode = project.code; - if (insertResult.length === 0) { - throw new Error('Failed to create form record'); + // IM 모드에서는 baseMappings에서 같은 formCode를 가진 매핑을 찾음 + let formCode = mapping.formCode; + if (mode === 'IM') { + // baseMapping에서 동일한 formCode를 가진 매핑 찾기 + const originalMapping = baseMappings.find( + baseMapping => baseMapping.formCode === mapping.formCode + ); + + // 찾았으면 해당 formCode 사용, 못 찾았으면 현재 매핑의 formCode 유지 + if (originalMapping) { + formCode = originalMapping.formCode; + } } - - formId = insertResult[0].id; - } - // 진행 상황 보고 - if (progressCallback) progressCallback(30); + // 진행 상황 보고 - 매핑별 진행률 조정 + if (progressCallback) { + const baseProgress = 15; + const mappingProgress = Math.floor(15 * (mappingIndex + 1) / mappings.length); + progressCallback(baseProgress + mappingProgress); + } - // Step 5: Call the external API to get tag data - const tagData = await fetchTagDataFromSEDP(projectCode, formCode); + // Step 4: Find the form ID + const form = await db.query.forms.findFirst({ + where: and( + eq(forms.contractItemId, packageId), + eq(forms.formCode, formCode) + ) + }); - // 진행 상황 보고 - if (progressCallback) progressCallback(50); + let formId; + + // If form doesn't exist, create it + if (!form) { + // 폼이 없는 경우 새로 생성 - 모드에 따른 필드 설정 + const insertValues: any = { + contractItemId: packageId, + formCode: formCode, + formName: mapping.formName + }; + + // 모드 정보가 있으면 해당 필드 설정 + if (mode) { + if (mode === "ENG") { + insertValues.eng = true; + } else if (mode === "IM") { + insertValues.im = true; + } + } - // Step 6: Process the data and insert into the tags table - let processedCount = 0; - let excludedCount = 0; - const errors: string[] = []; + const insertResult = await db.insert(forms).values(insertValues).returning({ id: forms.id }); - // Get the first key from the response as the table name - const tableName = Object.keys(tagData)[0]; - const tagEntries = tagData[tableName]; + if (insertResult.length === 0) { + allErrors.push(`Failed to create form record for formCode ${formCode}`); + continue; // 다음 매핑으로 진행 + } - if (!Array.isArray(tagEntries) || tagEntries.length === 0) { - throw new Error('No tag data found in the API response'); - } + formId = insertResult[0].id; + } else { + // 폼이 이미 존재하는 경우 - 필요시 모드 필드 업데이트 + formId = form.id; + + if (mode) { + let shouldUpdate = false; + const updateValues: any = {}; + + if (mode === "ENG" && form.eng !== true) { + updateValues.eng = true; + shouldUpdate = true; + } else if (mode === "IM" && form.im !== true) { + updateValues.im = true; + shouldUpdate = true; + } - const totalEntries = tagEntries.length; + if (shouldUpdate) { + await db.update(forms) + .set({ + ...updateValues, + updatedAt: new Date() + }) + .where(eq(forms.id, formId)); - // Process each tag entry - for (let i = 0; i < tagEntries.length; i++) { - try { - const entry = tagEntries[i]; - - // TAG_TYPE_ID가 null이거나 빈 문자열인 경우 제외 - if (entry.TAG_TYPE_ID === null || entry.TAG_TYPE_ID === "") { - excludedCount++; - - // 주기적으로 진행 상황 보고 (건너뛰어도 진행률은 업데이트) - if (progressCallback && (i % 10 === 0 || i === tagEntries.length - 1)) { - progressCallback(Math.floor(50 + (i / tagEntries.length) * 50)); + console.log(`Updated form ${formId} with ${mode} mode enabled`); } - - continue; // 이 항목은 건너뜀 } + } + + // 진행 상황 보고 - 매핑별 진행률 조정 + if (progressCallback) { + const baseProgress = 30; + const mappingProgress = Math.floor(20 * (mappingIndex + 1) / mappings.length); + progressCallback(baseProgress + mappingProgress); + } + + try { + // Step 5: Call the external API to get tag data + - // Get tag type description - const tagType = await db.query.tagTypes.findFirst({ - where: and( - eq(tagTypes.code, entry.TAG_TYPE_ID), - eq(tagTypes.projectId, mapping.projectId) - ) - }); - - // Get tag class label - const tagClass = await db.query.tagClasses.findFirst({ - where: and( - eq(tagClasses.code, entry.CLS_ID), - eq(tagClasses.projectId, mapping.projectId) - ) - }); - - // Insert or update the tag - await db.insert(tags).values({ - contractItemId: packageId, - formId: formId, - tagNo: entry.TAG_NO, - tagType: tagType?.description || entry.TAG_TYPE_ID, - class: tagClass?.label || entry.CLS_ID, - description: entry.TAG_DESC - }).onConflictDoUpdate({ - target: [tags.contractItemId, tags.tagNo], - set: { - formId: formId, - tagType: tagType?.description || entry.TAG_TYPE_ID, - class: tagClass?.label || entry.CLS_ID, - description: entry.TAG_DESC, - updatedAt: new Date() - } - }); + const tagData = await fetchTagDataFromSEDP(projectCode, baseMappings[0].formCode); + + // 진행 상황 보고 + if (progressCallback) { + const baseProgress = 50; + const mappingProgress = Math.floor(10 * (mappingIndex + 1) / mappings.length); + progressCallback(baseProgress + mappingProgress); + } - processedCount++; + // Step 6: Process the data and insert into the tags table + let processedCount = 0; + let excludedCount = 0; - // 주기적으로 진행 상황 보고 - if (progressCallback && (i % 10 === 0 || i === tagEntries.length - 1)) { - progressCallback(Math.floor(50 + (i / tagEntries.length) * 50)); + // Get the first key from the response as the table name + const tableName = Object.keys(tagData)[0]; + const tagEntries = tagData[tableName]; + + if (!Array.isArray(tagEntries) || tagEntries.length === 0) { + allErrors.push(`No tag data found in the API response for formCode ${baseFormCode}`); + continue; // 다음 매핑으로 진행 } + + const entriesCount = tagEntries.length; + totalEntriesCount += entriesCount; + + // Process each tag entry + for (let i = 0; i < tagEntries.length; i++) { + try { + const entry = tagEntries[i]; + + // TAG_TYPE_ID가 null이거나 빈 문자열인 경우 제외 + if (entry.TAG_TYPE_ID === null || entry.TAG_TYPE_ID === "") { + excludedCount++; + totalExcludedCount++; + + // 주기적으로 진행 상황 보고 (건너뛰어도 진행률은 업데이트) + if (progressCallback && (i % 10 === 0 || i === tagEntries.length - 1)) { + const baseProgress = 60; + const entryProgress = Math.floor(30 * ((mappingIndex * entriesCount + i) / (mappings.length * entriesCount))); + progressCallback(baseProgress + entryProgress); + } + + continue; // 이 항목은 건너뜀 + } + + // Get tag type description + const tagType = await db.query.tagTypes.findFirst({ + where: and( + eq(tagTypes.code, entry.TAG_TYPE_ID), + eq(tagTypes.projectId, mapping.projectId) + ) + }); + + // Get tag class label + const tagClass = await db.query.tagClasses.findFirst({ + where: and( + eq(tagClasses.code, entry.CLS_ID), + eq(tagClasses.projectId, mapping.projectId) + ) + }); + + // Insert or update the tag + await db.insert(tags).values({ + contractItemId: packageId, + formId: formId, + tagNo: entry.TAG_NO, + tagType: tagType?.description || entry.TAG_TYPE_ID, + class: tagClass?.label || entry.CLS_ID, + description: entry.TAG_DESC + }).onConflictDoUpdate({ + target: [tags.contractItemId, tags.tagNo], + set: { + formId: formId, + tagType: tagType?.description || entry.TAG_TYPE_ID, + class: tagClass?.label || entry.CLS_ID, + description: entry.TAG_DESC, + updatedAt: new Date() + } + }); + + processedCount++; + totalProcessedCount++; + + // 주기적으로 진행 상황 보고 + if (progressCallback && (i % 10 === 0 || i === tagEntries.length - 1)) { + const baseProgress = 60; + const entryProgress = Math.floor(30 * ((mappingIndex * entriesCount + i) / (mappings.length * entriesCount))); + progressCallback(baseProgress + entryProgress); + } + } catch (error: any) { + console.error(`Error processing tag entry:`, error); + allErrors.push(error.message || 'Unknown error'); + } + } + + } catch (error: any) { - console.error(`Error processing tag entry:`, error); - errors.push(error.message || 'Unknown error'); + console.error(`Error processing mapping for formCode ${formCode}:`, error); + allErrors.push(`Error with formCode ${formCode}: ${error.message || 'Unknown error'}`); } } + // 모든 매핑 처리 완료 - 진행률 100% + if (progressCallback) { + progressCallback(100); + } + // 최종 결과 반환 return { - processedCount, - excludedCount, - totalEntries, - errors: errors.length > 0 ? errors : undefined + processedCount: totalProcessedCount, + excludedCount: totalExcludedCount, + totalEntries: totalEntriesCount, + errors: allErrors.length > 0 ? allErrors : undefined }; } catch (error: any) { console.error("Tag import error:", error); @@ -226,10 +422,10 @@ async function fetchTagDataFromSEDP(projectCode: string, formCode: string): Prom try { // Get the token const apiKey = await getSEDPToken(); - + // Define the API base URL const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api'; - + // Make the API call const response = await fetch( `${SEDP_API_BASE_URL}/Data/GetPubData`, @@ -248,12 +444,12 @@ async function fetchTagDataFromSEDP(projectCode: string, formCode: string): Prom }) } ); - + if (!response.ok) { const errorText = await response.text(); throw new Error(`SEDP API request failed: ${response.status} ${response.statusText} - ${errorText}`); } - + const data = await response.json(); return data; } catch (error: any) { diff --git a/lib/sedp/sedp-token.ts b/lib/sedp/sedp-token.ts index dc419c87..9335a74e 100644 --- a/lib/sedp/sedp-token.ts +++ b/lib/sedp/sedp-token.ts @@ -1,7 +1,7 @@ // 환경 변수 const SEDP_API_BASE_URL = process.env.SEDP_API_BASE_URL || 'http://sedpwebapi.ship.samsung.co.kr/api'; const SEDP_API_USER_ID = process.env.SEDP_API_USER_ID || 'EVCPUSER'; -const SEDP_API_PASSWORD = process.env.SEDP_API_PASSWORD || 'evcpuser@2025'; +const SEDP_API_PASSWORD = process.env.SEDP_API_PASSWORD || 'evcpusr@2025'; /** * SEDP API에서 인증 토큰을 가져옵니다. @@ -10,7 +10,7 @@ const SEDP_API_PASSWORD = process.env.SEDP_API_PASSWORD || 'evcpuser@2025'; export async function getSEDPToken(): Promise<string> { try { const response = await fetch( - `${SEDP_API_BASE_URL}/Security/RequestToken`, + `${SEDP_API_BASE_URL}/Security/RequestTokenWithMembership`, { method: 'POST', headers: { diff --git a/lib/tags/service.ts b/lib/tags/service.ts index e66c0438..307a4774 100644 --- a/lib/tags/service.ts +++ b/lib/tags/service.ts @@ -171,8 +171,8 @@ export async function createTag( for (const formMapping of formMappings) { // 4-1) 이미 존재하는 폼인지 확인 const existingForm = await tx - .select({ id: forms.id }) - .from(forms) + .select({ id: forms.id, im: forms.im }) // im 필드 추가로 조회 + .from(forms) .where( and( eq(forms.contractItemId, selectedPackageId), @@ -185,6 +185,16 @@ export async function createTag( if (existingForm.length > 0) { // 이미 존재하면 해당 ID 사용 formId = existingForm[0].id + + if (existingForm[0].im !== true) { + await tx + .update(forms) + .set({ im: true }) + .where(eq(forms.id, formId)) + + console.log(`Form ${formId} updated with im: true`) + } + createdOrExistingForms.push({ id: formId, formCode: formMapping.formCode, diff --git a/lib/tags/table/tag-table.tsx b/lib/tags/table/tag-table.tsx index 62f0a7c5..1986d933 100644 --- a/lib/tags/table/tag-table.tsx +++ b/lib/tags/table/tag-table.tsx @@ -19,6 +19,8 @@ import { TagsTableToolbarActions } from "./tags-table-toolbar-actions" import { TagsTableFloatingBar } from "./tags-table-floating-bar" import { getTags } from "../service" import { UpdateTagSheet } from "./update-tag-sheet" +import { useAtomValue } from 'jotai' +import { selectedModeAtom } from '@/atoms' // 여기서 받은 `promises`로부터 태그 목록을 가져와 상태를 세팅 // 예: "selectedPackageId"는 props로 전달 @@ -30,6 +32,7 @@ interface TagsTableProps { export function TagsTable({ promises, selectedPackageId }: TagsTableProps) { // 1) 데이터를 가져옴 (server component -> use(...) pattern) const [{ data, pageCount }] = React.use(promises) + const selectedMode = useAtomValue(selectedModeAtom) const [rowAction, setRowAction] = React.useState<DataTableRowAction<Tag> | null>(null) @@ -126,6 +129,7 @@ export function TagsTable({ promises, selectedPackageId }: TagsTableProps) { table={table} selectedPackageId={selectedPackageId} tableData={data} // <-- pass current data + selectedMode={selectedMode} /> </DataTableAdvancedToolbar> </DataTable> diff --git a/lib/vendor-document-list/enhanced-document-service.ts b/lib/vendor-document-list/enhanced-document-service.ts new file mode 100644 index 00000000..00f40ea6 --- /dev/null +++ b/lib/vendor-document-list/enhanced-document-service.ts @@ -0,0 +1,782 @@ +// enhanced-document-service.ts +"use server" + +import { revalidatePath, unstable_cache } from "next/cache" +import { and, asc, desc, eq, ilike, or, count, avg } from "drizzle-orm" +import db from "@/db/db" +import { documentAttachments, documents, enhancedDocumentsView, issueStages, revisions, type EnhancedDocumentsView } from "@/db/schema/vendorDocu" +import { filterColumns } from "@/lib/filter-columns" +import type { + CreateDocumentInput, + UpdateDocumentInput, + CreateStageInput, + UpdateStageInput, + CreateRevisionInput, + UpdateRevisionStatusInput, + ApiResponse, + StageWithRevisions, + FullDocument, + DocumentAttachment, + Revision + } from "@/types/enhanced-documents" + +// 스키마 타입 정의 +export interface GetEnhancedDocumentsSchema { + page: number + perPage: number + search?: string + filters?: Array<{ + id: string + value: string | string[] + operator?: "eq" | "ne" | "like" | "ilike" | "in" | "notin" | "lt" | "lte" | "gt" | "gte" + }> + joinOperator?: "and" | "or" + sort?: Array<{ + id: keyof EnhancedDocumentsView + desc: boolean + }> +} + +// Repository 함수들 +export async function selectEnhancedDocuments( + tx: any, + options: { + where?: any + orderBy?: any + offset?: number + limit?: number + } +) { + const { where, orderBy, offset, limit } = options + + let query = tx.select().from(enhancedDocumentsView) + + if (where) { + query = query.where(where) + } + + if (orderBy) { + query = query.orderBy(...orderBy) + } + + if (offset !== undefined) { + query = query.offset(offset) + } + + if (limit !== undefined) { + query = query.limit(limit) + } + + return await query +} + +export async function countEnhancedDocuments(tx: any, where?: any) { + let query = tx.select({ count: count() }).from(enhancedDocumentsView) + + if (where) { + query = query.where(where) + } + + const result = await query + return result[0]?.count || 0 +} + +// 메인 서버 액션 +export async function getEnhancedDocuments( + input: GetEnhancedDocumentsSchema, + contractId: number +) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage + + // 고급 필터 처리 + const advancedWhere = filterColumns({ + table: enhancedDocumentsView, + filters: input.filters || [], + joinOperator: input.joinOperator || "and", + }) + + // 전역 검색 처리 + let globalWhere + if (input.search) { + const searchTerm = `%${input.search}%` + globalWhere = or( + ilike(enhancedDocumentsView.title, searchTerm), + ilike(enhancedDocumentsView.docNumber, searchTerm), + ilike(enhancedDocumentsView.currentStageName, searchTerm), + ilike(enhancedDocumentsView.currentStageAssigneeName, searchTerm) + ) + } + + // 최종 WHERE 조건 + const finalWhere = and( + advancedWhere, + globalWhere, + eq(enhancedDocumentsView.contractId, contractId) + ) + + // 정렬 처리 + const orderBy = input.sort && input.sort.length > 0 + ? input.sort.map((item) => + item.desc + ? desc(enhancedDocumentsView[item.id]) + : asc(enhancedDocumentsView[item.id]) + ) + : [desc(enhancedDocumentsView.createdAt)] + + // 트랜잭션 실행 + const { data, total } = await db.transaction(async (tx) => { + const data = await selectEnhancedDocuments(tx, { + where: finalWhere, + orderBy, + offset, + limit: input.perPage, + }) + + const total = await countEnhancedDocuments(tx, finalWhere) + + return { data, total } + }) + + const pageCount = Math.ceil(total / input.perPage) + + return { data, pageCount, total } + } catch (err) { + console.error("Error fetching enhanced documents:", err) + return { data: [], pageCount: 0, total: 0 } + } + }, + [JSON.stringify(input), String(contractId)], + { + revalidate: 3600, + tags: [`enhanced-documents-${contractId}`], + } + )() +} + +// 통계 데이터 가져오기 +export async function getDocumentStatistics(contractId: number) { + return unstable_cache( + async () => { + try { + const result = await db + .select({ + total: count(), + overdue: count(enhancedDocumentsView.isOverdue), + dueSoon: count(), // 별도 필터링 필요 + highPriority: count(), + avgProgress: count(), // 별도 계산 필요 + }) + .from(enhancedDocumentsView) + .where(eq(enhancedDocumentsView.contractId, contractId)) + + // 더 정확한 통계를 위한 별도 쿼리들 + const [overdue, dueSoon, highPriority] = await Promise.all([ + db + .select({ count: count() }) + .from(enhancedDocumentsView) + .where( + and( + eq(enhancedDocumentsView.contractId, contractId), + eq(enhancedDocumentsView.isOverdue, true) + ) + ), + db + .select({ count: count() }) + .from(enhancedDocumentsView) + .where( + and( + eq(enhancedDocumentsView.contractId, contractId), + eq(enhancedDocumentsView.isOverdue, false), + // daysUntilDue <= 3 AND daysUntilDue >= 0 조건 추가 필요 + ) + ), + db + .select({ count: count() }) + .from(enhancedDocumentsView) + .where( + and( + eq(enhancedDocumentsView.contractId, contractId), + eq(enhancedDocumentsView.currentStagePriority, "HIGH") + ) + ) + ]) + + // 평균 진행률 계산 + const avgProgressResult = await db + .select({ + avgProgress: avg(enhancedDocumentsView.progressPercentage) + }) + .from(enhancedDocumentsView) + .where(eq(enhancedDocumentsView.contractId, contractId)) + + return { + total: result[0]?.total || 0, + overdue: overdue[0]?.count || 0, + dueSoon: dueSoon[0]?.count || 0, + highPriority: highPriority[0]?.count || 0, + avgProgress: Math.round(avgProgressResult[0]?.avgProgress || 0), + } + } catch (err) { + console.error("Error fetching document statistics:", err) + return { + total: 0, + overdue: 0, + dueSoon: 0, + highPriority: 0, + avgProgress: 0, + } + } + }, + [`document-stats-${contractId}`], + { + revalidate: 1800, // 30분 캐시 + tags: [`document-stats-${contractId}`], + } + )() +} + +// 빠른 필터 데이터 +export async function getQuickFilterData(contractId: number) { + return unstable_cache( + async () => { + try { + const [all, overdue, dueSoon, inProgress, highPriority] = await Promise.all([ + countEnhancedDocuments(db, eq(enhancedDocumentsView.contractId, contractId)), + countEnhancedDocuments(db, and( + eq(enhancedDocumentsView.contractId, contractId), + eq(enhancedDocumentsView.isOverdue, true) + )), + // dueSoon 조건은 SQL에서 직접 처리하거나 별도 뷰 필요 + countEnhancedDocuments(db, and( + eq(enhancedDocumentsView.contractId, contractId), + eq(enhancedDocumentsView.currentStageStatus, "IN_PROGRESS") + )), + countEnhancedDocuments(db, and( + eq(enhancedDocumentsView.contractId, contractId), + eq(enhancedDocumentsView.currentStageStatus, "IN_PROGRESS") + )), + countEnhancedDocuments(db, and( + eq(enhancedDocumentsView.contractId, contractId), + eq(enhancedDocumentsView.currentStagePriority, "HIGH") + )) + ]) + + return { + all, + overdue, + dueSoon: 0, // 별도 계산 필요 + inProgress, + highPriority, + } + } catch (err) { + console.error("Error fetching quick filter data:", err) + return { + all: 0, + overdue: 0, + dueSoon: 0, + inProgress: 0, + highPriority: 0, + } + } + }, + [`quick-filter-${contractId}`], + { + revalidate: 1800, + tags: [`quick-filter-${contractId}`], + } + )() +} + +// 단일 문서 상세 정보 +export async function getDocumentDetails(documentId: number) { + return unstable_cache( + async () => { + try { + const result = await db + .select() + .from(enhancedDocumentsView) + .where(eq(enhancedDocumentsView.documentId, documentId)) + .limit(1) + + return result[0] || null + } catch (err) { + console.error("Error fetching document details:", err) + return null + } + }, + [`document-details-${documentId}`], + { + revalidate: 1800, + tags: [`document-details-${documentId}`], + } + )() +} + + +// 문서 CRUD 작업들 + export async function createDocument(input: CreateDocumentInput): Promise<ApiResponse<number>> { + try { + const [newDocument] = await db.insert(documents).values({ + contractId: input.contractId, + docNumber: input.docNumber, + title: input.title, + pic: input.pic, + issuedDate: input.issuedDate, + }).returning({ id: documents.id }) + + revalidatePath("/documents") + return { + success: true, + data: newDocument.id, + message: "문서가 성공적으로 생성되었습니다." + } + } catch (error) { + console.error("Error creating document:", error) + return { + success: false, + error: "문서 생성 중 오류가 발생했습니다." + } + } + } + + export async function updateDocument(input: UpdateDocumentInput): Promise<ApiResponse<void>> { + try { + await db.update(documents) + .set({ + ...input, + updatedAt: new Date(), + }) + .where(eq(documents.id, input.id)) + + revalidatePath("/documents") + return { + success: true, + message: "문서가 성공적으로 업데이트되었습니다." + } + } catch (error) { + console.error("Error updating document:", error) + return { + success: false, + error: "문서 업데이트 중 오류가 발생했습니다." + } + } + } + + export async function deleteDocument(id: number): Promise<ApiResponse<void>> { + try { + await db.delete(documents).where(eq(documents.id, id)) + + revalidatePath("/documents") + return { + success: true, + message: "문서가 성공적으로 삭제되었습니다." + } + } catch (error) { + console.error("Error deleting document:", error) + return { + success: false, + error: "문서 삭제 중 오류가 발생했습니다." + } + } + } + + // 스테이지 CRUD 작업들 + export async function createStage(input: CreateStageInput): Promise<ApiResponse<number>> { + try { + const [newStage] = await db.insert(issueStages).values({ + documentId: input.documentId, + stageName: input.stageName, + planDate: input.planDate, + stageOrder: input.stageOrder ?? 0, + priority: input.priority ?? 'MEDIUM', + assigneeId: input.assigneeId, + assigneeName: input.assigneeName, + description: input.description, + reminderDays: input.reminderDays ?? 3, + }).returning({ id: issueStages.id }) + + revalidatePath("/documents") + return { + success: true, + data: newStage.id, + message: "스테이지가 성공적으로 생성되었습니다." + } + } catch (error) { + console.error("Error creating stage:", error) + return { + success: false, + error: "스테이지 생성 중 오류가 발생했습니다." + } + } + } + + export async function updateStage(input: UpdateStageInput): Promise<ApiResponse<void>> { + try { + await db.update(issueStages) + .set({ + ...input, + updatedAt: new Date(), + }) + .where(eq(issueStages.id, input.id)) + + revalidatePath("/documents") + return { + success: true, + message: "스테이지가 성공적으로 업데이트되었습니다." + } + } catch (error) { + console.error("Error updating stage:", error) + return { + success: false, + error: "스테이지 업데이트 중 오류가 발생했습니다." + } + } + } + + export async function updateStageStatus( + stageId: number, + status: string + ): Promise<ApiResponse<void>> { + try { + const updateData: any = { + stageStatus: status, + updatedAt: new Date(), + } + + // 상태에 따른 자동 날짜 업데이트 + if (status === 'COMPLETED' || status === 'APPROVED') { + updateData.actualDate = new Date().toISOString().split('T')[0] + } + + await db.update(issueStages) + .set(updateData) + .where(eq(issueStages.id, stageId)) + + revalidatePath("/documents") + return { + success: true, + message: "스테이지 상태가 업데이트되었습니다." + } + } catch (error) { + console.error("Error updating stage status:", error) + return { + success: false, + error: "스테이지 상태 업데이트 중 오류가 발생했습니다." + } + } + } + + // 리비전 CRUD 작업들 + export async function createRevision(input: CreateRevisionInput): Promise<ApiResponse<number>> { + try { + const result = await db.transaction(async (tx) => { + // 리비전 생성 + const [newRevision] = await tx.insert(revisions).values({ + issueStageId: input.issueStageId, + revision: input.revision, + uploaderType: input.uploaderType, + uploaderId: input.uploaderId, + uploaderName: input.uploaderName, + comment: input.comment, + submittedDate: new Date().toISOString().split('T')[0], + }).returning({ id: revisions.id }) + + // 첨부파일들 생성 + if (input.attachments && input.attachments.length > 0) { + await tx.insert(documentAttachments).values( + input.attachments.map(attachment => ({ + revisionId: newRevision.id, + fileName: attachment.fileName, + filePath: attachment.filePath, + fileType: attachment.fileType, + fileSize: attachment.fileSize, + })) + ) + } + + // 스테이지 상태를 SUBMITTED로 업데이트 + await tx.update(issueStages) + .set({ + stageStatus: 'SUBMITTED', + updatedAt: new Date(), + }) + .where(eq(issueStages.id, input.issueStageId)) + + return newRevision.id + }) + + revalidatePath("/documents") + return { + success: true, + data: result, + message: "리비전이 성공적으로 업로드되었습니다." + } + } catch (error) { + console.error("Error creating revision:", error) + return { + success: false, + error: "리비전 업로드 중 오류가 발생했습니다." + } + } + } + + export async function updateRevisionStatus(input: UpdateRevisionStatusInput): Promise<ApiResponse<void>> { + try { + const updateData: any = { + revisionStatus: input.revisionStatus, + reviewerId: input.reviewerId, + reviewerName: input.reviewerName, + reviewComments: input.reviewComments, + updatedAt: new Date(), + } + + // 상태에 따른 자동 날짜 업데이트 + const today = new Date().toISOString().split('T')[0] + if (input.revisionStatus === 'UNDER_REVIEW') { + updateData.reviewStartDate = today + } else if (input.revisionStatus === 'APPROVED') { + updateData.approvedDate = today + } else if (input.revisionStatus === 'REJECTED') { + updateData.rejectedDate = today + } + + await db.update(revisions) + .set(updateData) + .where(eq(revisions.id, input.id)) + + revalidatePath("/documents") + return { + success: true, + message: "리비전 상태가 업데이트되었습니다." + } + } catch (error) { + console.error("Error updating revision status:", error) + return { + success: false, + error: "리비전 상태 업데이트 중 오류가 발생했습니다." + } + } + } + + // 조회 작업들 + export async function getDocumentWithStages(documentId: number): Promise<ApiResponse<FullDocument>> { + try { + // 문서 기본 정보 + const [document] = await db.select() + .from(documents) + .where(eq(documents.id, documentId)) + + if (!document) { + return { + success: false, + error: "문서를 찾을 수 없습니다." + } + } + + // 스테이지와 리비전, 첨부파일 조회 + const stagesData = await db.select({ + stage: issueStages, + revision: revisions, + attachment: documentAttachments, + }) + .from(issueStages) + .leftJoin(revisions, eq(issueStages.id, revisions.issueStageId)) + .leftJoin(documentAttachments, eq(revisions.id, documentAttachments.revisionId)) + .where(eq(issueStages.documentId, documentId)) + .orderBy(asc(issueStages.stageOrder), desc(revisions.createdAt)) + + // 데이터 구조화 + const stagesMap = new Map<number, StageWithRevisions>() + + stagesData.forEach(({ stage, revision, attachment }) => { + if (!stagesMap.has(stage.id)) { + stagesMap.set(stage.id, { + ...stage, + revisions: [] + }) + } + + const stageData = stagesMap.get(stage.id)! + + if (revision) { + let revisionData = stageData.revisions.find(r => r.id === revision.id) + if (!revisionData) { + revisionData = { + ...revision, + attachments: [] + } + stageData.revisions.push(revisionData) + } + + if (attachment) { + revisionData.attachments.push(attachment) + } + } + }) + + const stages = Array.from(stagesMap.values()) + + return { + success: true, + data: { + ...document, + stages, + currentStage: stages.find(s => s.stageStatus === 'IN_PROGRESS'), + latestRevision: stages + .flatMap(s => s.revisions) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())[0] + } + } + } catch (error) { + console.error("Error getting document with stages:", error) + return { + success: false, + error: "문서 조회 중 오류가 발생했습니다." + } + } + } + + // 문서의 스테이지와 리비전만 가져오는 경량화된 함수 + export async function getDocumentStagesWithRevisions(documentId: number): Promise<ApiResponse<StageWithRevisions[]>> { + try { + const stagesData = await db.select({ + stage: issueStages, + revision: revisions, + attachment: documentAttachments, + }) + .from(issueStages) + .leftJoin(revisions, eq(issueStages.id, revisions.issueStageId)) + .leftJoin(documentAttachments, eq(revisions.id, documentAttachments.revisionId)) + .where(eq(issueStages.documentId, documentId)) + .orderBy(asc(issueStages.stageOrder), desc(revisions.createdAt)) + + console.log(documentId, stagesData) + + // 데이터 구조화 + const stagesMap = new Map<number, StageWithRevisions>() + + stagesData.forEach(({ stage, revision, attachment }) => { + if (!stagesMap.has(stage.id)) { + stagesMap.set(stage.id, { + ...stage, + revisions: [] + }) + } + + const stageData = stagesMap.get(stage.id)! + + if (revision) { + let revisionData = stageData.revisions.find(r => r.id === revision.id) + if (!revisionData) { + revisionData = { + ...revision, + attachments: [] + } + stageData.revisions.push(revisionData) + } + + if (attachment) { + revisionData.attachments.push(attachment) + } + } + }) + + const stages = Array.from(stagesMap.values()) + + return { + success: true, + data: stages + } + } catch (error) { + console.log(error) + console.error("Error getting document stages with revisions:", error) + return { + success: false, + error: "스테이지 조회 중 오류가 발생했습니다." + } + } + } + + // 특정 스테이지의 리비전들만 가져오는 함수 + export async function getStageRevisions(stageId: number): Promise<ApiResponse<Array<Revision & { attachments: DocumentAttachment[] }>>> { + try { + const revisionsData = await db.select({ + revision: revisions, + attachment: documentAttachments, + }) + .from(revisions) + .leftJoin(documentAttachments, eq(revisions.id, documentAttachments.revisionId)) + .where(eq(revisions.issueStageId, stageId)) + .orderBy(desc(revisions.createdAt)) + + console.log(stageId, revisionsData) + + // 데이터 구조화 + const revisionsMap = new Map<number, Revision & { attachments: DocumentAttachment[] }>() + + revisionsData.forEach(({ revision, attachment }) => { + if (!revisionsMap.has(revision.id)) { + revisionsMap.set(revision.id, { + ...revision, + attachments: [] + }) + } + + const revisionData = revisionsMap.get(revision.id)! + + if (attachment) { + revisionData.attachments.push(attachment) + } + }) + + return { + success: true, + data: Array.from(revisionsMap.values()) + } + } catch (error) { + console.error("Error getting stage revisions:", error) + return { + success: false, + error: "리비전 조회 중 오류가 발생했습니다." + } + } + } + + export async function bulkUpdateStageStatus( + stageIds: number[], + status: string + ): Promise<ApiResponse<void>> { + try { + const updateData: any = { + stageStatus: status, + updatedAt: new Date(), + } + + if (status === 'COMPLETED' || status === 'APPROVED') { + updateData.actualDate = new Date().toISOString().split('T')[0] + } + + await db.update(issueStages) + .set(updateData) + .where( + and( + ...stageIds.map(id => eq(issueStages.id, id)) + ) + ) + + revalidatePath("/documents") + return { + success: true, + message: `${stageIds.length}개 스테이지가 업데이트되었습니다.` + } + } catch (error) { + console.error("Error bulk updating stage status:", error) + return { + success: false, + error: "일괄 상태 업데이트 중 오류가 발생했습니다." + } + } + }
\ No newline at end of file diff --git a/lib/vendor-document-list/sync-client.ts b/lib/vendor-document-list/sync-client.ts new file mode 100644 index 00000000..11439dcb --- /dev/null +++ b/lib/vendor-document-list/sync-client.ts @@ -0,0 +1,28 @@ +export class SyncClient { + static async getSyncStatus(contractId: number, targetSystem: string = 'SHI') { + const response = await fetch(`/api/sync/status?contractId=${contractId}&targetSystem=${targetSystem}`) + if (!response.ok) throw new Error('Failed to fetch sync status') + return response.json() + } + + static async triggerSync(contractId: number, targetSystem: string = 'SHI') { + const response = await fetch('/api/sync/trigger', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ contractId, targetSystem }) + }) + + if (!response.ok) { + const error = await response.json() + throw new Error(error.message || 'Sync failed') + } + + return response.json() + } + + static async getSyncBatches(contractId: number, targetSystem: string = 'SHI', limit: number = 10) { + const response = await fetch(`/api/sync/batches?contractId=${contractId}&targetSystem=${targetSystem}&limit=${limit}`) + if (!response.ok) throw new Error('Failed to fetch sync batches') + return response.json() + } + }
\ No newline at end of file diff --git a/lib/vendor-document-list/sync-service.ts b/lib/vendor-document-list/sync-service.ts new file mode 100644 index 00000000..6978c1cc --- /dev/null +++ b/lib/vendor-document-list/sync-service.ts @@ -0,0 +1,491 @@ +// lib/sync-service.ts +import db from "@/db/db" +import { + syncConfigs, + changeLogs, + syncBatches, + syncStatusView, + type SyncConfig, + type ChangeLog, + type SyncBatch +} from "@/db/schema/vendorDocu" +import { documents, revisions, documentAttachments } from "@/db/schema/vendorDocu" +import { eq, and, lt, desc, sql, inArray } from "drizzle-orm" +import { toast } from "sonner" + +export interface SyncableEntity { + entityType: 'document' | 'revision' | 'attachment' + entityId: number + action: 'CREATE' | 'UPDATE' | 'DELETE' + data: any + metadata?: Record<string, any> +} + +export interface SyncResult { + batchId: number + success: boolean + successCount: number + failureCount: number + errors?: string[] +} + +class SyncService { + + /** + * 변경사항을 change_logs에 기록 + */ + async logChange( + contractId: number, + entityType: 'document' | 'revision' | 'attachment', + entityId: number, + action: 'CREATE' | 'UPDATE' | 'DELETE', + newValues?: any, + oldValues?: any, + userId?: number, + userName?: string + ) { + try { + const changedFields = this.detectChangedFields(oldValues, newValues) + + await db.insert(changeLogs).values({ + contractId, + entityType, + entityId, + action, + changedFields, + oldValues, + newValues, + userId, + userName, + targetSystems: ['SHI'], // 기본적으로 SHI로 동기화 + }) + + console.log(`Change logged: ${entityType}/${entityId} - ${action}`) + } catch (error) { + console.error('Failed to log change:', error) + throw error + } + } + + /** + * 변경된 필드 감지 + */ + private detectChangedFields(oldValues: any, newValues: any): Record<string, any> | null { + if (!oldValues || !newValues) return null + + const changes: Record<string, any> = {} + + for (const [key, newValue] of Object.entries(newValues)) { + if (JSON.stringify(oldValues[key]) !== JSON.stringify(newValue)) { + changes[key] = { + from: oldValues[key], + to: newValue + } + } + } + + return Object.keys(changes).length > 0 ? changes : null + } + + /** + * 계약별 동기화 설정 조회 + */ + async getSyncConfig(contractId: number, targetSystem: string = 'SHI'): Promise<SyncConfig | null> { + const [config] = await db + .select() + .from(syncConfigs) + .where(and( + eq(syncConfigs.contractId, contractId), + eq(syncConfigs.targetSystem, targetSystem) + )) + .limit(1) + + return config || null + } + + /** + * 동기화 설정 생성/업데이트 + */ + async upsertSyncConfig(config: Partial<SyncConfig> & { + contractId: number + targetSystem: string + endpointUrl: string + }) { + const existing = await this.getSyncConfig(config.contractId, config.targetSystem) + + if (existing) { + await db + .update(syncConfigs) + .set({ ...config, updatedAt: new Date() }) + .where(eq(syncConfigs.id, existing.id)) + } else { + await db.insert(syncConfigs).values(config) + } + } + + /** + * 동기화할 변경사항 조회 (증분) + */ + async getPendingChanges( + contractId: number, + targetSystem: string = 'SHI', + limit: number = 100 + ): Promise<ChangeLog[]> { + return await db + .select() + .from(changeLogs) + .where(and( + eq(changeLogs.contractId, contractId), + eq(changeLogs.isSynced, false), + lt(changeLogs.syncAttempts, 3), // 최대 3회 재시도 + sql`(${changeLogs.targetSystems} IS NULL OR ${changeLogs.targetSystems} @> ${JSON.stringify([targetSystem])})` + )) + .orderBy(changeLogs.createdAt) + .limit(limit) + } + + /** + * 동기화 배치 생성 + */ + async createSyncBatch( + contractId: number, + targetSystem: string, + changeLogIds: number[] + ): Promise<number> { + const [batch] = await db + .insert(syncBatches) + .values({ + contractId, + targetSystem, + batchSize: changeLogIds.length, + changeLogIds, + status: 'PENDING' + }) + .returning({ id: syncBatches.id }) + + return batch.id + } + + /** + * 메인 동기화 실행 함수 + */ + async syncToExternalSystem( + contractId: number, + targetSystem: string = 'SHI', + manualTrigger: boolean = false + ): Promise<SyncResult> { + try { + // 1. 동기화 설정 확인 + const config = await this.getSyncConfig(contractId, targetSystem) + if (!config || !config.syncEnabled) { + throw new Error(`Sync not enabled for contract ${contractId} to ${targetSystem}`) + } + + // 2. 대기 중인 변경사항 조회 + const pendingChanges = await this.getPendingChanges( + contractId, + targetSystem, + config.maxBatchSize || 100 + ) + + if (pendingChanges.length === 0) { + return { + batchId: 0, + success: true, + successCount: 0, + failureCount: 0 + } + } + + // 3. 배치 생성 + const batchId = await this.createSyncBatch( + contractId, + targetSystem, + pendingChanges.map(c => c.id) + ) + + // 4. 배치 상태를 PROCESSING으로 업데이트 + await db + .update(syncBatches) + .set({ + status: 'PROCESSING', + startedAt: new Date(), + updatedAt: new Date() + }) + .where(eq(syncBatches.id, batchId)) + + // 5. 실제 데이터 동기화 수행 + const syncResult = await this.performSync(config, pendingChanges) + + // 6. 배치 상태 업데이트 + await db + .update(syncBatches) + .set({ + status: syncResult.success ? 'SUCCESS' : (syncResult.successCount > 0 ? 'PARTIAL' : 'FAILED'), + completedAt: new Date(), + successCount: syncResult.successCount, + failureCount: syncResult.failureCount, + errorMessage: syncResult.errors?.join('; '), + updatedAt: new Date() + }) + .where(eq(syncBatches.id, batchId)) + + // 7. 성공한 변경사항들을 동기화 완료로 표시 + if (syncResult.successCount > 0) { + const successfulChangeIds = pendingChanges + .slice(0, syncResult.successCount) + .map(c => c.id) + + await db + .update(changeLogs) + .set({ + isSynced: true, + syncedAt: new Date() + }) + .where(inArray(changeLogs.id, successfulChangeIds)) + } + + // 8. 실패한 변경사항들의 재시도 횟수 증가 + if (syncResult.failureCount > 0) { + const failedChangeIds = pendingChanges + .slice(syncResult.successCount) + .map(c => c.id) + + await db + .update(changeLogs) + .set({ + syncAttempts: sql`${changeLogs.syncAttempts} + 1`, + lastSyncError: syncResult.errors?.[0] || 'Unknown error' + }) + .where(inArray(changeLogs.id, failedChangeIds)) + } + + // 9. 동기화 설정의 마지막 동기화 시간 업데이트 + await db + .update(syncConfigs) + .set({ + lastSyncAttempt: new Date(), + ...(syncResult.success && { lastSuccessfulSync: new Date() }), + updatedAt: new Date() + }) + .where(eq(syncConfigs.id, config.id)) + + return { + batchId, + success: syncResult.success, + successCount: syncResult.successCount, + failureCount: syncResult.failureCount, + errors: syncResult.errors + } + + } catch (error) { + console.error('Sync failed:', error) + throw error + } + } + + /** + * 실제 외부 시스템으로 데이터 전송 + */ + private async performSync( + config: SyncConfig, + changes: ChangeLog[] + ): Promise<{ success: boolean; successCount: number; failureCount: number; errors?: string[] }> { + const errors: string[] = [] + let successCount = 0 + let failureCount = 0 + + try { + // 변경사항을 외부 시스템 형태로 변환 + const syncData = await this.transformChangesForExternalSystem(changes) + + // 외부 API 호출 + const response = await fetch(config.endpointUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${config.authToken}`, + 'X-API-Version': config.apiVersion || 'v1' + }, + body: JSON.stringify({ + contractId: changes[0]?.contractId, + changes: syncData, + batchSize: changes.length, + timestamp: new Date().toISOString() + }) + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`HTTP ${response.status}: ${errorText}`) + } + + const result = await response.json() + + // 응답에 따라 성공/실패 카운트 처리 + if (result.success) { + successCount = changes.length + } else if (result.partialSuccess) { + successCount = result.successCount || 0 + failureCount = changes.length - successCount + if (result.errors) { + errors.push(...result.errors) + } + } else { + failureCount = changes.length + if (result.error) { + errors.push(result.error) + } + } + + } catch (error) { + console.error('External sync failed:', error) + failureCount = changes.length + errors.push(error instanceof Error ? error.message : 'Unknown error') + } + + return { + success: failureCount === 0, + successCount, + failureCount, + errors: errors.length > 0 ? errors : undefined + } + } + + /** + * 변경사항을 외부 시스템 형태로 변환 + */ + private async transformChangesForExternalSystem(changes: ChangeLog[]): Promise<SyncableEntity[]> { + const syncData: SyncableEntity[] = [] + + for (const change of changes) { + try { + let entityData = null + + // 엔티티 타입별로 현재 데이터 조회 + switch (change.entityType) { + case 'document': + if (change.action !== 'DELETE') { + const [document] = await db + .select() + .from(documents) + .where(eq(documents.id, change.entityId)) + .limit(1) + entityData = document + } + break + + case 'revision': + if (change.action !== 'DELETE') { + const [revision] = await db + .select() + .from(revisions) + .where(eq(revisions.id, change.entityId)) + .limit(1) + entityData = revision + } + break + + case 'attachment': + if (change.action !== 'DELETE') { + const [attachment] = await db + .select() + .from(documentAttachments) + .where(eq(documentAttachments.id, change.entityId)) + .limit(1) + entityData = attachment + } + break + } + + syncData.push({ + entityType: change.entityType as any, + entityId: change.entityId, + action: change.action as any, + data: entityData || change.oldValues, // DELETE의 경우 oldValues 사용 + metadata: { + changeId: change.id, + changedAt: change.createdAt, + changedBy: change.userName, + changedFields: change.changedFields + } + }) + + } catch (error) { + console.error(`Failed to transform change ${change.id}:`, error) + } + } + + return syncData + } + + /** + * 동기화 상태 조회 + */ + async getSyncStatus(contractId: number, targetSystem: string = 'SHI') { + const [status] = await db + .select() + .from(syncStatusView) + .where(and( + eq(syncStatusView.contractId, contractId), + eq(syncStatusView.targetSystem, targetSystem) + )) + .limit(1) + + return status + } + + /** + * 최근 동기화 배치 목록 조회 + */ + async getRecentSyncBatches(contractId: number, targetSystem: string = 'SHI', limit: number = 10) { + return await db + .select() + .from(syncBatches) + .where(and( + eq(syncBatches.contractId, contractId), + eq(syncBatches.targetSystem, targetSystem) + )) + .orderBy(desc(syncBatches.createdAt)) + .limit(limit) + } +} + +export const syncService = new SyncService() + +// 편의 함수들 +export async function logDocumentChange( + contractId: number, + documentId: number, + action: 'CREATE' | 'UPDATE' | 'DELETE', + newValues?: any, + oldValues?: any, + userId?: number, + userName?: string +) { + return syncService.logChange(contractId, 'document', documentId, action, newValues, oldValues, userId, userName) +} + +export async function logRevisionChange( + contractId: number, + revisionId: number, + action: 'CREATE' | 'UPDATE' | 'DELETE', + newValues?: any, + oldValues?: any, + userId?: number, + userName?: string +) { + return syncService.logChange(contractId, 'revision', revisionId, action, newValues, oldValues, userId, userName) +} + +export async function logAttachmentChange( + contractId: number, + attachmentId: number, + action: 'CREATE' | 'UPDATE' | 'DELETE', + newValues?: any, + oldValues?: any, + userId?: number, + userName?: string +) { + return syncService.logChange(contractId, 'attachment', attachmentId, action, newValues, oldValues, userId, userName) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/table/bulk-upload-dialog.tsx b/lib/vendor-document-list/table/bulk-upload-dialog.tsx new file mode 100644 index 00000000..b7021985 --- /dev/null +++ b/lib/vendor-document-list/table/bulk-upload-dialog.tsx @@ -0,0 +1,1162 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { toast } from "sonner" +import { useRouter } from "next/navigation" +import { useSession } from "next-auth/react" +import ExcelJS from 'exceljs' + +import { +Dialog, +DialogContent, +DialogDescription, +DialogFooter, +DialogHeader, +DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { +Form, +FormControl, +FormField, +FormItem, +FormLabel, +FormMessage, +} from "@/components/ui/form" +import { +Dropzone, +DropzoneDescription, +DropzoneInput, +DropzoneTitle, +DropzoneUploadIcon, +DropzoneZone, +} from "@/components/ui/dropzone" +import { +FileList, +FileListAction, +FileListHeader, +FileListIcon, +FileListInfo, +FileListItem, +FileListName, +FileListSize, +} from "@/components/ui/file-list" +import { Badge } from "@/components/ui/badge" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Separator } from "@/components/ui/separator" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { +Upload, +X, +Loader2, +FileSpreadsheet, +Files, +CheckCircle2, +AlertCircle, +Download +} from "lucide-react" +import prettyBytes from "pretty-bytes" +import type { EnhancedDocument } from "@/types/enhanced-documents" + +// 일괄 업로드 스키마 +const bulkUploadSchema = z.object({ +uploaderName: z.string().optional(), +comment: z.string().optional(), +templateFile: z.instanceof(File).optional(), +attachmentFiles: z.array(z.instanceof(File)).min(1, "최소 1개 파일이 필요합니다"), +}) + +type BulkUploadSchema = z.infer<typeof bulkUploadSchema> + +interface ParsedUploadItem { +documentId: number +docNumber: string +title: string +stage: string +revision: string +fileNames: string[] // ';'로 구분된 파일명들 +} + +interface FileMatchResult { +matched: { file: File; item: ParsedUploadItem }[] +unmatched: File[] +missingFiles: string[] +} + +interface BulkUploadDialogProps { +open: boolean +onOpenChange: (open: boolean) => void +documents: EnhancedDocument[] +projectType: "ship" | "plant" +contractId: number // ✅ contractId 추가 +} + +export function BulkUploadDialog({ +open, +onOpenChange, +documents, +projectType, +contractId, // ✅ contractId 받기 +}: BulkUploadDialogProps) { +const [selectedFiles, setSelectedFiles] = React.useState<File[]>([]) +const [templateFile, setTemplateFile] = React.useState<File | null>(null) +const [parsedData, setParsedData] = React.useState<ParsedUploadItem[]>([]) +const [matchResult, setMatchResult] = React.useState<FileMatchResult | null>(null) +const [isUploading, setIsUploading] = React.useState(false) +const [uploadProgress, setUploadProgress] = React.useState(0) +const [currentStep, setCurrentStep] = React.useState<'template' | 'files' | 'review' | 'upload'>('template') + +const router = useRouter() +const { data: session } = useSession() + +const form = useForm<BulkUploadSchema>({ + resolver: zodResolver(bulkUploadSchema), + defaultValues: { + uploaderName: session?.user?.name || "", + comment: "", + templateFile: undefined, + attachmentFiles: [], + }, +}) + +React.useEffect(() => { + if (session?.user?.name) { + form.setValue('uploaderName', session.user.name) + } +}, [session?.user?.name, form]) + +// 다이얼로그가 열릴 때마다 업로더명 리프레시 +React.useEffect(() => { + if (open && session?.user?.name) { + form.setValue('uploaderName', session.user.name) + } +}, [open, session?.user?.name, form]) + +// 리비전 정렬 및 최신 리비전 찾기 헬퍼 함수들 +const compareRevisions = (a: string, b: string): number => { + // 알파벳 리비전 (A, B, C, ..., Z, AA, AB, ...) + const aIsAlpha = /^[A-Z]+$/.test(a) + const bIsAlpha = /^[A-Z]+$/.test(b) + + if (aIsAlpha && bIsAlpha) { + // 길이 먼저 비교 (A < AA) + if (a.length !== b.length) { + return a.length - b.length + } + // 같은 길이면 알파벳 순서 + return a.localeCompare(b) + } + + // 숫자 리비전 (0, 1, 2, ...) + const aIsNumber = /^\d+$/.test(a) + const bIsNumber = /^\d+$/.test(b) + + if (aIsNumber && bIsNumber) { + return parseInt(a) - parseInt(b) + } + + // 혼재된 경우 알파벳이 먼저 + if (aIsAlpha && bIsNumber) return -1 + if (aIsNumber && bIsAlpha) return 1 + + // 기타 복잡한 형태는 문자열 비교 + return a.localeCompare(b) +} + +const getLatestRevisionInStage = (document: EnhancedDocument, stageName: string): string => { + const stage = document.allStages?.find(s => s.stageName === stageName) + if (!stage || !stage.revisions || stage.revisions.length === 0) { + return '' + } + + // 리비전들을 정렬해서 최신 것 찾기 + const sortedRevisions = [...stage.revisions].sort((a, b) => + compareRevisions(a.revision, b.revision) + ) + + return sortedRevisions[sortedRevisions.length - 1]?.revision || '' +} + +const getNextRevision = (currentRevision: string): string => { + if (!currentRevision) return "A" + + // 알파벳 리비전 (A, B, C...) + if (/^[A-Z]+$/.test(currentRevision)) { + // 한 글자인 경우 + if (currentRevision.length === 1) { + const charCode = currentRevision.charCodeAt(0) + if (charCode < 90) { // Z가 아닌 경우 + return String.fromCharCode(charCode + 1) + } + return "AA" // Z 다음은 AA + } + + // 여러 글자인 경우 (AA, AB, ... AZ, BA, ...) + let result = currentRevision + let carry = true + let newResult = '' + + for (let i = result.length - 1; i >= 0 && carry; i--) { + let charCode = result.charCodeAt(i) + if (charCode < 90) { // Z가 아닌 경우 + newResult = String.fromCharCode(charCode + 1) + newResult + carry = false + } else { // Z인 경우 + newResult = 'A' + newResult + } + } + + if (carry) { + newResult = 'A' + newResult + } else { + newResult = result.substring(0, result.length - newResult.length) + newResult + } + + return newResult + } + + // 숫자 리비전 (0, 1, 2...) + if (/^\d+$/.test(currentRevision)) { + return String(parseInt(currentRevision) + 1) + } + + // 기타 복잡한 리비전 형태는 그대로 반환 + return currentRevision +} + +// 템플릿 export 함수 +const exportTemplate = async () => { + try { + const workbook = new ExcelJS.Workbook() + const worksheet = workbook.addWorksheet('BulkUploadTemplate') + + // 헤더 정의 + const headers = [ + 'documentId', + 'docNumber', + 'title', + 'currentStage', + 'latestRevision', + 'targetStage', + 'targetRevision', + 'fileNames' + ] + + // 헤더 스타일링 + const headerRow = worksheet.addRow(headers) + headerRow.eachCell((cell, colNumber) => { + cell.font = { bold: true, color: { argb: 'FFFFFF' } } + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: '366092' } + } + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + } + cell.alignment = { horizontal: 'center', vertical: 'middle' } + }) + + // 데이터 추가 + documents.forEach(doc => { + const currentStageName = doc.currentStageName || '' + const latestRevision = getLatestRevisionInStage(doc, currentStageName) + + const row = worksheet.addRow([ + doc.documentId, + doc.docNumber, + doc.title, + currentStageName, + latestRevision, // 현재 스테이지의 최신 리비전 + currentStageName, // 기본값으로 현재 스테이지 설정 + latestRevision, // 기본값으로 현재 최신 리비전 설정 (사용자가 선택) + '', // 사용자가 입력할 파일명들 (';'로 구분) + ]) + + // 데이터 행 스타일링 + row.eachCell((cell, colNumber) => { + cell.border = { + top: { style: 'thin' }, + left: { style: 'thin' }, + bottom: { style: 'thin' }, + right: { style: 'thin' } + } + + // 편집 가능한 칼럼 (targetStage, targetRevision, fileNames) 강조 + if (colNumber >= 6) { + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFF2CC' } // 연한 노란색 + } + } + }) + }) + + // 칼럼 너비 설정 + worksheet.columns = [ + { width: 12 }, // documentId + { width: 18 }, // docNumber + { width: 35 }, // title + { width: 20 }, // currentStage + { width: 15 }, // latestRevision + { width: 20 }, // targetStage + { width: 15 }, // targetRevision + { width: 60 }, // fileNames + ] + + // 헤더 고정 + worksheet.views = [{ state: 'frozen', ySplit: 1 }] + + // 주석 추가 + const instructionRow = worksheet.insertRow(1, [ + '지침:', + '1. latestRevision: 현재 스테이지의 최신 리비전', + '2. targetStage: 업로드할 스테이지명 (수정 가능)', + '3. targetRevision: 같은 리비전에 파일 추가 시 그대로, 새 리비전 생성 시 수정', + '4. fileNames: 파일명들을 세미콜론(;)으로 구분', + '예: file1.pdf;file2.dwg;file3.xlsx', + '', + '← 이 행은 삭제하고 사용해도 됩니다' + ]) + + instructionRow.eachCell((cell) => { + cell.font = { italic: true, color: { argb: '888888' } } + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'F0F0F0' } + } + }) + + // 파일 다운로드 + const buffer = await workbook.xlsx.writeBuffer() + const blob = new Blob([buffer], { + type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + }) + + const url = window.URL.createObjectURL(blob) + const link = document.createElement('a') + link.href = url + link.download = `bulk-upload-template-${new Date().toISOString().split('T')[0]}.xlsx` + document.body.appendChild(link) + link.click() + document.body.removeChild(link) + window.URL.revokeObjectURL(url) + + toast.success("템플릿이 다운로드되었습니다. targetRevision은 기본값(최신 리비전)이 설정되어 있습니다!") + + } catch (error) { + console.error('템플릿 생성 오류:', error) + toast.error('템플릿 생성에 실패했습니다.') + } +} + +// 템플릿 파일 파싱 +const parseTemplateFile = async (file: File) => { + try { + const arrayBuffer = await file.arrayBuffer() + const workbook = new ExcelJS.Workbook() + await workbook.xlsx.load(arrayBuffer) + + const worksheet = workbook.getWorksheet(1) // 첫 번째 워크시트 + if (!worksheet) { + throw new Error('워크시트를 찾을 수 없습니다.') + } + + // 헤더 행 찾기 (지침 행이 있을 수 있으므로) + let headerRowIndex = 1 + let headers: string[] = [] + + // 최대 5행까지 헤더를 찾아본다 + for (let i = 1; i <= 5; i++) { + const row = worksheet.getRow(i) + const firstCell = row.getCell(1).value + + if (firstCell && String(firstCell).includes('documentId')) { + headerRowIndex = i + headers = [] + + // 헤더 추출 + for (let col = 1; col <= 8; col++) { + const cellValue = row.getCell(col).value + headers.push(String(cellValue || '')) + } + break + } + } + + const expectedHeaders = ['documentId', 'docNumber', 'title', 'currentStage', 'latestRevision', 'targetStage', 'targetRevision', 'fileNames'] + const missingHeaders = expectedHeaders.filter(h => !headers.includes(h)) + + if (missingHeaders.length > 0) { + throw new Error(`필수 칼럼이 누락되었습니다: ${missingHeaders.join(', ')}`) + } + + // 데이터 파싱 + const parsed: ParsedUploadItem[] = [] + const rowCount = worksheet.rowCount + + console.log(`📊 파싱 시작: 총 ${rowCount}행, 헤더 행: ${headerRowIndex}`) + + for (let i = headerRowIndex + 1; i <= rowCount; i++) { + const row = worksheet.getRow(i) + + // 빈 행 스킵 + if (!row.hasValues) { + console.log(`행 ${i}: 빈 행 스킵`) + continue + } + + const documentIdCell = row.getCell(headers.indexOf('documentId') + 1).value + const docNumberCell = row.getCell(headers.indexOf('docNumber') + 1).value + const titleCell = row.getCell(headers.indexOf('title') + 1).value + const stageCell = row.getCell(headers.indexOf('targetStage') + 1).value + const revisionCell = row.getCell(headers.indexOf('targetRevision') + 1).value + const fileNamesCell = row.getCell(headers.indexOf('fileNames') + 1).value + + // 값들을 안전하게 변환 + const documentId = Number(documentIdCell) || 0 + const docNumber = String(docNumberCell || '').trim() + const title = String(titleCell || '').trim() + const stage = String(stageCell || '').trim().replace(/[ \s]/g, ' ').trim() // 전각공백 처리 + const revision = String(revisionCell || '').trim() + const fileNamesStr = String(fileNamesCell || '').trim().replace(/[ \s]/g, ' ').trim() // 전각공백 처리 + + console.log(`행 ${i} 파싱 결과:`, { + documentId, docNumber, title, stage, revision, fileNamesStr, + originalCells: { documentIdCell, docNumberCell, titleCell, stageCell, revisionCell, fileNamesCell } + }) + + // 필수 데이터 체크 (documentId와 docNumber만 체크, stage와 revision은 빈 값 허용) + if (!documentId || !docNumber) { + console.warn(`행 ${i}: 필수 데이터 누락 (documentId: ${documentId}, docNumber: ${docNumber})`) + continue + } + + // fileNames가 비어있는 행은 무시 + if (!fileNamesStr || fileNamesStr === '' || fileNamesStr === 'undefined' || fileNamesStr === 'null') { + console.log(`행 ${i}: fileNames가 비어있어 스킵합니다. (${docNumber}) - fileNamesStr: "${fileNamesStr}"`) + continue + } + + // stage와 revision이 비어있는 경우 기본값 설정 + const finalStage = stage || 'Default Stage' + const finalRevision = revision || 'A' + + const fileNames = fileNamesStr.split(';').map(name => name.trim()).filter(Boolean) + if (fileNames.length === 0) { + console.warn(`행 ${i}: 파일명 파싱 실패 (${docNumber}) - 원본: "${fileNamesStr}"`) + continue + } + + console.log(`✅ 행 ${i} 파싱 성공:`, { + documentId, docNumber, stage: finalStage, revision: finalRevision, fileNames + }) + + parsed.push({ + documentId, + docNumber, + title, + stage: finalStage, + revision: finalRevision, + fileNames, + }) + } + + console.log(`📋 파싱 완료: ${parsed.length}개 항목`) + + if (parsed.length === 0) { + console.error('파싱된 데이터:', parsed) + throw new Error('파싱할 수 있는 유효한 데이터가 없습니다. fileNames 칼럼에 파일명이 입력되어 있는지 확인해주세요.') + } + + setParsedData(parsed) + setCurrentStep('files') + toast.success(`템플릿 파싱 완료: ${parsed.length}개 항목, 총 ${parsed.reduce((sum, item) => sum + item.fileNames.length, 0)}개 파일 필요`) + + } catch (error) { + console.error('템플릿 파싱 오류:', error) + toast.error(error instanceof Error ? error.message : '템플릿 파싱에 실패했습니다.') + } +} + +// 파일 매칭 로직 +const matchFiles = (files: File[], uploadItems: ParsedUploadItem[]): FileMatchResult => { + const matched: { file: File; item: ParsedUploadItem }[] = [] + const unmatched: File[] = [] + const missingFiles: string[] = [] + + // 모든 필요한 파일명 수집 + const requiredFileNames = new Set<string>() + uploadItems.forEach(item => { + item.fileNames.forEach(fileName => requiredFileNames.add(fileName)) + }) + + // 파일 매칭 + files.forEach(file => { + let isMatched = false + + for (const item of uploadItems) { + if (item.fileNames.some(fileName => fileName === file.name)) { + matched.push({ file, item }) + isMatched = true + break + } + } + + if (!isMatched) { + unmatched.push(file) + } + }) + + // 누락된 파일 찾기 + const uploadedFileNames = new Set(files.map(f => f.name)) + requiredFileNames.forEach(fileName => { + if (!uploadedFileNames.has(fileName)) { + missingFiles.push(fileName) + } + }) + + return { matched, unmatched, missingFiles } +} + +// 템플릿 드롭 처리 +const handleTemplateDropAccepted = (acceptedFiles: File[]) => { + const file = acceptedFiles[0] + if (!file) return + + if (!file.name.endsWith('.xlsx') && !file.name.endsWith('.xls')) { + toast.error('Excel 파일(.xlsx, .xls)만 업로드 가능합니다.') + return + } + + setTemplateFile(file) + form.setValue('templateFile', file) + parseTemplateFile(file) +} + +// 파일 드롭 처리 +const handleFilesDropAccepted = (acceptedFiles: File[]) => { + const newFiles = [...selectedFiles, ...acceptedFiles] + setSelectedFiles(newFiles) + form.setValue('attachmentFiles', newFiles, { shouldValidate: true }) + + // 파일 매칭 수행 + if (parsedData.length > 0) { + const result = matchFiles(newFiles, parsedData) + setMatchResult(result) + setCurrentStep('review') + } +} + +// 파일 제거 +const removeFile = (index: number) => { + const updatedFiles = [...selectedFiles] + updatedFiles.splice(index, 1) + setSelectedFiles(updatedFiles) + form.setValue('attachmentFiles', updatedFiles, { shouldValidate: true }) + + if (parsedData.length > 0) { + const result = matchFiles(updatedFiles, parsedData) + setMatchResult(result) + } +} + +// 일괄 업로드 처리 +const onSubmit = async (data: BulkUploadSchema) => { + if (!matchResult || matchResult.matched.length === 0) { + toast.error('매칭된 파일이 없습니다.') + return + } + + setIsUploading(true) + setUploadProgress(0) + setCurrentStep('upload') + + try { + const formData = new FormData() + + // 메타데이터 + formData.append('uploaderName', data.uploaderName || '') + formData.append('comment', data.comment || '') + formData.append('projectType', projectType) + if (contractId) { + formData.append('contractId', String(contractId)) // ✅ contractId 추가 + } + formData.append('contractId', String(contractId)) // ✅ contractId 추가 + + // 매칭된 파일들과 메타데이터 + const uploadData = matchResult.matched.map(({ file, item }) => ({ + documentId: item.documentId, + stage: item.stage, + revision: item.revision, + fileName: file.name, + })) + + formData.append('uploadData', JSON.stringify(uploadData)) + + // 파일들 추가 + matchResult.matched.forEach(({ file }, index) => { + formData.append(`file_${index}`, file) + }) + + // 진행률 시뮬레이션 + const progressInterval = setInterval(() => { + setUploadProgress(prev => Math.min(prev + 10, 90)) + }, 500) + + const response = await fetch('/api/bulk-upload', { + method: 'POST', + body: formData, + }) + + clearInterval(progressInterval) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || '일괄 업로드에 실패했습니다.') + } + + const result = await response.json() + setUploadProgress(100) + + toast.success(`${result.data?.uploadedCount || 0}개 파일이 성공적으로 업로드되었습니다.`) + + setTimeout(() => { + handleDialogClose() + router.refresh() + }, 1000) + + } catch (error) { + console.error('일괄 업로드 오류:', error) + toast.error(error instanceof Error ? error.message : "업로드 중 오류가 발생했습니다") + } finally { + setIsUploading(false) + setTimeout(() => setUploadProgress(0), 2000) + } +} + +const handleDialogClose = () => { + form.reset({ + uploaderName: session?.user?.name || "", // ✅ 항상 최신 session 값으로 리셋 + comment: "", + templateFile: undefined, + attachmentFiles: [], + }) + setSelectedFiles([]) + setTemplateFile(null) + setParsedData([]) + setMatchResult(null) + setCurrentStep('template') + setIsUploading(false) + setUploadProgress(0) + onOpenChange(false) +} + +const canProceedToUpload = matchResult && matchResult.matched.length > 0 && matchResult.missingFiles.length === 0 + +return ( + <Dialog open={open} onOpenChange={handleDialogClose}> + <DialogContent className="sm:max-w-6xl max-h-[90vh] overflow-y-auto"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <Files className="w-5 h-5" /> + 일괄 업로드 + </DialogTitle> + <DialogDescription> + 템플릿을 다운로드하여 파일명을 입력한 후, 실제 파일들을 업로드하세요. + </DialogDescription> + + <div className="flex items-center gap-2 pt-2"> + <Badge variant={projectType === "ship" ? "default" : "secondary"}> + {projectType === "ship" ? "조선 프로젝트" : "플랜트 프로젝트"} + </Badge> + <Badge variant="outline"> + 총 {documents.length}개 문서 + </Badge> + </div> + </DialogHeader> + + {/* 단계별 진행 상태 */} + <div className="flex items-center gap-2 mb-4"> + {[ + { key: 'template', label: '템플릿' }, + { key: 'files', label: '파일 업로드' }, + { key: 'review', label: '검토' }, + { key: 'upload', label: '업로드' }, + ].map((step, index) => ( + <React.Fragment key={step.key}> + <div className={`flex items-center gap-1 px-2 py-1 rounded text-xs ${ + currentStep === step.key ? 'bg-primary text-primary-foreground' : + ['template', 'files', 'review'].indexOf(currentStep) > ['template', 'files', 'review'].indexOf(step.key) ? 'bg-green-100 text-green-700' : + 'bg-gray-100 text-gray-500' + }`}> + {step.label} + </div> + {index < 3 && <div className="w-2 h-px bg-gray-300" />} + </React.Fragment> + ))} + </div> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6"> + + {/* 1단계: 템플릿 다운로드 및 업로드 */} + {currentStep === 'template' && ( + <div className="space-y-4"> + <Card> + <CardHeader> + <CardTitle className="text-lg flex items-center gap-2"> + <Download className="w-4 h-4" /> + 1단계: 템플릿 다운로드 + </CardTitle> + </CardHeader> + <CardContent className="space-y-4"> + <p className="text-sm text-gray-600"> + 현재 문서 목록을 기반으로 업로드 템플릿을 생성합니다. + 마지막 "fileNames" 칼럼에 업로드할 파일명을 ';'로 구분하여 입력하세요. + </p> + <Button type="button" onClick={exportTemplate} className="gap-2"> + <Download className="w-4 h-4" /> + 템플릿 다운로드 ({documents.length}개 문서) + </Button> + </CardContent> + </Card> + + <Card> + <CardHeader> + <CardTitle className="text-lg flex items-center gap-2"> + <Upload className="w-4 h-4" /> + 작성된 템플릿 업로드 + </CardTitle> + </CardHeader> + <CardContent> + <Dropzone + maxSize={10e6} // 10MB + multiple={false} + accept={{ + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], + 'application/vnd.ms-excel': ['.xls'] + }} + onDropAccepted={handleTemplateDropAccepted} + disabled={isUploading} + > + <DropzoneZone> + <FormControl> + <DropzoneInput /> + </FormControl> + <div className="flex items-center gap-6"> + <FileSpreadsheet className="w-8 h-8 text-gray-400" /> + <div className="grid gap-0.5"> + <DropzoneTitle>작성된 Excel 템플릿을 업로드하세요</DropzoneTitle> + <DropzoneDescription> + .xlsx, .xls 파일을 지원합니다 + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + </Dropzone> + + {templateFile && ( + <div className="mt-4 p-3 bg-green-50 border border-green-200 rounded-lg"> + <div className="flex items-center gap-2"> + <CheckCircle2 className="w-4 h-4 text-green-600" /> + <span className="text-sm text-green-700"> + 템플릿 업로드 완료: {templateFile.name} + </span> + </div> + </div> + )} + </CardContent> + </Card> + </div> + )} + + {/* 2단계: 파일 업로드 */} + {currentStep === 'files' && ( + <div className="space-y-4"> + <Card> + <CardHeader> + <CardTitle className="text-lg flex items-center gap-2"> + <Files className="w-4 h-4" /> + 2단계: 실제 파일들 업로드 + </CardTitle> + </CardHeader> + <CardContent> + <div className="mb-4 p-3 bg-blue-50 border border-blue-200 rounded-lg"> + <p className="text-sm text-blue-700"> + 템플릿에서 {parsedData.length}개 항목, 총 {parsedData.reduce((sum, item) => sum + item.fileNames.length, 0)}개 파일이 필요합니다. + </p> + </div> + + <Dropzone + maxSize={3e9} // 3GB + multiple={true} + onDropAccepted={handleFilesDropAccepted} + disabled={isUploading} + > + <DropzoneZone> + <FormControl> + <DropzoneInput /> + </FormControl> + <div className="flex items-center gap-6"> + <DropzoneUploadIcon /> + <div className="grid gap-0.5"> + <DropzoneTitle>실제 파일들을 여기에 드롭하세요</DropzoneTitle> + <DropzoneDescription> + 또는 클릭하여 파일들을 선택하세요 + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + </Dropzone> + + {selectedFiles.length > 0 && ( + <div className="mt-4 space-y-2"> + <h6 className="text-sm font-semibold"> + 업로드된 파일 ({selectedFiles.length}) + </h6> + <ScrollArea className="max-h-[200px]"> + <FileList> + {selectedFiles.map((file, index) => ( + <FileListItem key={index} className="p-3"> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName>{file.name}</FileListName> + <FileListSize>{prettyBytes(file.size)}</FileListSize> + </FileListInfo> + <FileListAction + onClick={() => removeFile(index)} + disabled={isUploading} + > + <X className="h-4 w-4" /> + </FileListAction> + </FileListHeader> + </FileListItem> + ))} + </FileList> + </ScrollArea> + </div> + )} + </CardContent> + </Card> + </div> + )} + + {/* 3단계: 매칭 결과 검토 */} + {currentStep === 'review' && matchResult && ( + <div className="space-y-4"> + <Card> + <CardHeader> + <CardTitle className="text-lg flex items-center gap-2"> + <CheckCircle2 className="w-4 h-4" /> + 3단계: 매칭 결과 검토 + </CardTitle> + </CardHeader> + <CardContent className="space-y-6"> + + {/* 통합된 매칭 결과 요약 */} + <div className="grid grid-cols-3 gap-4"> + <div className="p-4 bg-green-50 border border-green-200 rounded-lg text-center"> + <div className="text-2xl font-bold text-green-600">{matchResult.matched.length}</div> + <div className="text-sm text-green-700">매칭 성공</div> + </div> + <div className="p-4 bg-yellow-50 border border-yellow-200 rounded-lg text-center"> + <div className="text-2xl font-bold text-yellow-600">{matchResult.unmatched.length}</div> + <div className="text-sm text-yellow-700">매칭 실패</div> + </div> + <div className="p-4 bg-red-50 border border-red-200 rounded-lg text-center"> + <div className="text-2xl font-bold text-red-600">{matchResult.missingFiles.length}</div> + <div className="text-sm text-red-700">누락된 파일</div> + </div> + </div> + + {/* 통합된 상세 결과 */} + <div className="border border-gray-200 rounded-lg overflow-hidden"> + {/* 매칭 성공 섹션 */} + {matchResult.matched.length > 0 && ( + <div className="border-b border-gray-200"> + <div className="p-4 bg-green-50 flex items-center justify-between"> + <h6 className="font-semibold text-green-700 flex items-center gap-2"> + <CheckCircle2 className="w-4 h-4" /> + 매칭 성공 ({matchResult.matched.length}개) + </h6> + <Button + variant="ghost" + size="sm" + type="button" + onClick={(e) => { + e.preventDefault() + e.stopPropagation() + const element = document.getElementById('matched-details') + if (element) { + element.style.display = element.style.display === 'none' ? 'block' : 'none' + } + }} + > + {matchResult.matched.length <= 5 ? '모두보기' : '상세보기'} + </Button> + </div> + + {/* 미리보기 */} + <div className="p-4 bg-green-25"> + <div className="space-y-2"> + {matchResult.matched.slice(0, 5).map((match, index) => ( + <div key={index} className="flex items-center justify-between text-sm"> + <span className="font-mono text-green-600 truncate max-w-[300px]" title={match.file.name}> + {match.file.name} + </span> + <span className="text-green-700 ml-4 whitespace-nowrap flex-shrink-0"> + → {match.item.docNumber} Rev.{match.item.revision} + </span> + </div> + ))} + {matchResult.matched.length > 5 && ( + <div className="text-gray-500 text-center text-sm py-2 border-t border-green-200"> + ... 외 {matchResult.matched.length - 5}개 (상세보기로 확인) + </div> + )} + </div> + + {/* 펼침 상세 내용 */} + <div id="matched-details" style={{ display: 'none' }} className="mt-4 pt-4 border-t border-green-200"> + <div className="max-h-64 overflow-y-auto"> + <div className="space-y-2"> + {matchResult.matched.map((match, index) => ( + <div key={index} className="flex items-center justify-between text-sm py-1"> + <span className="font-mono text-green-600 truncate max-w-[300px]" title={match.file.name}> + {match.file.name} + </span> + <span className="text-green-700 ml-4 whitespace-nowrap flex-shrink-0"> + → {match.item.docNumber} ({match.item.stage} Rev.{match.item.revision}) + </span> + </div> + ))} + </div> + </div> + </div> + </div> + </div> + )} + + {/* 매칭 실패 섹션 */} + {matchResult.unmatched.length > 0 && ( + <div className="border-b border-gray-200"> + <div className="p-4 bg-yellow-50 flex items-center justify-between"> + <h6 className="font-semibold text-yellow-700 flex items-center gap-2"> + <AlertCircle className="w-4 h-4" /> + 매칭되지 않은 파일 ({matchResult.unmatched.length}개) + </h6> + <Button + variant="ghost" + size="sm" + type="button" + onClick={() => { + const element = document.getElementById('unmatched-details') + if (element) { + element.style.display = element.style.display === 'none' ? 'block' : 'none' + } + }} + > + 상세보기 + </Button> + </div> + + <div className="p-4 bg-yellow-25"> + <div className="space-y-1"> + {matchResult.unmatched.slice(0, 3).map((file, index) => ( + <div key={index} className="text-sm text-yellow-600 font-mono truncate max-w-full" title={file.name}> + {file.name} + </div> + ))} + {matchResult.unmatched.length > 3 && ( + <div className="text-gray-500 text-center text-sm py-2"> + ... 외 {matchResult.unmatched.length - 3}개 + </div> + )} + </div> + + <div id="unmatched-details" style={{ display: 'none' }} className="mt-4 pt-4 border-t border-yellow-200"> + <div className="max-h-40 overflow-y-auto"> + <div className="space-y-1"> + {matchResult.unmatched.map((file, index) => ( + <div key={index} className="text-sm text-yellow-600 font-mono truncate max-w-full" title={file.name}> + {file.name} + </div> + ))} + </div> + </div> + </div> + </div> + </div> + )} + + {/* 누락된 파일 섹션 */} + {matchResult.missingFiles.length > 0 && ( + <div> + <div className="p-4 bg-red-50 flex items-center justify-between"> + <h6 className="font-semibold text-red-700 flex items-center gap-2"> + <X className="w-4 h-4" /> + 누락된 파일 ({matchResult.missingFiles.length}개) + </h6> + <Button + variant="ghost" + size="sm" + type="button" + onClick={() => { + const element = document.getElementById('missing-details') + if (element) { + element.style.display = element.style.display === 'none' ? 'block' : 'none' + } + }} + > + 상세보기 + </Button> + </div> + + <div className="p-4 bg-red-25"> + <div className="space-y-1"> + {matchResult.missingFiles.slice(0, 3).map((fileName, index) => ( + <div key={index} className="text-sm text-red-600 font-mono truncate max-w-full" title={fileName}> + {fileName} + </div> + ))} + {matchResult.missingFiles.length > 3 && ( + <div className="text-gray-500 text-center text-sm py-2"> + ... 외 {matchResult.missingFiles.length - 3}개 + </div> + )} + </div> + + <div id="missing-details" style={{ display: 'none' }} className="mt-4 pt-4 border-t border-red-200"> + <div className="max-h-40 overflow-y-auto"> + <div className="space-y-1"> + {matchResult.missingFiles.map((fileName, index) => ( + <div key={index} className="text-sm text-red-600 font-mono truncate max-w-full" title={fileName}> + {fileName} + </div> + ))} + </div> + </div> + </div> + </div> + </div> + )} + </div> + + {/* 업로드 불가 경고 */} + {!canProceedToUpload && ( + <div className="p-4 bg-red-50 border border-red-200 rounded-lg"> + <div className="flex items-center gap-2"> + <AlertCircle className="w-5 h-5 text-red-600 flex-shrink-0" /> + <span className="text-sm text-red-700"> + 누락된 파일이 있어 업로드를 진행할 수 없습니다. 누락된 파일들을 추가해주세요. + </span> + </div> + </div> + )} + </CardContent> + </Card> + + {/* 추가 정보 입력 */} + <div className="grid grid-cols-1 gap-4"> + <FormField + control={form.control} + name="uploaderName" + render={({ field }) => ( + <FormItem> + <FormLabel>업로더명</FormLabel> + <FormControl> + <Input {...field} placeholder="업로더 이름" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="comment" + render={({ field }) => ( + <FormItem> + <FormLabel>코멘트 (선택)</FormLabel> + <FormControl> + <Textarea {...field} placeholder="일괄 업로드 코멘트" rows={2} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + </div> + )} + + {/* 4단계: 업로드 진행 */} + {currentStep === 'upload' && ( + <div className="space-y-4"> + <Card> + <CardHeader> + <CardTitle className="text-lg flex items-center gap-2"> + <Upload className="w-4 h-4" /> + 4단계: 업로드 진행중 + </CardTitle> + </CardHeader> + <CardContent> + <div className="space-y-4"> + <div className="flex items-center gap-2"> + <Loader2 className="h-4 w-4 animate-spin" /> + <span className="text-sm">{uploadProgress}% 업로드 중...</span> + </div> + <div className="h-2 w-full bg-muted rounded-full overflow-hidden"> + <div + className="h-full bg-primary rounded-full transition-all" + style={{ width: `${uploadProgress}%` }} + /> + </div> + {matchResult && ( + <p className="text-sm text-gray-600"> + {matchResult.matched.length}개 파일을 업로드하고 있습니다... + </p> + )} + </div> + </CardContent> + </Card> + </div> + )} + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={handleDialogClose} + disabled={isUploading} + > + 취소 + </Button> + + {currentStep === 'review' && ( + <Button + type="submit" + disabled={!canProceedToUpload || isUploading} + > + <Upload className="mr-2 h-4 w-4" /> + 일괄 업로드 ({matchResult?.matched.length || 0}개 파일) + </Button> + )} + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> +) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/table/enhanced-doc-table-columns.tsx b/lib/vendor-document-list/table/enhanced-doc-table-columns.tsx new file mode 100644 index 00000000..534a80a0 --- /dev/null +++ b/lib/vendor-document-list/table/enhanced-doc-table-columns.tsx @@ -0,0 +1,612 @@ +// updated-enhanced-doc-table-columns.tsx +"use client" + +import * as React from "react" +import { ColumnDef } from "@tanstack/react-table" +import { formatDate, formatDateTime } from "@/lib/utils" +import { Checkbox } from "@/components/ui/checkbox" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" +import { DataTableRowAction } from "@/types/table" +import { EnhancedDocumentsView } from "@/db/schema/vendorDocu" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuShortcut, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { Button } from "@/components/ui/button" +import { Badge } from "@/components/ui/badge" +import { Progress } from "@/components/ui/progress" +import { + Ellipsis, + AlertTriangle, + Clock, + CheckCircle, + Upload, + Calendar, + User, + FileText, + Eye, + Edit, + Trash2 +} from "lucide-react" +import { cn } from "@/lib/utils" + +interface GetColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<EnhancedDocumentsView> | null>> + projectType: string | null +} + +// 유틸리티 함수들 +const getStatusColor = (status: string, isOverdue = false) => { + if (isOverdue) return 'destructive' + switch (status) { + case 'COMPLETED': case 'APPROVED': return 'success' + case 'IN_PROGRESS': return 'default' + case 'SUBMITTED': case 'UNDER_REVIEW': return 'secondary' + case 'REJECTED': return 'destructive' + default: return 'outline' + } +} + +const getPriorityColor = (priority: string) => { + switch (priority) { + case 'HIGH': return 'destructive' + case 'MEDIUM': return 'default' + case 'LOW': return 'secondary' + default: return 'outline' + } +} + +const getStatusText = (status: string) => { + switch (status) { + case 'PLANNED': return '계획됨' + case 'IN_PROGRESS': return '진행중' + case 'SUBMITTED': return '제출됨' + case 'UNDER_REVIEW': return '검토중' + case 'APPROVED': return '승인됨' + case 'REJECTED': return '반려됨' + case 'COMPLETED': return '완료됨' + default: return status + } +} + +const getPriorityText = (priority: string) => { + switch (priority) { + case 'HIGH': return '높음' + case 'MEDIUM': return '보통' + case 'LOW': return '낮음' + default: return priority + } +} + +// 마감일 정보 컴포넌트 +const DueDateInfo = ({ + daysUntilDue, + isOverdue, + className = "" +}: { + daysUntilDue: number | null + isOverdue: boolean + className?: string +}) => { + if (isOverdue && daysUntilDue !== null && daysUntilDue < 0) { + return ( + <div className={cn("flex items-center gap-1 text-red-600", className)}> + <AlertTriangle className="w-4 h-4" /> + <span className="text-sm font-medium">{Math.abs(daysUntilDue)}일 지연</span> + </div> + ) + } + + if (daysUntilDue === 0) { + return ( + <div className={cn("flex items-center gap-1 text-orange-600", className)}> + <Clock className="w-4 h-4" /> + <span className="text-sm font-medium">오늘 마감</span> + </div> + ) + } + + if (daysUntilDue && daysUntilDue > 0 && daysUntilDue <= 3) { + return ( + <div className={cn("flex items-center gap-1 text-orange-600", className)}> + <Clock className="w-4 h-4" /> + <span className="text-sm font-medium">{daysUntilDue}일 남음</span> + </div> + ) + } + + if (daysUntilDue && daysUntilDue > 0) { + return ( + <div className={cn("flex items-center gap-1 text-gray-600", className)}> + <Calendar className="w-4 h-4" /> + <span className="text-sm">{daysUntilDue}일 남음</span> + </div> + ) + } + + return ( + <div className={cn("flex items-center gap-1 text-green-600", className)}> + <CheckCircle className="w-4 h-4" /> + <span className="text-sm">완료</span> + </div> + ) +} + +export function getUpdatedEnhancedColumns({ + setRowAction, + projectType +}: GetColumnsProps): ColumnDef<EnhancedDocumentsView>[] { + return [ + // 체크박스 선택 + { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + }, + + // 문서번호 + 우선순위 + { + accessorKey: "docNumber", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="문서번호" /> + ), + cell: ({ row }) => { + const doc = row.original + return ( + <div className="flex flex-col gap-1 items-start"> {/* ✅ items-start 추가 */} + <span className="font-mono text-sm font-medium">{doc.docNumber}</span> + {/* {doc.currentStagePriority && ( + <Badge variant={getPriorityColor(doc.currentStagePriority)} className="self-start inline-flex w-auto shrink-0 whitespace-nowrap text-xs" > + {getPriorityText(doc.currentStagePriority)} + </Badge> + )} */} + </div> + ) + }, + size: 120, + enableResizing: true, + meta: { + excelHeader: "문서번호" + }, + }, + + // 문서명 + 담당자 + { + accessorKey: "title", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="문서명" /> + ), + cell: ({ row }) => { + const doc = row.original + return ( + <div className="min-w-0 flex-1"> + <div className="font-medium text-gray-900 truncate" title={doc.title}> + {doc.title} + </div> + <div className="flex items-center gap-2 text-sm text-gray-500 mt-1"> + {doc.pic && ( + <span className="text-xs bg-gray-100 px-2 py-0.5 rounded"> + PIC: {doc.pic} + </span> + )} + {doc.currentStageAssigneeName && ( + <div className="flex items-center gap-1"> + <User className="w-3 h-3" /> + <span>{doc.currentStageAssigneeName}</span> + </div> + )} + </div> + </div> + ) + }, + size: 250, + enableResizing: true, + meta: { + excelHeader: "문서명" + }, + }, + + // 현재 스테이지 + { + accessorKey: "currentStageName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="현재 스테이지" /> + ), + cell: ({ row }) => { + const doc = row.original + if (!doc.currentStageName) return <span className="text-gray-400">-</span> + + return ( + <div className="flex flex-col gap-1 items-start"> + <span className="text-sm font-medium">{doc.currentStageName}</span> + <Badge + variant={getStatusColor(doc.currentStageStatus || '', doc.isOverdue || false)} + className="self-start inline-flex w-auto shrink-0 whitespace-nowrap text-xs" + > + {getStatusText(doc.currentStageStatus || '')} + </Badge> + </div> + ) + }, + size: 140, + enableResizing: true, + meta: { + excelHeader: "현재 스테이지" + }, + }, + + // 일정 정보 + { + accessorKey: "currentStagePlanDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="일정" /> + ), + cell: ({ row }) => { + const doc = row.original + if (!doc.currentStagePlanDate) return <span className="text-gray-400">-</span> + + return ( + <div className="flex flex-col gap-1"> + <div className="text-sm"> + <span className="text-gray-500">계획: </span> + <span>{formatDate(doc.currentStagePlanDate)}</span> + </div> + {doc.currentStageActualDate && ( + <div className="text-sm"> + <span className="text-gray-500">실제: </span> + <span>{formatDate(doc.currentStageActualDate)}</span> + </div> + )} + <DueDateInfo + daysUntilDue={doc.daysUntilDue} + isOverdue={doc.isOverdue || false} + /> + </div> + ) + }, + size: 140, + enableResizing: true, + meta: { + excelHeader: "계획일" + }, + }, + + // 진행률 + { + accessorKey: "progressPercentage", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="진행률" /> + ), + cell: ({ row }) => { + const doc = row.original + const progress = doc.progressPercentage || 0 + const completed = doc.completedStages || 0 + const total = doc.totalStages || 0 + + return ( + <div className="flex flex-col gap-2"> + <div className="flex items-center gap-2"> + <Progress value={progress} className="flex-1" /> + <span className="text-sm font-medium text-gray-600 min-w-[3rem]"> + {progress}% + </span> + </div> + <span className="text-xs text-gray-500"> + {completed} / {total} 스테이지 + </span> + </div> + ) + }, + size: 120, + enableResizing: true, + meta: { + excelHeader: "진행률" + }, + }, + + // 최신 리비전 + { + accessorKey: "latestRevision", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="최신 리비전" /> + ), + cell: ({ row }) => { + const doc = row.original + if (!doc.latestRevision) return <span className="text-gray-400">없음</span> + + return ( + <div className="flex flex-col gap-1 items-start"> + <span className="font-mono text-sm font-medium">{doc.latestRevision}</span> + {/* <div className="text-xs text-gray-500">{doc.latestRevisionUploaderName}</div> */} + {doc.latestRevisionStatus && ( + <Badge variant={getStatusColor(doc.latestRevisionStatus)} className="self-start inline-flex w-auto shrink-0 whitespace-nowrap text-xs" > + {getStatusText(doc.latestRevisionStatus)} + </Badge> + )} + {doc.latestSubmittedDate && ( + <div className="text-xs text-gray-500"> + {formatDate(doc.latestSubmittedDate)} + </div> + )} + </div> + ) + }, + size: 140, + enableResizing: true, + meta: { + excelHeader: "최신 리비전" + }, + }, + + // 업데이트 일시 + { + accessorKey: "updatedAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="업데이트" /> + ), + cell: ({ cell }) => ( + <span className="text-sm text-gray-600"> + {formatDateTime(cell.getValue() as Date)} + </span> + ), + size: 140, + enableResizing: true, + meta: { + excelHeader: "업데이트" + }, + }, + + // 액션 메뉴 + // 액션 메뉴 + { + id: "actions", + enableHiding: false, + cell: function Cell({ row }) { + const doc = row.original + const canSubmit = doc.currentStageStatus === 'IN_PROGRESS' + const canApprove = doc.currentStageStatus === 'SUBMITTED' + const isPlantProject = projectType === "plant" + + // 메뉴 아이템들을 그룹별로 정의 + const viewActions = [ + { + key: "view", + label: "상세보기", + icon: Eye, + action: () => setRowAction({ row, type: "view" }), + show: true + } + ] + + const editActions = [ + { + key: "update", + label: "편집", + icon: Edit, + action: () => setRowAction({ row, type: "update" }), + show: isPlantProject + } + ] + + const fileActions = [ + { + key: "upload", + label: "리비전 업로드", + icon: Upload, + action: () => setRowAction({ row, type: "upload" }), + show: canSubmit + } + ] + + const dangerActions = [ + { + key: "delete", + label: "삭제", + icon: Trash2, + action: () => setRowAction({ row, type: "delete" }), + show: isPlantProject, + className: "text-red-600", + shortcut: "⌘⌫" + } + ] + + // 각 그룹에서 표시될 아이템이 있는지 확인 + const hasEditActions = editActions.some(action => action.show) + const hasFileActions = fileActions.some(action => action.show) + const hasDangerActions = dangerActions.some(action => action.show) + + return ( + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-7 p-0 data-[state=open]:bg-muted" + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end" className="w-48"> + {/* 기본 액션 그룹 */} + {viewActions.map(action => action.show && ( + <DropdownMenuItem + key={action.key} + onSelect={action.action} + className={action.className} + > + <action.icon className="mr-2 h-4 w-4" /> + {action.label} + {action.shortcut && ( + <DropdownMenuShortcut>{action.shortcut}</DropdownMenuShortcut> + )} + </DropdownMenuItem> + ))} + + {/* 편집 액션 그룹 */} + {hasEditActions && ( + <> + <DropdownMenuSeparator /> + {editActions.map(action => action.show && ( + <DropdownMenuItem + key={action.key} + onSelect={action.action} + className={action.className} + > + <action.icon className="mr-2 h-4 w-4" /> + {action.label} + {action.shortcut && ( + <DropdownMenuShortcut>{action.shortcut}</DropdownMenuShortcut> + )} + </DropdownMenuItem> + ))} + </> + )} + + {/* 파일 액션 그룹 */} + {hasFileActions && ( + <> + <DropdownMenuSeparator /> + {fileActions.map(action => action.show && ( + <DropdownMenuItem + key={action.key} + onSelect={action.action} + className={action.className} + > + <action.icon className="mr-2 h-4 w-4" /> + {action.label} + {action.shortcut && ( + <DropdownMenuShortcut>{action.shortcut}</DropdownMenuShortcut> + )} + </DropdownMenuItem> + ))} + </> + )} + + {/* 위험한 액션 그룹 */} + {hasDangerActions && ( + <> + <DropdownMenuSeparator /> + {dangerActions.map(action => action.show && ( + <DropdownMenuItem + key={action.key} + onSelect={action.action} + className={action.className} + > + <action.icon className="mr-2 h-4 w-4" /> + {action.label} + {action.shortcut && ( + <DropdownMenuShortcut>{action.shortcut}</DropdownMenuShortcut> + )} + </DropdownMenuItem> + ))} + </> + )} + </DropdownMenuContent> + </DropdownMenu> + ) + }, + size: 40, + } + ] +} + +// 확장된 행 컨텐츠 컴포넌트 (업데이트된 버전) +export const UpdatedExpandedRowContent = ({ + document +}: { + document: EnhancedDocumentsView +}) => { + if (!document.allStages || document.allStages.length === 0) { + return ( + <div className="p-4 text-sm text-gray-500 italic"> + 스테이지 정보가 없습니다. + </div> + ) + } + + return ( + <div className="p-4 w-1/2"> + <h4 className="font-medium mb-3 flex items-center gap-2"> + <FileText className="w-4 h-4" /> + 전체 스테이지 현황 + </h4> + + <div className="grid gap-3"> + {document.allStages.map((stage, index) => ( + <div key={stage.id} className="flex items-center justify-between p-3 bg-white rounded-lg border"> + <div className="flex items-center gap-3"> + <div className="flex items-center gap-2"> + <div className="w-6 h-6 rounded-full bg-gray-100 flex items-center justify-center text-xs font-medium"> + {stage.stageOrder || index + 1} + </div> + <div className={cn( + "w-3 h-3 rounded-full", + stage.stageStatus === 'COMPLETED' ? 'bg-green-500' : + stage.stageStatus === 'IN_PROGRESS' ? 'bg-blue-500' : + stage.stageStatus === 'SUBMITTED' ? 'bg-purple-500' : + 'bg-gray-300' + )} /> + </div> + + <div> + <div className="font-medium text-sm">{stage.stageName}</div> + {stage.assigneeName && ( + <div className="text-xs text-gray-500 flex items-center gap-1 mt-1"> + <User className="w-3 h-3" /> + {stage.assigneeName} + </div> + )} + </div> + </div> + + <div className="flex items-center gap-4 text-sm"> + <div> + <span className="text-gray-500">계획: </span> + <span>{formatDate(stage.planDate)}</span> + </div> + {stage.actualDate && ( + <div> + <span className="text-gray-500">완료: </span> + <span>{formatDate(stage.actualDate)}</span> + </div> + )} + + <div className="flex items-center gap-2"> + <Badge variant={getPriorityColor(stage.priority)} className="text-xs"> + {getPriorityText(stage.priority)} + </Badge> + <Badge variant={getStatusColor(stage.stageStatus)} className="text-xs"> + {getStatusText(stage.stageStatus)} + </Badge> + </div> + </div> + </div> + ))} + </div> + </div> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/table/enhanced-doc-table-toolbar-actions.tsx b/lib/vendor-document-list/table/enhanced-doc-table-toolbar-actions.tsx new file mode 100644 index 00000000..f9d4d695 --- /dev/null +++ b/lib/vendor-document-list/table/enhanced-doc-table-toolbar-actions.tsx @@ -0,0 +1,106 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, Upload, Plus, Files } from "lucide-react" +import { toast } from "sonner" + +import { exportTableToExcel } from "@/lib/export" +import { Button } from "@/components/ui/button" +import { EnhancedDocumentsView } from "@/db/schema/vendorDocu" +import { AddDocumentListDialog } from "./add-doc-dialog" +import { DeleteDocumentsDialog } from "./delete-docs-dialog" +import { BulkUploadDialog } from "./bulk-upload-dialog" +import type { EnhancedDocument } from "@/types/enhanced-documents" +import { SendToSHIButton } from "./send-to-shi-button" + +interface EnhancedDocTableToolbarActionsProps { + table: Table<EnhancedDocument> + projectType: "ship" | "plant" + selectedPackageId: number + onNewDocument: () => void + onBulkAction: (action: string, selectedRows: any[]) => Promise<void> +} + +export function EnhancedDocTableToolbarActions({ + table, + projectType, + selectedPackageId, + onNewDocument, + onBulkAction +}: EnhancedDocTableToolbarActionsProps) { + const [bulkUploadDialogOpen, setBulkUploadDialogOpen] = React.useState(false) + + // 현재 테이블의 모든 데이터 (필터링된 상태) + const allDocuments = table.getFilteredRowModel().rows.map(row => row.original) + + const handleSyncComplete = () => { + // 동기화 완료 후 테이블 새로고침 + table.resetRowSelection() + // 필요시 추가 액션 수행 + } + + return ( + <div className="flex items-center gap-2"> + {/* 기존 액션들 */} + {table.getFilteredSelectedRowModel().rows.length > 0 ? ( + <DeleteDocumentsDialog + documents={table + .getFilteredSelectedRowModel() + .rows.map((row) => row.original)} + onSuccess={() => table.toggleAllRowsSelected(false)} + /> + ) : null} + + {/* 메인 액션 버튼들 */} + {projectType === "plant" && ( + <Button onClick={onNewDocument} className="flex items-center gap-2"> + <Plus className="w-4 h-4" /> + 새 문서 + </Button> + )} + + {/* 일괄 업로드 버튼 */} + <Button + variant="outline" + onClick={() => setBulkUploadDialogOpen(true)} + className="flex items-center gap-2" + > + <Files className="w-4 h-4" /> + 일괄 업로드 + </Button> + + {/* Export 버튼 */} + <Button + variant="outline" + size="sm" + onClick={() => + exportTableToExcel(table, { + filename: "Document-list", + excludeColumns: ["select", "actions"], + }) + } + className="gap-2" + > + <Download className="size-4" aria-hidden="true" /> + <span className="hidden sm:inline">Export</span> + </Button> + + {/* ✅ 새로운 Send to SHI 버튼으로 교체 */} + <SendToSHIButton + contractId={selectedPackageId} + documents={allDocuments} + onSyncComplete={handleSyncComplete} + /> + + {/* 일괄 업로드 다이얼로그 */} + <BulkUploadDialog + open={bulkUploadDialogOpen} + onOpenChange={setBulkUploadDialogOpen} + documents={allDocuments} + projectType={projectType} + contractId={selectedPackageId} + /> + </div> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/table/enhanced-document-sheet.tsx b/lib/vendor-document-list/table/enhanced-document-sheet.tsx new file mode 100644 index 00000000..88e342c8 --- /dev/null +++ b/lib/vendor-document-list/table/enhanced-document-sheet.tsx @@ -0,0 +1,939 @@ +// enhanced-document-sheet.tsx +"use client" + +import * as React from "react" +import { zodResolver } from "@hookform/resolvers/zod" +import { useForm } from "react-hook-form" +import { toast } from "sonner" +import { z } from "zod" +import { useRouter } from "next/navigation" +import { + Loader, + Save, + Upload, + Calendar, + User, + FileText, + AlertTriangle, + CheckCircle, + Clock, + Plus, + X +} from "lucide-react" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormDescription, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Select, + SelectContent, + SelectGroup, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" +import { + Tabs, + TabsContent, + TabsList, + TabsTrigger, +} from "@/components/ui/tabs" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { Badge } from "@/components/ui/badge" +import { Separator } from "@/components/ui/separator" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Calendar as CalendarComponent } from "@/components/ui/calendar" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" +import { cn } from "@/lib/utils" +import { format } from "date-fns" +import { ko } from "date-fns/locale" +import { EnhancedDocumentsView } from "@/db/schema/vendorDocu" + +// 드롭존과 파일 관련 컴포넌트들 +import { + Dropzone, + DropzoneDescription, + DropzoneInput, + DropzoneTitle, + DropzoneUploadIcon, + DropzoneZone, +} from "@/components/ui/dropzone" +import { + FileList, + FileListAction, + FileListDescription, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, + FileListSize, +} from "@/components/ui/file-list" +import prettyBytes from "pretty-bytes" + +// 스키마 정의 +const enhancedDocumentSchema = z.object({ + // 기본 문서 정보 + docNumber: z.string().min(1, "문서번호는 필수입니다"), + title: z.string().min(1, "제목은 필수입니다"), + pic: z.string().optional(), + status: z.string().min(1, "상태는 필수입니다"), + issuedDate: z.date().optional(), + + // 스테이지 관리 (plant 타입에서만 수정 가능) + stages: z.array(z.object({ + id: z.number().optional(), + stageName: z.string().min(1, "스테이지명은 필수입니다"), + stageOrder: z.number(), + priority: z.enum(["HIGH", "MEDIUM", "LOW"]).default("MEDIUM"), + planDate: z.date().optional(), + assigneeName: z.string().optional(), + description: z.string().optional(), + })).optional(), + + // 리비전 업로드 (현재 스테이지에 대한) + newRevision: z.object({ + stage: z.string().optional(), + revision: z.string().optional(), + uploaderType: z.enum(["vendor", "client", "shi"]).default("vendor"), + uploaderName: z.string().optional(), + comment: z.string().optional(), + attachments: z.array(z.instanceof(File)).optional(), + }).optional(), +}) + +type EnhancedDocumentSchema = z.infer<typeof enhancedDocumentSchema> + +// 상태 옵션 정의 +const statusOptions = [ + { value: "ACTIVE", label: "활성" }, + { value: "INACTIVE", label: "비활성" }, + { value: "COMPLETED", label: "완료" }, + { value: "CANCELLED", label: "취소" }, +] + +const priorityOptions = [ + { value: "HIGH", label: "높음" }, + { value: "MEDIUM", label: "보통" }, + { value: "LOW", label: "낮음" }, +] + +const stageStatusOptions = [ + { value: "PLANNED", label: "계획됨" }, + { value: "IN_PROGRESS", label: "진행중" }, + { value: "SUBMITTED", label: "제출됨" }, + { value: "APPROVED", label: "승인됨" }, + { value: "REJECTED", label: "반려됨" }, + { value: "COMPLETED", label: "완료됨" }, +] + +interface EnhancedDocumentSheetProps + extends React.ComponentPropsWithRef<typeof Sheet> { + document: EnhancedDocumentsView | null + projectType: "ship" | "plant" + mode: "view" | "edit" | "upload" | "schedule" | "approve" +} + +export function EnhancedDocumentSheet({ + document, + projectType, + mode = "view", + ...props +}: EnhancedDocumentSheetProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + const [selectedFiles, setSelectedFiles] = React.useState<File[]>([]) + const [uploadProgress, setUploadProgress] = React.useState(0) + const [activeTab, setActiveTab] = React.useState("info") + const router = useRouter() + + // 권한 계산 + const permissions = React.useMemo(() => { + const canEdit = projectType === "plant" || mode === "edit" + const canUpload = mode === "upload" || mode === "edit" + const canApprove = mode === "approve" && projectType === "ship" + const canSchedule = mode === "schedule" || (projectType === "plant" && mode === "edit") + + return { canEdit, canUpload, canApprove, canSchedule } + }, [projectType, mode]) + + const form = useForm<EnhancedDocumentSchema>({ + resolver: zodResolver(enhancedDocumentSchema), + defaultValues: { + docNumber: "", + title: "", + pic: "", + status: "ACTIVE", + issuedDate: undefined, + stages: [], + newRevision: { + stage: "", + revision: "", + uploaderType: "vendor", + uploaderName: "", + comment: "", + attachments: [], + }, + }, + }) + + // 폼 초기화 + React.useEffect(() => { + if (document) { + form.reset({ + docNumber: document.docNumber, + title: document.title, + pic: document.pic || "", + status: document.status, + issuedDate: document.issuedDate ? new Date(document.issuedDate) : undefined, + stages: document.allStages?.map((stage, index) => ({ + id: stage.id, + stageName: stage.stageName, + stageOrder: stage.stageOrder || index, + priority: stage.priority as "HIGH" | "MEDIUM" | "LOW" || "MEDIUM", + planDate: stage.planDate ? new Date(stage.planDate) : undefined, + assigneeName: stage.assigneeName || "", + description: "", + })) || [], + newRevision: { + stage: document.currentStageName || "", + revision: "", + uploaderType: "vendor", + uploaderName: "", + comment: "", + attachments: [], + }, + }) + + // 모드에 따른 기본 탭 설정 + if (mode === "upload") { + setActiveTab("upload") + } else if (mode === "schedule") { + setActiveTab("schedule") + } else if (mode === "approve") { + setActiveTab("approve") + } + } + }, [document, form, mode]) + + // 파일 처리 + const handleDropAccepted = (acceptedFiles: File[]) => { + const newFiles = [...selectedFiles, ...acceptedFiles] + setSelectedFiles(newFiles) + form.setValue('newRevision.attachments', newFiles) + } + + const removeFile = (index: number) => { + const updatedFiles = [...selectedFiles] + updatedFiles.splice(index, 1) + setSelectedFiles(updatedFiles) + form.setValue('newRevision.attachments', updatedFiles) + } + + // 스테이지 추가/제거 + const addStage = () => { + const currentStages = form.getValues("stages") || [] + const newStage = { + stageName: "", + stageOrder: currentStages.length, + priority: "MEDIUM" as const, + planDate: undefined, + assigneeName: "", + description: "", + } + form.setValue("stages", [...currentStages, newStage]) + } + + const removeStage = (index: number) => { + const currentStages = form.getValues("stages") || [] + const updatedStages = currentStages.filter((_, i) => i !== index) + form.setValue("stages", updatedStages) + } + + // 제출 처리 + function onSubmit(input: EnhancedDocumentSchema) { + startUpdateTransition(async () => { + if (!document) return + + try { + // 모드에 따른 다른 처리 + switch (mode) { + case "edit": + // 문서 정보 업데이트 + 스테이지 관리 + await updateDocumentInfo(input) + break + case "upload": + // 리비전 업로드 + await uploadRevision(input) + break + case "approve": + // 승인 처리 + await approveRevision(input) + break + case "schedule": + // 스케줄 관리 + await updateSchedule(input) + break + } + + form.reset() + setSelectedFiles([]) + props.onOpenChange?.(false) + toast.success("성공적으로 처리되었습니다") + router.refresh() + } catch (error) { + toast.error("처리 중 오류가 발생했습니다") + console.error(error) + } + }) + } + + // 개별 처리 함수들 + const updateDocumentInfo = async (input: EnhancedDocumentSchema) => { + // 문서 기본 정보 업데이트 API 호출 + console.log("문서 정보 업데이트:", input) + } + + const uploadRevision = async (input: EnhancedDocumentSchema) => { + if (!input.newRevision?.attachments?.length) { + throw new Error("파일을 선택해주세요") + } + + // 파일 업로드 처리 + const formData = new FormData() + formData.append("documentId", String(document?.documentId)) + formData.append("stage", input.newRevision.stage || "") + formData.append("revision", input.newRevision.revision || "") + formData.append("uploaderType", input.newRevision.uploaderType) + + input.newRevision.attachments.forEach((file) => { + formData.append("attachments", file) + }) + + // API 호출 + console.log("리비전 업로드:", formData) + } + + const approveRevision = async (input: EnhancedDocumentSchema) => { + // 승인 처리 API 호출 + console.log("리비전 승인:", input) + } + + const updateSchedule = async (input: EnhancedDocumentSchema) => { + // 스케줄 업데이트 API 호출 + console.log("스케줄 업데이트:", input) + } + + // 제목 및 설명 생성 + const getSheetTitle = () => { + switch (mode) { + case "edit": return "문서 정보 수정" + case "upload": return "리비전 업로드" + case "approve": return "문서 승인" + case "schedule": return "일정 관리" + default: return "문서 상세" + } + } + + const getSheetDescription = () => { + const docInfo = document ? `${document.docNumber} - ${document.title}` : "" + switch (mode) { + case "edit": return `문서 정보를 수정합니다. ${docInfo}` + case "upload": return `새 리비전을 업로드합니다. ${docInfo}` + case "approve": return `문서를 검토하고 승인 처리합니다. ${docInfo}` + case "schedule": return `문서의 일정을 관리합니다. ${docInfo}` + default: return docInfo + } + } + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-2xl w-full"> + <SheetHeader className="text-left"> + <SheetTitle className="flex items-center gap-2"> + {mode === "upload" && <Upload className="w-5 h-5" />} + {mode === "approve" && <CheckCircle className="w-5 h-5" />} + {mode === "schedule" && <Calendar className="w-5 h-5" />} + {mode === "edit" && <FileText className="w-5 h-5" />} + {getSheetTitle()} + </SheetTitle> + <SheetDescription> + {getSheetDescription()} + </SheetDescription> + + {/* 프로젝트 타입 및 권한 표시 */} + <div className="flex items-center gap-2 pt-2"> + <Badge variant={projectType === "ship" ? "default" : "secondary"}> + {projectType === "ship" ? "조선 프로젝트" : "플랜트 프로젝트"} + </Badge> + {document?.isOverdue && ( + <Badge variant="destructive" className="flex items-center gap-1"> + <AlertTriangle className="w-3 h-3" /> + 지연 + </Badge> + )} + {document?.currentStagePriority === "HIGH" && ( + <Badge variant="destructive">높은 우선순위</Badge> + )} + </div> + </SheetHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex-1 flex flex-col"> + <Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1 flex flex-col"> + <TabsList className="grid w-full grid-cols-4"> + <TabsTrigger value="info">기본정보</TabsTrigger> + <TabsTrigger value="schedule" disabled={!permissions.canSchedule}> + 일정관리 + </TabsTrigger> + <TabsTrigger value="upload" disabled={!permissions.canUpload}> + 리비전업로드 + </TabsTrigger> + <TabsTrigger value="approve" disabled={!permissions.canApprove}> + 승인처리 + </TabsTrigger> + </TabsList> + + {/* 기본 정보 탭 */} + <TabsContent value="info" className="flex-1 space-y-4"> + <ScrollArea className="h-full pr-4"> + <div className="space-y-4"> + <FormField + control={form.control} + name="docNumber" + render={({ field }) => ( + <FormItem> + <FormLabel>문서번호</FormLabel> + <FormControl> + <Input {...field} disabled={!permissions.canEdit} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="title" + render={({ field }) => ( + <FormItem> + <FormLabel>제목</FormLabel> + <FormControl> + <Input {...field} disabled={!permissions.canEdit} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <div className="grid grid-cols-2 gap-4"> + <FormField + control={form.control} + name="pic" + render={({ field }) => ( + <FormItem> + <FormLabel>담당자 (PIC)</FormLabel> + <FormControl> + <Input {...field} disabled={!permissions.canEdit} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="status" + render={({ field }) => ( + <FormItem> + <FormLabel>상태</FormLabel> + <Select + onValueChange={field.onChange} + value={field.value} + disabled={!permissions.canEdit} + > + <FormControl> + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + </FormControl> + <SelectContent> + {statusOptions.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <FormField + control={form.control} + name="issuedDate" + render={({ field }) => ( + <FormItem> + <FormLabel>발행일</FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + className={cn( + "w-full pl-3 text-left font-normal", + !field.value && "text-muted-foreground" + )} + disabled={!permissions.canEdit} + > + {field.value ? ( + format(field.value, "yyyy년 MM월 dd일", { locale: ko }) + ) : ( + <span>날짜를 선택하세요</span> + )} + <Calendar className="ml-auto h-4 w-4 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <CalendarComponent + mode="single" + selected={field.value} + onSelect={field.onChange} + disabled={(date) => date > new Date()} + initialFocus + /> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + + {/* 현재 상태 정보 표시 */} + {document && ( + <div className="space-y-3 p-4 bg-gray-50 rounded-lg"> + <h4 className="font-medium flex items-center gap-2"> + <Clock className="w-4 h-4" /> + 현재 진행 상황 + </h4> + <div className="grid grid-cols-2 gap-4 text-sm"> + <div> + <span className="text-gray-500">현재 스테이지:</span> + <p className="font-medium">{document.currentStageName || "-"}</p> + </div> + <div> + <span className="text-gray-500">진행률:</span> + <p className="font-medium">{document.progressPercentage || 0}%</p> + </div> + <div> + <span className="text-gray-500">최신 리비전:</span> + <p className="font-medium">{document.latestRevision || "-"}</p> + </div> + <div> + <span className="text-gray-500">담당자:</span> + <p className="font-medium">{document.currentStageAssigneeName || "-"}</p> + </div> + </div> + </div> + )} + </div> + </ScrollArea> + </TabsContent> + + {/* 일정 관리 탭 */} + <TabsContent value="schedule" className="flex-1 space-y-4"> + <ScrollArea className="h-full pr-4"> + <div className="space-y-4"> + <div className="flex items-center justify-between"> + <h4 className="font-medium">스테이지 일정 관리</h4> + {projectType === "plant" && ( + <Button + type="button" + variant="outline" + size="sm" + onClick={addStage} + className="flex items-center gap-1" + > + <Plus className="w-4 h-4" /> + 스테이지 추가 + </Button> + )} + </div> + + {form.watch("stages")?.map((stage, index) => ( + <div key={index} className="p-4 border rounded-lg space-y-3"> + <div className="flex items-center justify-between"> + <h5 className="font-medium">스테이지 {index + 1}</h5> + {projectType === "plant" && ( + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => removeStage(index)} + > + <X className="w-4 h-4" /> + </Button> + )} + </div> + + <div className="grid grid-cols-2 gap-3"> + <FormField + control={form.control} + name={`stages.${index}.stageName`} + render={({ field }) => ( + <FormItem> + <FormLabel>스테이지명</FormLabel> + <FormControl> + <Input {...field} disabled={projectType === "ship"} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name={`stages.${index}.priority`} + render={({ field }) => ( + <FormItem> + <FormLabel>우선순위</FormLabel> + <Select onValueChange={field.onChange} value={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + </FormControl> + <SelectContent> + {priorityOptions.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name={`stages.${index}.planDate`} + render={({ field }) => ( + <FormItem> + <FormLabel>계획일</FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + className={cn( + "w-full text-left font-normal", + !field.value && "text-muted-foreground" + )} + > + {field.value ? ( + format(field.value, "MM/dd", { locale: ko }) + ) : ( + <span>날짜 선택</span> + )} + <Calendar className="ml-auto h-4 w-4 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <CalendarComponent + mode="single" + selected={field.value} + onSelect={field.onChange} + initialFocus + /> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name={`stages.${index}.assigneeName`} + render={({ field }) => ( + <FormItem> + <FormLabel>담당자</FormLabel> + <FormControl> + <Input {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + </div> + ))} + </div> + </ScrollArea> + </TabsContent> + + {/* 리비전 업로드 탭 */} + <TabsContent value="upload" className="flex-1 space-y-4"> + <ScrollArea className="h-full pr-4"> + <div className="space-y-4"> + <div className="grid grid-cols-2 gap-4"> + <FormField + control={form.control} + name="newRevision.stage" + render={({ field }) => ( + <FormItem> + <FormLabel>스테이지</FormLabel> + <FormControl> + <Input {...field} placeholder="예: Issued for Review" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="newRevision.revision" + render={({ field }) => ( + <FormItem> + <FormLabel>리비전</FormLabel> + <FormControl> + <Input {...field} placeholder="예: A, B, 1, 2..." /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <FormField + control={form.control} + name="newRevision.uploaderName" + render={({ field }) => ( + <FormItem> + <FormLabel>업로더명 (선택)</FormLabel> + <FormControl> + <Input {...field} placeholder="업로더 이름을 입력하세요" /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="newRevision.comment" + render={({ field }) => ( + <FormItem> + <FormLabel>코멘트 (선택)</FormLabel> + <FormControl> + <Textarea {...field} placeholder="코멘트를 입력하세요" rows={3} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 파일 업로드 드롭존 */} + <FormField + control={form.control} + name="newRevision.attachments" + render={() => ( + <FormItem> + <FormLabel>파일 첨부</FormLabel> + <Dropzone + maxSize={3e9} // 3GB + multiple={true} + onDropAccepted={handleDropAccepted} + disabled={isUpdatePending} + > + <DropzoneZone className="flex justify-center"> + <FormControl> + <DropzoneInput /> + </FormControl> + <div className="flex items-center gap-6"> + <DropzoneUploadIcon /> + <div className="grid gap-0.5"> + <DropzoneTitle>파일을 여기에 드롭하세요</DropzoneTitle> + <DropzoneDescription> + 또는 클릭하여 파일을 선택하세요 + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + </Dropzone> + <FormMessage /> + </FormItem> + )} + /> + + {/* 선택된 파일 목록 */} + {selectedFiles.length > 0 && ( + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <h6 className="text-sm font-semibold"> + 선택된 파일 ({selectedFiles.length}) + </h6> + </div> + <FileList className="max-h-[200px]"> + {selectedFiles.map((file, index) => ( + <FileListItem key={index} className="p-3"> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName>{file.name}</FileListName> + <FileListDescription> + {prettyBytes(file.size)} + </FileListDescription> + </FileListInfo> + <FileListAction + onClick={() => removeFile(index)} + disabled={isUpdatePending} + > + <X className="h-4 w-4" /> + </FileListAction> + </FileListHeader> + </FileListItem> + ))} + </FileList> + </div> + )} + + {/* 업로드 진행 상태 */} + {isUpdatePending && uploadProgress > 0 && ( + <div className="space-y-2"> + <div className="flex items-center gap-2"> + <Loader className="h-4 w-4 animate-spin" /> + <span className="text-sm">{uploadProgress}% 업로드 중...</span> + </div> + <div className="h-2 w-full bg-muted rounded-full overflow-hidden"> + <div + className="h-full bg-primary rounded-full transition-all" + style={{ width: `${uploadProgress}%` }} + /> + </div> + </div> + )} + </div> + </ScrollArea> + </TabsContent> + + {/* 승인 처리 탭 */} + <TabsContent value="approve" className="flex-1 space-y-4"> + <ScrollArea className="h-full pr-4"> + <div className="space-y-4"> + <div className="p-4 bg-blue-50 rounded-lg"> + <h4 className="font-medium mb-2 flex items-center gap-2"> + <CheckCircle className="w-4 h-4 text-blue-600" /> + 승인 대상 문서 + </h4> + <div className="text-sm space-y-1"> + <p><span className="font-medium">문서:</span> {document?.docNumber} - {document?.title}</p> + <p><span className="font-medium">현재 스테이지:</span> {document?.currentStageName}</p> + <p><span className="font-medium">최신 리비전:</span> {document?.latestRevision}</p> + <p><span className="font-medium">업로더:</span> {document?.latestRevisionUploaderName}</p> + </div> + </div> + + <div className="space-y-3"> + <div className="flex gap-3"> + <Button + type="button" + className="flex-1 bg-green-600 hover:bg-green-700" + onClick={() => { + // 승인 처리 로직 + console.log("승인 처리") + }} + > + <CheckCircle className="w-4 h-4 mr-2" /> + 승인 + </Button> + <Button + type="button" + variant="destructive" + className="flex-1" + onClick={() => { + // 반려 처리 로직 + console.log("반려 처리") + }} + > + <X className="w-4 h-4 mr-2" /> + 반려 + </Button> + </div> + + <FormField + control={form.control} + name="newRevision.comment" + render={({ field }) => ( + <FormItem> + <FormLabel>검토 의견</FormLabel> + <FormControl> + <Textarea + {...field} + placeholder="승인/반려 사유를 입력하세요" + rows={4} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + </div> + </ScrollArea> + </TabsContent> + </Tabs> + + <Separator /> + + <SheetFooter className="gap-2 pt-4"> + <SheetClose asChild> + <Button type="button" variant="outline"> + 취소 + </Button> + </SheetClose> + <Button + type="submit" + disabled={isUpdatePending} + className={mode === "approve" ? "bg-green-600 hover:bg-green-700" : ""} + > + {isUpdatePending && <Loader className="mr-2 size-4 animate-spin" />} + {mode === "upload" && <Upload className="mr-2 size-4" />} + {mode === "approve" && <CheckCircle className="mr-2 size-4" />} + {mode === "schedule" && <Calendar className="mr-2 size-4" />} + {mode === "edit" && <Save className="mr-2 size-4" />} + + {mode === "upload" ? "업로드" : + mode === "approve" ? "승인 처리" : + mode === "schedule" ? "일정 저장" : "저장"} + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/table/enhanced-documents-table copy.tsx b/lib/vendor-document-list/table/enhanced-documents-table copy.tsx new file mode 100644 index 00000000..2ac871db --- /dev/null +++ b/lib/vendor-document-list/table/enhanced-documents-table copy.tsx @@ -0,0 +1,604 @@ +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { useDataTable } from "@/hooks/use-data-table" +import { StageRevisionExpandedContent } from "./stage-revision-expanded-content" +import { RevisionUploadDialog } from "./revision-upload-dialog" +import { SimplifiedDocumentEditDialog } from "./simplified-document-edit-dialog" +import { getEnhancedDocuments } from "../enhanced-document-service" +import type { EnhancedDocument } from "@/types/enhanced-documents" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { Badge } from "@/components/ui/badge" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { Button } from "@/components/ui/button" +import { + AlertTriangle, + Clock, + TrendingUp, + Target, + Users, + Plus, + Upload, + CheckCircle, + Edit, + Eye, + Settings +} from "lucide-react" +import { getUpdatedEnhancedColumns } from "./enhanced-doc-table-columns" +import { ExpandableDataTable } from "@/components/data-table/expandable-data-table" +import { toast } from "sonner" + +interface FinalIntegratedDocumentsTableProps { + promises: Promise<[Awaited<ReturnType<typeof getEnhancedDocuments>>]> + selectedPackageId: number + projectType: "ship" | "plant" +} + +export function EnhancedDocumentsTable({ + promises, + selectedPackageId, + projectType, +}: FinalIntegratedDocumentsTableProps) { + // 데이터 로딩 + const [{ data, pageCount, total }] = React.use(promises) + + // 상태 관리 + const [rowAction, setRowAction] = React.useState<DataTableRowAction<EnhancedDocument> | null>(null) + const [expandedRows, setExpandedRows] = React.useState<Set<string>>(new Set()) + const [quickFilter, setQuickFilter] = React.useState<'all' | 'overdue' | 'due_soon' | 'in_progress' | 'high_priority'>('all') + + // ✅ 스테이지 확장 상태 관리 (문서별로 관리) + const [expandedStages, setExpandedStages] = React.useState<Record<string, Record<number, boolean>>>({}) + + // 다이얼로그 상태들 + const [uploadDialogOpen, setUploadDialogOpen] = React.useState(false) + const [editDialogOpen, setEditDialogOpen] = React.useState(false) + const [viewDialogOpen, setViewDialogOpen] = React.useState(false) + const [selectedDocument, setSelectedDocument] = React.useState<EnhancedDocument | null>(null) + const [selectedStage, setSelectedStage] = React.useState<string>("") + const [selectedRevision, setSelectedRevision] = React.useState<string>("") + const [selectedRevisions, setSelectedRevisions] = React.useState<any[]>([]) + const [uploadMode, setUploadMode] = React.useState<'new' | 'append'>('new') + + // 다음 리비전 계산 함수 + const getNextRevision = React.useCallback((currentRevision: string): string => { + if (!currentRevision) return "A" + + // 알파벳 리비전 (A, B, C...) + if (/^[A-Z]$/.test(currentRevision)) { + const charCode = currentRevision.charCodeAt(0) + if (charCode < 90) { // Z가 아닌 경우 + return String.fromCharCode(charCode + 1) + } + return "AA" // Z 다음은 AA + } + + // 숫자 리비전 (1, 2, 3...) + if (/^\d+$/.test(currentRevision)) { + return String(parseInt(currentRevision) + 1) + } + + // 기타 복잡한 리비전 형태는 그대로 반환 + return currentRevision + }, []) + + // 컬럼 정의 + const columns = React.useMemo( + () => getUpdatedEnhancedColumns({ + setRowAction: (action) => { + setRowAction(action) + if (action) { + setSelectedDocument(action.row.original) + + // 액션 타입에 따른 다이얼로그 열기 + switch (action.type) { + case "update": + setEditDialogOpen(true) + break + case "upload": + setSelectedStage(action.row.original.currentStageName || "") + setUploadDialogOpen(true) + break + case "view": + // 상세보기는 확장된 행으로 대체 + const rowId = action.row.id + const newExpanded = new Set(expandedRows) + if (newExpanded.has(rowId)) { + newExpanded.delete(rowId) + } else { + newExpanded.add(rowId) + } + setExpandedRows(newExpanded) + break + } + } + } + }), + [expandedRows] + ) + + // 통계 계산 + const stats = React.useMemo(() => { + const totalDocs = data.length + const overdue = data.filter(doc => doc.isOverdue).length + const dueSoon = data.filter(doc => + doc.daysUntilDue !== null && + doc.daysUntilDue >= 0 && + doc.daysUntilDue <= 3 + ).length + const inProgress = data.filter(doc => doc.currentStageStatus === 'IN_PROGRESS').length + const highPriority = data.filter(doc => doc.currentStagePriority === 'HIGH').length + const avgProgress = totalDocs > 0 + ? Math.round(data.reduce((sum, doc) => sum + (doc.progressPercentage || 0), 0) / totalDocs) + : 0 + + return { + total: totalDocs, + overdue, + dueSoon, + inProgress, + highPriority, + avgProgress + } + }, [data]) + + // 빠른 필터링 + const filteredData = React.useMemo(() => { + switch (quickFilter) { + case 'overdue': + return data.filter(doc => doc.isOverdue) + case 'due_soon': + return data.filter(doc => + doc.daysUntilDue !== null && + doc.daysUntilDue >= 0 && + doc.daysUntilDue <= 3 + ) + case 'in_progress': + return data.filter(doc => doc.currentStageStatus === 'IN_PROGRESS') + case 'high_priority': + return data.filter(doc => doc.currentStagePriority === 'HIGH') + default: + return data + } + }, [data, quickFilter]) + + // ✅ 핸들러 함수 수정: 모드 매개변수 추가 + const handleUploadRevision = React.useCallback((document: EnhancedDocument, stageName?: string, currentRevision?: string, mode: 'new' | 'append' = 'new') => { + setSelectedDocument(document) + setSelectedStage(stageName || document.currentStageName || "") + setUploadMode(mode) // ✅ 모드 설정 + + if (mode === 'new') { + // 새 리비전 생성: currentRevision이 있으면 다음 리비전을 자동 계산 + if (currentRevision) { + const nextRevision = getNextRevision(currentRevision) + setSelectedRevision(nextRevision) + } else { + // 스테이지의 최신 리비전을 찾아서 다음 리비전 계산 + const latestRevision = findLatestRevisionInStage(document, stageName || document.currentStageName || "") + if (latestRevision) { + setSelectedRevision(getNextRevision(latestRevision)) + } else { + setSelectedRevision("A") // 첫 번째 리비전 + } + } + } else { + // 기존 리비전에 파일 추가: 같은 리비전 번호 사용 + setSelectedRevision(currentRevision || "") + } + + setUploadDialogOpen(true) + }, [getNextRevision]) + + // ✅ 스테이지에서 최신 리비전을 찾는 헬퍼 함수 + const findLatestRevisionInStage = React.useCallback((document: EnhancedDocument, stageName: string) => { + const stage = document.allStages?.find(s => s.stageName === stageName) + if (!stage || !stage.revisions || stage.revisions.length === 0) { + return null + } + + // 리비전들을 정렬해서 최신 것 찾기 (간단한 알파벳/숫자 정렬) + const sortedRevisions = [...stage.revisions].sort((a, b) => { + // 알파벳과 숫자를 구분해서 정렬 + const aIsAlpha = /^[A-Z]+$/.test(a.revision) + const bIsAlpha = /^[A-Z]+$/.test(b.revision) + + if (aIsAlpha && bIsAlpha) { + return a.revision.localeCompare(b.revision) + } else if (!aIsAlpha && !bIsAlpha) { + return parseInt(a.revision) - parseInt(b.revision) + } else { + return aIsAlpha ? -1 : 1 // 알파벳이 숫자보다 먼저 + } + }) + + return sortedRevisions[sortedRevisions.length - 1]?.revision || null + }, []) + + const handleEditDocument = (document: EnhancedDocument) => { + setSelectedDocument(document) + setEditDialogOpen(true) + } + + const handleViewRevisions = (revisions: any[]) => { + setSelectedRevisions(revisions) + setViewDialogOpen(true) + } + + const handleNewDocument = () => { + setSelectedDocument(null) + setEditDialogOpen(true) + } + + // ✅ 스테이지 토글 핸들러 추가 + const handleStageToggle = React.useCallback((documentId: string, stageId: number) => { + setExpandedStages(prev => ({ + ...prev, + [documentId]: { + ...prev[documentId], + [stageId]: !prev[documentId]?.[stageId] + } + })) + }, []) + + const handleBulkAction = async (action: string, selectedRows: any[]) => { + try { + if (action === 'bulk_approve') { + // 일괄 승인 로직 + const stageIds = selectedRows + .map(row => row.original.currentStageId) + .filter(Boolean) + + if (stageIds.length > 0) { + // await bulkUpdateStageStatus(stageIds, 'APPROVED') + toast.success(`${stageIds.length}개 항목이 승인되었습니다.`) + } + } else if (action === 'bulk_upload') { + // 일괄 업로드 로직 + toast.info("일괄 업로드 기능은 준비 중입니다.") + } + } catch (error) { + toast.error("일괄 작업 중 오류가 발생했습니다.") + } + } + + // 다이얼로그 닫기 + const closeAllDialogs = () => { + setUploadDialogOpen(false) + setEditDialogOpen(false) + setViewDialogOpen(false) + setSelectedDocument(null) + setSelectedStage("") + setSelectedRevision("") + setSelectedRevisions([]) + setUploadMode('new') // ✅ 모드 초기화 + setRowAction(null) + } + + // 필터 필드 정의 + const filterFields: DataTableFilterField<EnhancedDocument>[] = [ + { + label: "문서번호", + value: "docNumber", + placeholder: "문서번호로 검색...", + }, + { + label: "제목", + value: "title", + placeholder: "제목으로 검색...", + }, + ] + + const advancedFilterFields: DataTableAdvancedFilterField<EnhancedDocument>[] = [ + { + id: "docNumber", + label: "문서번호", + type: "text", + }, + { + id: "title", + label: "문서제목", + type: "text", + }, + { + id: "currentStageStatus", + label: "스테이지 상태", + type: "select", + options: [ + { label: "계획됨", value: "PLANNED" }, + { label: "진행중", value: "IN_PROGRESS" }, + { label: "제출됨", value: "SUBMITTED" }, + { label: "승인됨", value: "APPROVED" }, + { label: "완료됨", value: "COMPLETED" }, + ], + }, + { + id: "currentStagePriority", + label: "우선순위", + type: "select", + options: [ + { label: "높음", value: "HIGH" }, + { label: "보통", value: "MEDIUM" }, + { label: "낮음", value: "LOW" }, + ], + }, + { + id: "isOverdue", + label: "지연 여부", + type: "select", + options: [ + { label: "지연됨", value: "true" }, + { label: "정상", value: "false" }, + ], + }, + { + id: "currentStageAssigneeName", + label: "담당자", + type: "text", + }, + { + id: "createdAt", + label: "생성일", + type: "date", + }, + ] + + // 데이터 테이블 훅 + const { table } = useDataTable({ + data: filteredData, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.documentId), + shallow: false, + clearOnDefault: true, + columnResizeMode: "onEnd", + }) + + return ( + <div className="space-y-6"> + {/* 통계 대시보드 */} + <div className="grid grid-cols-2 md:grid-cols-4 gap-4"> + <Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('all')}> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">전체 문서</CardTitle> + <TrendingUp className="h-4 w-4 text-muted-foreground" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold">{stats.total}</div> + <p className="text-xs text-muted-foreground"> + 총 {total}개 중 {stats.total}개 표시 + </p> + </CardContent> + </Card> + + <Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('overdue')}> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">지연 문서</CardTitle> + <AlertTriangle className="h-4 w-4 text-red-500" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-red-600">{stats.overdue}</div> + <p className="text-xs text-muted-foreground">즉시 확인 필요</p> + </CardContent> + </Card> + + <Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('due_soon')}> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">마감 임박</CardTitle> + <Clock className="h-4 w-4 text-orange-500" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-orange-600">{stats.dueSoon}</div> + <p className="text-xs text-muted-foreground">3일 이내 마감</p> + </CardContent> + </Card> + + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">평균 진행률</CardTitle> + <Target className="h-4 w-4 text-green-500" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-green-600">{stats.avgProgress}%</div> + <p className="text-xs text-muted-foreground">전체 프로젝트 진행도</p> + </CardContent> + </Card> + </div> + + {/* 빠른 필터 및 액션 버튼 */} + <div className="flex flex-col sm:flex-row gap-4 justify-between"> + {/* 빠른 필터 */} + <div className="flex gap-2 overflow-x-auto pb-2"> + <Badge + variant={quickFilter === 'all' ? 'default' : 'outline'} + className="cursor-pointer hover:bg-primary hover:text-primary-foreground whitespace-nowrap" + onClick={() => setQuickFilter('all')} + > + 전체 ({stats.total}) + </Badge> + <Badge + variant={quickFilter === 'overdue' ? 'destructive' : 'outline'} + className="cursor-pointer hover:bg-destructive hover:text-destructive-foreground whitespace-nowrap" + onClick={() => setQuickFilter('overdue')} + > + <AlertTriangle className="w-3 h-3 mr-1" /> + 지연 ({stats.overdue}) + </Badge> + <Badge + variant={quickFilter === 'due_soon' ? 'default' : 'outline'} + className="cursor-pointer hover:bg-orange-500 hover:text-white whitespace-nowrap" + onClick={() => setQuickFilter('due_soon')} + > + <Clock className="w-3 h-3 mr-1" /> + 마감임박 ({stats.dueSoon}) + </Badge> + <Badge + variant={quickFilter === 'in_progress' ? 'default' : 'outline'} + className="cursor-pointer hover:bg-blue-500 hover:text-white whitespace-nowrap" + onClick={() => setQuickFilter('in_progress')} + > + <Users className="w-3 h-3 mr-1" /> + 진행중 ({stats.inProgress}) + </Badge> + <Badge + variant={quickFilter === 'high_priority' ? 'destructive' : 'outline'} + className="cursor-pointer hover:bg-destructive hover:text-destructive-foreground whitespace-nowrap" + onClick={() => setQuickFilter('high_priority')} + > + <Target className="w-3 h-3 mr-1" /> + 높은우선순위 ({stats.highPriority}) + </Badge> + </div> + + {/* 메인 액션 버튼들 */} + <div className="flex gap-2 flex-shrink-0"> + {projectType === "plant" && ( + <Button onClick={handleNewDocument} className="flex items-center gap-2"> + <Plus className="w-4 h-4" /> + 새 문서 + </Button> + )} + + <Button variant="outline" onClick={() => { + const selectedRows = table.getFilteredSelectedRowModel().rows + if (selectedRows.length > 0) { + handleBulkAction('bulk_approve', selectedRows) + } else { + toast.info("승인할 항목을 선택해주세요.") + } + }}> + <CheckCircle className="w-4 h-4 mr-2" /> + 일괄 승인 + </Button> + + <Button variant="outline" onClick={() => { + const selectedRows = table.getFilteredSelectedRowModel().rows + if (selectedRows.length > 0) { + handleBulkAction('bulk_upload', selectedRows) + } else { + toast.info("업로드할 항목을 선택해주세요.") + } + }}> + <Upload className="w-4 h-4 mr-2" /> + 일괄 업로드 + </Button> + </div> + </div> + + {/* 메인 테이블 - 가로스크롤 문제 해결을 위한 구조 개선 */} + <div className="space-y-4"> + <div className="rounded-md border bg-white overflow-hidden"> + <ExpandableDataTable + table={table} + expandable={true} + expandedRows={expandedRows} + setExpandedRows={setExpandedRows} + renderExpandedContent={(document) => ( + <div className="w-full bg-gray-50 border-t"> + {/* 👇 새 래퍼: 뷰포트 폭을 상한으로, 내부에만 스크롤 */} + <div className="max-w-full overflow-x-auto"> + <StageRevisionExpandedContent + document={document} + onUploadRevision={handleUploadRevision} + onViewRevision={handleViewRevisions} + projectType={projectType} + expandedStages={expandedStages[String(document.documentId)] || {}} + onStageToggle={(stageId) => + handleStageToggle(String(document.documentId), stageId) + } + /> + </div> + </div> + )} + expandedRowClassName="!p-0" + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + /> + </ExpandableDataTable> + </div> + + {/* 선택된 항목 정보 */} + {/* {table.getFilteredSelectedRowModel().rows.length > 0 && ( + <div className="flex items-center justify-between p-4 bg-blue-50 border border-blue-200 rounded-lg"> + <span className="text-sm text-blue-700"> + {table.getFilteredSelectedRowModel().rows.length}개 항목이 선택되었습니다 + </span> + <div className="flex gap-2"> + <Button + size="sm" + variant="outline" + onClick={() => table.toggleAllRowsSelected(false)} + > + 선택 해제 + </Button> + <Button + size="sm" + onClick={() => { + const selectedRows = table.getFilteredSelectedRowModel().rows + handleBulkAction('bulk_approve', selectedRows) + }} + > + 선택 항목 승인 + </Button> + </div> + </div> + )} */} + </div> + + {/* 분리된 다이얼로그들 */} + + {/* ✅ 리비전 업로드 다이얼로그 - mode props 추가 */} + <RevisionUploadDialog + open={uploadDialogOpen} + onOpenChange={(open) => { + if (!open) closeAllDialogs() + else setUploadDialogOpen(open) + }} + document={selectedDocument} + projectType={projectType} + presetStage={selectedStage} + presetRevision={selectedRevision} + mode={uploadMode} + /> + + {/* 문서 편집 다이얼로그 */} + <SimplifiedDocumentEditDialog + open={editDialogOpen} + onOpenChange={(open) => { + if (!open) closeAllDialogs() + else setEditDialogOpen(open) + }} + document={selectedDocument} + projectType={projectType} + /> + + {/* PDF 뷰어 다이얼로그 (기존 ViewDocumentDialog 재사용) */} + {/* + <ViewDocumentDialog + open={viewDialogOpen} + onOpenChange={(open) => { + if (!open) closeAllDialogs() + else setViewDialogOpen(open) + }} + revisions={selectedRevisions} + /> + */} + </div> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/table/enhanced-documents-table.tsx b/lib/vendor-document-list/table/enhanced-documents-table.tsx new file mode 100644 index 00000000..3b623193 --- /dev/null +++ b/lib/vendor-document-list/table/enhanced-documents-table.tsx @@ -0,0 +1,570 @@ +"use client" + +import * as React from "react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { useDataTable } from "@/hooks/use-data-table" +import { StageRevisionExpandedContent } from "./stage-revision-expanded-content" +import { RevisionUploadDialog } from "./revision-upload-dialog" +import { SimplifiedDocumentEditDialog } from "./simplified-document-edit-dialog" +import { EnhancedDocTableToolbarActions } from "./enhanced-doc-table-toolbar-actions" +import { getEnhancedDocuments } from "../enhanced-document-service" +import type { EnhancedDocument } from "@/types/enhanced-documents" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { Badge } from "@/components/ui/badge" +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" +import { + AlertTriangle, + Clock, + TrendingUp, + Target, + Users, +} from "lucide-react" +import { getUpdatedEnhancedColumns } from "./enhanced-doc-table-columns" +import { ExpandableDataTable } from "@/components/data-table/expandable-data-table" +import { toast } from "sonner" +// import { ViewDocumentDialog } from "@/components/documents/view-document-dialog" + +interface FinalIntegratedDocumentsTableProps { + promises: Promise<[Awaited<ReturnType<typeof getEnhancedDocuments>>]> + selectedPackageId: number + projectType: "ship" | "plant" +} + +export function EnhancedDocumentsTable({ + promises, + selectedPackageId, + projectType, +}: FinalIntegratedDocumentsTableProps) { + // 데이터 로딩 + const [{ data, pageCount, total }] = React.use(promises) + + // 상태 관리 + const [rowAction, setRowAction] = React.useState<DataTableRowAction<EnhancedDocument> | null>(null) + const [expandedRows, setExpandedRows] = React.useState<Set<string>>(new Set()) + const [quickFilter, setQuickFilter] = React.useState<'all' | 'overdue' | 'due_soon' | 'in_progress' | 'high_priority'>('all') + + // ✅ 스테이지 확장 상태 관리 (문서별로 관리) + const [expandedStages, setExpandedStages] = React.useState<Record<string, Record<number, boolean>>>({}) + + // 다이얼로그 상태들 + const [uploadDialogOpen, setUploadDialogOpen] = React.useState(false) + const [editDialogOpen, setEditDialogOpen] = React.useState(false) + // const [viewDialogOpen, setViewDialogOpen] = React.useState(false) + const [selectedDocument, setSelectedDocument] = React.useState<EnhancedDocument | null>(null) + const [selectedStage, setSelectedStage] = React.useState<string>("") + const [selectedRevision, setSelectedRevision] = React.useState<string>("") + // const [selectedRevisions, setSelectedRevisions] = React.useState<any[]>([]) + const [uploadMode, setUploadMode] = React.useState<'new' | 'append'>('new') + + // 다음 리비전 계산 함수 + const getNextRevision = React.useCallback((currentRevision: string): string => { + if (!currentRevision) return "A" + + // 알파벳 리비전 (A, B, C...) + if (/^[A-Z]$/.test(currentRevision)) { + const charCode = currentRevision.charCodeAt(0) + if (charCode < 90) { // Z가 아닌 경우 + return String.fromCharCode(charCode + 1) + } + return "AA" // Z 다음은 AA + } + + // 숫자 리비전 (1, 2, 3...) + if (/^\d+$/.test(currentRevision)) { + return String(parseInt(currentRevision) + 1) + } + + // 기타 복잡한 리비전 형태는 그대로 반환 + return currentRevision + }, []) + + // 컬럼 정의 + const columns = React.useMemo( + () => getUpdatedEnhancedColumns({ + setRowAction: (action) => { + setRowAction(action) + if (action) { + setSelectedDocument(action.row.original) + + // 액션 타입에 따른 다이얼로그 열기 + switch (action.type) { + case "update": + setEditDialogOpen(true) + break + case "upload": + setSelectedStage(action.row.original.currentStageName || "") + setUploadDialogOpen(true) + break + case "view": + // 상세보기는 확장된 행으로 대체 + const rowId = action.row.id + const newExpanded = new Set(expandedRows) + if (newExpanded.has(rowId)) { + newExpanded.delete(rowId) + } else { + newExpanded.add(rowId) + } + setExpandedRows(newExpanded) + break + } + } + }, + + projectType + + }), + [expandedRows, projectType] + ) + + // 통계 계산 + const stats = React.useMemo(() => { + const totalDocs = data.length + const overdue = data.filter(doc => doc.isOverdue).length + const dueSoon = data.filter(doc => + doc.daysUntilDue !== null && + doc.daysUntilDue >= 0 && + doc.daysUntilDue <= 3 + ).length + const inProgress = data.filter(doc => doc.currentStageStatus === 'IN_PROGRESS').length + const highPriority = data.filter(doc => doc.currentStagePriority === 'HIGH').length + const avgProgress = totalDocs > 0 + ? Math.round(data.reduce((sum, doc) => sum + (doc.progressPercentage || 0), 0) / totalDocs) + : 0 + + return { + total: totalDocs, + overdue, + dueSoon, + inProgress, + highPriority, + avgProgress + } + }, [data]) + + // 빠른 필터링 + const filteredData = React.useMemo(() => { + switch (quickFilter) { + case 'overdue': + return data.filter(doc => doc.isOverdue) + case 'due_soon': + return data.filter(doc => + doc.daysUntilDue !== null && + doc.daysUntilDue >= 0 && + doc.daysUntilDue <= 3 + ) + case 'in_progress': + return data.filter(doc => doc.currentStageStatus === 'IN_PROGRESS') + case 'high_priority': + return data.filter(doc => doc.currentStagePriority === 'HIGH') + default: + return data + } + }, [data, quickFilter]) + + // ✅ 핸들러 함수 수정: 모드 매개변수 추가 + const handleUploadRevision = React.useCallback((document: EnhancedDocument, stageName?: string, currentRevision?: string, mode: 'new' | 'append' = 'new') => { + setSelectedDocument(document) + setSelectedStage(stageName || document.currentStageName || "") + setUploadMode(mode) // ✅ 모드 설정 + + if (mode === 'new') { + // 새 리비전 생성: currentRevision이 있으면 다음 리비전을 자동 계산 + if (currentRevision) { + const nextRevision = getNextRevision(currentRevision) + setSelectedRevision(nextRevision) + } else { + // 스테이지의 최신 리비전을 찾아서 다음 리비전 계산 + const latestRevision = findLatestRevisionInStage(document, stageName || document.currentStageName || "") + if (latestRevision) { + setSelectedRevision(getNextRevision(latestRevision)) + } else { + setSelectedRevision("A") // 첫 번째 리비전 + } + } + } else { + // 기존 리비전에 파일 추가: 같은 리비전 번호 사용 + setSelectedRevision(currentRevision || "") + } + + setUploadDialogOpen(true) + }, [getNextRevision]) + + // ✅ 스테이지에서 최신 리비전을 찾는 헬퍼 함수 + const findLatestRevisionInStage = React.useCallback((document: EnhancedDocument, stageName: string) => { + const stage = document.allStages?.find(s => s.stageName === stageName) + if (!stage || !stage.revisions || stage.revisions.length === 0) { + return null + } + + // 리비전들을 정렬해서 최신 것 찾기 (간단한 알파벳/숫자 정렬) + const sortedRevisions = [...stage.revisions].sort((a, b) => { + // 알파벳과 숫자를 구분해서 정렬 + const aIsAlpha = /^[A-Z]+$/.test(a.revision) + const bIsAlpha = /^[A-Z]+$/.test(b.revision) + + if (aIsAlpha && bIsAlpha) { + return a.revision.localeCompare(b.revision) + } else if (!aIsAlpha && !bIsAlpha) { + return parseInt(a.revision) - parseInt(b.revision) + } else { + return aIsAlpha ? -1 : 1 // 알파벳이 숫자보다 먼저 + } + }) + + return sortedRevisions[sortedRevisions.length - 1]?.revision || null + }, []) + + // const handleEditDocument = (document: EnhancedDocument) => { + // setSelectedDocument(document) + // setEditDialogOpen(true) + // } + + // const handleViewRevisions = (revisions: any[]) => { + // setSelectedRevisions(revisions) + // setViewDialogOpen(true) + // } + + const handleNewDocument = () => { + setSelectedDocument(null) + setEditDialogOpen(true) + } + + // ✅ 스테이지 토글 핸들러 추가 + const handleStageToggle = React.useCallback((documentId: string, stageId: number) => { + setExpandedStages(prev => ({ + ...prev, + [documentId]: { + ...prev[documentId], + [stageId]: !prev[documentId]?.[stageId] + } + })) + }, []) + + const handleBulkAction = async (action: string, selectedRows: any[]) => { + try { + if (action === 'bulk_approve') { + // 일괄 승인 로직 + const stageIds = selectedRows + .map(row => row.original.currentStageId) + .filter(Boolean) + + if (stageIds.length > 0) { + // await bulkUpdateStageStatus(stageIds, 'APPROVED') + toast.success(`${stageIds.length}개 항목이 승인되었습니다.`) + } + } else if (action === 'bulk_upload') { + // 일괄 업로드 로직 + toast.info("일괄 업로드 기능은 준비 중입니다.") + } + } catch (error) { + toast.error("일괄 작업 중 오류가 발생했습니다.") + } + } + + // 다이얼로그 닫기 + const closeAllDialogs = () => { + setUploadDialogOpen(false) + setEditDialogOpen(false) + // setViewDialogOpen(false) + setSelectedDocument(null) + setSelectedStage("") + setSelectedRevision("") + // setSelectedRevisions([]) + setUploadMode('new') // ✅ 모드 초기화 + setRowAction(null) + } + + // 필터 필드 정의 + const filterFields: DataTableFilterField<EnhancedDocument>[] = [ + { + label: "문서번호", + value: "docNumber", + placeholder: "문서번호로 검색...", + }, + { + label: "제목", + value: "title", + placeholder: "제목으로 검색...", + }, + ] + + const advancedFilterFields: DataTableAdvancedFilterField<EnhancedDocument>[] = [ + { + id: "docNumber", + label: "문서번호", + type: "text", + }, + { + id: "title", + label: "문서제목", + type: "text", + }, + { + id: "currentStageStatus", + label: "스테이지 상태", + type: "select", + options: [ + { label: "계획됨", value: "PLANNED" }, + { label: "진행중", value: "IN_PROGRESS" }, + { label: "제출됨", value: "SUBMITTED" }, + { label: "승인됨", value: "APPROVED" }, + { label: "완료됨", value: "COMPLETED" }, + ], + }, + { + id: "currentStagePriority", + label: "우선순위", + type: "select", + options: [ + { label: "높음", value: "HIGH" }, + { label: "보통", value: "MEDIUM" }, + { label: "낮음", value: "LOW" }, + ], + }, + { + id: "isOverdue", + label: "지연 여부", + type: "select", + options: [ + { label: "지연됨", value: "true" }, + { label: "정상", value: "false" }, + ], + }, + { + id: "currentStageAssigneeName", + label: "담당자", + type: "text", + }, + { + id: "createdAt", + label: "생성일", + type: "date", + }, + ] + + // 데이터 테이블 훅 + const { table } = useDataTable({ + data: filteredData, + columns, + pageCount, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState: { + sorting: [{ id: "createdAt", desc: true }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => String(originalRow.documentId), + shallow: false, + clearOnDefault: true, + columnResizeMode: "onEnd", + }) + + return ( + <div className="space-y-6"> + {/* 통계 대시보드 */} + <div className="grid grid-cols-2 md:grid-cols-4 gap-4"> + <Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('all')}> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">전체 문서</CardTitle> + <TrendingUp className="h-4 w-4 text-muted-foreground" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold">{stats.total}</div> + <p className="text-xs text-muted-foreground"> + 총 {total}개 중 {stats.total}개 표시 + </p> + </CardContent> + </Card> + + <Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('overdue')}> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">지연 문서</CardTitle> + <AlertTriangle className="h-4 w-4 text-red-500" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-red-600">{stats.overdue}</div> + <p className="text-xs text-muted-foreground">즉시 확인 필요</p> + </CardContent> + </Card> + + <Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('due_soon')}> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">마감 임박</CardTitle> + <Clock className="h-4 w-4 text-orange-500" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-orange-600">{stats.dueSoon}</div> + <p className="text-xs text-muted-foreground">3일 이내 마감</p> + </CardContent> + </Card> + + <Card> + <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> + <CardTitle className="text-sm font-medium">평균 진행률</CardTitle> + <Target className="h-4 w-4 text-green-500" /> + </CardHeader> + <CardContent> + <div className="text-2xl font-bold text-green-600">{stats.avgProgress}%</div> + <p className="text-xs text-muted-foreground">전체 프로젝트 진행도</p> + </CardContent> + </Card> + </div> + + {/* 빠른 필터 */} + <div className="flex gap-2 overflow-x-auto pb-2"> + <Badge + variant={quickFilter === 'all' ? 'default' : 'outline'} + className="cursor-pointer hover:bg-primary hover:text-primary-foreground whitespace-nowrap" + onClick={() => setQuickFilter('all')} + > + 전체 ({stats.total}) + </Badge> + <Badge + variant={quickFilter === 'overdue' ? 'destructive' : 'outline'} + className="cursor-pointer hover:bg-destructive hover:text-destructive-foreground whitespace-nowrap" + onClick={() => setQuickFilter('overdue')} + > + <AlertTriangle className="w-3 h-3 mr-1" /> + 지연 ({stats.overdue}) + </Badge> + <Badge + variant={quickFilter === 'due_soon' ? 'default' : 'outline'} + className="cursor-pointer hover:bg-orange-500 hover:text-white whitespace-nowrap" + onClick={() => setQuickFilter('due_soon')} + > + <Clock className="w-3 h-3 mr-1" /> + 마감임박 ({stats.dueSoon}) + </Badge> + <Badge + variant={quickFilter === 'in_progress' ? 'default' : 'outline'} + className="cursor-pointer hover:bg-blue-500 hover:text-white whitespace-nowrap" + onClick={() => setQuickFilter('in_progress')} + > + <Users className="w-3 h-3 mr-1" /> + 진행중 ({stats.inProgress}) + </Badge> + <Badge + variant={quickFilter === 'high_priority' ? 'destructive' : 'outline'} + className="cursor-pointer hover:bg-destructive hover:text-destructive-foreground whitespace-nowrap" + onClick={() => setQuickFilter('high_priority')} + > + <Target className="w-3 h-3 mr-1" /> + 높은우선순위 ({stats.highPriority}) + </Badge> + </div> + + {/* 메인 테이블 - 가로스크롤 문제 해결을 위한 구조 개선 */} + <div className="space-y-4"> + <div className="rounded-md border bg-white overflow-hidden"> + <ExpandableDataTable + table={table} + expandable={true} + expandedRows={expandedRows} + setExpandedRows={setExpandedRows} + renderExpandedContent={(document) => ( + // ✅ 확장된 내용을 별도 컨테이너로 분리하여 가로스크롤 영향 차단 + <div className=""> + <StageRevisionExpandedContent + document={document} + onUploadRevision={handleUploadRevision} + // onViewRevision={handleViewRevisions} + projectType={projectType} + expandedStages={expandedStages[String(document.documentId)] || {}} + onStageToggle={(stageId) => handleStageToggle(String(document.documentId), stageId)} + /> + </div> + )} + // 확장된 행에 대한 특별한 스타일링 + expandedRowClassName="!p-0" + > + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <EnhancedDocTableToolbarActions + table={table} + projectType={projectType} + selectedPackageId={selectedPackageId} + onNewDocument={handleNewDocument} + onBulkAction={handleBulkAction} + /> + </DataTableAdvancedToolbar> + </ExpandableDataTable> + </div> + + {/* 선택된 항목 정보 */} + {/* {table.getFilteredSelectedRowModel().rows.length > 0 && ( + <div className="flex items-center justify-between p-4 bg-blue-50 border border-blue-200 rounded-lg"> + <span className="text-sm text-blue-700"> + {table.getFilteredSelectedRowModel().rows.length}개 항목이 선택되었습니다 + </span> + <div className="flex gap-2"> + <Button + size="sm" + variant="outline" + onClick={() => table.toggleAllRowsSelected(false)} + > + 선택 해제 + </Button> + <Button + size="sm" + onClick={() => { + const selectedRows = table.getFilteredSelectedRowModel().rows + handleBulkAction('bulk_approve', selectedRows) + }} + > + 선택 항목 승인 + </Button> + </div> + </div> + )} */} + </div> + + {/* 분리된 다이얼로그들 */} + + {/* ✅ 리비전 업로드 다이얼로그 - mode props 추가 */} + <RevisionUploadDialog + open={uploadDialogOpen} + onOpenChange={(open) => { + if (!open) closeAllDialogs() + else setUploadDialogOpen(open) + }} + document={selectedDocument} + projectType={projectType} + presetStage={selectedStage} + presetRevision={selectedRevision} + mode={uploadMode} + /> + + {/* 문서 편집 다이얼로그 */} + <SimplifiedDocumentEditDialog + open={editDialogOpen} + onOpenChange={(open) => { + if (!open) closeAllDialogs() + else setEditDialogOpen(open) + }} + document={selectedDocument} + projectType={projectType} + /> + + {/* PDF 뷰어 다이얼로그 (기존 ViewDocumentDialog 재사용) */} + + {/* <ViewDocumentDialog + open={viewDialogOpen} + onOpenChange={(open) => { + if (!open) closeAllDialogs() + else setViewDialogOpen(open) + }} + revisions={selectedRevisions} + /> + */} + </div> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/table/revision-upload-dialog.tsx b/lib/vendor-document-list/table/revision-upload-dialog.tsx new file mode 100644 index 00000000..ac58b974 --- /dev/null +++ b/lib/vendor-document-list/table/revision-upload-dialog.tsx @@ -0,0 +1,486 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { toast } from "sonner" +import { useRouter } from "next/navigation" +import { useSession } from "next-auth/react" + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { + Dropzone, + DropzoneDescription, + DropzoneInput, + DropzoneTitle, + DropzoneUploadIcon, + DropzoneZone, +} from "@/components/ui/dropzone" +import { + FileList, + FileListAction, + FileListHeader, + FileListIcon, + FileListInfo, + FileListItem, + FileListName, + FileListSize, +} from "@/components/ui/file-list" +import { Badge } from "@/components/ui/badge" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Upload, X, Loader2 } from "lucide-react" +import prettyBytes from "pretty-bytes" +import { EnhancedDocumentsView } from "@/db/schema/vendorDocu" + +// 리비전 업로드 스키마 +const revisionUploadSchema = z.object({ + stage: z.string().min(1, "스테이지는 필수입니다"), + revision: z.string().min(1, "리비전은 필수입니다"), + uploaderName: z.string().optional(), + comment: z.string().optional(), + attachments: z.array(z.instanceof(File)).min(1, "최소 1개 파일이 필요합니다"), +}) + +type RevisionUploadSchema = z.infer<typeof revisionUploadSchema> + +interface RevisionUploadDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + document: EnhancedDocumentsView | null + projectType: "ship" | "plant" + presetStage?: string + presetRevision?: string + mode?: 'new' | 'append' +} + +export function RevisionUploadDialog({ + open, + onOpenChange, + document, + projectType, + presetStage, + presetRevision, + mode = 'new', +}: RevisionUploadDialogProps) { + const [selectedFiles, setSelectedFiles] = React.useState<File[]>([]) + const [isUploading, setIsUploading] = React.useState(false) + const [uploadProgress, setUploadProgress] = React.useState(0) + const router = useRouter() + + // ✅ next-auth session 가져오기 + const { data: session } = useSession() + + // 사용 가능한 스테이지 옵션 + const stageOptions = React.useMemo(() => { + if (document?.allStages) { + return document.allStages.map(stage => stage.stageName) + } + return ["Issued for Review", "AFC", "Final Issue"] + }, [document]) + + const form = useForm<RevisionUploadSchema>({ + resolver: zodResolver(revisionUploadSchema), + defaultValues: { + stage: presetStage || document?.currentStageName || "", + revision: presetRevision || "", + uploaderName: session?.user?.name || "", // ✅ session.user.name 사용 + comment: "", + attachments: [], + }, + }) + + // ✅ session이 로드되면 uploaderName 업데이트 + React.useEffect(() => { + if (session?.user?.name) { + form.setValue('uploaderName', session.user.name) + } + }, [session?.user?.name, form]) + + // ✅ presetStage와 presetRevision이 변경될 때 폼 값 업데이트 + React.useEffect(() => { + if (presetStage) { + form.setValue('stage', presetStage) + } + if (presetRevision) { + form.setValue('revision', presetRevision) + } + }, [presetStage, presetRevision, form]) + + // 파일 드롭 처리 + const handleDropAccepted = (acceptedFiles: File[]) => { + const newFiles = [...selectedFiles, ...acceptedFiles] + setSelectedFiles(newFiles) + form.setValue('attachments', newFiles, { shouldValidate: true }) + } + + const removeFile = (index: number) => { + const updatedFiles = [...selectedFiles] + updatedFiles.splice(index, 1) + setSelectedFiles(updatedFiles) + form.setValue('attachments', updatedFiles, { shouldValidate: true }) + } + + // 업로드 처리 + async function onSubmit(data: RevisionUploadSchema) { + if (!document) return + + setIsUploading(true) + setUploadProgress(0) + + try { + const formData = new FormData() + formData.append("documentId", String(document.documentId)) + formData.append("stage", data.stage) + formData.append("revision", data.revision) + formData.append("mode", mode) // 'new' 또는 'append' + + if (data.uploaderName) { + formData.append("uploaderName", data.uploaderName) + } + + if (data.comment) { + formData.append("comment", data.comment) + } + + // 파일들 추가 + data.attachments.forEach((file) => { + formData.append("attachments", file) + }) + + // 진행률 업데이트 시뮬레이션 + const updateProgress = (progress: number) => { + setUploadProgress(Math.min(progress, 95)) // 95%까지만 진행률 표시 + } + + // 파일 크기에 따른 진행률 시뮬레이션 + const totalSize = data.attachments.reduce((sum, file) => sum + file.size, 0) + let uploadedSize = 0 + + const progressInterval = setInterval(() => { + uploadedSize += totalSize * 0.1 // 10%씩 증가 시뮬레이션 + const progress = Math.min((uploadedSize / totalSize) * 100, 90) + updateProgress(progress) + }, 300) + + // ✅ 실제 API 호출 + const response = await fetch('/api/revision-upload', { + method: 'POST', + body: formData, + }) + + clearInterval(progressInterval) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || errorData.details || '업로드에 실패했습니다.') + } + + const result = await response.json() + setUploadProgress(100) + + toast.success( + result.message || + `리비전 ${data.revision}이 성공적으로 업로드되었습니다. (${result.data?.uploadedFiles?.length || 0}개 파일)` + ) + + console.log('✅ 업로드 성공:', result) + + // 잠시 대기 후 다이얼로그 닫기 + setTimeout(() => { + handleDialogClose() + router.refresh() + }, 1000) + + } catch (error) { + console.error('❌ 업로드 오류:', error) + toast.error(error instanceof Error ? error.message : "업로드 중 오류가 발생했습니다") + } finally { + setIsUploading(false) + setTimeout(() => setUploadProgress(0), 2000) // 2초 후 진행률 리셋 + } + } + + const handleDialogClose = () => { + form.reset({ + stage: presetStage || document?.currentStageName || "", + revision: presetRevision || "", + uploaderName: session?.user?.name || "", // ✅ 다이얼로그 닫을 때도 session 값으로 리셋 + comment: "", + attachments: [], + }) + setSelectedFiles([]) + setIsUploading(false) + setUploadProgress(0) + onOpenChange(false) + } + + return ( + <Dialog open={open} onOpenChange={handleDialogClose}> + <DialogContent className="sm:max-w-md"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <Upload className="w-5 h-5" /> + {mode === 'new' ? '새 리비전 업로드' : '파일 추가'} + </DialogTitle> + <DialogDescription> + {document ? `${document.docNumber} - ${document.title}` : + mode === 'new' ? "문서에 새 리비전을 업로드합니다." : "기존 리비전에 파일을 추가합니다."} + </DialogDescription> + + <div className="flex items-center gap-2 pt-2"> + <Badge variant={projectType === "ship" ? "default" : "secondary"}> + {projectType === "ship" ? "조선 프로젝트" : "플랜트 프로젝트"} + </Badge> + {/* ✅ 현재 사용자 정보 표시 */} + {session?.user?.name && ( + <Badge variant="outline" className="text-xs"> + 업로더: {session.user.name} + </Badge> + )} + {/* ✅ 모드에 따른 정보 표시 */} + {mode === 'append' && presetRevision && ( + <Badge variant="outline" className="text-xs"> + 리비전 {presetRevision}에 파일 추가 + </Badge> + )} + {mode === 'new' && presetRevision && ( + <Badge variant="outline" className="text-xs"> + 다음 리비전: {presetRevision} + </Badge> + )} + </div> + </DialogHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + <div className="grid grid-cols-2 gap-4"> + <FormField + control={form.control} + name="stage" + render={({ field }) => ( + <FormItem> + <FormLabel>스테이지</FormLabel> + <Select onValueChange={field.onChange} value={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="스테이지 선택" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {stageOptions.map((stage) => ( + <SelectItem key={stage} value={stage}> + {stage} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="revision" + render={({ field }) => ( + <FormItem> + <FormLabel>리비전</FormLabel> + <FormControl> + <Input + {...field} + placeholder="예: A, B, 1, 2..." + readOnly={mode === 'append'} + className={mode === 'append' ? 'bg-gray-50' : ''} + /> + </FormControl> + <FormMessage /> + {/* ✅ 모드에 따른 도움말 표시 */} + {mode === 'new' && presetRevision && ( + <p className="text-xs text-gray-500"> + 자동으로 계산된 다음 리비전입니다. + </p> + )} + {mode === 'append' && ( + <p className="text-xs text-gray-500"> + 기존 리비전에 파일을 추가합니다. + </p> + )} + </FormItem> + )} + /> + </div> + + <FormField + control={form.control} + name="uploaderName" + render={({ field }) => ( + <FormItem> + <FormLabel>업로더명</FormLabel> + <FormControl> + <Input + {...field} + placeholder="업로더 이름을 입력하세요" + className="bg-gray-50" // ✅ session 값이므로 읽기 전용 느낌으로 스타일링 + /> + </FormControl> + <FormMessage /> + <p className="text-xs text-gray-500"> + 로그인된 사용자 정보가 자동으로 입력됩니다. + </p> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="comment" + render={({ field }) => ( + <FormItem> + <FormLabel>코멘트 (선택)</FormLabel> + <FormControl> + <Textarea {...field} placeholder="코멘트를 입력하세요" rows={3} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 파일 업로드 영역 */} + <FormField + control={form.control} + name="attachments" + render={() => ( + <FormItem> + <FormLabel>파일 첨부</FormLabel> + <Dropzone + maxSize={3e9} // 3GB + multiple={true} + onDropAccepted={handleDropAccepted} + disabled={isUploading} + > + <DropzoneZone className="flex justify-center"> + <FormControl> + <DropzoneInput /> + </FormControl> + <div className="flex items-center gap-6"> + <DropzoneUploadIcon /> + <div className="grid gap-0.5"> + <DropzoneTitle>파일을 여기에 드롭하세요</DropzoneTitle> + <DropzoneDescription> + 또는 클릭하여 파일을 선택하세요 + </DropzoneDescription> + </div> + </div> + </DropzoneZone> + </Dropzone> + <FormMessage /> + </FormItem> + )} + /> + + {/* 선택된 파일 목록 */} + {selectedFiles.length > 0 && ( + <div className="space-y-2"> + <div className="flex items-center justify-between"> + <h6 className="text-sm font-semibold"> + 선택된 파일 ({selectedFiles.length}) + </h6> + </div> + <ScrollArea className="max-h-[200px]"> + <FileList> + {selectedFiles.map((file, index) => ( + <FileListItem key={index} className="p-3"> + <FileListHeader> + <FileListIcon /> + <FileListInfo> + <FileListName>{file.name}</FileListName> + <FileListSize>{prettyBytes(file.size)}</FileListSize> + </FileListInfo> + <FileListAction + onClick={() => removeFile(index)} + disabled={isUploading} + > + <X className="h-4 w-4" /> + </FileListAction> + </FileListHeader> + </FileListItem> + ))} + </FileList> + </ScrollArea> + </div> + )} + + {/* 업로드 진행 상태 */} + {isUploading && ( + <div className="space-y-2"> + <div className="flex items-center gap-2"> + <Loader2 className="h-4 w-4 animate-spin" /> + <span className="text-sm">{uploadProgress}% 업로드 중...</span> + </div> + <div className="h-2 w-full bg-muted rounded-full overflow-hidden"> + <div + className="h-full bg-primary rounded-full transition-all" + style={{ width: `${uploadProgress}%` }} + /> + </div> + </div> + )} + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={handleDialogClose} + disabled={isUploading} + > + 취소 + </Button> + <Button + type="submit" + disabled={isUploading || selectedFiles.length === 0} + > + {isUploading ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + 업로드 중... + </> + ) : ( + <> + <Upload className="mr-2 h-4 w-4" /> + 업로드 + </> + )} + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/table/send-to-shi-button.tsx b/lib/vendor-document-list/table/send-to-shi-button.tsx new file mode 100644 index 00000000..e0360144 --- /dev/null +++ b/lib/vendor-document-list/table/send-to-shi-button.tsx @@ -0,0 +1,342 @@ +// components/sync/send-to-shi-button.tsx (최종 버전) +"use client" + +import * as React from "react" +import { Send, Loader2, CheckCircle, AlertTriangle, Settings } from "lucide-react" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { Badge } from "@/components/ui/badge" +import { Progress } from "@/components/ui/progress" +import { Separator } from "@/components/ui/separator" +import { useSyncStatus, useTriggerSync } from "@/hooks/use-sync-status" +import type { EnhancedDocument } from "@/types/enhanced-documents" + +interface SendToSHIButtonProps { + contractId: number + documents?: EnhancedDocument[] + onSyncComplete?: () => void +} + +export function SendToSHIButton({ + contractId, + documents = [], + onSyncComplete +}: SendToSHIButtonProps) { + const [isDialogOpen, setIsDialogOpen] = React.useState(false) + const [syncProgress, setSyncProgress] = React.useState(0) + + const { + syncStatus, + isLoading: statusLoading, + error: statusError, + refetch: refetchStatus + } = useSyncStatus(contractId, 'SHI') + + const { + triggerSync, + isLoading: isSyncing, + error: syncError + } = useTriggerSync() + + // 에러 상태 표시 + React.useEffect(() => { + if (statusError) { + console.warn('Failed to load sync status:', statusError) + } + }, [statusError]) + + const handleSync = async () => { + if (!contractId) return + + setSyncProgress(0) + + try { + // 진행률 시뮬레이션 + const progressInterval = setInterval(() => { + setSyncProgress(prev => Math.min(prev + 10, 90)) + }, 200) + + const result = await triggerSync({ + contractId, + targetSystem: 'SHI' + }) + + clearInterval(progressInterval) + setSyncProgress(100) + + setTimeout(() => { + setSyncProgress(0) + setIsDialogOpen(false) + + if (result?.success) { + toast.success( + `동기화 완료: ${result.successCount || 0}건 성공`, + { + description: result.successCount > 0 + ? `${result.successCount}개 항목이 SHI 시스템으로 전송되었습니다.` + : '전송할 새로운 변경사항이 없습니다.' + } + ) + } else { + toast.error( + `동기화 부분 실패: ${result?.successCount || 0}건 성공, ${result?.failureCount || 0}건 실패`, + { + description: result?.errors?.[0] || '일부 항목 전송에 실패했습니다.' + } + ) + } + + refetchStatus() // SWR 캐시 갱신 + onSyncComplete?.() + }, 500) + + } catch (error) { + setSyncProgress(0) + + toast.error('동기화 실패', { + description: error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.' + }) + } + } + + const getSyncStatusBadge = () => { + if (statusLoading) { + return <Badge variant="secondary">확인 중...</Badge> + } + + if (statusError) { + return <Badge variant="destructive">오류</Badge> + } + + if (!syncStatus) { + return <Badge variant="secondary">데이터 없음</Badge> + } + + if (syncStatus.pendingChanges > 0) { + return ( + <Badge variant="destructive" className="gap-1"> + <AlertTriangle className="w-3 h-3" /> + {syncStatus.pendingChanges}건 대기 + </Badge> + ) + } + + if (syncStatus.syncedChanges > 0) { + return ( + <Badge variant="default" className="gap-1 bg-green-500 hover:bg-green-600"> + <CheckCircle className="w-3 h-3" /> + 동기화됨 + </Badge> + ) + } + + return <Badge variant="secondary">변경사항 없음</Badge> + } + + const canSync = !statusError && syncStatus?.syncEnabled && syncStatus?.pendingChanges > 0 + + return ( + <> + <Popover> + <PopoverTrigger asChild> + <Button + variant="default" + size="sm" + className="gap-2 relative bg-blue-600 hover:bg-blue-700" + disabled={isSyncing || statusLoading} + > + {isSyncing ? ( + <Loader2 className="w-4 h-4 animate-spin" /> + ) : ( + <Send className="w-4 h-4" /> + )} + <span className="hidden sm:inline">Send to SHI</span> + {syncStatus?.pendingChanges > 0 && ( + <Badge + variant="destructive" + className="absolute -top-2 -right-2 h-5 w-5 p-0 text-xs flex items-center justify-center" + > + {syncStatus.pendingChanges} + </Badge> + )} + </Button> + </PopoverTrigger> + + <PopoverContent className="w-80"> + <div className="space-y-4"> + <div className="space-y-2"> + <h4 className="font-medium">SHI 동기화 상태</h4> + <div className="flex items-center justify-between"> + <span className="text-sm text-muted-foreground">현재 상태</span> + {getSyncStatusBadge()} + </div> + </div> + + {syncStatus && !statusError && ( + <div className="space-y-3"> + <Separator /> + + <div className="grid grid-cols-2 gap-4 text-sm"> + <div> + <div className="text-muted-foreground">대기 중</div> + <div className="font-medium">{syncStatus.pendingChanges || 0}건</div> + </div> + <div> + <div className="text-muted-foreground">동기화됨</div> + <div className="font-medium">{syncStatus.syncedChanges || 0}건</div> + </div> + </div> + + {syncStatus.failedChanges > 0 && ( + <div className="text-sm"> + <div className="text-muted-foreground">실패</div> + <div className="font-medium text-red-600">{syncStatus.failedChanges}건</div> + </div> + )} + + {syncStatus.lastSyncAt && ( + <div className="text-sm"> + <div className="text-muted-foreground">마지막 동기화</div> + <div className="font-medium"> + {new Date(syncStatus.lastSyncAt).toLocaleString()} + </div> + </div> + )} + </div> + )} + + {statusError && ( + <div className="space-y-2"> + <Separator /> + <div className="text-sm text-red-600"> + <div className="font-medium">연결 오류</div> + <div className="text-xs">동기화 상태를 확인할 수 없습니다.</div> + </div> + </div> + )} + + <Separator /> + + <div className="flex gap-2"> + <Button + onClick={() => setIsDialogOpen(true)} + disabled={!canSync || isSyncing} + className="flex-1" + size="sm" + > + {isSyncing ? ( + <> + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> + 동기화 중... + </> + ) : ( + <> + <Send className="w-4 h-4 mr-2" /> + 지금 동기화 + </> + )} + </Button> + + <Button + variant="outline" + size="sm" + onClick={() => refetchStatus()} + disabled={statusLoading} + > + {statusLoading ? ( + <Loader2 className="w-4 h-4 animate-spin" /> + ) : ( + <Settings className="w-4 h-4" /> + )} + </Button> + </div> + </div> + </PopoverContent> + </Popover> + + {/* 동기화 진행 다이얼로그 */} + <Dialog open={isDialogOpen} onOpenChange={setIsDialogOpen}> + <DialogContent className="sm:max-w-md"> + <DialogHeader> + <DialogTitle>SHI 시스템으로 동기화</DialogTitle> + <DialogDescription> + 변경된 문서 데이터를 SHI 시스템으로 전송합니다. + </DialogDescription> + </DialogHeader> + + <div className="space-y-4"> + {syncStatus && !statusError && ( + <div className="rounded-lg border p-4 space-y-3"> + <div className="flex items-center justify-between text-sm"> + <span>전송 대상</span> + <span className="font-medium">{syncStatus.pendingChanges || 0}건</span> + </div> + + <div className="text-xs text-muted-foreground"> + 문서, 리비전, 첨부파일의 변경사항이 포함됩니다. + </div> + + {isSyncing && ( + <div className="space-y-2"> + <div className="flex items-center justify-between text-sm"> + <span>진행률</span> + <span>{syncProgress}%</span> + </div> + <Progress value={syncProgress} className="h-2" /> + </div> + )} + </div> + )} + + {statusError && ( + <div className="rounded-lg border border-red-200 p-4"> + <div className="text-sm text-red-600"> + 동기화 상태를 확인할 수 없습니다. 네트워크 연결을 확인해주세요. + </div> + </div> + )} + + <div className="flex justify-end gap-2"> + <Button + variant="outline" + onClick={() => setIsDialogOpen(false)} + disabled={isSyncing} + > + 취소 + </Button> + <Button + onClick={handleSync} + disabled={isSyncing || !canSync} + > + {isSyncing ? ( + <> + <Loader2 className="w-4 h-4 mr-2 animate-spin" /> + 동기화 중... + </> + ) : ( + <> + <Send className="w-4 h-4 mr-2" /> + 동기화 시작 + </> + )} + </Button> + </div> + </div> + </DialogContent> + </Dialog> + </> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/table/simplified-document-edit-dialog.tsx b/lib/vendor-document-list/table/simplified-document-edit-dialog.tsx new file mode 100644 index 00000000..933df263 --- /dev/null +++ b/lib/vendor-document-list/table/simplified-document-edit-dialog.tsx @@ -0,0 +1,287 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { toast } from "sonner" + +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Calendar as CalendarComponent } from "@/components/ui/calendar" +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover" +import { Calendar, Edit, Loader2 } from "lucide-react" +import { format } from "date-fns" +import { ko } from "date-fns/locale" +import { cn } from "@/lib/utils" +import { EnhancedDocumentsView } from "@/db/schema/vendorDocu" + +// 단순화된 문서 편집 스키마 +const documentEditSchema = z.object({ + docNumber: z.string().min(1, "문서번호는 필수입니다"), + title: z.string().min(1, "제목은 필수입니다"), + pic: z.string().optional(), + status: z.string().min(1, "상태는 필수입니다"), + issuedDate: z.date().optional(), + description: z.string().optional(), +}) + +type DocumentEditSchema = z.infer<typeof documentEditSchema> + +const statusOptions = [ + { value: "ACTIVE", label: "활성" }, + { value: "INACTIVE", label: "비활성" }, + { value: "COMPLETED", label: "완료" }, + { value: "CANCELLED", label: "취소" }, +] + +interface SimplifiedDocumentEditDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + document: EnhancedDocumentsView | null + projectType: "ship" | "plant" +} + +export function SimplifiedDocumentEditDialog({ + open, + onOpenChange, + document, + projectType, +}: SimplifiedDocumentEditDialogProps) { + const [isUpdating, setIsUpdating] = React.useState(false) + + const form = useForm<DocumentEditSchema>({ + resolver: zodResolver(documentEditSchema), + defaultValues: { + docNumber: "", + title: "", + pic: "", + status: "ACTIVE", + issuedDate: undefined, + description: "", + }, + }) + + // 폼 초기화 + React.useEffect(() => { + if (document) { + form.reset({ + docNumber: document.docNumber, + title: document.title, + pic: document.pic || "", + status: document.status, + issuedDate: document.issuedDate ? new Date(document.issuedDate) : undefined, + description: "", + }) + } + }, [document, form]) + + async function onSubmit(data: DocumentEditSchema) { + if (!document) return + + setIsUpdating(true) + try { + // 실제 업데이트 API 호출 (구현 필요) + // await updateDocumentInfo({ documentId: document.documentId, ...data }) + + toast.success("문서 정보가 업데이트되었습니다") + onOpenChange(false) + } catch (error) { + toast.error("업데이트 중 오류가 발생했습니다") + console.error(error) + } finally { + setIsUpdating(false) + } + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-md"> + <DialogHeader> + <DialogTitle className="flex items-center gap-2"> + <Edit className="w-5 h-5" /> + 문서 정보 수정 + </DialogTitle> + <DialogDescription> + {document ? `${document.docNumber}의 기본 정보를 수정합니다.` : "문서 기본 정보를 수정합니다."} + </DialogDescription> + </DialogHeader> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4"> + <FormField + control={form.control} + name="docNumber" + render={({ field }) => ( + <FormItem> + <FormLabel>문서번호</FormLabel> + <FormControl> + <Input {...field} disabled={projectType === "ship"} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="title" + render={({ field }) => ( + <FormItem> + <FormLabel>제목</FormLabel> + <FormControl> + <Input {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <div className="grid grid-cols-2 gap-4"> + <FormField + control={form.control} + name="pic" + render={({ field }) => ( + <FormItem> + <FormLabel>담당자 (PIC)</FormLabel> + <FormControl> + <Input {...field} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="status" + render={({ field }) => ( + <FormItem> + <FormLabel>상태</FormLabel> + <Select onValueChange={field.onChange} value={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue /> + </SelectTrigger> + </FormControl> + <SelectContent> + {statusOptions.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + </div> + + <FormField + control={form.control} + name="issuedDate" + render={({ field }) => ( + <FormItem> + <FormLabel>발행일</FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + className={cn( + "w-full pl-3 text-left font-normal", + !field.value && "text-muted-foreground" + )} + > + {field.value ? ( + format(field.value, "yyyy년 MM월 dd일", { locale: ko }) + ) : ( + <span>날짜를 선택하세요</span> + )} + <Calendar className="ml-auto h-4 w-4 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <CalendarComponent + mode="single" + selected={field.value} + onSelect={field.onChange} + disabled={(date) => date > new Date()} + initialFocus + /> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="description" + render={({ field }) => ( + <FormItem> + <FormLabel>설명 (선택)</FormLabel> + <FormControl> + <Textarea {...field} placeholder="문서에 대한 설명을 입력하세요" rows={3} /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <DialogFooter> + <Button + type="button" + variant="outline" + onClick={() => onOpenChange(false)} + disabled={isUpdating} + > + 취소 + </Button> + <Button type="submit" disabled={isUpdating}> + {isUpdating ? ( + <> + <Loader2 className="mr-2 h-4 w-4 animate-spin" /> + 저장 중... + </> + ) : ( + <> + <Edit className="mr-2 h-4 w-4" /> + 저장 + </> + )} + </Button> + </DialogFooter> + </form> + </Form> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/table/stage-revision-expanded-content.tsx b/lib/vendor-document-list/table/stage-revision-expanded-content.tsx new file mode 100644 index 00000000..c2395aa8 --- /dev/null +++ b/lib/vendor-document-list/table/stage-revision-expanded-content.tsx @@ -0,0 +1,719 @@ +"use client" + +import * as React from "react" +import { WebViewerInstance } from "@pdftron/webviewer" +import { formatDate } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { ScrollArea } from "@/components/ui/scroll-area" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog" +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table" +import { + FileText, + User, + Calendar, + Clock, + CheckCircle, + AlertTriangle, + ChevronDown, + ChevronRight, + Upload, + Eye, + Download, + FileIcon, + MoreHorizontal, + Loader2 +} from "lucide-react" +import { cn } from "@/lib/utils" +import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu" +import type { EnhancedDocument } from "@/types/enhanced-documents" + +// 유틸리티 함수들 +const getStatusColor = (status: string) => { + switch (status) { + case 'COMPLETED': case 'APPROVED': return 'bg-green-100 text-green-800' + case 'IN_PROGRESS': return 'bg-blue-100 text-blue-800' + case 'SUBMITTED': case 'UNDER_REVIEW': return 'bg-purple-100 text-purple-800' + case 'REJECTED': return 'bg-red-100 text-red-800' + default: return 'bg-gray-100 text-gray-800' + } +} + +const getPriorityColor = (priority: string) => { + switch (priority) { + case 'HIGH': return 'bg-red-100 text-red-800 border-red-200' + case 'MEDIUM': return 'bg-yellow-100 text-yellow-800 border-yellow-200' + case 'LOW': return 'bg-green-100 text-green-800 border-green-200' + default: return 'bg-gray-100 text-gray-800 border-gray-200' + } +} + +const getStatusText = (status: string) => { + switch (status) { + case 'PLANNED': return '계획됨' + case 'IN_PROGRESS': return '진행중' + case 'SUBMITTED': return '제출됨' + case 'UNDER_REVIEW': return '검토중' + case 'APPROVED': return '승인됨' + case 'REJECTED': return '반려됨' + case 'COMPLETED': return '완료됨' + default: return status + } +} + +const getPriorityText = (priority: string) => { + switch (priority) { + case 'HIGH': return '높음' + case 'MEDIUM': return '보통' + case 'LOW': return '낮음' + default: return priority + } +} + +const getFileIconColor = (fileName: string) => { + const ext = fileName.split('.').pop()?.toLowerCase() + switch(ext) { + case 'pdf': return 'text-red-500' + case 'doc': case 'docx': return 'text-blue-500' + case 'xls': case 'xlsx': return 'text-green-500' + case 'dwg': return 'text-amber-500' + default: return 'text-gray-500' + } +} + +interface StageRevisionExpandedContentProps { + document: EnhancedDocument + onUploadRevision: (documentData: EnhancedDocument, stageName?: string, currentRevision?: string, mode?: 'new' | 'append') => void + onStageStatusUpdate?: (stageId: number, status: string) => void + onRevisionStatusUpdate?: (revisionId: number, status: string) => void + projectType: "ship" | "plant" + expandedStages?: Record<number, boolean> + onStageToggle?: (stageId: number) => void +} + +export const StageRevisionExpandedContent = ({ + document: documentData, + onUploadRevision, + onStageStatusUpdate, + onRevisionStatusUpdate, + projectType, + expandedStages = {}, + onStageToggle, +}: StageRevisionExpandedContentProps) => { + // 로컬 상태 관리 + const [localExpandedStages, setLocalExpandedStages] = React.useState<Record<number, boolean>>({}) + const [expandedRevisions, setExpandedRevisions] = React.useState<Set<number>>(new Set()) + + // ✅ 문서 뷰어 상태 관리 + const [viewerOpen, setViewerOpen] = React.useState(false) + const [selectedRevisions, setSelectedRevisions] = React.useState<any[]>([]) + const [instance, setInstance] = React.useState<WebViewerInstance | null>(null) + const [viewerLoading, setViewerLoading] = React.useState(true) + const [fileSetLoading, setFileSetLoading] = React.useState(true) + const viewer = React.useRef<HTMLDivElement>(null) + const initialized = React.useRef(false) + const isCancelled = React.useRef(false) + + // 상위에서 관리하는지 로컬에서 관리하는지 결정 + const isExternallyManaged = onStageToggle !== undefined + const currentExpandedStages = isExternallyManaged ? expandedStages : localExpandedStages + + const handleStageToggle = React.useCallback((stageId: number) => { + if (isExternallyManaged && onStageToggle) { + onStageToggle(stageId) + } else { + setLocalExpandedStages(prev => ({ + ...prev, + [stageId]: !prev[stageId] + })) + } + }, [isExternallyManaged, onStageToggle]) + + const toggleRevisionFiles = React.useCallback((revisionId: number) => { + setExpandedRevisions(prev => { + const newSet = new Set(prev) + if (newSet.has(revisionId)) { + newSet.delete(revisionId) + } else { + newSet.add(revisionId) + } + return newSet + }) + }, []) + + // ✅ PDF 뷰어 정리 함수 + const cleanupHtmlStyle = React.useCallback(() => { + const htmlElement = window.document.documentElement + const originalStyle = htmlElement.getAttribute("style") || "" + const colorSchemeStyle = originalStyle + .split(";") + .map((s) => s.trim()) + .find((s) => s.startsWith("color-scheme:")) + + if (colorSchemeStyle) { + htmlElement.setAttribute("style", colorSchemeStyle + ";") + } else { + htmlElement.removeAttribute("style") + } + }, []) + + // ✅ 문서 뷰어 열기 함수 + const handleViewRevision = React.useCallback((revisions: any[]) => { + setSelectedRevisions(revisions) + setViewerOpen(true) + setViewerLoading(true) + setFileSetLoading(true) + initialized.current = false + }, []) + + // ✅ 파일 다운로드 함수 - 새로운 document-download API 사용 + const handleDownloadFile = React.useCallback(async (attachment: any) => { + console.log(attachment) + try { + // ID를 우선으로 사용, 없으면 filePath 사용 + const queryParam = attachment.id + ? `id=${encodeURIComponent(attachment.id)}` + : `path=${encodeURIComponent(attachment.filePath)}` + + const response = await fetch(`/api/document-download?${queryParam}`) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || '파일 다운로드에 실패했습니다.') + } + + const blob = await response.blob() + const url = window.URL.createObjectURL(blob) + const link = window.document.createElement('a') + link.href = url + link.download = attachment.fileName + window.document.body.appendChild(link) + link.click() + window.document.body.removeChild(link) + window.URL.revokeObjectURL(url) + + console.log('✅ 파일 다운로드 완료:', attachment.fileName) + } catch (error) { + console.error('❌ 파일 다운로드 오류:', error) + // 실제 앱에서는 toast나 alert로 에러 표시 + alert(`파일 다운로드 실패: ${error instanceof Error ? error.message : '알 수 없는 오류'}`) + } + }, []) + + // ✅ WebViewer 초기화 + React.useEffect(() => { + if (viewerOpen && !initialized.current) { + initialized.current = true + isCancelled.current = false + + requestAnimationFrame(() => { + if (viewer.current && !isCancelled.current) { + import("@pdftron/webviewer").then(({ default: WebViewer }) => { + if (isCancelled.current) { + console.log("📛 WebViewer 초기화 취소됨 (Dialog 닫힘)") + return + } + + WebViewer( + { + path: "/pdftronWeb", + licenseKey: "demo:1739264618684:616161d7030000000091db1c97c6f386d41d3506ab5b507381ef2ee2bd", + fullAPI: true, + css: "/globals.css", + }, + viewer.current as HTMLDivElement + ).then(async (instance: WebViewerInstance) => { + if (!isCancelled.current) { + setInstance(instance) + instance.UI.enableFeatures([instance.UI.Feature.MultiTab]) + instance.UI.disableElements(["addTabButton", "multiTabsEmptyPage"]) + setViewerLoading(false) + } + }) + }) + } + }) + } + + return () => { + if (instance) { + instance.UI.dispose() + } + setTimeout(() => cleanupHtmlStyle(), 500) + } + }, [viewerOpen, cleanupHtmlStyle]) + + // ✅ 문서 로드 + React.useEffect(() => { + const loadDocument = async () => { + if (instance && selectedRevisions.length > 0) { + const { UI } = instance + const optionsArray: any[] = [] + + selectedRevisions.forEach((revision) => { + const { attachments } = revision + attachments?.forEach((attachment: any) => { + const { fileName, filePath, fileType } = attachment + const fileTypeCur = fileType ?? "" + + const options = { + filename: fileName, + ...(fileTypeCur.includes("xlsx") && { + officeOptions: { + formatOptions: { + applyPageBreaksToSheet: true, + }, + }, + }), + } + + optionsArray.push({ filePath, options }) + }) + }) + + const tabIds = [] + for (const option of optionsArray) { + const { filePath, options } = option + try { + const response = await fetch(filePath) + const blob = await response.blob() + const tab = await UI.TabManager.addTab(blob, options) + tabIds.push(tab) + } catch (error) { + console.error("파일 로드 실패:", filePath, error) + } + } + + if (tabIds.length > 0) { + await UI.TabManager.setActiveTab(tabIds[0]) + } + + setFileSetLoading(false) + } + } + loadDocument() + }, [instance, selectedRevisions]) + + // ✅ 뷰어 닫기 + const handleCloseViewer = React.useCallback(async () => { + if (!fileSetLoading) { + isCancelled.current = true + + if (instance) { + try { + await instance.UI.dispose() + setInstance(null) + } catch (e) { + console.warn("dispose error", e) + } + } + + setViewerLoading(false) + setViewerOpen(false) + setTimeout(() => cleanupHtmlStyle(), 1000) + } + }, [fileSetLoading, instance, cleanupHtmlStyle]) + + // 뷰에서 가져온 allStages 데이터를 바로 사용 + const stagesWithRevisions = documentData.allStages || [] + + if (stagesWithRevisions.length === 0) { + return ( + <div className="p-6 text-center text-gray-500"> + <FileText className="w-12 h-12 mx-auto mb-4 text-gray-300" /> + <h4 className="font-medium mb-2">스테이지 정보가 없습니다</h4> + <p className="text-sm">이 문서에 대한 스테이지를 먼저 설정해주세요.</p> + </div> + ) + } + + return ( + <> + <div className="w-full max-w-none bg-gray-50" onClick={(e) => e.stopPropagation()}> + <div className="p-4"> + <div className="flex items-center justify-between mb-4"> + <div> + <h4 className="font-semibold flex items-center gap-2"> + <FileText className="w-4 h-4" /> + 스테이지별 리비전 현황 + </h4> + <p className="text-xs text-gray-600 mt-1"> + 총 {stagesWithRevisions.length}개 스테이지, {stagesWithRevisions.reduce((acc, stage) => acc + (stage.revisions?.length || 0), 0)}개 리비전 + </p> + </div> + {/* <Button + size="sm" + onClick={() => onUploadRevision(document, undefined, undefined, 'new')} + className="flex items-center gap-2" + > + <Upload className="w-3 h-3" /> + 새 리비전 업로드 + </Button> */} + </div> + + <ScrollArea className="h-[400px] w-full"> + <div className="space-y-3 pr-4"> + {stagesWithRevisions.map((stage) => { + const isExpanded = currentExpandedStages[stage.id] || false + const revisions = stage.revisions || [] + + return ( + <div key={stage.id} className="bg-white rounded border shadow-sm overflow-hidden"> + {/* 스테이지 헤더 */} + <div className="py-2 px-3 bg-gray-50 border-b"> + <div className="flex items-center justify-between"> + <div className="flex items-center gap-3"> + <button + className="flex items-center gap-2 hover:bg-gray-100 p-1 rounded transition-colors" + onClick={(e) => { + e.preventDefault() + e.stopPropagation() + handleStageToggle(stage.id) + }} + > + <div className="flex items-center gap-2"> + <div className="w-6 h-6 rounded-full bg-white border-2 border-gray-300 flex items-center justify-center text-xs font-medium"> + {stage.stageOrder || 1} + </div> + <div className={cn( + "w-2 h-2 rounded-full", + stage.stageStatus === 'COMPLETED' ? 'bg-green-500' : + stage.stageStatus === 'IN_PROGRESS' ? 'bg-blue-500' : + stage.stageStatus === 'SUBMITTED' ? 'bg-purple-500' : + 'bg-gray-300' + )} /> + {isExpanded ? + <ChevronDown className="w-3 h-3 text-gray-500" /> : + <ChevronRight className="w-3 h-3 text-gray-500" /> + } + </div> + </button> + + <div className="flex-1"> + <div className="flex items-center gap-2"> + <div className="font-medium text-sm">{stage.stageName}</div> + <Badge className={cn("text-xs", getStatusColor(stage.stageStatus))}> + {getStatusText(stage.stageStatus)} + </Badge> + <span className="text-xs text-gray-500"> + {revisions.length}개 리비전 + </span> + </div> + </div> + </div> + + <div className="flex items-center gap-4"> + <div className="grid grid-cols-2 gap-2 text-xs"> + <div> + <span className="text-gray-500">계획: </span> + <span className="font-medium">{stage.planDate ? formatDate(stage.planDate) : '-'}</span> + </div> + {stage.actualDate && ( + <div> + <span className="text-gray-500">완료: </span> + <span className="font-medium">{formatDate(stage.actualDate)}</span> + </div> + )} + {stage.assigneeName && ( + <div className="col-span-2 flex items-center gap-1 text-gray-600"> + <User className="w-3 h-3" /> + <span className="text-xs">{stage.assigneeName}</span> + </div> + )} + </div> + + {/* 스테이지 액션 메뉴 */} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Button + variant="ghost" + size="sm" + className="h-7 w-7 p-0" + > + <MoreHorizontal className="h-3 w-3" /> + </Button> + </DropdownMenuTrigger> + <DropdownMenuContent align="end"> + {onStageStatusUpdate && ( + <> + <DropdownMenuItem onClick={() => onStageStatusUpdate(stage.id, 'IN_PROGRESS')}> + 진행 시작 + </DropdownMenuItem> + <DropdownMenuItem onClick={() => onStageStatusUpdate(stage.id, 'COMPLETED')}> + 완료 처리 + </DropdownMenuItem> + </> + )} + <DropdownMenuItem onClick={() => onUploadRevision(documentData, stage.stageName)}> + 리비전 업로드 + </DropdownMenuItem> + {/* ✅ 스테이지에 첨부파일이 있는 리비전이 있을 때만 문서 보기 버튼 표시 */} + {revisions.some(rev => rev.attachments && rev.attachments.length > 0) && ( + <DropdownMenuItem onClick={() => handleViewRevision(revisions.filter(rev => rev.attachments && rev.attachments.length > 0))}> + <Eye className="w-3 h-3 mr-1" /> + 스테이지 문서 보기 + </DropdownMenuItem> + )} + </DropdownMenuContent> + </DropdownMenu> + </div> + </div> + </div> + + {/* 리비전 목록 - 테이블 형태 */} + {isExpanded && ( + <div className="max-h-72 overflow-y-auto"> + {revisions.length > 0 ? ( + <div className="border-t"> + <Table> + <TableHeader> + <TableRow className="bg-gray-50/50 h-8"> + <TableHead className="w-16 py-1 px-2 text-xs"></TableHead> + <TableHead className="w-16 py-1 px-2 text-xs">리비전</TableHead> + <TableHead className="w-20 py-1 px-2 text-xs">상태</TableHead> + <TableHead className="w-24 py-1 px-2 text-xs">업로더</TableHead> + <TableHead className="w-32 py-1 px-2 text-xs">제출일</TableHead> + <TableHead className="w-32 py-1 px-2 text-xs">승인/반려일</TableHead> + <TableHead className="min-w-[120px] py-1 px-2 text-xs">첨부파일</TableHead> + <TableHead className="w-16 py-1 px-2 text-xs">액션</TableHead> + <TableHead className="min-w-0 py-1 px-2 text-xs">코멘트</TableHead> + </TableRow> + </TableHeader> + <TableBody> + {revisions.map((revision) => { + const hasAttachments = revision.attachments && revision.attachments.length > 0 + + return ( + <TableRow key={revision.id} className="hover:bg-gray-50 h-10"> + {/* 리비전 */} + <TableCell className="py-1 px-2"> + <span className="text-xs font-semibold"> + {revision.uploaderType ==="vendor"?"To SHI":"From SHI"} + </span> + </TableCell> + + <TableCell className="py-1 px-2"> + <span className="font-mono text-xs font-semibold bg-gray-100 px-1.5 py-0.5 rounded"> + {revision.revision} + </span> + </TableCell> + + {/* 상태 */} + <TableCell className="py-1 px-2"> + <Badge className={cn("text-xs px-1.5 py-0.5", getStatusColor(revision.revisionStatus))}> + {getStatusText(revision.revisionStatus)} + </Badge> + </TableCell> + + {/* 업로더 */} + <TableCell className="py-1 px-2"> + <div className="flex items-center gap-1"> + <User className="w-3 h-3 text-gray-400" /> + <span className="text-xs truncate max-w-[60px]">{revision.uploaderName || '-'}</span> + </div> + </TableCell> + + {/* 제출일 */} + <TableCell className="py-1 px-2"> + <span className="text-xs text-gray-600"> + {revision.submittedDate ? formatDate(revision.submittedDate) : '-'} + </span> + </TableCell> + + {/* 승인/반려일 */} + <TableCell className="py-1 px-2"> + <div className="text-xs text-gray-600"> + {revision.approvedDate && ( + <div className="flex items-center gap-1 text-green-600"> + <CheckCircle className="w-3 h-3" /> + <span className="text-xs">{formatDate(revision.approvedDate)}</span> + </div> + )} + {revision.rejectedDate && ( + <div className="flex items-center gap-1 text-red-600"> + <AlertTriangle className="w-3 h-3" /> + <span className="text-xs">{formatDate(revision.rejectedDate)}</span> + </div> + )} + {revision.reviewStartDate && !revision.approvedDate && !revision.rejectedDate && ( + <div className="flex items-center gap-1 text-blue-600"> + <Clock className="w-3 h-3" /> + <span className="text-xs">{formatDate(revision.reviewStartDate)}</span> + </div> + )} + {!revision.approvedDate && !revision.rejectedDate && !revision.reviewStartDate && ( + <span className="text-gray-400 text-xs">-</span> + )} + </div> + </TableCell> + + {/* ✅ 첨부파일 - 클릭 시 다운로드, 별도 뷰어 버튼 */} + <TableCell className="py-1 px-2"> + {hasAttachments ? ( + <div className="flex items-center gap-1 flex-wrap"> + {/* 파일 아이콘들 - 클릭 시 다운로드 */} + {revision.attachments.slice(0, 4).map((file: any) => ( + <Button + key={file.id} + variant="ghost" + size="sm" + onClick={() => handleDownloadFile(file)} + className="p-0.5 h-auto hover:bg-blue-50 rounded" + title={`${file.fileName} - 클릭해서 다운로드`} + > + <FileIcon className={cn("w-3 h-3", getFileIconColor(file.fileName))} /> + </Button> + ))} + {revision.attachments.length > 4 && ( + <span + className="text-xs text-gray-500 ml-0.5" + title={`총 ${revision.attachments.length}개 파일`} + > + +{revision.attachments.length - 4} + </span> + )} + {/* ✅ 모든 파일 보기 버튼 - 뷰어 열기 */} + <Button + variant="ghost" + size="sm" + onClick={() => handleViewRevision([revision])} + className="p-0.5 h-auto hover:bg-green-50 rounded ml-1" + title="모든 파일 보기" + > + <Eye className="w-3 h-3 text-green-600" /> + </Button> + </div> + ) : ( + <span className="text-gray-400 text-xs">-</span> + )} + </TableCell> + + {/* 액션 */} + <TableCell className="py-1 px-2"> + <div className="flex gap-0.5"> + {revision.revisionStatus === 'UNDER_REVIEW' && onRevisionStatusUpdate && ( + <> + <Button + size="sm" + variant="ghost" + onClick={() => onRevisionStatusUpdate(revision.id, 'APPROVED')} + className="text-green-600 hover:bg-green-50 h-6 px-1" + title="승인" + > + <CheckCircle className="w-3 h-3" /> + </Button> + <Button + size="sm" + variant="ghost" + onClick={() => onRevisionStatusUpdate(revision.id, 'REJECTED')} + className="text-red-600 hover:bg-red-50 h-6 px-1" + title="반려" + > + <AlertTriangle className="w-3 h-3" /> + </Button> + </> + )} + <Button + size="sm" + variant="ghost" + onClick={() => onUploadRevision(documentData, stage.stageName, revision.revision, 'append')} + className="text-blue-600 hover:bg-blue-50 h-6 px-1" + title="파일 추가" + > + <Upload className="w-3 h-3" /> + </Button> + </div> + </TableCell> + + {/* 코멘트 */} + <TableCell className="py-1 px-2"> + {revision.comment ? ( + <div className="max-w-24"> + <p className="text-xs text-gray-700 bg-gray-50 p-1 rounded truncate" title={revision.comment}> + {revision.comment} + </p> + </div> + ) : ( + <span className="text-gray-400 text-xs">-</span> + )} + </TableCell> + </TableRow> + ) + })} + </TableBody> + </Table> + </div> + ) : ( + <div className="p-6 text-center"> + <div className="flex flex-col items-center gap-3"> + <div className="w-12 h-12 rounded-full bg-gray-100 flex items-center justify-center"> + <FileText className="w-6 h-6 text-gray-300" /> + </div> + <div> + <h5 className="font-medium text-gray-700 mb-1 text-sm">리비전이 없습니다</h5> + <p className="text-xs text-gray-500 mb-3">아직 이 스테이지에 업로드된 리비전이 없습니다</p> + <Button + size="sm" + onClick={() => onUploadRevision(documentData, stage.stageName, undefined, 'new')} + className="text-xs" + > + <Upload className="w-3 h-3 mr-1" /> + 첫 리비전 업로드 + </Button> + </div> + </div> + </div> + )} + </div> + )} + </div> + ) + })} + </div> + </ScrollArea> + </div> + </div> + + {/* ✅ 통합된 문서 뷰어 다이얼로그 */} + <Dialog open={viewerOpen} onOpenChange={handleCloseViewer}> + <DialogContent className="w-[90vw] h-[90vh]" style={{ maxWidth: "none" }}> + <DialogHeader className="h-[38px]"> + <DialogTitle>문서 미리보기</DialogTitle> + <DialogDescription> + {selectedRevisions.length === 1 + ? `리비전 ${selectedRevisions[0]?.revision} 첨부파일` + : `${selectedRevisions.length}개 리비전 첨부파일` + } + </DialogDescription> + </DialogHeader> + <div + ref={viewer} + style={{ height: "calc(90vh - 20px - 38px - 1rem - 48px)" }} + > + {viewerLoading && ( + <div className="flex flex-col items-center justify-center py-12"> + <Loader2 className="h-8 w-8 text-blue-500 animate-spin mb-4" /> + <p className="text-sm text-muted-foreground"> + 문서 뷰어 로딩 중... + </p> + </div> + )} + </div> + </DialogContent> + </Dialog> + </> + ) +}
\ No newline at end of file diff --git a/lib/vendor-document-list/table/stage-revision-sheet.tsx b/lib/vendor-document-list/table/stage-revision-sheet.tsx new file mode 100644 index 00000000..2cc22cce --- /dev/null +++ b/lib/vendor-document-list/table/stage-revision-sheet.tsx @@ -0,0 +1,86 @@ +// StageRevisionDrawer.tsx +// Slide‑up drawer (bottom) that shows StageRevisionExpandedContent. +// Requires shadcn/ui Drawer primitives already installed. + +"use client" + +import * as React from "react" +import { + Drawer, + DrawerContent, + DrawerHeader, + DrawerTitle, + DrawerDescription, +} from "@/components/ui/drawer" + +import type { EnhancedDocument } from "@/types/enhanced-documents" +import { StageRevisionExpandedContent } from "./stage-revision-expanded-content" + +export interface StageRevisionDrawerProps { + /** whether the drawer is open */ + open: boolean + /** callback invoked when the open state should change */ + onOpenChange: (open: boolean) => void + /** the document whose stages / revisions are displayed */ + document: EnhancedDocument | null + /** project type to propagate further */ + projectType: "ship" | "plant" + /** callbacks forwarded to StageRevisionExpandedContent */ + onUploadRevision: ( + doc: EnhancedDocument, + stageName?: string, + currentRevision?: string, + mode?: "new" | "append" + ) => void + onViewRevision: (revisions: any[]) => void + onStageStatusUpdate?: (stageId: number, status: string) => void + onRevisionStatusUpdate?: (revisionId: number, status: string) => void +} + +/** + * Bottom‑anchored Drawer that presents Stage / Revision details. + * Fills up to 85 vh and slides up from the bottom edge. + */ +export const StageRevisionDrawer: React.FC<StageRevisionDrawerProps> = ({ + open, + onOpenChange, + document, + projectType, + onUploadRevision, + onViewRevision, + onStageStatusUpdate, + onRevisionStatusUpdate, +}) => { + return ( + <Drawer open={open} onOpenChange={onOpenChange}> + {/* No trigger – controlled by parent */} + <DrawerContent className="h-[85vh] flex flex-col p-0"> + <DrawerHeader className="border-b p-4"> + <DrawerTitle>스테이지 / 리비전 상세</DrawerTitle> + {document && ( + <DrawerDescription className="text-xs text-muted-foreground truncate"> + {document.docNumber} — {document.title} + </DrawerDescription> + )} + </DrawerHeader> + + <div className="flex-1 overflow-auto"> + {document ? ( + <StageRevisionExpandedContent + document={document} + projectType={projectType} + onUploadRevision={onUploadRevision} + onViewRevision={onViewRevision} + onStageStatusUpdate={onStageStatusUpdate} + onRevisionStatusUpdate={onRevisionStatusUpdate} + /> + ) : ( + <div className="flex h-full items-center justify-center text-sm text-gray-500"> + 문서가 선택되지 않았습니다. + </div> + )} + </div> + </DrawerContent> + </Drawer> + ) +} diff --git a/lib/vendor-investigation/service.ts b/lib/vendor-investigation/service.ts index e3d03cd4..bcf9efd4 100644 --- a/lib/vendor-investigation/service.ts +++ b/lib/vendor-investigation/service.ts @@ -1,6 +1,6 @@ "use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) -import { vendorInvestigationAttachments, vendorInvestigations, vendorInvestigationsView } from "@/db/schema/vendors" +import { items, vendorInvestigationAttachments, vendorInvestigations, vendorInvestigationsView, vendorPossibleItems, vendors } from "@/db/schema/" import { GetVendorsInvestigationSchema, updateVendorInvestigationSchema } from "./validations" import { asc, desc, ilike, inArray, and, or, gte, lte, eq, isNull, count } from "drizzle-orm"; import { revalidateTag, unstable_noStore } from "next/cache"; @@ -13,54 +13,62 @@ import fs from "fs" import path from "path" import { v4 as uuid } from "uuid" import { vendorsLogs } from "@/db/schema"; +import { cache } from "react" export async function getVendorsInvestigation(input: GetVendorsInvestigationSchema) { return unstable_cache( async () => { try { const offset = (input.page - 1) * input.perPage - + // 1) Advanced filters const advancedWhere = filterColumns({ table: vendorInvestigationsView, filters: input.filters, joinOperator: input.joinOperator, }) - + // 2) Global search let globalWhere if (input.search) { const s = `%${input.search}%` globalWhere = or( + // 협력업체 정보 ilike(vendorInvestigationsView.vendorName, s), ilike(vendorInvestigationsView.vendorCode, s), + + // 담당자 정보 (새로 추가) + ilike(vendorInvestigationsView.requesterName, s), + ilike(vendorInvestigationsView.qmManagerName, s), + + // 실사 정보 ilike(vendorInvestigationsView.investigationNotes, s), - ilike(vendorInvestigationsView.vendorEmail, s) - // etc. + ilike(vendorInvestigationsView.investigationStatus, s), + ilike(vendorInvestigationsView.evaluationType, s), + ilike(vendorInvestigationsView.investigationAddress, s), + ilike(vendorInvestigationsView.investigationMethod, s), + + // 평가 결과 + ilike(vendorInvestigationsView.evaluationResult, s) ) } - // 3) Combine finalWhere - // Example: Only show vendorStatus = "PQ_SUBMITTED" const finalWhere = and( advancedWhere, - globalWhere, - // eq(vendorInvestigationsView.vendorStatus, "PQ_APPROVED") + globalWhere ) - - - - // 5) Sorting + + // 4) Sorting const orderBy = input.sort && input.sort.length > 0 ? input.sort.map((item) => - item.desc - ? desc(vendorInvestigationsView[item.id]) - : asc(vendorInvestigationsView[item.id]) - ) - : [desc(vendorInvestigationsView.investigationCreatedAt)] - - // 6) Query & count + item.desc + ? desc(vendorInvestigationsView[item.id]) + : asc(vendorInvestigationsView[item.id]) + ) + : [desc(vendorInvestigationsView.createdAt)] + + // 5) Query & count const { data, total } = await db.transaction(async (tx) => { // a) Select from the view const investigationsData = await tx @@ -70,7 +78,7 @@ export async function getVendorsInvestigation(input: GetVendorsInvestigationSche .orderBy(...orderBy) .offset(offset) .limit(input.perPage) - + // b) Count total const resCount = await tx .select({ count: count() }) @@ -79,14 +87,11 @@ export async function getVendorsInvestigation(input: GetVendorsInvestigationSche return { data: investigationsData, total: resCount[0]?.count } }) - - // 7) Calculate pageCount + + // 6) Calculate pageCount const pageCount = Math.ceil(total / input.perPage) - - console.log(data,"data") - - // Now 'data' already contains JSON arrays of contacts & items - // thanks to the subqueries in the view definition! + + // Data is already in the correct format from the simplified view return { data, pageCount } } catch (err) { console.error(err) @@ -101,8 +106,6 @@ export async function getVendorsInvestigation(input: GetVendorsInvestigationSche } )() } - - /** * Get existing investigations for a list of vendor IDs * @@ -116,7 +119,7 @@ export async function getExistingInvestigationsForVendors(vendorIds: number[]) { // Query the vendorInvestigationsView using the vendorIds const investigations = await db.query.vendorInvestigations.findMany({ where: inArray(vendorInvestigationsView.vendorId, vendorIds), - orderBy: [desc(vendorInvestigationsView.investigationCreatedAt)], + orderBy: [desc(vendorInvestigationsView.createdAt)], }) return investigations @@ -188,9 +191,10 @@ export async function requestInvestigateVendors({ } +// 개선된 서버 액션 - 텍스트 데이터만 처리 export async function updateVendorInvestigationAction(formData: FormData) { try { - // 1) Separate text fields from file fields + // 1) 텍스트 필드만 추출 const textEntries: Record<string, string> = {} for (const [key, value] of formData.entries()) { if (typeof value === "string") { @@ -198,69 +202,432 @@ export async function updateVendorInvestigationAction(formData: FormData) { } } - // 2) Convert text-based "investigationId" to a number + // 2) 적절한 타입으로 변환 + const processedEntries: any = {} + + // 필수 필드 if (textEntries.investigationId) { - textEntries.investigationId = String(Number(textEntries.investigationId)) + processedEntries.investigationId = Number(textEntries.investigationId) + } + if (textEntries.investigationStatus) { + processedEntries.investigationStatus = textEntries.investigationStatus } - // 3) Parse/validate with Zod - const parsed = updateVendorInvestigationSchema.parse(textEntries) - // parsed is type UpdateVendorInvestigationSchema + // 선택적 enum 필드 + if (textEntries.evaluationType) { + processedEntries.evaluationType = textEntries.evaluationType + } - // 4) Update the vendor_investigations table - await db - .update(vendorInvestigations) - .set({ - investigationStatus: parsed.investigationStatus, - scheduledStartAt: parsed.scheduledStartAt - ? new Date(parsed.scheduledStartAt) - : null, - scheduledEndAt: parsed.scheduledEndAt ? new Date(parsed.scheduledEndAt) : null, - completedAt: parsed.completedAt ? new Date(parsed.completedAt) : null, - investigationNotes: parsed.investigationNotes ?? "", - updatedAt: new Date(), - }) - .where(eq(vendorInvestigations.id, parsed.investigationId)) + // 선택적 문자열 필드 + if (textEntries.investigationAddress) { + processedEntries.investigationAddress = textEntries.investigationAddress + } + if (textEntries.investigationMethod) { + processedEntries.investigationMethod = textEntries.investigationMethod + } + if (textEntries.investigationNotes) { + processedEntries.investigationNotes = textEntries.investigationNotes + } - // 5) Handle file attachments - // formData.getAll("attachments") can contain multiple files - const files = formData.getAll("attachments") as File[] + // 선택적 날짜 필드 + if (textEntries.forecastedAt) { + processedEntries.forecastedAt = new Date(textEntries.forecastedAt) + } + if (textEntries.requestedAt) { + processedEntries.requestedAt = new Date(textEntries.requestedAt) + } + if (textEntries.confirmedAt) { + processedEntries.confirmedAt = new Date(textEntries.confirmedAt) + } + if (textEntries.completedAt) { + processedEntries.completedAt = new Date(textEntries.completedAt) + } - // Make sure the folder exists - const uploadDir = path.join(process.cwd(), "public", "vendor-investigation") - if (!fs.existsSync(uploadDir)) { - fs.mkdirSync(uploadDir, { recursive: true }) + // 선택적 숫자 필드 + if (textEntries.evaluationScore) { + processedEntries.evaluationScore = Number(textEntries.evaluationScore) } - for (const file of files) { - if (file && file.size > 0) { - // Create a unique filename - const ext = path.extname(file.name) // e.g. ".pdf" - const newFileName = `${uuid()}${ext}` + // 선택적 평가 결과 + if (textEntries.evaluationResult) { + processedEntries.evaluationResult = textEntries.evaluationResult + } - const filePath = path.join(uploadDir, newFileName) + // 3) Zod로 파싱/검증 + const parsed = updateVendorInvestigationSchema.parse(processedEntries) - // 6) Write file to disk - const arrayBuffer = await file.arrayBuffer() - const buffer = Buffer.from(arrayBuffer) - fs.writeFileSync(filePath, buffer) + // 4) 업데이트 데이터 준비 - 실제로 제공된 필드만 포함 + const updateData: any = { + investigationStatus: parsed.investigationStatus, + updatedAt: new Date(), + } - // 7) Insert a record in vendor_investigation_attachments - await db.insert(vendorInvestigationAttachments).values({ - investigationId: parsed.investigationId, - fileName: file.name, // original name - filePath: `/vendor-investigation/${newFileName}`, // relative path in public/ - attachmentType: "REPORT", // or user-specified - }) - } + // 선택적 필드들은 존재할 때만 추가 + if (parsed.evaluationType !== undefined) { + updateData.evaluationType = parsed.evaluationType + } + if (parsed.investigationAddress !== undefined) { + updateData.investigationAddress = parsed.investigationAddress + } + if (parsed.investigationMethod !== undefined) { + updateData.investigationMethod = parsed.investigationMethod + } + if (parsed.forecastedAt !== undefined) { + updateData.forecastedAt = parsed.forecastedAt + } + if (parsed.requestedAt !== undefined) { + updateData.requestedAt = parsed.requestedAt + } + if (parsed.confirmedAt !== undefined) { + updateData.confirmedAt = parsed.confirmedAt + } + if (parsed.completedAt !== undefined) { + updateData.completedAt = parsed.completedAt + } + if (parsed.evaluationScore !== undefined) { + updateData.evaluationScore = parsed.evaluationScore + } + if (parsed.evaluationResult !== undefined) { + updateData.evaluationResult = parsed.evaluationResult + } + if (parsed.investigationNotes !== undefined) { + updateData.investigationNotes = parsed.investigationNotes } - // Revalidate anything if needed + // 5) vendor_investigations 테이블 업데이트 + await db + .update(vendorInvestigations) + .set(updateData) + .where(eq(vendorInvestigations.id, parsed.investigationId)) + + // 6) 캐시 무효화 revalidateTag("vendors-in-investigation") return { data: "OK", error: null } } catch (err: unknown) { + console.error("Investigation update error:", err) const message = err instanceof Error ? err.message : String(err) return { error: message } } -}
\ No newline at end of file +} +// 실사 첨부파일 조회 함수 +export async function getInvestigationAttachments(investigationId: number) { + try { + const attachments = await db + .select() + .from(vendorInvestigationAttachments) + .where(eq(vendorInvestigationAttachments.investigationId, investigationId)) + .orderBy(vendorInvestigationAttachments.createdAt) + + return { success: true, attachments } + } catch (error) { + console.error("첨부파일 조회 실패:", error) + return { success: false, error: "첨부파일 조회에 실패했습니다.", attachments: [] } + } +} + +// 첨부파일 삭제 함수 +export async function deleteInvestigationAttachment(attachmentId: number) { + try { + // 파일 정보 조회 + const [attachment] = await db + .select() + .from(vendorInvestigationAttachments) + .where(eq(vendorInvestigationAttachments.id, attachmentId)) + .limit(1) + + if (!attachment) { + return { success: false, error: "첨부파일을 찾을 수 없습니다." } + } + + // 실제 파일 삭제 + const fullFilePath = path.join(process.cwd(), "public", attachment.filePath) + if (fs.existsSync(fullFilePath)) { + fs.unlinkSync(fullFilePath) + } + + // 데이터베이스에서 레코드 삭제 + await db + .delete(vendorInvestigationAttachments) + .where(eq(vendorInvestigationAttachments.id, attachmentId)) + + // 캐시 무효화 + revalidateTag("vendors-in-investigation") + + return { success: true } + } catch (error) { + console.error("첨부파일 삭제 실패:", error) + return { success: false, error: "첨부파일 삭제에 실패했습니다." } + } +} + +// 첨부파일 다운로드 정보 조회 +export async function getAttachmentDownloadInfo(attachmentId: number) { + try { + const [attachment] = await db + .select({ + fileName: vendorInvestigationAttachments.fileName, + filePath: vendorInvestigationAttachments.filePath, + mimeType: vendorInvestigationAttachments.mimeType, + fileSize: vendorInvestigationAttachments.fileSize, + }) + .from(vendorInvestigationAttachments) + .where(eq(vendorInvestigationAttachments.id, attachmentId)) + .limit(1) + + if (!attachment) { + return { success: false, error: "첨부파일을 찾을 수 없습니다." } + } + + const fullFilePath = path.join(process.cwd(), "public", attachment.filePath) + if (!fs.existsSync(fullFilePath)) { + return { success: false, error: "파일이 존재하지 않습니다." } + } + + return { + success: true, + downloadInfo: { + fileName: attachment.fileName, + filePath: attachment.filePath, + mimeType: attachment.mimeType, + fileSize: attachment.fileSize, + } + } + } catch (error) { + console.error("첨부파일 정보 조회 실패:", error) + return { success: false, error: "첨부파일 정보 조회에 실패했습니다." } + } +} +/** + * Get vendor details by ID + */ +export const getVendorById = cache(async (vendorId: number) => { + try { + const [vendorData] = await db + .select({ + id: vendors.id, + name: vendors.vendorName, + code: vendors.vendorCode, + taxId: vendors.taxId, + email: vendors.email, + phone: vendors.phone, + website: vendors.website, + address: vendors.address, + country: vendors.country, + status: vendors.status, + description: vendors.items, // Using items field as description for now + vendorTypeId: vendors.vendorTypeId, + representativeName: vendors.representativeName, + representativeBirth: vendors.representativeBirth, + representativeEmail: vendors.representativeEmail, + representativePhone: vendors.representativePhone, + corporateRegistrationNumber: vendors.corporateRegistrationNumber, + creditAgency: vendors.creditAgency, + creditRating: vendors.creditRating, + cashFlowRating: vendors.cashFlowRating, + businessSize: vendors.businessSize, + createdAt: vendors.createdAt, + updatedAt: vendors.updatedAt, + }) + .from(vendors) + .where(eq(vendors.id, vendorId)) + .limit(1) + + if (!vendorData) { + throw new Error(`Vendor with ID ${vendorId} not found`) + } + + return vendorData + } catch (error) { + console.error("Error fetching vendor:", error) + throw new Error("Failed to fetch vendor details") + } +}) + +/** + * Get vendor items by vendor ID with caching + */ +export async function getVendorItemsByVendorId(vendorId: number) { + return unstable_cache( + async () => { + try { + // Join vendorPossibleItems with items table to get complete item information + const vendorItems = await db + .select({ + id: vendorPossibleItems.id, + vendorId: vendorPossibleItems.vendorId, + itemCode: vendorPossibleItems.itemCode, + itemName: items.itemName, + description: items.description, + createdAt: vendorPossibleItems.createdAt, + updatedAt: vendorPossibleItems.updatedAt, + }) + .from(vendorPossibleItems) + .leftJoin( + items, + eq(vendorPossibleItems.itemCode, items.itemCode) + ) + .where(eq(vendorPossibleItems.vendorId, vendorId)) + .orderBy(vendorPossibleItems.createdAt) + + return vendorItems + } catch (error) { + console.error("Error fetching vendor items:", error) + throw new Error("Failed to fetch vendor items") + } + }, + // Cache key + [`vendor-items-${vendorId}`], + { + revalidate: 3600, // Cache for 1 hour + tags: [`vendor-items-${vendorId}`, "vendor-items"], + } + )() +} + +/** + * Get all items for a vendor (alternative function name for clarity) + */ +export const getVendorPossibleItems = cache(async (vendorId: number) => { + return getVendorItemsByVendorId(vendorId) +}) + +/** + * Get vendor contacts by vendor ID + * This function assumes you have a vendorContacts table + */ +export const getVendorContacts = cache(async (vendorId: number) => { + try { + // Note: This assumes you have a vendorContacts table + // If you don't have this table yet, you can return an empty array + // or implement based on your actual contacts storage structure + + // For now, returning empty array since vendorContacts table wasn't provided + return [] + + /* + // Uncomment and modify when you have vendorContacts table: + const contacts = await db + .select({ + id: vendorContacts.id, + contactName: vendorContacts.name, + contactEmail: vendorContacts.email, + contactPhone: vendorContacts.phone, + contactPosition: vendorContacts.position, + isPrimary: vendorContacts.isPrimary, + isActive: vendorContacts.isActive, + createdAt: vendorContacts.createdAt, + updatedAt: vendorContacts.updatedAt, + }) + .from(vendorContacts) + .where( + and( + eq(vendorContacts.vendorId, vendorId), + eq(vendorContacts.isActive, true) + ) + ) + + return contacts + */ + } catch (error) { + console.error("Error fetching vendor contacts:", error) + return [] + } +}) + +/** + * Add an item to a vendor + */ +export async function addVendorItem(vendorId: number, itemCode: string) { + try { + // Check if the item exists + const [item] = await db + .select() + .from(items) + .where(eq(items.itemCode, itemCode)) + .limit(1) + + if (!item) { + throw new Error(`Item with code ${itemCode} not found`) + } + + // Check if the vendor-item relationship already exists + const [existingRelation] = await db + .select() + .from(vendorPossibleItems) + .where( + eq(vendorPossibleItems.vendorId, vendorId) && + eq(vendorPossibleItems.itemCode, itemCode) + ) + .limit(1) + + if (existingRelation) { + throw new Error("This item is already associated with the vendor") + } + + // Add the item to the vendor + const [newVendorItem] = await db + .insert(vendorPossibleItems) + .values({ + vendorId, + itemCode, + }) + .returning() + + // Revalidate cache + revalidateTag(`vendor-items-${vendorId}`) + revalidateTag("vendor-items") + + return newVendorItem + } catch (error) { + console.error("Error adding vendor item:", error) + throw new Error("Failed to add item to vendor") + } +} + +/** + * Remove an item from a vendor + */ +export async function removeVendorItem(vendorId: number, itemCode: string) { + try { + await db + .delete(vendorPossibleItems) + .where( + eq(vendorPossibleItems.vendorId, vendorId) && + eq(vendorPossibleItems.itemCode, itemCode) + ) + + // Revalidate cache + revalidateTag(`vendor-items-${vendorId}`) + revalidateTag("vendor-items") + + return { success: true } + } catch (error) { + console.error("Error removing vendor item:", error) + throw new Error("Failed to remove item from vendor") + } +} + +/** + * Get all available items (for adding to vendors) + */ +export const getAllItems = cache(async () => { + try { + const allItems = await db + .select({ + id: items.id, + itemCode: items.itemCode, + itemName: items.itemName, + description: items.description, + createdAt: items.createdAt, + updatedAt: items.updatedAt, + }) + .from(items) + .orderBy(items.itemName) + + return allItems + } catch (error) { + console.error("Error fetching all items:", error) + throw new Error("Failed to fetch items") + } +})
\ No newline at end of file diff --git a/lib/vendor-investigation/table/contract-dialog.tsx b/lib/vendor-investigation/table/contract-dialog.tsx deleted file mode 100644 index 28e6963b..00000000 --- a/lib/vendor-investigation/table/contract-dialog.tsx +++ /dev/null @@ -1,85 +0,0 @@ -"use client" - -import * as React from "react" -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogFooter, -} from "@/components/ui/dialog" -import { Button } from "@/components/ui/button" -import { Avatar } from "@/components/ui/avatar" -import { ScrollArea } from "@/components/ui/scroll-area" -import { ContactItem } from "@/config/vendorInvestigationsColumnsConfig" - -interface ContactsDialogProps { - open: boolean - onOpenChange: (open: boolean) => void - investigationId: number | null - contacts: ContactItem[] -} - -export function ContactsDialog({ - open, - onOpenChange, - investigationId, - contacts, -}: ContactsDialogProps) { - return ( - <Dialog open={open} onOpenChange={onOpenChange}> - <DialogContent className="sm:max-w-md"> - <DialogHeader> - <DialogTitle>Vendor Contacts</DialogTitle> - <DialogDescription> - {contacts.length > 0 - ? `Showing ${contacts.length} contacts for investigation #${investigationId}` - : `No contacts found for investigation #${investigationId}`} - </DialogDescription> - </DialogHeader> - <ScrollArea className="max-h-[60vh] pr-4"> - {contacts.length > 0 ? ( - <div className="space-y-4"> - {contacts.map((contact, index) => ( - <div - key={index} - className="flex items-start gap-4 p-3 rounded-lg border" - > - <Avatar className="w-10 h-10"> - <span>{contact.contactName?.charAt(0) || "C"}</span> - </Avatar> - <div className="flex-1 space-y-1"> - <p className="font-medium">{contact.contactName || "Unnamed"}</p> - {contact.contactEmail && ( - <p className="text-sm text-muted-foreground"> - {contact.contactEmail} - </p> - )} - {contact.contactPhone && ( - <p className="text-sm text-muted-foreground"> - {contact.contactPhone} - </p> - )} - {contact.contactPosition && ( - <p className="text-sm text-muted-foreground"> - Position: {contact.contactPosition} - </p> - )} - </div> - </div> - ))} - </div> - ) : ( - <div className="text-center py-6 text-muted-foreground"> - No contacts available - </div> - )} - </ScrollArea> - <DialogFooter> - <Button onClick={() => onOpenChange(false)}>Close</Button> - </DialogFooter> - </DialogContent> - </Dialog> - ) -}
\ No newline at end of file diff --git a/lib/vendor-investigation/table/investigation-table-columns.tsx b/lib/vendor-investigation/table/investigation-table-columns.tsx index fd76a9a5..6146d940 100644 --- a/lib/vendor-investigation/table/investigation-table-columns.tsx +++ b/lib/vendor-investigation/table/investigation-table-columns.tsx @@ -5,31 +5,30 @@ import { ColumnDef } from "@tanstack/react-table" import { Checkbox } from "@/components/ui/checkbox" import { Button } from "@/components/ui/button" import { Badge } from "@/components/ui/badge" -import { Ellipsis, Users, Boxes } from "lucide-react" -// import { toast } from "sonner" // If needed +import { Edit, Ellipsis } from "lucide-react" import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" -import { formatDate } from "@/lib/utils" // or your date util +import { formatDate } from "@/lib/utils" -// Example: If you have a type for row actions +// Import types import { type DataTableRowAction } from "@/types/table" -import { ContactItem, PossibleItem, vendorInvestigationsColumnsConfig, VendorInvestigationsViewWithContacts } from "@/config/vendorInvestigationsColumnsConfig" +import { + vendorInvestigationsColumnsConfig, + VendorInvestigationsViewWithContacts +} from "@/config/vendorInvestigationsColumnsConfig" -// Props that define how we handle special columns (contacts, items, actions, etc.) +// Props for the column generator function interface GetVendorInvestigationsColumnsProps { setRowAction?: React.Dispatch< React.SetStateAction< DataTableRowAction<VendorInvestigationsViewWithContacts> | null > > - openContactsModal?: (investigationId: number, contacts: ContactItem[]) => void - openItemsDrawer?: (investigationId: number, items: PossibleItem[]) => void + openVendorDetailsModal?: (vendorId: number) => void } -// This function returns the array of columns for TanStack Table export function getColumns({ setRowAction, - openContactsModal, - openItemsDrawer, + openVendorDetailsModal, }: GetVendorInvestigationsColumnsProps): ColumnDef< VendorInvestigationsViewWithContacts >[] { @@ -63,25 +62,22 @@ export function getColumns({ } // -------------------------------------------- - // 2) Actions column (optional) + // 2) Actions column // -------------------------------------------- const actionsColumn: ColumnDef<VendorInvestigationsViewWithContacts> = { id: "actions", enableHiding: false, cell: ({ row }) => { - const inv = row.original - return ( <Button variant="ghost" className="flex size-8 p-0 data-[state=open]:bg-muted" - aria-label="Open menu" + aria-label="실사 정보 수정" onClick={() => { - // e.g. open a dropdown or set your row action setRowAction?.({ type: "update", row }) }} > - <Ellipsis className="size-4" aria-hidden="true" /> + <Edit className="size-4" aria-hidden="true" /> </Button> ) }, @@ -89,97 +85,44 @@ export function getColumns({ } // -------------------------------------------- - // 3) Contacts column (badge count -> open modal) + // 3) Vendor Name with click handler // -------------------------------------------- - const contactsColumn: ColumnDef<VendorInvestigationsViewWithContacts> = { - id: "contacts", - header: "Contacts", + const vendorNameColumn: ColumnDef<VendorInvestigationsViewWithContacts> = { + accessorKey: "vendorName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="협력사명" /> + ), cell: ({ row }) => { - const { contacts, investigationId } = row.original - const count = contacts?.length ?? 0 - - const handleClick = () => { - openContactsModal?.(investigationId, contacts) - } + const vendorId = row.original.vendorId + const vendorName = row.getValue("vendorName") as string return ( <Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={handleClick} - aria-label={ - count > 0 ? `View ${count} contacts` : "Add contacts" - } + variant="link" + className="p-0 h-auto font-normal" + onClick={() => openVendorDetailsModal?.(vendorId)} > - <Users className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - {count > 0 && ( - <Badge - variant="secondary" - className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center" - > - {count} - </Badge> - )} - <span className="sr-only"> - {count > 0 ? `${count} Contacts` : "Add Contacts"} - </span> + {vendorName} </Button> ) }, - enableSorting: false, - size: 60, - } - - // -------------------------------------------- - // 4) Possible Items column (badge count -> open drawer) - // -------------------------------------------- - const possibleItemsColumn: ColumnDef<VendorInvestigationsViewWithContacts> = { - id: "possibleItems", - header: "Items", - cell: ({ row }) => { - const { possibleItems, investigationId } = row.original - const count = possibleItems?.length ?? 0 - - const handleClick = () => { - openItemsDrawer?.(investigationId, possibleItems) - } - - return ( - <Button - variant="ghost" - size="sm" - className="relative h-8 w-8 p-0 group" - onClick={handleClick} - aria-label={ - count > 0 ? `View ${count} items` : "Add items" - } - > - <Boxes className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" /> - {count > 0 && ( - <Badge - variant="secondary" - className="pointer-events-none absolute -top-1 -right-1 h-4 min-w-[1rem] p-0 text-[0.625rem] leading-none flex items-center justify-center" - > - {count} - </Badge> - )} - <span className="sr-only"> - {count > 0 ? `${count} Items` : "Add Items"} - </span> - </Button> - ) + meta: { + excelHeader: "협력사명", + group: "협력업체", }, - enableSorting: false, - size: 60, } // -------------------------------------------- - // 5) Build "grouped" columns from config + // 4) Build grouped columns from config // -------------------------------------------- const groupMap: Record<string, ColumnDef<VendorInvestigationsViewWithContacts>[]> = {} vendorInvestigationsColumnsConfig.forEach((cfg) => { + // Skip vendorName as we have a custom column for it + if (cfg.id === "vendorName") { + return + } + const groupName = cfg.group || "_noGroup" if (!groupMap[groupName]) { @@ -196,34 +139,120 @@ export function getColumns({ group: cfg.group, type: cfg.type, }, - cell: ({ row, cell }) => { - const val = cell.getValue() - - // Example: Format date fields + cell: ({ row, column }) => { + const value = row.getValue(column.id) + + // Handle date fields if ( - cfg.id === "investigationCreatedAt" || - cfg.id === "investigationUpdatedAt" || - cfg.id === "scheduledStartAt" || - cfg.id === "scheduledEndAt" || - cfg.id === "completedAt" + column.id === "scheduledStartAt" || + column.id === "scheduledEndAt" || + column.id === "forecastedAt" || + column.id === "requestedAt" || + column.id === "confirmedAt" || + column.id === "completedAt" || + column.id === "createdAt" || + column.id === "updatedAt" ) { - const dateVal = val ? new Date(val as string) : null - return dateVal ? formatDate(dateVal) : "" + if (!value) return "" + return formatDate(new Date(value as string), "KR") + } + + // Handle status fields with badges + if (column.id === "investigationStatus") { + if (!value) return "" + + return ( + <Badge variant={getStatusVariant(value as string)}> + {formatStatus(value as string)} + </Badge> + ) + } + + // Handle evaluation type + if (column.id === "evaluationType") { + if (!value) return "" + + return ( + <span> + {formatEnumValue(value as string)} + </span> + ) + } + + // Handle evaluation result + if (column.id === "evaluationResult") { + if (!value) return "" + + return ( + <Badge variant={getResultVariant(value as string)}> + {formatEnumValue(value as string)} + </Badge> + ) + } + + // Handle IDs for pqSubmissionId (keeping for reference) + if (column.id === "pqSubmissionId") { + return value ? `#${value}` : "" + } + + // Handle file attachment status + if (column.id === "hasAttachments") { + return ( + <div className="flex items-center justify-center"> + {value ? ( + <Badge variant="default" className="text-xs"> + 📎 첨부 + </Badge> + ) : ( + <span className="text-muted-foreground text-xs">-</span> + )} + </div> + ) } - // Example: You could show an icon for "investigationStatus" - if (cfg.id === "investigationStatus") { - return <span className="capitalize">{val as string}</span> + if (column.id === "requesterName") { + if (!value && !row.original.requesterEmail) { + return <span className="text-muted-foreground">미배정</span> + } + + return ( + <div className="flex flex-col"> + <span>{value || "미배정"}</span> + {row.original.requesterEmail && ( + <span className="text-xs text-muted-foreground">{row.original.requesterEmail}</span> + )} + </div> + ) + } + + if (column.id === "qmManagerName") { + if (!value && !row.original.qmManagerEmail) { + return <span className="text-muted-foreground">미배정</span> + } + + return ( + <div className="flex flex-col"> + <span>{value || "미배정"}</span> + {row.original.qmManagerEmail && ( + <span className="text-xs text-muted-foreground">{row.original.qmManagerEmail}</span> + )} + </div> + ) } - return val ?? "" + return value ?? "" }, } groupMap[groupName].push(childCol) }) - // Turn the groupMap into nested columns + // Insert custom vendorNameColumn in the 협력업체 group + if (groupMap["협력업체"]) { + groupMap["협력업체"].unshift(vendorNameColumn) + } + + // Convert the groupMap into nested columns const nestedColumns: ColumnDef<VendorInvestigationsViewWithContacts>[] = [] for (const [groupName, colDefs] of Object.entries(groupMap)) { if (groupName === "_noGroup") { @@ -238,14 +267,76 @@ export function getColumns({ } // -------------------------------------------- - // 6) Return final columns array - // (You can reorder these as you wish.) + // 5) Return final columns array (simplified) // -------------------------------------------- return [ selectColumn, ...nestedColumns, - contactsColumn, - possibleItemsColumn, actionsColumn, ] +} + +// Helper functions for formatting +function formatStatus(status: string): string { + switch (status) { + case "PLANNED": + return "계획됨" + case "IN_PROGRESS": + return "진행 중" + case "COMPLETED": + return "완료됨" + case "CANCELED": + return "취소됨" + default: + return status + } +} + +function formatEnumValue(value: string): string { + switch (value) { + // Evaluation types + case "SITE_AUDIT": + return "실사의뢰평가" + case "QM_SELF_AUDIT": + return "QM자체평가" + + // Evaluation results + case "APPROVED": + return "승인" + case "SUPPLEMENT": + return "보완" + case "REJECTED": + return "불가" + + default: + return value.replace(/_/g, " ").toLowerCase() + } +} + +function getStatusVariant(status: string): "default" | "secondary" | "outline" | "destructive" { + switch (status) { + case "PLANNED": + return "secondary" + case "IN_PROGRESS": + return "default" + case "COMPLETED": + return "outline" + case "CANCELED": + return "destructive" + default: + return "default" + } +} + +function getResultVariant(result: string): "default" | "secondary" | "outline" | "destructive" { + switch (result) { + case "APPROVED": + return "default" + case "SUPPLEMENT": + return "secondary" + case "REJECTED": + return "destructive" + default: + return "outline" + } }
\ No newline at end of file diff --git a/lib/vendor-investigation/table/investigation-table.tsx b/lib/vendor-investigation/table/investigation-table.tsx index 56aa7962..40b849fc 100644 --- a/lib/vendor-investigation/table/investigation-table.tsx +++ b/lib/vendor-investigation/table/investigation-table.tsx @@ -15,14 +15,9 @@ import { useFeatureFlags } from "./feature-flags-provider" import { getColumns } from "./investigation-table-columns" import { getVendorsInvestigation } from "../service" import { VendorsTableToolbarActions } from "./investigation-table-toolbar-actions" -import { - VendorInvestigationsViewWithContacts, - ContactItem, - PossibleItem -} from "@/config/vendorInvestigationsColumnsConfig" +import { VendorInvestigationsViewWithContacts } from "@/config/vendorInvestigationsColumnsConfig" import { UpdateVendorInvestigationSheet } from "./update-investigation-sheet" -import { ItemsDrawer } from "./items-dialog" -import { ContactsDialog } from "./contract-dialog" +import { VendorDetailsDialog } from "./vendor-details-dialog" interface VendorsTableProps { promises: Promise< @@ -38,38 +33,13 @@ export function VendorsInvestigationTable({ promises }: VendorsTableProps) { // Get data from Suspense const [rawResponse] = React.use(promises) - // Transform the data to match the expected types + // Transform the data to match the expected types (simplified) const transformedData: VendorInvestigationsViewWithContacts[] = React.useMemo(() => { return rawResponse.data.map(item => { - // Parse contacts field if it's a string - let contacts: ContactItem[] = [] - if (typeof item.contacts === 'string') { - try { - contacts = JSON.parse(item.contacts) as ContactItem[] - } catch (e) { - console.error('Failed to parse contacts:', e) - } - } else if (Array.isArray(item.contacts)) { - contacts = item.contacts - } - - // Parse possibleItems field if it's a string - let possibleItems: PossibleItem[] = [] - if (typeof item.possibleItems === 'string') { - try { - possibleItems = JSON.parse(item.possibleItems) as PossibleItem[] - } catch (e) { - console.error('Failed to parse possibleItems:', e) - } - } else if (Array.isArray(item.possibleItems)) { - possibleItems = item.possibleItems - } - - // Return a new object with the transformed fields + // Add id field for backward compatibility (maps to investigationId) return { ...item, - contacts, - possibleItems + id: item.investigationId, // Map investigationId to id for backward compatibility } as VendorInvestigationsViewWithContacts }) }, [rawResponse.data]) @@ -81,51 +51,102 @@ export function VendorsInvestigationTable({ promises }: VendorsTableProps) { // Add state for row actions const [rowAction, setRowAction] = React.useState<DataTableRowAction<VendorInvestigationsViewWithContacts> | null>(null) - // Add state for contacts dialog - const [contactsDialogOpen, setContactsDialogOpen] = React.useState(false) - const [selectedContacts, setSelectedContacts] = React.useState<ContactItem[]>([]) - const [selectedContactInvestigationId, setSelectedContactInvestigationId] = React.useState<number | null>(null) + // Add state for vendor details dialog + const [vendorDetailsOpen, setVendorDetailsOpen] = React.useState(false) + const [selectedVendorId, setSelectedVendorId] = React.useState<number | null>(null) - // Add state for items drawer - const [itemsDrawerOpen, setItemsDrawerOpen] = React.useState(false) - const [selectedItems, setSelectedItems] = React.useState<PossibleItem[]>([]) - const [selectedItemInvestigationId, setSelectedItemInvestigationId] = React.useState<number | null>(null) - - // Create handlers for opening the contacts dialog and items drawer - const openContactsModal = React.useCallback((investigationId: number, contacts: ContactItem[]) => { - setSelectedContactInvestigationId(investigationId) - setSelectedContacts(contacts || []) - setContactsDialogOpen(true) - }, []) - - const openItemsDrawer = React.useCallback((investigationId: number, items: PossibleItem[]) => { - setSelectedItemInvestigationId(investigationId) - setSelectedItems(items || []) - setItemsDrawerOpen(true) + // Create handler for opening vendor details modal + const openVendorDetailsModal = React.useCallback((vendorId: number) => { + setSelectedVendorId(vendorId) + setVendorDetailsOpen(true) }, []) // Get router const router = useRouter() - // Call getColumns() with all required functions + // Call getColumns() with required functions (simplified) const columns = React.useMemo( () => getColumns({ setRowAction, - openContactsModal, - openItemsDrawer + openVendorDetailsModal }), - [setRowAction, openContactsModal, openItemsDrawer] + [setRowAction, openVendorDetailsModal] ) + // 기본 필터 필드들 const filterFields: DataTableFilterField<VendorInvestigationsViewWithContacts>[] = [ - { id: "vendorCode", label: "Vendor Code" }, + { id: "vendorCode", label: "협력사 코드" }, + { id: "vendorName", label: "협력사명" }, + { id: "investigationStatus", label: "실사 상태" }, ] + // 고급 필터 필드들 const advancedFilterFields: DataTableAdvancedFilterField<VendorInvestigationsViewWithContacts>[] = [ - { id: "vendorName", label: "Vendor Name", type: "text" }, - { id: "vendorCode", label: "Vendor Code", type: "text" }, + // 협력업체 필터 + { id: "vendorName", label: "협력사명", type: "text" }, + { id: "vendorCode", label: "협력사 코드", type: "text" }, + + // 실사 상태 필터 + { + id: "investigationStatus", + label: "실사 상태", + type: "select", + options: [ + { label: "계획됨", value: "PLANNED" }, + { label: "진행 중", value: "IN_PROGRESS" }, + { label: "완료됨", value: "COMPLETED" }, + { label: "취소됨", value: "CANCELED" }, + ] + }, + { + id: "evaluationType", + label: "평가 유형", + type: "select", + options: [ + { label: "실사의뢰평가", value: "SITE_AUDIT" }, + { label: "QM자체평가", value: "QM_SELF_AUDIT" }, + ] + }, + { + id: "evaluationResult", + label: "평가 결과", + type: "select", + options: [ + { label: "승인", value: "APPROVED" }, + { label: "보완", value: "SUPPLEMENT" }, + { label: "불가", value: "REJECTED" }, + ] + }, + + // 점수 필터 + { id: "evaluationScore", label: "평가 점수", type: "number" }, + + // 담당자 필터 + { id: "requesterName", label: "의뢰자", type: "text" }, + { id: "qmManagerName", label: "QM 담당자", type: "text" }, + + // 첨부파일 필터 + { + id: "hasAttachments", + label: "첨부파일 유무", + type: "select", + options: [ + { label: "첨부파일 있음", value: "true" }, + { label: "첨부파일 없음", value: "false" }, + ] + }, + + // 주요 날짜 필터 + { id: "forecastedAt", label: "실사 예정일", type: "date" }, + { id: "requestedAt", label: "실사 의뢰일", type: "date" }, + { id: "confirmedAt", label: "실사 확정일", type: "date" }, + { id: "completedAt", label: "실제 실사일", type: "date" }, + + // 메모 필터 + { id: "investigationNotes", label: "QM 의견", type: "text" }, ] + // 데이터 테이블 초기화 const { table } = useDataTable({ data: transformedData, columns, @@ -134,10 +155,17 @@ export function VendorsInvestigationTable({ promises }: VendorsTableProps) { enablePinning: true, enableAdvancedFilter: true, initialState: { - sorting: [{ id: "investigationCreatedAt", desc: true }], + sorting: [{ id: "createdAt", desc: true }], columnPinning: { right: ["actions"] }, + columnVisibility: { + // 자주 사용하지 않는 컬럼들은 기본적으로 숨김 + // investigationAddress: false, + // investigationMethod: false, + // requestedAt: false, + // confirmedAt: false, + } }, - getRowId: (originalRow) => String(originalRow.investigationId), + getRowId: (originalRow) => String(originalRow.investigationId ?? originalRow.id), shallow: false, clearOnDefault: true, }) @@ -162,21 +190,12 @@ export function VendorsInvestigationTable({ promises }: VendorsTableProps) { onOpenChange={() => setRowAction(null)} investigation={rowAction?.row.original ?? null} /> - - {/* Contacts Dialog */} - <ContactsDialog - open={contactsDialogOpen} - onOpenChange={setContactsDialogOpen} - investigationId={selectedContactInvestigationId} - contacts={selectedContacts} - /> - - {/* Items Drawer */} - <ItemsDrawer - open={itemsDrawerOpen} - onOpenChange={setItemsDrawerOpen} - investigationId={selectedItemInvestigationId} - items={selectedItems} + + {/* Vendor Details Dialog */} + <VendorDetailsDialog + open={vendorDetailsOpen} + onOpenChange={setVendorDetailsOpen} + vendorId={selectedVendorId} /> </> ) diff --git a/lib/vendor-investigation/table/items-dialog.tsx b/lib/vendor-investigation/table/items-dialog.tsx deleted file mode 100644 index 5d010ff4..00000000 --- a/lib/vendor-investigation/table/items-dialog.tsx +++ /dev/null @@ -1,73 +0,0 @@ -"use client" - -import * as React from "react" -import { - Sheet, - SheetContent, - SheetDescription, - SheetHeader, - SheetTitle, - SheetFooter, -} from "@/components/ui/sheet" -import { Button } from "@/components/ui/button" -import { ScrollArea } from "@/components/ui/scroll-area" -import { PossibleItem } from "@/config/vendorInvestigationsColumnsConfig" - -interface ItemsDrawerProps { - open: boolean - onOpenChange: (open: boolean) => void - investigationId: number | null - items: PossibleItem[] -} - -export function ItemsDrawer({ - open, - onOpenChange, - investigationId, - items, -}: ItemsDrawerProps) { - return ( - <Sheet open={open} onOpenChange={onOpenChange}> - <SheetContent className="sm:max-w-md"> - <SheetHeader> - <SheetTitle>Possible Items</SheetTitle> - <SheetDescription> - {items.length > 0 - ? `Showing ${items.length} items for investigation #${investigationId}` - : `No items found for investigation #${investigationId}`} - </SheetDescription> - </SheetHeader> - <ScrollArea className="max-h-[70vh] mt-6 pr-4"> - {items.length > 0 ? ( - <div className="space-y-4"> - {items.map((item, index) => ( - <div - key={index} - className="flex flex-col gap-2 p-3 rounded-lg border" - > - <div className="flex justify-between items-start"> - <h4 className="font-medium">{item.itemName || "Unknown Item"}</h4> - {item.itemName && ( - <span className="text-xs bg-muted px-2 py-1 rounded"> - {item.itemCode} - </span> - )} - </div> - - - </div> - ))} - </div> - ) : ( - <div className="text-center py-6 text-muted-foreground"> - No items available - </div> - )} - </ScrollArea> - <SheetFooter className="mt-4"> - <Button onClick={() => onOpenChange(false)}>Close</Button> - </SheetFooter> - </SheetContent> - </Sheet> - ) -}
\ No newline at end of file diff --git a/lib/vendor-investigation/table/update-investigation-sheet.tsx b/lib/vendor-investigation/table/update-investigation-sheet.tsx index fe30c892..69f0d9ae 100644 --- a/lib/vendor-investigation/table/update-investigation-sheet.tsx +++ b/lib/vendor-investigation/table/update-investigation-sheet.tsx @@ -3,7 +3,8 @@ import * as React from "react" import { zodResolver } from "@hookform/resolvers/zod" import { useForm } from "react-hook-form" -import { Loader } from "lucide-react" +import { CalendarIcon, Loader } from "lucide-react" +import { format } from "date-fns" import { toast } from "sonner" import { Button } from "@/components/ui/button" @@ -16,6 +17,7 @@ import { FormMessage, } from "@/components/ui/form" import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" import { Sheet, SheetClose, @@ -33,33 +35,76 @@ import { SelectTrigger, SelectValue, } from "@/components/ui/select" +import { Calendar } from "@/components/ui/calendar" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { + Dropzone, + DropzoneZone, + DropzoneUploadIcon, + DropzoneTitle, + DropzoneDescription, + DropzoneInput +} from "@/components/ui/dropzone" import { updateVendorInvestigationSchema, type UpdateVendorInvestigationSchema, } from "../validations" -import { updateVendorInvestigationAction } from "../service" +import { updateVendorInvestigationAction, getInvestigationAttachments, deleteInvestigationAttachment } from "../service" import { VendorInvestigationsViewWithContacts } from "@/config/vendorInvestigationsColumnsConfig" -/** - * The shape of `vendorInvestigation` - * might come from your `vendorInvestigationsView` row - * or your existing type for a single investigation. - */ - interface UpdateVendorInvestigationSheetProps extends React.ComponentPropsWithoutRef<typeof Sheet> { investigation: VendorInvestigationsViewWithContacts | null } +// 첨부파일 정책 정의 +const getFileUploadConfig = (status: string) => { + // 취소된 상태에서만 파일 업로드 비활성화 + if (status === "CANCELED") { + return { + enabled: false, + label: "", + description: "", + accept: undefined, // undefined로 변경 + maxSize: 0, + maxSizeText: "" + } + } + + // 모든 활성 상태에서 동일한 정책 적용 + return { + enabled: true, + label: "실사 관련 첨부파일", + description: "실사와 관련된 모든 문서와 이미지를 첨부할 수 있습니다.", + accept: { + 'application/pdf': ['.pdf'], + 'application/msword': ['.doc'], + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': ['.docx'], + 'application/vnd.ms-excel': ['.xls'], + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': ['.xlsx'], + 'image/*': ['.png', '.jpg', '.jpeg', '.gif'], + }, + maxSize: 10 * 1024 * 1024, // 10MB + maxSizeText: "10MB" + } +} + /** - * A sheet for updating a vendor investigation (plus optional attachments). + * 실사 정보 수정 시트 */ export function UpdateVendorInvestigationSheet({ investigation, ...props }: UpdateVendorInvestigationSheetProps) { const [isPending, startTransition] = React.useTransition() + const [existingAttachments, setExistingAttachments] = React.useState<any[]>([]) + const [loadingAttachments, setLoadingAttachments] = React.useState(false) + const [uploadingFiles, setUploadingFiles] = React.useState(false) // RHF + Zod const form = useForm<UpdateVendorInvestigationSchema>({ @@ -67,138 +112,346 @@ export function UpdateVendorInvestigationSheet({ defaultValues: { investigationId: investigation?.investigationId ?? 0, investigationStatus: investigation?.investigationStatus ?? "PLANNED", - scheduledStartAt: investigation?.scheduledStartAt ?? undefined, - scheduledEndAt: investigation?.scheduledEndAt ?? undefined, + evaluationType: investigation?.evaluationType ?? undefined, + investigationAddress: investigation?.investigationAddress ?? "", + investigationMethod: investigation?.investigationMethod ?? "", + forecastedAt: investigation?.forecastedAt ?? undefined, + requestedAt: investigation?.requestedAt ?? undefined, + confirmedAt: investigation?.confirmedAt ?? undefined, completedAt: investigation?.completedAt ?? undefined, + evaluationScore: investigation?.evaluationScore ?? undefined, + evaluationResult: investigation?.evaluationResult ?? undefined, investigationNotes: investigation?.investigationNotes ?? "", + attachments: undefined, // 파일은 매번 새로 업로드 }, }) + // investigation이 변경될 때마다 폼 리셋 React.useEffect(() => { if (investigation) { form.reset({ investigationId: investigation.investigationId, investigationStatus: investigation.investigationStatus || "PLANNED", - scheduledStartAt: investigation.scheduledStartAt ?? undefined, - scheduledEndAt: investigation.scheduledEndAt ?? undefined, + evaluationType: investigation.evaluationType ?? undefined, + investigationAddress: investigation.investigationAddress ?? "", + investigationMethod: investigation.investigationMethod ?? "", + forecastedAt: investigation.forecastedAt ?? undefined, + requestedAt: investigation.requestedAt ?? undefined, + confirmedAt: investigation.confirmedAt ?? undefined, completedAt: investigation.completedAt ?? undefined, + evaluationScore: investigation.evaluationScore ?? undefined, + evaluationResult: investigation.evaluationResult ?? undefined, investigationNotes: investigation.investigationNotes ?? "", + attachments: undefined, // 파일은 매번 새로 업로드 }) + + // 기존 첨부파일 로드 + loadExistingAttachments(investigation.investigationId) } }, [investigation, form]) - // Format date for form data - const formatDateForFormData = (date: Date | undefined): string | null => { - if (!date) return null; - return date.toISOString(); + // 기존 첨부파일 로드 함수 + const loadExistingAttachments = async (investigationId: number) => { + setLoadingAttachments(true) + try { + const result = await getInvestigationAttachments(investigationId) + if (result.success) { + setExistingAttachments(result.attachments || []) + } else { + toast.error("첨부파일 목록을 불러오는데 실패했습니다.") + } + } catch (error) { + console.error("첨부파일 로드 실패:", error) + toast.error("첨부파일 목록을 불러오는 중 오류가 발생했습니다.") + } finally { + setLoadingAttachments(false) + } } - // Submit handler - async function onSubmit(values: UpdateVendorInvestigationSchema) { - if (!values.investigationId) return - - startTransition(async () => { - // 1) Build a FormData object for the server action - const formData = new FormData() - - // Add text fields - formData.append("investigationId", String(values.investigationId)) - formData.append("investigationStatus", values.investigationStatus) - - // Format dates properly before appending to FormData - if (values.scheduledStartAt) { - const formattedDate = formatDateForFormData(values.scheduledStartAt) - if (formattedDate) formData.append("scheduledStartAt", formattedDate) - } + // 첨부파일 삭제 함수 + const handleDeleteAttachment = async (attachmentId: number) => { + if (!investigation) return + + try { + const response = await fetch(`/api/vendor-investigations/${investigation.investigationId}/attachments?attachmentId=${attachmentId}`, { + method: "DELETE", + }) - if (values.scheduledEndAt) { - const formattedDate = formatDateForFormData(values.scheduledEndAt) - if (formattedDate) formData.append("scheduledEndAt", formattedDate) + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || "첨부파일 삭제 실패") } - if (values.completedAt) { - const formattedDate = formatDateForFormData(values.completedAt) - if (formattedDate) formData.append("completedAt", formattedDate) - } + toast.success("첨부파일이 삭제되었습니다.") + // 목록 새로고침 + loadExistingAttachments(investigation.investigationId) - if (values.investigationNotes) { - formData.append("investigationNotes", values.investigationNotes) - } + } catch (error) { + console.error("첨부파일 삭제 오류:", error) + toast.error(error instanceof Error ? error.message : "첨부파일 삭제 중 오류가 발생했습니다.") + } + } - // Add attachments (if any) - // Note: If you have multiple files in "attachments", we store them in the form under the same key. - const attachmentValue = form.getValues("attachments"); - if (attachmentValue instanceof FileList) { - for (let i = 0; i < attachmentValue.length; i++) { - formData.append("attachments", attachmentValue[i]); - } - } + // 파일 업로드 섹션 렌더링 + const renderFileUploadSection = () => { + const currentStatus = form.watch("investigationStatus") + const config = getFileUploadConfig(currentStatus) + + if (!config.enabled) return null - const { error } = await updateVendorInvestigationAction(formData) - if (error) { - toast.error(error) - return - } + return ( + <> + {/* 기존 첨부파일 목록 */} + {(existingAttachments.length > 0 || loadingAttachments) && ( + <div className="space-y-2"> + <FormLabel>기존 첨부파일</FormLabel> + <div className="border rounded-md p-3 space-y-2 max-h-32 overflow-y-auto"> + {loadingAttachments ? ( + <div className="flex items-center justify-center py-4"> + <Loader className="h-4 w-4 animate-spin" /> + <span className="ml-2 text-sm text-muted-foreground"> + 첨부파일 로딩 중... + </span> + </div> + ) : existingAttachments.length > 0 ? ( + existingAttachments.map((attachment) => ( + <div key={attachment.id} className="flex items-center justify-between text-sm"> + <div className="flex items-center space-x-2 flex-1 min-w-0"> + <span className="text-xs px-2 py-1 bg-muted rounded"> + {attachment.attachmentType} + </span> + <span className="truncate">{attachment.fileName}</span> + <span className="text-muted-foreground"> + ({Math.round(attachment.fileSize / 1024)}KB) + </span> + </div> + <Button + type="button" + variant="ghost" + size="sm" + onClick={() => handleDeleteAttachment(attachment.id)} + className="text-destructive hover:text-destructive" + disabled={isPending} + > + 삭제 + </Button> + </div> + )) + ) : ( + <div className="text-sm text-muted-foreground text-center py-2"> + 첨부된 파일이 없습니다. + </div> + )} + </div> + </div> + )} - toast.success("Investigation updated!") - form.reset() - props.onOpenChange?.(false) - }) + {/* 새 파일 업로드 */} + <FormField + control={form.control} + name="attachments" + render={({ field: { onChange, ...field } }) => ( + <FormItem> + <FormLabel>{config.label}</FormLabel> + <FormControl> + <Dropzone + onDrop={(acceptedFiles, rejectedFiles) => { + // 거부된 파일에 대한 상세 에러 메시지 + if (rejectedFiles.length > 0) { + rejectedFiles.forEach((file) => { + const error = file.errors[0] + if (error.code === 'file-too-large') { + toast.error(`${file.file.name}: 파일 크기가 ${config.maxSizeText}를 초과합니다.`) + } else if (error.code === 'file-invalid-type') { + toast.error(`${file.file.name}: 지원하지 않는 파일 형식입니다.`) + } else { + toast.error(`${file.file.name}: 파일 업로드에 실패했습니다.`) + } + }) + } + + if (acceptedFiles.length > 0) { + onChange(acceptedFiles) + toast.success(`${acceptedFiles.length}개 파일이 선택되었습니다.`) + } + }} + accept={config.accept} + multiple + maxSize={config.maxSize} + disabled={isPending || uploadingFiles} + > + <DropzoneZone> + <DropzoneUploadIcon /> + <DropzoneTitle> + {isPending || uploadingFiles + ? "파일 업로드 중..." + : "파일을 드래그하거나 클릭하여 업로드" + } + </DropzoneTitle> + <DropzoneDescription> + {config.description} (최대 {config.maxSizeText}) + </DropzoneDescription> + <DropzoneInput /> + </DropzoneZone> + </Dropzone> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </> + ) } - // Format date value for input field - const formatDateForInput = (date: Date | undefined): string => { - if (!date) return ""; - return date instanceof Date ? date.toISOString().slice(0, 10) : ""; + // 파일 업로드 함수 + const uploadFiles = async (files: File[], investigationId: number) => { + const uploadPromises = files.map(async (file) => { + const formData = new FormData() + formData.append("file", file) + + const response = await fetch(`/api/vendor-investigations/${investigationId}/attachments`, { + method: "POST", + body: formData, + }) + + if (!response.ok) { + const errorData = await response.json() + throw new Error(errorData.error || "파일 업로드 실패") + } + + return await response.json() + }) + + return await Promise.all(uploadPromises) } - // Handle date input change - const handleDateChange = (e: React.ChangeEvent<HTMLInputElement>, onChange: (...event: any[]) => void) => { - const val = e.target.value; - if (val) { - // Ensure proper date handling by setting to noon to avoid timezone issues - const newDate = new Date(`${val}T12:00:00`); - onChange(newDate); - } else { - onChange(undefined); - } + // Submit handler + async function onSubmit(values: UpdateVendorInvestigationSchema) { + if (!values.investigationId) return + + startTransition(async () => { + try { + // 1) 먼저 텍스트 데이터 업데이트 + const formData = new FormData() + + // 필수 필드 + formData.append("investigationId", String(values.investigationId)) + formData.append("investigationStatus", values.investigationStatus) + + // 선택적 필드들 + if (values.evaluationType) { + formData.append("evaluationType", values.evaluationType) + } + + if (values.investigationAddress) { + formData.append("investigationAddress", values.investigationAddress) + } + + if (values.investigationMethod) { + formData.append("investigationMethod", values.investigationMethod) + } + + if (values.forecastedAt) { + formData.append("forecastedAt", values.forecastedAt.toISOString()) + } + + if (values.requestedAt) { + formData.append("requestedAt", values.requestedAt.toISOString()) + } + + if (values.confirmedAt) { + formData.append("confirmedAt", values.confirmedAt.toISOString()) + } + + if (values.completedAt) { + formData.append("completedAt", values.completedAt.toISOString()) + } + + if (values.evaluationScore !== undefined) { + formData.append("evaluationScore", String(values.evaluationScore)) + } + + if (values.evaluationResult) { + formData.append("evaluationResult", values.evaluationResult) + } + + if (values.investigationNotes) { + formData.append("investigationNotes", values.investigationNotes) + } + + // 텍스트 데이터 업데이트 + const { error } = await updateVendorInvestigationAction(formData) + + if (error) { + toast.error(error) + return + } + + // 2) 파일이 있으면 업로드 + if (values.attachments && values.attachments.length > 0) { + setUploadingFiles(true) + + try { + await uploadFiles(values.attachments, values.investigationId) + toast.success(`실사 정보와 ${values.attachments.length}개 파일이 업데이트되었습니다!`) + + // 첨부파일 목록 새로고침 + loadExistingAttachments(values.investigationId) + } catch (fileError) { + toast.error(`데이터는 저장되었지만 파일 업로드 중 오류가 발생했습니다: ${fileError}`) + } finally { + setUploadingFiles(false) + } + } else { + toast.success("실사 정보가 업데이트되었습니다!") + } + + form.reset() + props.onOpenChange?.(false) + + } catch (error) { + console.error("실사 정보 업데이트 오류:", error) + toast.error("실사 정보 업데이트 중 오류가 발생했습니다.") + } + }) } return ( <Sheet {...props}> - <SheetContent className="flex flex-col gap-6 sm:max-w-md"> - <SheetHeader className="text-left"> - <SheetTitle>Update Investigation</SheetTitle> + <SheetContent className="flex flex-col h-full sm:max-w-md"> + <SheetHeader className="text-left flex-shrink-0"> + <SheetTitle>실사 업데이트</SheetTitle> <SheetDescription> - Change the investigation details & attachments + {investigation?.vendorName && ( + <span className="font-medium">{investigation.vendorName}</span> + )}의 실사 정보를 수정합니다. </SheetDescription> </SheetHeader> - <Form {...form}> - <form - onSubmit={form.handleSubmit(onSubmit)} - className="flex flex-col gap-4" - // Must use multipart to support file uploads - encType="multipart/form-data" - > - {/* investigationStatus */} + <div className="flex-1 overflow-y-auto py-4"> + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="flex flex-col gap-4" + > + {/* 실사 상태 */} <FormField control={form.control} name="investigationStatus" render={({ field }) => ( <FormItem> - <FormLabel>Status</FormLabel> + <FormLabel>실사 상태</FormLabel> <FormControl> <Select value={field.value} onValueChange={field.onChange}> - <SelectTrigger className="capitalize"> - <SelectValue placeholder="Select a status" /> + <SelectTrigger> + <SelectValue placeholder="상태를 선택하세요" /> </SelectTrigger> <SelectContent> <SelectGroup> - <SelectItem value="PLANNED">PLANNED</SelectItem> - <SelectItem value="IN_PROGRESS">IN_PROGRESS</SelectItem> - <SelectItem value="COMPLETED">COMPLETED</SelectItem> - <SelectItem value="CANCELED">CANCELED</SelectItem> + <SelectItem value="PLANNED">계획됨</SelectItem> + <SelectItem value="IN_PROGRESS">진행 중</SelectItem> + <SelectItem value="COMPLETED">완료됨</SelectItem> + <SelectItem value="CANCELED">취소됨</SelectItem> </SelectGroup> </SelectContent> </Select> @@ -208,37 +461,43 @@ export function UpdateVendorInvestigationSheet({ )} /> - {/* scheduledStartAt */} + {/* 평가 유형 */} <FormField control={form.control} - name="scheduledStartAt" + name="evaluationType" render={({ field }) => ( <FormItem> - <FormLabel>Scheduled Start</FormLabel> + <FormLabel>평가 유형</FormLabel> <FormControl> - <Input - type="date" - value={formatDateForInput(field.value)} - onChange={(e) => handleDateChange(e, field.onChange)} - /> + <Select value={field.value || ""} onValueChange={field.onChange}> + <SelectTrigger> + <SelectValue placeholder="평가 유형을 선택하세요" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + <SelectItem value="SITE_AUDIT">실사의뢰평가</SelectItem> + <SelectItem value="QM_SELF_AUDIT">QM자체평가</SelectItem> + </SelectGroup> + </SelectContent> + </Select> </FormControl> <FormMessage /> </FormItem> )} /> - {/* scheduledEndAt */} + {/* 실사 주소 */} <FormField control={form.control} - name="scheduledEndAt" + name="investigationAddress" render={({ field }) => ( <FormItem> - <FormLabel>Scheduled End</FormLabel> + <FormLabel>실사 주소</FormLabel> <FormControl> - <Input - type="date" - value={formatDateForInput(field.value)} - onChange={(e) => handleDateChange(e, field.onChange)} + <Textarea + placeholder="실사가 진행될 주소를 입력하세요..." + {...field} + className="min-h-[60px]" /> </FormControl> <FormMessage /> @@ -246,55 +505,200 @@ export function UpdateVendorInvestigationSheet({ )} /> - {/* completedAt */} + {/* 실사 방법 */} <FormField control={form.control} - name="completedAt" + name="investigationMethod" render={({ field }) => ( <FormItem> - <FormLabel>Completed At</FormLabel> + <FormLabel>실사 방법</FormLabel> <FormControl> - <Input - type="date" - value={formatDateForInput(field.value)} - onChange={(e) => handleDateChange(e, field.onChange)} - /> + <Input placeholder="실사 방법을 입력하세요..." {...field} /> </FormControl> <FormMessage /> </FormItem> )} /> - {/* investigationNotes */} + {/* 실사 예정일 */} <FormField control={form.control} - name="investigationNotes" + name="forecastedAt" render={({ field }) => ( - <FormItem> - <FormLabel>Notes</FormLabel> - <FormControl> - <Input placeholder="Notes about the investigation..." {...field} /> - </FormControl> + <FormItem className="flex flex-col"> + <FormLabel>실사 예정일</FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + className={`w-full pl-3 text-left font-normal ${!field.value && "text-muted-foreground"}`} + > + {field.value ? ( + format(field.value, "yyyy년 MM월 dd일") + ) : ( + <span>날짜를 선택하세요</span> + )} + <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <Calendar + mode="single" + selected={field.value} + onSelect={field.onChange} + initialFocus + /> + </PopoverContent> + </Popover> <FormMessage /> </FormItem> )} /> - {/* attachments: multiple file upload */} + {/* 실사 확정일 */} <FormField control={form.control} - name="attachments" - render={({ field: { value, onChange, ...fieldProps } }) => ( + name="confirmedAt" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel>실사 확정일</FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + className={`w-full pl-3 text-left font-normal ${!field.value && "text-muted-foreground"}`} + > + {field.value ? ( + format(field.value, "yyyy년 MM월 dd일") + ) : ( + <span>날짜를 선택하세요</span> + )} + <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <Calendar + mode="single" + selected={field.value} + onSelect={field.onChange} + initialFocus + /> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + + {/* 실제 실사일 */} + <FormField + control={form.control} + name="completedAt" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel>실제 실사일</FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + className={`w-full pl-3 text-left font-normal ${!field.value && "text-muted-foreground"}`} + > + {field.value ? ( + format(field.value, "yyyy년 MM월 dd일") + ) : ( + <span>날짜를 선택하세요</span> + )} + <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <Calendar + mode="single" + selected={field.value} + onSelect={field.onChange} + initialFocus + /> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + + {/* 평가 점수 - 완료된 상태일 때만 표시 */} + {form.watch("investigationStatus") === "COMPLETED" && ( + <FormField + control={form.control} + name="evaluationScore" + render={({ field }) => ( + <FormItem> + <FormLabel>평가 점수</FormLabel> + <FormControl> + <Input + type="number" + min={0} + max={100} + placeholder="0-100점" + {...field} + value={field.value || ""} + onChange={(e) => { + const value = e.target.value === "" ? undefined : parseInt(e.target.value, 10) + field.onChange(value) + }} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + )} + + {/* 평가 결과 - 완료된 상태일 때만 표시 */} + {form.watch("investigationStatus") === "COMPLETED" && ( + <FormField + control={form.control} + name="evaluationResult" + render={({ field }) => ( + <FormItem> + <FormLabel>평가 결과</FormLabel> + <FormControl> + <Select value={field.value || ""} onValueChange={field.onChange}> + <SelectTrigger> + <SelectValue placeholder="평가 결과를 선택하세요" /> + </SelectTrigger> + <SelectContent> + <SelectGroup> + <SelectItem value="APPROVED">승인</SelectItem> + <SelectItem value="SUPPLEMENT">보완</SelectItem> + <SelectItem value="REJECTED">불가</SelectItem> + </SelectGroup> + </SelectContent> + </Select> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + )} + + {/* QM 의견 */} + <FormField + control={form.control} + name="investigationNotes" + render={({ field }) => ( <FormItem> - <FormLabel>Attachments</FormLabel> + <FormLabel>QM 의견</FormLabel> <FormControl> - <Input - type="file" - multiple - onChange={(e) => { - onChange(e.target.files); // Store the FileList directly - }} - {...fieldProps} + <Textarea + placeholder="실사에 대한 QM 의견을 입력하세요..." + {...field} + className="min-h-[80px]" /> </FormControl> <FormMessage /> @@ -302,22 +706,29 @@ export function UpdateVendorInvestigationSheet({ )} /> - {/* Footer Buttons */} - <SheetFooter className="gap-2 pt-2 sm:space-x-0"> - <SheetClose asChild> - <Button type="button" variant="outline"> - Cancel - </Button> - </SheetClose> - <Button disabled={isPending}> - {isPending && ( - <Loader className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" /> - )} - Save - </Button> - </SheetFooter> - </form> - </Form> + {/* 파일 첨부 섹션 */} + {renderFileUploadSection()} + </form> + </Form> + </div> + + {/* Footer Buttons */} + <SheetFooter className="gap-2 pt-2 sm:space-x-0 flex-shrink-0"> + <SheetClose asChild> + <Button type="button" variant="outline" disabled={isPending || uploadingFiles}> + 취소 + </Button> + </SheetClose> + <Button + disabled={isPending || uploadingFiles} + onClick={form.handleSubmit(onSubmit)} + > + {(isPending || uploadingFiles) && ( + <Loader className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" /> + )} + {uploadingFiles ? "업로드 중..." : isPending ? "저장 중..." : "저장"} + </Button> + </SheetFooter> </SheetContent> </Sheet> ) diff --git a/lib/vendor-investigation/table/vendor-details-dialog.tsx b/lib/vendor-investigation/table/vendor-details-dialog.tsx new file mode 100644 index 00000000..27ed7826 --- /dev/null +++ b/lib/vendor-investigation/table/vendor-details-dialog.tsx @@ -0,0 +1,341 @@ +"use client" + +import * as React from "react" +import { Building, Globe, Mail, MapPin, Phone, RefreshCw, Search } from "lucide-react" +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogFooter, +} from "@/components/ui/dialog" +import { Button } from "@/components/ui/button" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { ScrollArea } from "@/components/ui/scroll-area" +import { Skeleton } from "@/components/ui/skeleton" +import { Badge } from "@/components/ui/badge" +import { Separator } from "@/components/ui/separator" +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" +import { useToast } from "@/hooks/use-toast" + +// Import vendor service +import { getVendorById, getVendorItemsByVendorId } from "@/lib/vendor-investigation/service" +import { useRouter } from "next/navigation" + +interface VendorDetailsDialogProps { + open: boolean + onOpenChange: (open: boolean) => void + vendorId: number | null +} + +export function VendorDetailsDialog({ + open, + onOpenChange, + vendorId, +}: VendorDetailsDialogProps) { + const { toast } = useToast() + const router = useRouter() + const [loading, setLoading] = React.useState(false) + const [vendorData, setVendorData] = React.useState<any>(null) + const [vendorItems, setVendorItems] = React.useState<any[]>([]) + const [activeTab, setActiveTab] = React.useState("details") + + // Fetch vendor details when the dialog opens + React.useEffect(() => { + if (open && vendorId) { + setLoading(true) + + // Fetch vendor details + Promise.all([ + getVendorById(vendorId), + getVendorItemsByVendorId(vendorId) + ]) + .then(([vendorDetails, items]) => { + setVendorData(vendorDetails) + setVendorItems(items || []) + }) + .catch((error) => { + console.error("Error fetching vendor data:", error) + toast({ + title: "Error", + description: "Failed to load vendor details. Please try again.", + variant: "destructive", + }) + }) + .finally(() => { + setLoading(false) + }) + } else { + // Reset state when the dialog closes + setVendorData(null) + setVendorItems([]) + } + }, [open, vendorId, toast]) + + // Handle refresh button click + const handleRefresh = () => { + if (!vendorId) return + + setLoading(true) + Promise.all([ + getVendorById(vendorId), + getVendorItemsByVendorId(vendorId) + ]) + .then(([vendorDetails, items]) => { + setVendorData(vendorDetails) + setVendorItems(items || []) + toast({ + title: "Refreshed", + description: "Vendor information has been refreshed.", + }) + }) + .catch((error) => { + console.error("Error refreshing vendor data:", error) + toast({ + title: "Error", + description: "Failed to refresh vendor details.", + variant: "destructive", + }) + }) + .finally(() => { + setLoading(false) + }) + } + + // Get vendor status badge variant + const getStatusVariant = (status: string) => { + switch (status?.toUpperCase()) { + case "ACTIVE": + return "default" + case "PENDING": + return "secondary" + case "SUSPENDED": + return "destructive" + case "APPROVED": + return "outline" + default: + return "secondary" + } + } + + // Navigate to full vendor profile page + const navigateToVendorProfile = () => { + if (!vendorId) return + + // Close dialog + onOpenChange(false) + + // Navigate to vendor profile page with router + router.push(`/evcp/vendors/${vendorId}`) + } + + return ( + <Dialog open={open} onOpenChange={onOpenChange}> + <DialogContent className="sm:max-w-[700px]"> + <DialogHeader> + <div className="flex items-center justify-between"> + <DialogTitle>협력업체 상세정보</DialogTitle> + <Button + variant="outline" + size="icon" + onClick={handleRefresh} + disabled={loading} + > + <RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} /> + <span className="sr-only">새로고침</span> + </Button> + </div> + <DialogDescription> + 협력업체 정보 상세보기 + </DialogDescription> + </DialogHeader> + + {loading ? ( + <div className="space-y-4 py-4"> + <div className="flex items-center space-x-4"> + <Skeleton className="h-12 w-12 rounded-full" /> + <div className="space-y-2"> + <Skeleton className="h-4 w-[200px]" /> + <Skeleton className="h-4 w-[150px]" /> + </div> + </div> + <Skeleton className="h-[200px] w-full" /> + </div> + ) : vendorData ? ( + <div className="py-4"> + {/* Vendor header with main info */} + <div className="flex items-start justify-between mb-6"> + <div> + <h2 className="text-xl font-semibold">{vendorData.name}</h2> + <div className="flex items-center mt-1 space-x-2"> + <span className="text-sm text-muted-foreground">업체코드: {vendorData.code}</span> + {vendorData.taxId && ( + <> + <span className="text-muted-foreground">•</span> + <span className="text-sm text-muted-foreground">사업자등록번호: {vendorData.taxId}</span> + </> + )} + </div> + </div> + {vendorData.status && ( + <Badge variant={getStatusVariant(vendorData.status)}> + {vendorData.status} + </Badge> + )} + </div> + + <Tabs defaultValue="details" onValueChange={setActiveTab}> + <TabsList className="mb-4"> + <TabsTrigger value="details">상세</TabsTrigger> + <TabsTrigger value="items">공급품목({vendorItems.length})</TabsTrigger> + </TabsList> + + {/* Details Tab */} + <TabsContent value="details" className="space-y-4"> + {/* Contact Information Card */} + <Card> + <CardHeader className="pb-2"> + <CardTitle className="text-base">연락처 정보</CardTitle> + </CardHeader> + <CardContent className="space-y-2"> + <div className="grid grid-cols-1 md:grid-cols-2 gap-4"> + {/* Email */} + <div className="flex items-center space-x-2"> + <Mail className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm">{vendorData.email || "No email provided"}</span> + </div> + + {/* Phone */} + <div className="flex items-center space-x-2"> + <Phone className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm">{vendorData.phone || "No phone provided"}</span> + </div> + + {/* Website */} + {vendorData.website && ( + <div className="flex items-center space-x-2"> + <Globe className="h-4 w-4 text-muted-foreground" /> + <a + href={vendorData.website.startsWith('http') ? vendorData.website : `https://${vendorData.website}`} + target="_blank" + rel="noopener noreferrer" + className="text-sm text-blue-600 hover:underline" + > + {vendorData.website} + </a> + </div> + )} + + {/* Address */} + {vendorData.address && ( + <div className="flex items-center space-x-2"> + <MapPin className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm">{vendorData.address}</span> + </div> + )} + + {/* Country */} + {vendorData.country && ( + <div className="flex items-center space-x-2"> + <Building className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm">{vendorData.country}</span> + </div> + )} + </div> + </CardContent> + </Card> + + {/* Additional Information */} + {vendorData.description && ( + <Card> + <CardHeader className="pb-2"> + <CardTitle className="text-base">협력업체 설명</CardTitle> + </CardHeader> + <CardContent> + <p className="text-sm">{vendorData.description}</p> + </CardContent> + </Card> + )} + + {/* Registration Information */} + <Card> + <CardHeader className="pb-2"> + <CardTitle className="text-base">등록 정보</CardTitle> + </CardHeader> + <CardContent> + <div className="grid grid-cols-2 gap-2"> + <div> + <p className="text-xs text-muted-foreground">협력업체 생성일</p> + <p className="text-sm"> + {vendorData.createdAt + ? new Date(vendorData.createdAt).toLocaleDateString() + : "Unknown" + } + </p> + </div> + <div> + <p className="text-xs text-muted-foreground">협력업체 정보 업데이트일</p> + <p className="text-sm"> + {vendorData.updatedAt + ? new Date(vendorData.updatedAt).toLocaleDateString() + : "Unknown" + } + </p> + </div> + </div> + </CardContent> + </Card> + </TabsContent> + + {/* Items Tab */} + <TabsContent value="items"> + <ScrollArea className="h-[300px] pr-4"> + {vendorItems.length > 0 ? ( + <div className="space-y-4"> + {vendorItems.map((item) => ( + <Card key={item.id}> + <CardHeader className="pb-2"> + <CardTitle className="text-base">{item.itemName}</CardTitle> + <CardDescription>Code: {item.itemCode}</CardDescription> + </CardHeader> + {item.description && ( + <CardContent> + <p className="text-sm">{item.description}</p> + </CardContent> + )} + </Card> + ))} + </div> + ) : ( + <div className="flex flex-col items-center justify-center h-full text-center p-8"> + <Search className="h-8 w-8 text-muted-foreground mb-2" /> + <h3 className="text-lg font-semibold">No items found</h3> + <p className="text-sm text-muted-foreground">해당 업체는 아직 공급품목이 등록되지 않았습니다.</p> + </div> + )} + </ScrollArea> + </TabsContent> + + </Tabs> + </div> + ) : ( + <div className="py-6 text-center"> + <p className="text-muted-foreground">No vendor information available</p> + </div> + )} + + <DialogFooter> + <Button variant="outline" onClick={() => onOpenChange(false)}> + 닫기 + </Button> + {vendorData && ( + <Button onClick={navigateToVendorProfile}> + 전체 정보 보러가기 + </Button> + )} + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/vendor-investigation/validations.ts b/lib/vendor-investigation/validations.ts index 18a50022..bfe2e988 100644 --- a/lib/vendor-investigation/validations.ts +++ b/lib/vendor-investigation/validations.ts @@ -1,4 +1,3 @@ -import { vendorInvestigationsView } from "@/db/schema/vendors" import { createSearchParamsCache, parseAsArrayOf, @@ -8,6 +7,7 @@ import { } from "nuqs/server" import * as z from "zod" import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" +import { vendorInvestigationsView } from "@/db/schema" export const searchParamsInvestigationCache = createSearchParamsCache({ // Common flags @@ -19,7 +19,7 @@ export const searchParamsInvestigationCache = createSearchParamsCache({ // Sorting - adjusting for vendorInvestigationsView sort: getSortingStateParser<typeof vendorInvestigationsView.$inferSelect>().withDefault([ - { id: "investigationCreatedAt", desc: true }, + { id: "createdAt", desc: true }, ]), // Advanced filter @@ -60,34 +60,28 @@ export const searchParamsInvestigationCache = createSearchParamsCache({ // Finally, export the type you can use in your server action: export type GetVendorsInvestigationSchema = Awaited<ReturnType<typeof searchParamsInvestigationCache.parse>> - export const updateVendorInvestigationSchema = z.object({ - investigationId: z.number(), - investigationStatus: z.enum(["PLANNED", "IN_PROGRESS", "COMPLETED", "CANCELED"]), - - // If the user might send empty strings, we'll allow it by unioning with z.literal('') - // Then transform empty string to undefined - scheduledStartAt: z.preprocess( - // null이나 빈 문자열을 undefined로 변환 - (val) => (val === null || val === '') ? undefined : val, - z.date().optional() - ), - - scheduledEndAt:z.preprocess( - // null이나 빈 문자열을 undefined로 변환 - (val) => (val === null || val === '') ? undefined : val, - z.date().optional() - ), - - completedAt: z.preprocess( - // null이나 빈 문자열을 undefined로 변환 - (val) => (val === null || val === '') ? undefined : val, - z.date().optional() - ), - investigationNotes: z.string().optional(), - attachments: z.any().optional(), - }) + investigationId: z.number({ + required_error: "Investigation ID is required", + }), + investigationStatus: z.enum(["PLANNED", "IN_PROGRESS", "COMPLETED", "CANCELED"], { + required_error: "실사 상태를 선택해주세요.", + }), + evaluationType: z.enum(["SITE_AUDIT", "QM_SELF_AUDIT"]).optional(), + investigationAddress: z.string().optional(), + investigationMethod: z.string().max(100, "실사 방법은 100자 이내로 입력해주세요.").optional(), + forecastedAt: z.date().optional(), + requestedAt: z.date().optional(), + confirmedAt: z.date().optional(), + completedAt: z.date().optional(), + evaluationScore: z.number() + .int("평가 점수는 정수여야 합니다.") + .min(0, "평가 점수는 0점 이상이어야 합니다.") + .max(100, "평가 점수는 100점 이하여야 합니다.") + .optional(), + evaluationResult: z.enum(["APPROVED", "SUPPLEMENT", "REJECTED"]).optional(), + investigationNotes: z.string().max(1000, "QM 의견은 1000자 이내로 입력해주세요.").optional(), + attachments: z.any().optional(), // File 업로드를 위한 필드 +}) -export type UpdateVendorInvestigationSchema = z.infer< - typeof updateVendorInvestigationSchema ->
\ No newline at end of file +export type UpdateVendorInvestigationSchema = z.infer<typeof updateVendorInvestigationSchema> diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts index 87a8336d..c9ee55be 100644 --- a/lib/vendors/service.ts +++ b/lib/vendors/service.ts @@ -2,7 +2,7 @@ import { revalidateTag, unstable_noStore } from "next/cache"; import db from "@/db/db"; -import { vendorAttachments, VendorContact, vendorContacts, vendorDetailView, vendorInvestigations, vendorInvestigationsView, vendorItemsView, vendorPossibleItems, vendors, vendorsWithTypesView, vendorTypes, type Vendor } from "@/db/schema/vendors"; +import { vendorAttachments, VendorContact, vendorContacts, vendorDetailView, vendorItemsView, vendorPossibleItems, vendors, vendorsWithTypesView, vendorTypes, type Vendor } from "@/db/schema"; import logger from '@/lib/logger'; import { filterColumns } from "@/lib/filter-columns"; @@ -42,7 +42,7 @@ import type { GetRfqHistorySchema, } from "./validations"; -import { asc, desc, ilike, inArray, and, or, gte, lte, eq, isNull, count } from "drizzle-orm"; +import { asc, desc, ilike, inArray, and, or, gte, lte, eq, isNull, count, sql } from "drizzle-orm"; import { rfqItems, rfqs, vendorRfqView } from "@/db/schema/rfq"; import path from "path"; import fs from "fs/promises"; @@ -55,7 +55,7 @@ import { items } from "@/db/schema/items"; import { users } from "@/db/schema/users"; import { getServerSession } from "next-auth"; import { authOptions } from "@/app/api/auth/[...nextauth]/route"; -import { contracts, contractsDetailView, projects, vendorProjectPQs, vendorsLogs } from "@/db/schema"; +import { contracts, contractsDetailView, projects, vendorPQSubmissions, vendorProjectPQs, vendorsLogs } from "@/db/schema"; import { Hospital } from "lucide-react"; @@ -154,6 +154,8 @@ export async function getVendors(input: GetVendorsSchema) { const total = await countVendorsWithTypes(tx, where); return { data: vendorsWithAttachments, total }; }); + + console.log(total) // 페이지 수 const pageCount = Math.ceil(total / input.perPage); @@ -1181,9 +1183,71 @@ export async function approveVendors(input: ApproveVendorsInput & { userId: numb } } +/** + * 유니크한 PQ 번호 생성 함수 + * + * 형식: PQ-YYMMDD-XXXXX + * YYMMDD: 연도(YY), 월(MM), 일(DD) + * XXXXX: 시퀀스 번호 (00001부터 시작) + * + * 예: PQ-240520-00001, PQ-240520-00002, ... + */ +export async function generatePQNumber(isProject: boolean = false) { + try { + // 현재 날짜 가져오기 + const now = new Date(); + const year = now.getFullYear().toString().slice(-2); // 년도의 마지막 2자리 + const month = (now.getMonth() + 1).toString().padStart(2, '0'); // 월 (01-12) + const day = now.getDate().toString().padStart(2, '0'); // 일 (01-31) + const dateStr = `${year}${month}${day}`; + + // 접두사 설정 (일반 PQ vs 프로젝트 PQ) + const prefix = isProject ? "PPQ" : "PQ"; + const datePrefix = `${prefix}-${dateStr}`; + + // 오늘 생성된 가장 큰 시퀀스 번호 조회 + const latestPQ = await db + .select({ pqNumber: vendorPQSubmissions.pqNumber }) + .from(vendorPQSubmissions) + .where( + sql`${vendorPQSubmissions.pqNumber} LIKE ${datePrefix + '-%'}` + ) + .orderBy(desc(vendorPQSubmissions.pqNumber)) + .limit(1); + + let sequenceNumber = 1; // 기본값은 1 + + // 오늘 생성된 PQ가 있으면 다음 시퀀스 번호 계산 + if (latestPQ.length > 0 && latestPQ[0].pqNumber) { + const lastPQ = latestPQ[0].pqNumber; + const lastSequence = lastPQ.split('-')[2]; + if (lastSequence && !isNaN(parseInt(lastSequence))) { + sequenceNumber = parseInt(lastSequence) + 1; + } + } + + // 5자리 시퀀스 번호로 포맷팅 (00001, 00002, ...) + const formattedSequence = sequenceNumber.toString().padStart(5, '0'); + + // 최종 PQ 번호 생성 + const pqNumber = `${datePrefix}-${formattedSequence}`; + + return pqNumber; + } catch (error) { + console.error('Error generating PQ number:', error); + // 문제 발생 시 대체 번호 생성 (타임스탬프 기반) + const timestamp = Date.now().toString(); + const prefix = isProject ? "PPQ" : "PQ"; + return `${prefix}-${timestamp}`; + } +} + export async function requestPQVendors(input: ApproveVendorsInput & { userId: number }) { unstable_noStore(); + const session = await getServerSession(authOptions); + const requesterId = session?.user?.id ? Number(session.user.id) : null; + try { // 프로젝트 정보 가져오기 (projectId가 있는 경우) let projectInfo = null; @@ -1234,18 +1298,55 @@ export async function requestPQVendors(input: ApproveVendorsInput & { userId: nu .from(vendors) .where(inArray(vendors.id, input.ids)); - // 3. 프로젝트 PQ인 경우, vendorProjectPQs 테이블에 레코드 추가 - if (input.projectId && projectInfo) { - // 각 벤더에 대해 프로젝트 PQ 연결 생성 - const vendorProjectPQsData = input.ids.map(vendorId => ({ - vendorId, - projectId: input.projectId!, - status: "REQUESTED", - createdAt: new Date(), - updatedAt: new Date(), - })); + // 3. vendorPQSubmissions 테이블에 레코드 추가 (프로젝트 PQ와 일반 PQ 모두) + const pqType = input.projectId ? "PROJECT" : "GENERAL"; + const currentDate = new Date(); + + // 기존 PQ 요청이 있는지 확인 (중복 방지) + const existingSubmissions = await tx + .select({ + vendorId: vendorPQSubmissions.vendorId, + projectId: vendorPQSubmissions.projectId, + type: vendorPQSubmissions.type + }) + .from(vendorPQSubmissions) + .where( + and( + inArray(vendorPQSubmissions.vendorId, input.ids), + eq(vendorPQSubmissions.type, pqType), + input.projectId + ? eq(vendorPQSubmissions.projectId, input.projectId) + : isNull(vendorPQSubmissions.projectId) + ) + ); + + // 중복되지 않는 벤더에 대해서만 새 PQ 요청 생성 + const existingVendorIds = new Set(existingSubmissions.map(s => s.vendorId)); + const newVendorIds = input.ids.filter(id => !existingVendorIds.has(id)); + + if (newVendorIds.length > 0) { + // 각 벤더별로 유니크한 PQ 번호 생성 및 저장 + const vendorPQDataPromises = newVendorIds.map(async (vendorId) => { + // PQ 번호 생성 (프로젝트 PQ인지 여부 전달) + const pqNumber = await generatePQNumber(pqType === "PROJECT"); + + return { + vendorId, + pqNumber, // 생성된 PQ 번호 저장 + projectId: input.projectId || null, + type: pqType, + status: "REQUESTED", + requesterId: input.userId || requesterId, // 요청자 ID 저장 + createdAt: currentDate, + updatedAt: currentDate, + }; + }); - await tx.insert(vendorProjectPQs).values(vendorProjectPQsData); + // 모든 PQ 번호 생성 완료 대기 + const vendorPQData = await Promise.all(vendorPQDataPromises); + + // 트랜잭션 내에서 데이터 삽입 + await tx.insert(vendorPQSubmissions).values(vendorPQData); } // 4. 로그 기록 @@ -1259,7 +1360,7 @@ export async function requestPQVendors(input: ApproveVendorsInput & { userId: nu newStatus: "IN_PQ", comment: input.projectId ? `Project PQ requested (Project: ${projectInfo?.projectCode || input.projectId})` - : "PQ requested", + : "General PQ requested", }); }) ); @@ -1275,10 +1376,26 @@ export async function requestPQVendors(input: ApproveVendorsInput & { userId: nu try { const userLang = "en"; // 기본값, 필요시 협력업체 언어 설정에서 가져오기 + // PQ 번호 조회 (이메일에 포함하기 위해) + const vendorPQ = await tx + .select({ pqNumber: vendorPQSubmissions.pqNumber }) + .from(vendorPQSubmissions) + .where( + and( + eq(vendorPQSubmissions.vendorId, vendor.id), + eq(vendorPQSubmissions.type, pqType), + input.projectId + ? eq(vendorPQSubmissions.projectId, input.projectId) + : isNull(vendorPQSubmissions.projectId) + ) + ) + .limit(1) + .then(rows => rows[0]); + // 프로젝트 PQ인지 일반 PQ인지에 따라 제목 변경 const subject = input.projectId - ? `[eVCP] You are invited to submit Project PQ for ${projectInfo?.projectCode || 'a project'}` - : "[eVCP] You are invited to submit PQ"; + ? `[eVCP] You are invited to submit Project PQ ${vendorPQ?.pqNumber || ''} for ${projectInfo?.projectCode || 'a project'}` + : `[eVCP] You are invited to submit PQ ${vendorPQ?.pqNumber || ''}`; // 로그인 URL에 프로젝트 ID 추가 (프로젝트 PQ인 경우) const baseLoginUrl = `${host}/partners/pq`; @@ -1289,8 +1406,7 @@ export async function requestPQVendors(input: ApproveVendorsInput & { userId: nu await sendEmail({ to: vendor.email, subject, - template:input.projectId ? "project-pq" : "pq", // 프로젝트별 템플릿 사용 - // template: "vendor-pq-status", // 프로젝트별 템플릿 사용 + template: input.projectId ? "project-pq" : "pq", // 프로젝트별 템플릿 사용 context: { vendorName: vendor.vendorName, loginUrl, @@ -1298,6 +1414,7 @@ export async function requestPQVendors(input: ApproveVendorsInput & { userId: nu projectCode: projectInfo?.projectCode || '', projectName: projectInfo?.projectName || '', hasProject: !!input.projectId, + pqNumber: vendorPQ?.pqNumber || '', // PQ 번호 추가 }, }); } catch (emailError) { @@ -1313,8 +1430,11 @@ export async function requestPQVendors(input: ApproveVendorsInput & { userId: nu // 캐시 무효화 revalidateTag("vendors"); revalidateTag("vendor-status-counts"); + revalidateTag("vendor-pq-submissions"); + if (input.projectId) { revalidateTag(`project-${input.projectId}`); + revalidateTag(`project-pq-submissions-${input.projectId}`); } return { data: result, error: null }; @@ -1323,6 +1443,7 @@ export async function requestPQVendors(input: ApproveVendorsInput & { userId: nu return { data: null, error: getErrorMessage(err) }; } } + interface SendVendorsInput { ids: number[]; } diff --git a/lib/vendors/table/vendors-table-toolbar-actions.tsx b/lib/vendors/table/vendors-table-toolbar-actions.tsx index 1c788911..12f1dfcd 100644 --- a/lib/vendors/table/vendors-table-toolbar-actions.tsx +++ b/lib/vendors/table/vendors-table-toolbar-actions.tsx @@ -182,13 +182,7 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions /> )} - {pqApprovedVendors.length > 0 && ( - <RequestVendorsInvestigateDialog - vendors={pqApprovedVendors} - onSuccess={() => table.toggleAllRowsSelected(false)} - /> - )} - + {/* Export 드롭다운 메뉴로 변경 */} <DropdownMenu> <DropdownMenuTrigger asChild> |
