summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/api-utils.ts45
-rw-r--r--lib/filter-columns.ts139
-rw-r--r--lib/form-list/repository.ts24
-rw-r--r--lib/form-list/service.ts151
-rw-r--r--lib/form-list/table/formLists-table-columns.tsx14
-rw-r--r--lib/form-list/table/formLists-table-toolbar-actions.tsx4
-rw-r--r--lib/form-list/table/formLists-table.tsx8
-rw-r--r--lib/form-list/table/meta-sheet.tsx5
-rw-r--r--lib/form-list/validation.ts10
-rw-r--r--lib/pq/helper.ts96
-rw-r--r--lib/pq/pq-review-table-new/cancel-investigation-dialog.tsx69
-rw-r--r--lib/pq/pq-review-table-new/feature-flags-provider.tsx108
-rw-r--r--lib/pq/pq-review-table-new/pq-container.tsx151
-rw-r--r--lib/pq/pq-review-table-new/pq-filter-sheet.tsx651
-rw-r--r--lib/pq/pq-review-table-new/request-investigation-dialog.tsx331
-rw-r--r--lib/pq/pq-review-table-new/send-results-dialog.tsx69
-rw-r--r--lib/pq/pq-review-table-new/user-combobox.tsx122
-rw-r--r--lib/pq/pq-review-table-new/vendors-table-columns.tsx640
-rw-r--r--lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx351
-rw-r--r--lib/pq/pq-review-table-new/vendors-table.tsx308
-rw-r--r--lib/pq/service.ts1327
-rw-r--r--lib/pq/validations.ts40
-rw-r--r--lib/procurement-rfqs/services.ts1
-rw-r--r--lib/procurement-rfqs/table/detail-table/add-vendor-dialog.tsx512
-rw-r--r--lib/procurement-rfqs/table/detail-table/delete-vendor-dialog.tsx150
-rw-r--r--lib/procurement-rfqs/table/detail-table/rfq-detail-column.tsx369
-rw-r--r--lib/procurement-rfqs/table/detail-table/rfq-detail-table.tsx521
-rw-r--r--lib/procurement-rfqs/table/detail-table/update-vendor-sheet.tsx449
-rw-r--r--lib/procurement-rfqs/table/detail-table/vendor-communication-drawer.tsx518
-rw-r--r--lib/procurement-rfqs/table/detail-table/vendor-quotation-comparison-dialog.tsx665
-rw-r--r--lib/procurement-rfqs/table/rfq-filter-sheet.tsx686
-rw-r--r--lib/procurement-rfqs/table/rfq-table copy.tsx209
-rw-r--r--lib/procurement-rfqs/table/rfq-table.tsx411
-rw-r--r--lib/rfqs/validations.ts2
-rw-r--r--lib/sedp/get-tags.ts458
-rw-r--r--lib/sedp/sedp-token.ts4
-rw-r--r--lib/tags/service.ts14
-rw-r--r--lib/tags/table/tag-table.tsx4
-rw-r--r--lib/vendor-document-list/enhanced-document-service.ts782
-rw-r--r--lib/vendor-document-list/sync-client.ts28
-rw-r--r--lib/vendor-document-list/sync-service.ts491
-rw-r--r--lib/vendor-document-list/table/bulk-upload-dialog.tsx1162
-rw-r--r--lib/vendor-document-list/table/enhanced-doc-table-columns.tsx612
-rw-r--r--lib/vendor-document-list/table/enhanced-doc-table-toolbar-actions.tsx106
-rw-r--r--lib/vendor-document-list/table/enhanced-document-sheet.tsx939
-rw-r--r--lib/vendor-document-list/table/enhanced-documents-table copy.tsx604
-rw-r--r--lib/vendor-document-list/table/enhanced-documents-table.tsx570
-rw-r--r--lib/vendor-document-list/table/revision-upload-dialog.tsx486
-rw-r--r--lib/vendor-document-list/table/send-to-shi-button.tsx342
-rw-r--r--lib/vendor-document-list/table/simplified-document-edit-dialog.tsx287
-rw-r--r--lib/vendor-document-list/table/stage-revision-expanded-content.tsx719
-rw-r--r--lib/vendor-document-list/table/stage-revision-sheet.tsx86
-rw-r--r--lib/vendor-investigation/service.ts523
-rw-r--r--lib/vendor-investigation/table/contract-dialog.tsx85
-rw-r--r--lib/vendor-investigation/table/investigation-table-columns.tsx313
-rw-r--r--lib/vendor-investigation/table/investigation-table.tsx177
-rw-r--r--lib/vendor-investigation/table/items-dialog.tsx73
-rw-r--r--lib/vendor-investigation/table/update-investigation-sheet.tsx713
-rw-r--r--lib/vendor-investigation/table/vendor-details-dialog.tsx341
-rw-r--r--lib/vendor-investigation/validations.ts56
-rw-r--r--lib/vendors/service.ts159
-rw-r--r--lib/vendors/table/vendors-table-toolbar-actions.tsx8
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>
+ 이 작업은 되돌릴 수 없습니다. 벤더 &quot;{detail?.vendorName}&quot;({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>
+ 이 작업은 되돌릴 수 없습니다. 벤더 &quot;{detail?.vendorName}&quot;({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 &amp; 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>