From 20800b214145ee6056f94ca18fa1054f145eb977 Mon Sep 17 00:00:00 2001 From: dujinkim Date: Wed, 28 May 2025 00:32:31 +0000 Subject: (대표님) lib 파트 커밋 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/api-utils.ts | 45 + lib/filter-columns.ts | 139 +- lib/form-list/repository.ts | 24 +- lib/form-list/service.ts | 151 ++- lib/form-list/table/formLists-table-columns.tsx | 14 +- .../table/formLists-table-toolbar-actions.tsx | 4 +- lib/form-list/table/formLists-table.tsx | 8 +- lib/form-list/table/meta-sheet.tsx | 5 +- lib/form-list/validation.ts | 10 +- lib/pq/helper.ts | 96 ++ .../cancel-investigation-dialog.tsx | 69 + .../pq-review-table-new/feature-flags-provider.tsx | 108 ++ lib/pq/pq-review-table-new/pq-container.tsx | 151 +++ lib/pq/pq-review-table-new/pq-filter-sheet.tsx | 651 ++++++++++ .../request-investigation-dialog.tsx | 331 +++++ lib/pq/pq-review-table-new/send-results-dialog.tsx | 69 + lib/pq/pq-review-table-new/user-combobox.tsx | 122 ++ .../pq-review-table-new/vendors-table-columns.tsx | 640 ++++++++++ .../vendors-table-toolbar-actions.tsx | 351 ++++++ lib/pq/pq-review-table-new/vendors-table.tsx | 308 +++++ lib/pq/service.ts | 1327 ++++++++++++++++++-- lib/pq/validations.ts | 40 +- lib/procurement-rfqs/services.ts | 1 + .../table/detail-table/add-vendor-dialog.tsx | 512 ++++++++ .../table/detail-table/delete-vendor-dialog.tsx | 150 +++ .../table/detail-table/rfq-detail-column.tsx | 369 ++++++ .../table/detail-table/rfq-detail-table.tsx | 521 ++++++++ .../table/detail-table/update-vendor-sheet.tsx | 449 +++++++ .../detail-table/vendor-communication-drawer.tsx | 518 ++++++++ .../vendor-quotation-comparison-dialog.tsx | 665 ++++++++++ lib/procurement-rfqs/table/rfq-filter-sheet.tsx | 686 ++++++++++ lib/procurement-rfqs/table/rfq-table copy.tsx | 209 --- lib/procurement-rfqs/table/rfq-table.tsx | 411 +++--- lib/rfqs/validations.ts | 2 +- lib/sedp/get-tags.ts | 458 +++++-- lib/sedp/sedp-token.ts | 4 +- lib/tags/service.ts | 14 +- lib/tags/table/tag-table.tsx | 4 + .../enhanced-document-service.ts | 782 ++++++++++++ lib/vendor-document-list/sync-client.ts | 28 + lib/vendor-document-list/sync-service.ts | 491 ++++++++ .../table/bulk-upload-dialog.tsx | 1162 +++++++++++++++++ .../table/enhanced-doc-table-columns.tsx | 612 +++++++++ .../table/enhanced-doc-table-toolbar-actions.tsx | 106 ++ .../table/enhanced-document-sheet.tsx | 939 ++++++++++++++ .../table/enhanced-documents-table copy.tsx | 604 +++++++++ .../table/enhanced-documents-table.tsx | 570 +++++++++ .../table/revision-upload-dialog.tsx | 486 +++++++ .../table/send-to-shi-button.tsx | 342 +++++ .../table/simplified-document-edit-dialog.tsx | 287 +++++ .../table/stage-revision-expanded-content.tsx | 719 +++++++++++ .../table/stage-revision-sheet.tsx | 86 ++ lib/vendor-investigation/service.ts | 523 ++++++-- lib/vendor-investigation/table/contract-dialog.tsx | 85 -- .../table/investigation-table-columns.tsx | 313 +++-- .../table/investigation-table.tsx | 177 +-- lib/vendor-investigation/table/items-dialog.tsx | 73 -- .../table/update-investigation-sheet.tsx | 713 ++++++++--- .../table/vendor-details-dialog.tsx | 341 +++++ lib/vendor-investigation/validations.ts | 56 +- lib/vendors/service.ts | 159 ++- .../table/vendors-table-toolbar-actions.tsx | 8 +- 62 files changed, 17911 insertions(+), 1387 deletions(-) create mode 100644 lib/api-utils.ts create mode 100644 lib/pq/helper.ts create mode 100644 lib/pq/pq-review-table-new/cancel-investigation-dialog.tsx create mode 100644 lib/pq/pq-review-table-new/feature-flags-provider.tsx create mode 100644 lib/pq/pq-review-table-new/pq-container.tsx create mode 100644 lib/pq/pq-review-table-new/pq-filter-sheet.tsx create mode 100644 lib/pq/pq-review-table-new/request-investigation-dialog.tsx create mode 100644 lib/pq/pq-review-table-new/send-results-dialog.tsx create mode 100644 lib/pq/pq-review-table-new/user-combobox.tsx create mode 100644 lib/pq/pq-review-table-new/vendors-table-columns.tsx create mode 100644 lib/pq/pq-review-table-new/vendors-table-toolbar-actions.tsx create mode 100644 lib/pq/pq-review-table-new/vendors-table.tsx create mode 100644 lib/procurement-rfqs/table/detail-table/add-vendor-dialog.tsx create mode 100644 lib/procurement-rfqs/table/detail-table/delete-vendor-dialog.tsx create mode 100644 lib/procurement-rfqs/table/detail-table/rfq-detail-column.tsx create mode 100644 lib/procurement-rfqs/table/detail-table/rfq-detail-table.tsx create mode 100644 lib/procurement-rfqs/table/detail-table/update-vendor-sheet.tsx create mode 100644 lib/procurement-rfqs/table/detail-table/vendor-communication-drawer.tsx create mode 100644 lib/procurement-rfqs/table/detail-table/vendor-quotation-comparison-dialog.tsx create mode 100644 lib/procurement-rfqs/table/rfq-filter-sheet.tsx delete mode 100644 lib/procurement-rfqs/table/rfq-table copy.tsx create mode 100644 lib/vendor-document-list/enhanced-document-service.ts create mode 100644 lib/vendor-document-list/sync-client.ts create mode 100644 lib/vendor-document-list/sync-service.ts create mode 100644 lib/vendor-document-list/table/bulk-upload-dialog.tsx create mode 100644 lib/vendor-document-list/table/enhanced-doc-table-columns.tsx create mode 100644 lib/vendor-document-list/table/enhanced-doc-table-toolbar-actions.tsx create mode 100644 lib/vendor-document-list/table/enhanced-document-sheet.tsx create mode 100644 lib/vendor-document-list/table/enhanced-documents-table copy.tsx create mode 100644 lib/vendor-document-list/table/enhanced-documents-table.tsx create mode 100644 lib/vendor-document-list/table/revision-upload-dialog.tsx create mode 100644 lib/vendor-document-list/table/send-to-shi-button.tsx create mode 100644 lib/vendor-document-list/table/simplified-document-edit-dialog.tsx create mode 100644 lib/vendor-document-list/table/stage-revision-expanded-content.tsx create mode 100644 lib/vendor-document-list/table/stage-revision-sheet.tsx delete mode 100644 lib/vendor-investigation/table/contract-dialog.tsx delete mode 100644 lib/vendor-investigation/table/items-dialog.tsx create mode 100644 lib/vendor-investigation/table/vendor-details-dialog.tsx (limited to 'lib') 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) { + 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 +// 조인된 테이블들의 타입 정의 +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({ table, filters, joinOperator, + joinedTables, + customColumnMapping, }: { table: T filters: Filter[] 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({ (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( + 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( 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 = Parameters>[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 | null>> + setRowAction: React.Dispatch | null>> } /** * tanstack table 컬럼 정의 (중첩 헤더 버전) */ -export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef[] { +export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef[] { // ---------------------------------------------------------------- // 1) select 컬럼 (체크박스) // ---------------------------------------------------------------- @@ -35,7 +35,7 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef = { + const actionsColumn: ColumnDef = { id: "actions", enableHiding: false, cell: function Cell({ row }) { @@ -65,7 +65,7 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef[] } - const groupMap: Record[]> = {} + const groupMap: Record[]> = {} formListsColumnsConfig.forEach((cfg) => { // 만약 group가 없으면 "_noGroup" 처리 @@ -76,7 +76,7 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef = { + const childCol: ColumnDef = { accessorKey: cfg.id, enableResizing: true, header: ({ column }) => ( @@ -104,7 +104,7 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef[] = [] + const nestedColumns: ColumnDef[] = [] // 순서를 고정하고 싶다면 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 + table: Table } 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 | null>(null) + React.useState | 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[] = [ + const filterFields: DataTableFilterField[] = [ ] @@ -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[] = [ + const advancedFilterFields: DataTableAdvancedFilterField[] = [ { 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().withDefault([ + sort: getSortingStateParser().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 + 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 ( + !open && onClose()}> + + + 실사 의뢰 취소 + + 선택한 {selectedCount}개 협력업체의 실사 의뢰를 취소하시겠습니까? + 계획 상태인 실사만 취소할 수 있습니다. + + + + + + + + + ) +} \ 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({ + 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( + "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 ( + void setFeatureFlags(value), + }} + > +
+ setFeatureFlags(value)} + className="w-fit gap-0" + > + {dataTableConfig.featureFlags.map((flag, index) => ( + + + + + + +
{flag.tooltipTitle}
+
+ {flag.tooltipDescription} +
+
+
+ ))} +
+
+ {children} +
+ ) +} 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>]> + // 컨테이너 클래스명 (옵션) + 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(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으로 화면 최대 좌측에서 시작 */} +
+ {/* Filter Content */} +
+ setIsFilterPanelOpen(false)} + onSearch={handleSearch} + isLoading={false} // 로딩 상태 제거 + /> +
+
+ + {/* Main Content Container */} +
+
+ {/* Main Content - 너비 조정으로 필터 패널 공간 확보 */} +
+ {/* Header Bar */} +
+
+ +
+
+ + {/* Table Content Area */} +
+
+ {/* Promise를 직접 전달 - Items와 동일한 패턴 */} + +
+
+
+
+
+ + ) +} \ 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 + +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("") + + // 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({ + 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 ( +
+ {/* Filter Panel Header */} +
+

PQ 검색 필터

+
+ {getActiveFilterCount() > 0 && ( + + {getActiveFilterCount()}개 필터 적용됨 + + )} +
+
+ + {/* Join Operator Selection */} +
+ + +
+ +
+ + {/* Scrollable content area */} +
+
+ {/* 요청자명 */} + ( + + 요청자명 + +
+ + {field.value && ( + + )} +
+
+ +
+ )} + /> + + {/* PQ 번호 */} + ( + + PQ 번호 + +
+ + {field.value && ( + + )} +
+
+ +
+ )} + /> + + {/* 협력업체명 */} + ( + + 협력업체명 + +
+ + {field.value && ( + + )} +
+
+ +
+ )} + /> + + {/* PQ 상태 */} + ( + + PQ 상태 + + + + )} + /> + + {/* 평가 결과 */} + ( + + 평가 결과 + + + + )} + /> + + {/* PQ 생성일 */} + ( + + PQ 생성일 + +
+ + {(field.value?.from || field.value?.to) && ( + + )} +
+
+ +
+ )} + /> +
+
+ + {/* Fixed buttons at bottom */} +
+
+ + +
+
+
+ +
+ ) +} \ 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 + +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 + 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([]) + const [isLoadingManagers, setIsLoadingManagers] = React.useState(false) + + // form 객체 생성 시 initialData 활용 + const form = useForm({ + 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 ( + !open && onClose()}> + + + 실사 의뢰 + + {selectedCount}개 협력업체에 대한 실사를 의뢰합니다. 실사 관련 정보를 입력해주세요. + + +
+ + ( + + 평가 유형 + + + + )} + /> + + ( + + QM 담당자 + + + + + + )} + /> + + ( + + 실사 예정일 + + + + + + + + date < new Date()} + initialFocus + /> + + + + + )} + /> + + ( + + 실사 장소 + +