summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/evaluation-criteria/repository.ts198
-rw-r--r--lib/evaluation-criteria/service.ts221
-rw-r--r--lib/evaluation-criteria/table/reg-eval-criteria-columns.tsx364
-rw-r--r--lib/evaluation-criteria/table/reg-eval-criteria-delete-dialog.tsx147
-rw-r--r--lib/evaluation-criteria/table/reg-eval-criteria-form-sheet.tsx586
-rw-r--r--lib/evaluation-criteria/table/reg-eval-criteria-table-toolbar-actions.tsx161
-rw-r--r--lib/evaluation-criteria/table/reg-eval-criteria-table.tsx189
-rw-r--r--lib/evaluation-criteria/validations.ts41
-rw-r--r--lib/evaluation-target-list/service.ts372
-rw-r--r--lib/evaluation-target-list/table/evaluation-target-table.tsx543
-rw-r--r--lib/evaluation-target-list/table/evaluation-targets-columns.tsx351
-rw-r--r--lib/evaluation-target-list/table/evaluation-targets-filter-sheet.tsx2
-rw-r--r--lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx4
-rw-r--r--lib/evaluation-target-list/table/update-evaluation-target.tsx4
-rw-r--r--lib/evaluation/service.ts125
-rw-r--r--lib/evaluation/table/evaluation-columns.tsx35
-rw-r--r--lib/evaluation/table/evaluation-filter-sheet.tsx2
-rw-r--r--lib/evaluation/table/evaluation-table.tsx72
-rw-r--r--lib/spread-js/fns.ts353
-rw-r--r--lib/tech-project-avl/table/accepted-quotations-table-columns.tsx324
-rw-r--r--lib/tech-project-avl/table/accepted-quotations-table-toolbar-actions.tsx51
-rw-r--r--lib/tech-project-avl/table/accepted-quotations-table.tsx117
-rw-r--r--lib/tech-project-avl/validations.ts41
-rw-r--r--lib/tech-vendor-candidates/service.ts395
-rw-r--r--lib/tech-vendor-candidates/table/add-candidates-dialog.tsx394
-rw-r--r--lib/tech-vendor-candidates/table/candidates-table-columns.tsx199
-rw-r--r--lib/tech-vendor-candidates/table/candidates-table-floating-bar.tsx395
-rw-r--r--lib/tech-vendor-candidates/table/candidates-table-toolbar-actions.tsx93
-rw-r--r--lib/tech-vendor-candidates/table/candidates-table.tsx173
-rw-r--r--lib/tech-vendor-candidates/table/delete-candidates-dialog.tsx159
-rw-r--r--lib/tech-vendor-candidates/table/excel-template-download.tsx128
-rw-r--r--lib/tech-vendor-candidates/table/feature-flags-provider.tsx108
-rw-r--r--lib/tech-vendor-candidates/table/feature-flags.tsx96
-rw-r--r--lib/tech-vendor-candidates/table/import-button.tsx233
-rw-r--r--lib/tech-vendor-candidates/table/invite-candidates-dialog.tsx230
-rw-r--r--lib/tech-vendor-candidates/table/update-candidate-sheet.tsx437
-rw-r--r--lib/tech-vendor-candidates/utils.ts40
-rw-r--r--lib/tech-vendor-candidates/validations.ts148
-rw-r--r--lib/tech-vendors/service.ts180
-rw-r--r--lib/tech-vendors/table/add-vendor-dialog.tsx50
-rw-r--r--lib/tech-vendors/table/excel-template-download.tsx4
-rw-r--r--lib/tech-vendors/table/tech-vendors-table-columns.tsx5
-rw-r--r--lib/tech-vendors/table/update-vendor-sheet.tsx58
-rw-r--r--lib/tech-vendors/table/vendor-all-export.ts1
-rw-r--r--lib/tech-vendors/validations.ts12
-rw-r--r--lib/techsales-rfq/actions.ts30
-rw-r--r--lib/techsales-rfq/repository.ts29
-rw-r--r--lib/techsales-rfq/service.ts1043
-rw-r--r--lib/techsales-rfq/table/create-rfq-hull-dialog.tsx8
-rw-r--r--lib/techsales-rfq/table/create-rfq-ship-dialog.tsx4
-rw-r--r--lib/techsales-rfq/table/create-rfq-top-dialog.tsx21
-rw-r--r--lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx10
-rw-r--r--lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx312
-rw-r--r--lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx101
-rw-r--r--lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx178
-rw-r--r--lib/techsales-rfq/table/detail-table/vendor-quotation-comparison-dialog.tsx341
-rw-r--r--lib/techsales-rfq/table/rfq-items-view-dialog.tsx4
-rw-r--r--lib/techsales-rfq/table/rfq-table-column.tsx99
-rw-r--r--lib/techsales-rfq/table/rfq-table.tsx22
-rw-r--r--lib/techsales-rfq/table/tech-sales-quotation-attachments-sheet.tsx231
-rw-r--r--lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx183
-rw-r--r--lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx228
-rw-r--r--lib/techsales-rfq/vendor-response/quotation-editor.tsx559
-rw-r--r--lib/techsales-rfq/vendor-response/quotation-item-editor.tsx664
-rw-r--r--lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx32
-rw-r--r--lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx22
66 files changed, 8952 insertions, 3010 deletions
diff --git a/lib/evaluation-criteria/repository.ts b/lib/evaluation-criteria/repository.ts
new file mode 100644
index 00000000..d406f45a
--- /dev/null
+++ b/lib/evaluation-criteria/repository.ts
@@ -0,0 +1,198 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+/* IMPORT */
+import {
+ asc,
+ count,
+ desc,
+ eq,
+} from 'drizzle-orm';
+import { PgTransaction } from 'drizzle-orm/pg-core';
+import {
+ regEvalCriteria,
+ regEvalCriteriaDetails,
+ regEvalCriteriaView,
+ type NewRegEvalCriteria,
+ type NewRegEvalCriteriaDetails,
+} from '@/db/schema';
+
+// ----------------------------------------------------------------------------------------------------
+
+/* SELECT VIEW TRANSACTION */
+async function selectRegEvalCriteria(
+ tx: PgTransaction<any, any, any>,
+ params: {
+ where?: any;
+ orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[];
+ offset?: number;
+ limit?: number;
+ },
+) {
+ const {
+ where,
+ orderBy,
+ offset = 0,
+ limit = 10,
+ } = params;
+ const result = await tx
+ .select()
+ .from(regEvalCriteriaView)
+ .where(where)
+ .orderBy(...(orderBy ?? []))
+ .offset(offset)
+ .limit(limit);
+
+ return result;
+}
+
+/* SELECT COUNT TRANSACTION */
+async function countRegEvalCriteria(
+ tx: PgTransaction<any, any, any>,
+ where?: any,
+) {
+ const result = await tx
+ .select({ count: count() })
+ .from(regEvalCriteriaView)
+ .where(where);
+
+ return result[0]?.count ?? 0;
+}
+
+/* SELECT JOIN TRANSACTION */
+async function selectRegEvalCriteriaWithDetails(
+ tx: PgTransaction<any, any, any>,
+ id: number,
+) {
+ const criteria = await tx
+ .select()
+ .from(regEvalCriteria)
+ .where(eq(regEvalCriteria.id, id));
+
+ if (!criteria[0]) {
+ return null;
+ }
+
+ const details = await tx
+ .select()
+ .from(regEvalCriteriaDetails)
+ .where(eq(regEvalCriteriaDetails.criteriaId, id))
+ .orderBy(regEvalCriteriaDetails.orderIndex);
+
+ return {
+ ...criteria[0],
+ criteriaDetails: details,
+ };
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* INSERT CRITERIA TRANSACTION */
+async function insertRegEvalCriteria(
+ tx: PgTransaction<any, any, any>,
+ data: NewRegEvalCriteria,
+) {
+ const [insertRes] = await tx
+ .insert(regEvalCriteria)
+ .values(data)
+ .returning();
+
+ return insertRes;
+}
+
+/* INSERT CRITERIA DETAILS TRANSACTION */
+async function insertRegEvalCriteriaDetails(
+ tx: PgTransaction<any, any, any>,
+ data: NewRegEvalCriteriaDetails,
+) {
+ const [insertRes] = await tx
+ .insert(regEvalCriteriaDetails)
+ .values(data)
+ .returning();
+
+ return insertRes;
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* UPDATE CRITERIA TRANSACTION */
+async function updateRegEvalCriteria(
+ tx: PgTransaction<any, any, any>,
+ criteriaId: number,
+ data: Partial<NewRegEvalCriteria>,
+) {
+ const [updateRes] = await tx
+ .update(regEvalCriteria)
+ .set(data)
+ .where(eq(regEvalCriteria.id, criteriaId))
+ .returning();
+
+ return updateRes;
+}
+
+/* UPDATE CRITERIA DETAILS TRANSACTION */
+async function updateRegEvalCriteriaDetails(
+ tx: PgTransaction<any, any, any>,
+ detailId: number,
+ data: Partial<NewRegEvalCriteriaDetails>,
+) {
+ const [updateRes] = await tx
+ .update(regEvalCriteriaDetails)
+ .set(data)
+ .where(eq(regEvalCriteriaDetails.id, detailId))
+ .returning();
+
+ return updateRes;
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* DELETE CRITERIA TRANSACTION */
+async function deleteRegEvalCriteria(
+ tx: PgTransaction<any, any, any>,
+ criteriaId: number,
+) {
+ const [deleteRes] = await tx
+ .delete(regEvalCriteria)
+ .where(eq(regEvalCriteria.id, criteriaId))
+ .returning();
+
+ return deleteRes;
+}
+
+/* DELETE CRITERIA TRANSACTION */
+async function deleteRegEvalCriteriaDetails(
+ tx: PgTransaction<any, any, any>,
+ datailId: number,
+) {
+ const [deleteRes] = await tx
+ .delete(regEvalCriteriaDetails)
+ .where(eq(regEvalCriteriaDetails.id, datailId))
+ .returning();
+
+ return deleteRes;
+}
+
+/* DELETE ALL TRANSACTION */
+async function deleteAllRegEvalCriteria(
+ tx: PgTransaction<any, any, any>,
+) {
+ const [deleteRes] = await tx.delete(regEvalCriteria);
+
+ return deleteRes;
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* EXPORT */
+export {
+ countRegEvalCriteria,
+ deleteAllRegEvalCriteria,
+ deleteRegEvalCriteria,
+ deleteRegEvalCriteriaDetails,
+ insertRegEvalCriteria,
+ insertRegEvalCriteriaDetails,
+ selectRegEvalCriteria,
+ selectRegEvalCriteriaWithDetails,
+ updateRegEvalCriteria,
+ updateRegEvalCriteriaDetails,
+}; \ No newline at end of file
diff --git a/lib/evaluation-criteria/service.ts b/lib/evaluation-criteria/service.ts
new file mode 100644
index 00000000..ec23c9e4
--- /dev/null
+++ b/lib/evaluation-criteria/service.ts
@@ -0,0 +1,221 @@
+'use server';
+
+/* IMPORT */
+import {
+ and,
+ asc,
+ desc,
+ ilike,
+ or,
+} from 'drizzle-orm';
+import {
+ countRegEvalCriteria,
+ deleteRegEvalCriteria,
+ deleteRegEvalCriteriaDetails,
+ insertRegEvalCriteria,
+ insertRegEvalCriteriaDetails,
+ selectRegEvalCriteria,
+ selectRegEvalCriteriaWithDetails,
+ updateRegEvalCriteria,
+ updateRegEvalCriteriaDetails,
+} from './repository';
+import db from '@/db/db';
+import { filterColumns } from '@/lib/filter-columns';
+import {
+ regEvalCriteriaView,
+ type NewRegEvalCriteria,
+ type NewRegEvalCriteriaDetails,
+ type RegEvalCriteria,
+ type RegEvalCriteriaDetails,
+} from '@/db/schema';
+import { type GetRegEvalCriteriaSchema } from './validations';
+
+// ----------------------------------------------------------------------------------------------------
+
+/* FUNCTION FOR GETTING CRITERIA */
+async function getRegEvalCriteria(input: GetRegEvalCriteriaSchema) {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+ const advancedWhere = filterColumns({
+ table: regEvalCriteriaView,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ });
+
+ // Filtering
+ let globalWhere;
+ if (input.search) {
+ const s = `%${input.search}%`;
+ globalWhere = or(
+ ilike(regEvalCriteriaView.category, s),
+ ilike(regEvalCriteriaView.item, s),
+ ilike(regEvalCriteriaView.classification, s),
+ );
+ }
+ const finalWhere = and(advancedWhere, globalWhere);
+
+ // Sorting
+ const orderBy = input.sort.length > 0
+ ? input.sort.map((item) => {
+ return item.desc
+ ? desc(regEvalCriteriaView[item.id])
+ : asc(regEvalCriteriaView[item.id]);
+ })
+ : [asc(regEvalCriteriaView.id)];
+
+ // Getting Data
+ const { data, total } = await db.transaction(async (tx) => {
+ const data = await selectRegEvalCriteria(tx, {
+ where: finalWhere,
+ orderBy,
+ offset,
+ limit: input.perPage,
+ });
+ const total = await countRegEvalCriteria(tx, finalWhere);
+
+ return { data, total };
+ });
+ const pageCount = Math.ceil(total / input.perPage);
+ return { data, pageCount };
+ } catch (err) {
+ console.error('Error in Getting Regular Evaluation Criteria: ', err);
+ return { data: [], pageCount: 0 };
+ }
+}
+
+/* FUNCTION FOR GETTING CRITERIA WITH DETAILS */
+async function getRegEvalCriteriaWithDetails(id: number) {
+ try {
+ return await db.transaction(async (tx) => {
+ return await selectRegEvalCriteriaWithDetails(tx, id);
+ });
+ } catch (err) {
+ console.error('Error in Getting Regular Evaluation Criteria with Details: ', err);
+ return null;
+ }
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* FUNCTION FOR CREATING CRITERIA WITH DETAILS */
+async function createRegEvalCriteriaWithDetails(
+ criteriaData: NewRegEvalCriteria,
+ detailList: Omit<NewRegEvalCriteriaDetails, 'criteriaId'>[],
+) {
+ try {
+ return await db.transaction(async (tx) => {
+ const criteria = await insertRegEvalCriteria(tx, criteriaData);
+ const criteriaId = criteria.id;
+ const newDetailList = detailList.map((detailItem, index) => ({
+ ...detailItem,
+ criteriaId,
+ orderIndex: index,
+ }));
+
+ const criteriaDetails: NewRegEvalCriteriaDetails[] = [];
+ for (let idx = 0; idx < newDetailList.length; idx += 1) {
+ criteriaDetails.push(await insertRegEvalCriteriaDetails(tx, newDetailList[idx]));
+ }
+
+ return { ...criteria, criteriaDetails };
+ });
+ } catch (error) {
+ console.error('Error in Creating New Regular Evaluation Criteria with Details: ', error);
+ throw new Error('Failed to Create New Regular Evaluation Criteria with Details');
+ }
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* FUNCTION FOR MODIFYING CRITERIA WITH DETAILS */
+async function modifyRegEvalCriteriaWithDetails(
+ id: number,
+ criteriaData: Partial<RegEvalCriteria>,
+ detailList: Partial<RegEvalCriteriaDetails>[],
+) {
+ try {
+ return await db.transaction(async (tx) => {
+ const modifiedCriteria = await updateRegEvalCriteria(tx, id, criteriaData);
+
+ console.log('here!');
+ console.log(detailList);
+
+ const originCriteria = await getRegEvalCriteriaWithDetails(id);
+ const originCriteriaDetails = originCriteria?.criteriaDetails || [];
+ const detailIdList = detailList
+ .filter(item => item.id !== undefined)
+ .map(item => item.id);
+ const toDeleteIdList = originCriteriaDetails.filter(
+ (item) => !detailIdList.includes(item.id),
+ );
+
+ for (const item of toDeleteIdList) {
+ await deleteRegEvalCriteriaDetails(tx, item.id);
+ }
+
+ const criteriaDetails = [];
+ for (let idx = 0; idx < detailList.length; idx += 1) {
+ const detailItem = detailList[idx];
+ const isUpdate = detailItem.id;
+ const isInsert = !detailItem.id && detailItem.detail;
+
+ if (isUpdate) {
+ const updatedDetail = await updateRegEvalCriteriaDetails(tx, detailItem.id!, detailItem);
+ criteriaDetails.push(updatedDetail);
+ } else if (isInsert) {
+ const newDetailItem = {
+ ...detailItem,
+ criteriaId: id,
+ detail: detailItem.detail!,
+ orderIndex: idx,
+ };
+ const insertedDetail = await insertRegEvalCriteriaDetails(tx, newDetailItem);
+ criteriaDetails.push(insertedDetail);
+ }
+ }
+
+ return { ...modifiedCriteria, criteriaDetails };
+ });
+ } catch (error) {
+ console.error('Error in Modifying Regular Evaluation Criteria with Details: ', error);
+ throw new Error('Failed to Modify Regular Evaluation Criteria with Details');
+ }
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* FUNCTION FOR REMOVING CRITERIA WITH DETAILS */
+async function removeRegEvalCriteria(id: number) {
+ try {
+ return await db.transaction(async (tx) => {
+ return await deleteRegEvalCriteria(tx, id);
+ });
+ } catch (err) {
+ console.error('Error in Removing Regular Evaluation Criteria with Details: ', err);
+ throw new Error('Failed to Remove Regular Evaluation Criteria with Details');
+ }
+}
+
+/* FUNCTION FOR REMOVING CRITERIA DETAILS */
+async function removeRegEvalCriteriaDetails(id: number) {
+ try {
+ return await db.transaction(async (tx) => {
+ return await deleteRegEvalCriteriaDetails(tx, id);
+ });
+ } catch (err) {
+ console.error('Error in Removing Regular Evaluation Criteria Details: ', err);
+ throw new Error('Failed to Remove Regular Evaluation Criteria Details');
+ }
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* EXPORT */
+export {
+ createRegEvalCriteriaWithDetails,
+ modifyRegEvalCriteriaWithDetails,
+ getRegEvalCriteria,
+ getRegEvalCriteriaWithDetails,
+ removeRegEvalCriteria,
+ removeRegEvalCriteriaDetails,
+}; \ No newline at end of file
diff --git a/lib/evaluation-criteria/table/reg-eval-criteria-columns.tsx b/lib/evaluation-criteria/table/reg-eval-criteria-columns.tsx
new file mode 100644
index 00000000..7367fabb
--- /dev/null
+++ b/lib/evaluation-criteria/table/reg-eval-criteria-columns.tsx
@@ -0,0 +1,364 @@
+'use client';
+
+/* IMPORT */
+import { Badge } from '@/components/ui/badge';
+import { Button } from '@/components/ui/button';
+import { Checkbox } from '@/components/ui/checkbox';
+import { DataTableColumnHeaderSimple } from '@/components/data-table/data-table-column-simple-header';
+import { Dispatch, SetStateAction } from 'react';
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from '@/components/ui/dropdown-menu';
+import { PenToolIcon, TrashIcon } from 'lucide-react';
+import {
+ REG_EVAL_CRITERIA_CATEGORY,
+ REG_EVAL_CRITERIA_ITEM,
+ REG_EVAL_CRITERIA_CATEGORY2,
+ type RegEvalCriteriaView,
+} from '@/db/schema';
+import { type ColumnDef } from '@tanstack/react-table';
+import { type DataTableRowAction } from '@/types/table';
+
+// ----------------------------------------------------------------------------------------------------
+
+/* TYPES */
+interface GetColumnsProps {
+ setRowAction: Dispatch<SetStateAction<DataTableRowAction<RegEvalCriteriaView> | null>>,
+};
+
+// ----------------------------------------------------------------------------------------------------
+
+/* FUNCTION FOR GETTING COLUMNS SETTING */
+function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<RegEvalCriteriaView>[] {
+
+ // [1] SELECT COLUMN - CHECKBOX
+ const selectColumn: ColumnDef<RegEvalCriteriaView> = {
+ 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"
+ />
+ ),
+ enableSorting: false,
+ enableHiding: false,
+ size:40,
+ };
+
+ // [2] CRITERIA COLUMNS
+ const criteriaColumns: ColumnDef<RegEvalCriteriaView>[] = [
+ {
+ accessorKey: 'category',
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="평가부문" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue<string>('category');
+ const label = REG_EVAL_CRITERIA_CATEGORY.find(item => item.value === value)?.label ?? value;
+ return (
+ <Badge variant="default">
+ {label}
+ </Badge>
+ );
+ },
+ enableSorting: true,
+ enableHiding: false,
+ meta: {
+ excelHeader: 'Category',
+ type: 'select',
+ },
+ },
+ {
+ accessorKey: 'scoreCategory',
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="점수구분" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue<string>('scoreCategory');
+ const label = REG_EVAL_CRITERIA_CATEGORY2.find(item => item.value === value)?.label ?? value;
+ return (
+ <Badge variant="secondary">
+ {label}
+ </Badge>
+ );
+ },
+ enableSorting: true,
+ enableHiding: false,
+ meta: {
+ excelHeader: 'Score Category',
+ type: 'select',
+ },
+ },
+ {
+ accessorKey: 'item',
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="항목" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue<string>('item');
+ const label = REG_EVAL_CRITERIA_ITEM.find(item => item.value === value)?.label ?? value;
+ return (
+ <Badge variant="outline">
+ {label}
+ </Badge>
+ );
+ },
+ enableSorting: true,
+ enableHiding: false,
+ meta: {
+ excelHeader: 'Item',
+ type: 'select',
+ },
+ },
+ {
+ accessorKey: 'classification',
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="구분" />
+ ),
+ cell: ({ row }) => (
+ <div className="font-regular">
+ {row.getValue('classification')}
+ </div>
+ ),
+ enableSorting: true,
+ enableHiding: false,
+ meta: {
+ excelHeader: 'Classification',
+ type: 'text',
+ },
+ },
+ {
+ accessorKey: 'range',
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="범위" />
+ ),
+ cell: ({ row }) => (
+ <div className="font-regular">
+ {row.getValue('range') || '-'}
+ </div>
+ ),
+ enableSorting: true,
+ enableHiding: false,
+ meta: {
+ excelHeader: 'Range',
+ type: 'text',
+ },
+ },
+ {
+ accessorKey: 'detail',
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="평가내용" />
+ ),
+ cell: ({ row }) => (
+ <div className="font-bold">
+ {row.getValue('detail')}
+ </div>
+ ),
+ enableSorting: true,
+ enableHiding: false,
+ meta: {
+ excelHeader: 'Detail',
+ type: 'text',
+ },
+ },
+ ];
+
+ // [3] SCORE COLUMNS
+ const scoreEquipColumns: ColumnDef<RegEvalCriteriaView>[] = [
+ {
+ accessorKey: 'scoreEquipShip',
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="조선" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue<string>('scoreEquipShip');
+ const displayValue = typeof value === 'string'
+ ? parseFloat(parseFloat(value).toFixed(2)).toString()
+ : '-';
+ return (
+ <div className="font-bold">
+ {displayValue}
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: false,
+ meta: {
+ excelHeader: 'Equipment-Shipbuilding Score',
+ group: 'Equipment Score',
+ type: 'number',
+ },
+ },
+ {
+ accessorKey: 'scoreEquipMarine',
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="해양" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue<string>('scoreEquipMarine');
+ const displayValue = typeof value === 'string'
+ ? parseFloat(parseFloat(value).toFixed(2)).toString()
+ : '-';
+ return (
+ <div className="font-bold">
+ {displayValue}
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: false,
+ meta: {
+ excelHeader: 'Equipment-Marine Engineering Score',
+ group: 'Equipment Score',
+ type: 'number',
+ },
+ },
+ ];
+ const scoreBulkColumns: ColumnDef<RegEvalCriteriaView>[] = [
+ {
+ accessorKey: 'scoreBulkShip',
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="조선" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue<string>('scoreBulkShip');
+ const displayValue = typeof value === 'string'
+ ? parseFloat(parseFloat(value).toFixed(2)).toString()
+ : '-';
+ return (
+ <div className="font-bold">
+ {displayValue}
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: false,
+ meta: {
+ excelHeader: 'Bulk-Shipbuiling Score',
+ group: 'Bulk Score',
+ type: 'number',
+ },
+ },
+ {
+ accessorKey: 'scoreBulkMarine',
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="해양" />
+ ),
+ cell: ({ row }) => {
+ const value = row.getValue<string>('scoreBulkMarine');
+ const displayValue = typeof value === 'string'
+ ? parseFloat(parseFloat(value).toFixed(2)).toString()
+ : '-';
+ return (
+ <div className="font-bold">
+ {displayValue}
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: false,
+ meta: {
+ excelHeader: 'Bulk-Marine Engineering Score',
+ group: 'Bulk Score',
+ type: 'number',
+ },
+ },
+ ];
+
+ // [4] REMARKS COLUMN
+ const remarksColumn: ColumnDef<RegEvalCriteriaView> = {
+ accessorKey: 'remarks',
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="비고" />
+ ),
+ cell: ({ row }) => (
+ <div className="font-regular">
+ {row.getValue('remarks') || '-'}
+ </div>
+ ),
+ enableSorting: true,
+ enableHiding: false,
+ meta: {
+ excelHeader: 'Remarks',
+ type: 'text',
+ },
+ };
+
+ // [5] ACTIONS COLUMN - DROPDOWN MENU
+ const actionsColumn: ColumnDef<RegEvalCriteriaView> = {
+ id: 'actions',
+ header: '작업',
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ return (
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="ghost" size="icon">
+ <PenToolIcon className="h-4 w-4" />
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuItem
+ onClick={() => setRowAction({ row, type: 'update' })}
+ >
+ <PenToolIcon className="mr-2 h-4 w-4" />
+ Modify Criteria
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onClick={() => setRowAction({ row, type: 'delete' })}
+ className="text-destructive"
+ >
+ <TrashIcon className="mr-2 h-4 w-4" />
+ Delete Criteria
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 80,
+ };
+
+ return [
+ selectColumn,
+ ...criteriaColumns,
+ {
+ id: 'score',
+ header: '배점',
+ columns: [
+ {
+ id: 'scoreEquip',
+ header: '기자재',
+ columns: scoreEquipColumns,
+ },
+ {
+ id: 'scoreBulk',
+ header: '벌크',
+ columns: scoreBulkColumns,
+ },
+ ],
+ },
+ remarksColumn,
+ actionsColumn,
+ ];
+};
+
+// ----------------------------------------------------------------------------------------------------
+
+/* EXPORT */
+export default getColumns; \ No newline at end of file
diff --git a/lib/evaluation-criteria/table/reg-eval-criteria-delete-dialog.tsx b/lib/evaluation-criteria/table/reg-eval-criteria-delete-dialog.tsx
new file mode 100644
index 00000000..aac7db29
--- /dev/null
+++ b/lib/evaluation-criteria/table/reg-eval-criteria-delete-dialog.tsx
@@ -0,0 +1,147 @@
+'use client';
+
+/* IMPORT */
+import { Button } from '@/components/ui/button';
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog';
+import {
+ getRegEvalCriteriaWithDetails,
+ removeRegEvalCriteria,
+} from '../service';
+import { LoaderCircle } from 'lucide-react';
+import { toast } from 'sonner';
+import {
+ REG_EVAL_CRITERIA_CATEGORY,
+ REG_EVAL_CRITERIA_ITEM,
+ REG_EVAL_CRITERIA_SCORE_CATEGORY,
+ type RegEvalCriteriaView,
+ type RegEvalCriteriaWithDetails,
+} from '@/db/schema';
+import { useEffect, useState } from 'react';
+
+// ----------------------------------------------------------------------------------------------------
+
+/* TYPES */
+interface RegEvalCriteriaDeleteDialogProps {
+ open: boolean,
+ onOpenChange: (open: boolean) => void,
+ criteriaViewData: RegEvalCriteriaView,
+ onSuccess: () => void,
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* REGULAR EVALUATION CRITERIA DELETE DIALOG COMPONENT */
+function RegEvalCriteriaDeleteDialog(props: RegEvalCriteriaDeleteDialogProps) {
+ const { open, onOpenChange, criteriaViewData, onSuccess } = props;
+ const [isLoading, setIsLoading] = useState<boolean>(false);
+ const [isDeleting, setIsDeleting] = useState<boolean>(false);
+ const [targetData, setTargetData] = useState<RegEvalCriteriaWithDetails | null>();
+
+ useEffect(() => {
+ const fetchData = async () => {
+ if (!criteriaViewData?.criteriaId) {
+ return;
+ }
+ setIsLoading(true);
+ try {
+ const result = await getRegEvalCriteriaWithDetails(criteriaViewData.criteriaId);
+ setTargetData(result);
+ } catch (error) {
+ console.error('Error in Loading Target Data for Deletion: ', error);
+ } finally {
+ setIsLoading(false);
+ }
+ }
+ fetchData();
+ }, [criteriaViewData.criteriaId]);
+
+ const handleDelete = async () => {
+ if (!criteriaViewData || !criteriaViewData.criteriaId) {
+ return;
+ }
+
+ try {
+ setIsDeleting(true);
+ await removeRegEvalCriteria(criteriaViewData.criteriaId);
+ toast.success('평가 기준이 삭제되었습니다.');
+ onSuccess();
+ } catch (error) {
+ console.error('Error in Deleting Regular Evaluation Criteria: ', error);
+ toast.error(
+ error instanceof Error ? error.message : '삭제 중 오류가 발생했습니다.'
+ );
+ } finally {
+ setIsDeleting(false);
+ }
+ }
+
+ if (!criteriaViewData) {
+ return null;
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent>
+ {isLoading ? (
+ <div className="flex flex-col items-center justify-center h-32">
+ <LoaderCircle className="h-8 w-8 animate-spin" />
+ <p className="mt-4 text-base text-gray-700">Loading...</p>
+ </div>
+ ) : (
+ <>
+ <DialogHeader>
+ <DialogTitle>협력업체 평가 기준 삭제</DialogTitle>
+ <DialogDescription>
+ 정말로 이 협력업체 평가 기준을 삭제하시겠습니까?
+ <br />
+ <br />
+ <strong>삭제될 평가 기준:</strong>
+ <br />
+ • 평가부문: {REG_EVAL_CRITERIA_CATEGORY.find((c) => c.value === criteriaViewData.category)?.label ?? '-'}
+ <br />
+ • 점수구분: {REG_EVAL_CRITERIA_SCORE_CATEGORY.find((c) => c.value === criteriaViewData.scoreCategory)?.label ?? '-'}
+ <br />
+ • 항목: {REG_EVAL_CRITERIA_ITEM.find((c) => c.value === criteriaViewData.item)?.label ?? '-'}
+ <br />
+ • 구분: {criteriaViewData.classification || '-'}
+ <br />
+ • 범위: {criteriaViewData.range || '-'}
+ <br />
+ <br />
+ <b>이 작업은 되돌릴 수 없으며</b>, 평가기준과 그에 속한 <b>{targetData?.criteriaDetails.length}개의 평가항목도 함께 삭제</b>됩니다.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isDeleting}
+ >
+ Cancel
+ </Button>
+ <Button
+ variant="destructive"
+ onClick={handleDelete}
+ disabled={isDeleting}
+ >
+ {isDeleting ? 'Deleting...' : 'Delete'}
+ </Button>
+ </DialogFooter>
+ </>
+ )}
+ </DialogContent>
+ </Dialog>
+ )
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* EXPORT */
+export default RegEvalCriteriaDeleteDialog; \ No newline at end of file
diff --git a/lib/evaluation-criteria/table/reg-eval-criteria-form-sheet.tsx b/lib/evaluation-criteria/table/reg-eval-criteria-form-sheet.tsx
new file mode 100644
index 00000000..d33e7d29
--- /dev/null
+++ b/lib/evaluation-criteria/table/reg-eval-criteria-form-sheet.tsx
@@ -0,0 +1,586 @@
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+'use client';
+
+/* IMPORT */
+import { Button } from '@/components/ui/button';
+import {
+ Card,
+ CardContent,
+ CardDescription,
+ CardHeader,
+ CardTitle,
+} from '@/components/ui/card';
+import {
+ createRegEvalCriteriaWithDetails,
+ getRegEvalCriteriaWithDetails,
+ modifyRegEvalCriteriaWithDetails,
+} from '../service';
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from '@/components/ui/form';
+import { Input } from '@/components/ui/input';
+import { Plus, Trash2 } from 'lucide-react';
+import {
+ REG_EVAL_CRITERIA_CATEGORY,
+ REG_EVAL_CRITERIA_CATEGORY2,
+ REG_EVAL_CRITERIA_ITEM,
+ type RegEvalCriteriaDetails,
+ type RegEvalCriteriaView,
+} from '@/db/schema';
+import { ScrollArea } from '@/components/ui/scroll-area';
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue
+} from '@/components/ui/select';
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from '@/components/ui/sheet';
+import { Textarea } from '@/components/ui/textarea';
+import { toast } from 'sonner';
+import { useForm, useFieldArray } from 'react-hook-form';
+import { useEffect, useTransition } from 'react';
+import { z } from 'zod';
+import { zodResolver } from '@hookform/resolvers/zod';
+
+// ----------------------------------------------------------------------------------------------------
+
+/* TYPES */
+const regEvalCriteriaFormSchema = z.object({
+ category: z.string().min(1, '평가부문은 필수 항목입니다.'),
+ scoreCategory: z.string().min(1, '점수구분은 필수 항목입니다.'),
+ item: z.string().min(1, '항목은 필수 항목입니다.'),
+ classification: z.string().min(1, '구분은 필수 항목입니다.'),
+ range: z.string().nullable().optional(),
+ remarks: z.string().nullable().optional(),
+ criteriaDetails: z.array(
+ z.object({
+ id: z.number().optional(),
+ detail: z.string().min(1, '평가내용은 필수 항목입니다.'),
+ scoreEquipShip: z.coerce.number().nullable().optional(),
+ scoreEquipMarine: z.coerce.number().nullable().optional(),
+ scoreBulkShip: z.coerce.number().nullable().optional(),
+ scoreBulkMarine: z.coerce.number().nullable().optional(),
+ })
+ ).min(1, '최소 1개의 평가 내용이 필요합니다.'),
+});
+type RegEvalCriteriaFormData = z.infer<typeof regEvalCriteriaFormSchema>;
+interface CriteriaDetailFormProps {
+ index: number
+ form: any
+ onRemove: () => void
+ canRemove: boolean
+ disabled?: boolean
+}
+interface RegEvalCriteriaFormSheetProps {
+ open: boolean,
+ onOpenChange: (open: boolean) => void,
+ criteriaViewData: RegEvalCriteriaView | null,
+ onSuccess: () => void,
+};
+
+// ----------------------------------------------------------------------------------------------------
+
+/* CRITERIA DETAIL FORM COPONENT */
+function CriteriaDetailForm({
+ index,
+ form,
+ onRemove,
+ canRemove,
+ disabled = false,
+}: CriteriaDetailFormProps) {
+
+ return (
+ <Card>
+ <CardHeader>
+ <div className="flex items-center justify-between">
+ <CardTitle className="text-lg">Detail Item - {index + 1}</CardTitle>
+ {canRemove && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={onRemove}
+ className="text-destructive hover:text-destructive"
+ disabled={disabled}
+ >
+ <Trash2 className="w-4 h-4" />
+ </Button>
+ )}
+ </div>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <FormField
+ control={form.control}
+ name={`criteriaDetails.${index}.id`}
+ render={({ field }) => (
+ <Input type="hidden" {...field} />
+ )}
+ />
+ <FormField
+ control={form.control}
+ name={`criteriaDetails.${index}.detail`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>평가내용</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="평가내용을 입력하세요."
+ {...field}
+ disabled={disabled}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name={`criteriaDetails.${index}.scoreEquipShip`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>배점/기자재/조선</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ step="0.1"
+ placeholder="배점/기자재/조선"
+ {...field}
+ value={field.value ?? 0}
+ disabled={disabled}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name={`criteriaDetails.${index}.scoreEquipMarine`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>배점/기자재/해양</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ step="0.1"
+ placeholder="배점/기자재/해양"
+ {...field}
+ value={field.value ?? 0}
+ disabled={disabled}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name={`criteriaDetails.${index}.scoreBulkShip`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>배점/벌크/조선</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ step="0.1"
+ placeholder="배점/벌크/조선"
+ {...field}
+ value={field.value ?? 0}
+ disabled={disabled}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name={`criteriaDetails.${index}.scoreBulkMarine`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>배점/벌크/해양</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ step="0.1"
+ placeholder="배점/벌크/해양"
+ {...field}
+ value={field.value ?? 0}
+ disabled={disabled}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </CardContent>
+ </Card>
+ )
+}
+
+/* CRITERIA FORM SHEET COPONENT */
+function RegEvalCriteriaFormSheet({
+ open,
+ onOpenChange,
+ criteriaViewData,
+ onSuccess,
+}: RegEvalCriteriaFormSheetProps) {
+ const [isPending, startTransition] = useTransition();
+ const isUpdateMode = !!criteriaViewData;
+
+ const form = useForm<RegEvalCriteriaFormData>({
+ resolver: zodResolver(regEvalCriteriaFormSchema),
+ defaultValues: {
+ category: '',
+ scoreCategory: '',
+ item: '',
+ classification: '',
+ range: '',
+ remarks: '',
+ criteriaDetails: [
+ {
+ id: undefined,
+ detail: '',
+ scoreEquipShip: null,
+ scoreEquipMarine: null,
+ scoreBulkShip: null,
+ scoreBulkMarine: null,
+ },
+ ],
+ },
+ });
+
+ const { fields, append, remove } = useFieldArray({
+ control: form.control,
+ name: 'criteriaDetails',
+ });
+
+ useEffect(() => {
+ if (open && isUpdateMode && criteriaViewData) {
+ startTransition(async () => {
+ try {
+ const targetData = await getRegEvalCriteriaWithDetails(criteriaViewData.criteriaId!);
+ if (targetData) {
+ form.reset({
+ category: targetData.category,
+ scoreCategory: targetData.scoreCategory,
+ item: targetData.item,
+ classification: targetData.classification,
+ range: targetData.range,
+ remarks: targetData.remarks,
+ criteriaDetails: targetData.criteriaDetails?.map((detailItem: RegEvalCriteriaDetails) => ({
+ id: detailItem.id,
+ detail: detailItem.detail,
+ scoreEquipShip: detailItem.scoreEquipShip !== null
+ ? Number(detailItem.scoreEquipShip) : null,
+ scoreEquipMarine: detailItem.scoreEquipMarine !== null
+ ? Number(detailItem.scoreEquipMarine) : null,
+ scoreBulkShip: detailItem.scoreBulkShip !== null
+ ? Number(detailItem.scoreBulkShip) : null,
+ scoreBulkMarine: detailItem.scoreBulkMarine !== null
+ ? Number(detailItem.scoreBulkMarine) : null,
+ })) || [],
+ })
+ }
+ } catch (error) {
+ console.error('Error in Loading Regular Evaluation Criteria for Updating:', error)
+ toast.error(error instanceof Error ? error.message : '편집할 데이터를 불러오는 데 실패했습니다.')
+ }
+ })
+ } else if (open && !isUpdateMode) {
+ form.reset({
+ category: '',
+ scoreCategory: '',
+ item: '',
+ classification: '',
+ range: '',
+ remarks: '',
+ criteriaDetails: [
+ {
+ id: undefined,
+ detail: '',
+ scoreEquipShip: null,
+ scoreEquipMarine: null,
+ scoreBulkShip: null,
+ scoreBulkMarine: null,
+ },
+ ],
+ })
+ }
+ }, [open, isUpdateMode, criteriaViewData, form]);
+
+ const onSubmit = async (data: RegEvalCriteriaFormData) => {
+ startTransition(async () => {
+ try {
+ const criteriaData = {
+ category: data.category,
+ scoreCategory: data.scoreCategory,
+ item: data.item,
+ classification: data.classification,
+ range: data.range,
+ remarks: data.remarks,
+ };
+ const detailList = data.criteriaDetails.map((detailItem) => ({
+ id: detailItem.id,
+ detail: detailItem.detail,
+ scoreEquipShip: detailItem.scoreEquipShip != null
+ ? String(detailItem.scoreEquipShip) : null,
+ scoreEquipMarine: detailItem.scoreEquipMarine != null
+ ? String(detailItem.scoreEquipMarine) : null,
+ scoreBulkShip: detailItem.scoreBulkShip != null
+ ? String(detailItem.scoreBulkShip) : null,
+ scoreBulkMarine: detailItem.scoreBulkMarine != null
+ ? String(detailItem.scoreBulkMarine) : null,
+ }));
+
+ if (isUpdateMode && criteriaViewData) {
+ await modifyRegEvalCriteriaWithDetails(criteriaViewData.criteriaId!, criteriaData, detailList);
+ toast.success('평가 기준표가 수정되었습니다.');
+ } else {
+ await createRegEvalCriteriaWithDetails(criteriaData, detailList);
+ toast.success('평가 기준표가 생성되었습니다.');
+ }
+ onSuccess();
+ onOpenChange(false);
+ } catch (error) {
+ console.error('Error in Saving Regular Evaluation Criteria:', error);
+ toast.error(
+ error instanceof Error ? error.message : '평가 기준표 저장 중 오류가 발생했습니다.'
+ );
+ }
+ })
+ }
+
+ if (!open) {
+ return null;
+ }
+
+ return (
+ <Sheet open={open} onOpenChange={onOpenChange}>
+ <SheetContent className="w-[900px] sm:max-w-[900px] overflow-y-auto">
+ <SheetHeader className="mb-4">
+ <SheetTitle className="font-bold">
+ {isUpdateMode ? '협력업체 평가 기준표 수정' : '새 협력업체 평가 기준표 생성'}
+ </SheetTitle>
+ <SheetDescription>
+ {isUpdateMode ? '협력업체 평가 기준표의 정보를 수정합니다.' : '새로운 협력업체 평가 기준표를 생성합니다.'}
+ </SheetDescription>
+ </SheetHeader>
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
+ <ScrollArea className="h-[calc(100vh-200px)] pr-4">
+ <div className="space-y-6">
+ <Card>
+ <CardHeader>
+ <CardTitle>Criterion Info</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <FormField
+ control={form.control}
+ name="category"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>평가부문</FormLabel>
+ <FormControl>
+ <Select onValueChange={field.onChange} value={field.value || ""}>
+ <SelectTrigger>
+ <SelectValue placeholder="선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {REG_EVAL_CRITERIA_CATEGORY.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="scoreCategory"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>점수구분</FormLabel>
+ <FormControl>
+ <Select onValueChange={field.onChange} value={field.value || ""}>
+ <SelectTrigger>
+ <SelectValue placeholder="선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {REG_EVAL_CRITERIA_CATEGORY2.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="item"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>항목</FormLabel>
+ <FormControl>
+ <Select onValueChange={field.onChange} value={field.value || ""}>
+ <SelectTrigger>
+ <SelectValue placeholder="선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {REG_EVAL_CRITERIA_ITEM.map((option) => (
+ <SelectItem key={option.value} value={option.value}>
+ {option.label}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="classification"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>구분</FormLabel>
+ <FormControl>
+ <Input placeholder="구분을 입력하세요." {...field} />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="range"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>범위</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="범위를 입력하세요." {...field}
+ value={field.value ?? ''}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <FormField
+ control={form.control}
+ name="remarks"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>비고</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="비고를 입력하세요."
+ {...field}
+ value={field.value ?? ''}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </CardContent>
+ </Card>
+ <Card>
+ <CardHeader>
+ <div className="flex items-center justify-between">
+ <div>
+ <CardTitle>Evaluation Criteria Item</CardTitle>
+ <CardDescription>
+ Set Evaluation Criteria Item.
+ </CardDescription>
+ </div>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ className="ml-4"
+ onClick={() =>
+ append({
+ id: undefined,
+ detail: '',
+ scoreEquipShip: null,
+ scoreEquipMarine: null,
+ scoreBulkShip: null,
+ scoreBulkMarine: null,
+ })
+ }
+ disabled={isPending}
+ >
+ <Plus className="w-4 h-4 mr-2" />
+ New Item
+ </Button>
+ </div>
+ </CardHeader>
+ <CardContent>
+ <div className="space-y-4">
+ {fields.map((field, index) => (
+ <CriteriaDetailForm
+ key={field.id}
+ index={index}
+ form={form}
+ onRemove={() => remove(index)}
+ canRemove={fields.length > 1}
+ disabled={isPending}
+ />
+ ))}
+ </div>
+ </CardContent>
+ </Card>
+ </div>
+ </ScrollArea>
+ <div className="flex justify-end gap-2 pt-4 border-t">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isPending}
+ >
+ Cancel
+ </Button>
+ <Button type="submit" disabled={isPending}>
+ {isPending
+ ? 'Saving...'
+ : isUpdateMode
+ ? 'Modify'
+ : 'Create'}
+ </Button>
+ </div>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* EXPORT */
+export default RegEvalCriteriaFormSheet; \ No newline at end of file
diff --git a/lib/evaluation-criteria/table/reg-eval-criteria-table-toolbar-actions.tsx b/lib/evaluation-criteria/table/reg-eval-criteria-table-toolbar-actions.tsx
new file mode 100644
index 00000000..95b2171e
--- /dev/null
+++ b/lib/evaluation-criteria/table/reg-eval-criteria-table-toolbar-actions.tsx
@@ -0,0 +1,161 @@
+'use client';
+
+/* IMPORT */
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger
+} from '@/components/ui/alert-dialog';
+import { Button } from '@/components/ui/button';
+import { Download, Plus, Trash2 } from 'lucide-react';
+import { exportTableToExcel } from '@/lib/export';
+import { removeRegEvalCriteria } from '../service';
+import { type RegEvalCriteriaView } from '@/db/schema';
+import { type Table } from '@tanstack/react-table';
+import { toast } from 'sonner';
+import { useMemo, useState } from 'react';
+
+// ----------------------------------------------------------------------------------------------------
+
+/* TYPES */
+interface RegEvalCriteriaTableToolbarActionsProps {
+ table: Table<RegEvalCriteriaView>,
+ onCreateCriteria?: () => void,
+ onRefresh?: () => void,
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* REGULAR EVALUATION CRITERIA TABLE TOOLBAR ACTIONS COMPONENT */
+function RegEvalCriteriaTableToolbarActions(props: RegEvalCriteriaTableToolbarActionsProps) {
+ const { table, onCreateCriteria, onRefresh } = props;
+ const [isDeleting, setIsDeleting] = useState<boolean>(false);
+ const selectedRows = table.getFilteredSelectedRowModel().rows;
+ const hasSelection = selectedRows.length > 0;
+ const selectedIds = useMemo(() => {
+ return [...new Set(selectedRows.map(row => row.original.criteriaId))];
+ }, [selectedRows]);
+
+ // Function for Create New Criteria
+ const handleCreateNew = () => {
+ if (!onCreateCriteria) {
+ return;
+ }
+ onCreateCriteria();
+ }
+
+ const handleDeleteSelected = async () => {
+ if (!hasSelection) {
+ return;
+ }
+ try {
+ setIsDeleting(true);
+
+ for (const selectedId of selectedIds) {
+ if (selectedId) {
+ await removeRegEvalCriteria(selectedId);
+ }
+ }
+ table.resetRowSelection();
+ toast.success(`${selectedIds.length}개의 평가 기준이 삭제되었습니다.`);
+
+ if (onRefresh) {
+ onRefresh();
+ } else {
+ window.location.reload();
+ }
+ } catch (error) {
+ console.error('Error in Deleting Regular Evaluation Critria: ', error);
+ toast.error(
+ error instanceof Error
+ ? error.message
+ : '평가 기준 삭제 중 오류가 발생했습니다.'
+ );
+ } finally {
+ setIsDeleting(false);
+ }
+ }
+
+ // Excel Export
+ const handleExport = () => {
+ try {
+ exportTableToExcel(table, {
+ filename: 'Regular_Evaluation_Criteria',
+ excludeColumns: ['select', 'actions'],
+ });
+ toast.success('Excel 파일이 다운로드되었습니다.');
+ } catch (error) {
+ console.error('Error in Exporting to Excel: ', error);
+ toast.error('Excel 내보내기 중 오류가 발생했습니다.');
+ }
+ };
+
+ return (
+ <div className="flex items-center gap-2">
+ <Button
+ variant="default"
+ size="sm"
+ className="gap-2"
+ onClick={handleCreateNew}
+ >
+ <Plus className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">New Criteria</span>
+ </Button>
+ {hasSelection && (
+ <AlertDialog>
+ <AlertDialogTrigger asChild>
+ <Button
+ variant="destructive"
+ size="sm"
+ className="gap-2"
+ disabled={isDeleting}
+ >
+ <Trash2 className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">
+ 선택 삭제 ({selectedIds.length})
+ </span>
+ </Button>
+ </AlertDialogTrigger>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>정말 삭제하시겠습니까?</AlertDialogTitle>
+ <AlertDialogDescription>
+ 선택된 {selectedIds.length}개의 협력업체 평가 기준 항목이 영구적으로 삭제됩니다.
+ 이 작업은 되돌릴 수 없으며, 연관된 평가 기준과 항목들도 함께 삭제됩니다.
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel>Cancel</AlertDialogCancel>
+ <AlertDialogAction
+ onClick={handleDeleteSelected}
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
+ >
+ {isDeleting ? 'Deleting...' : 'Delete'}
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ )}
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleExport}
+ className="gap-2"
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Export</span>
+ </Button>
+ </div>
+ );
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* EXPORT */
+export default RegEvalCriteriaTableToolbarActions; \ No newline at end of file
diff --git a/lib/evaluation-criteria/table/reg-eval-criteria-table.tsx b/lib/evaluation-criteria/table/reg-eval-criteria-table.tsx
new file mode 100644
index 00000000..a2242309
--- /dev/null
+++ b/lib/evaluation-criteria/table/reg-eval-criteria-table.tsx
@@ -0,0 +1,189 @@
+'use client';
+
+/* IMPORT */
+import { DataTable } from '@/components/data-table/data-table';
+import { DataTableAdvancedToolbar } from '@/components/data-table/data-table-advanced-toolbar';
+import getColumns from './reg-eval-criteria-columns';
+import { getRegEvalCriteria } from '../service';
+import {
+ REG_EVAL_CRITERIA_CATEGORY,
+ REG_EVAL_CRITERIA_ITEM,
+ REG_EVAL_CRITERIA_CATEGORY2,
+ type RegEvalCriteriaView
+} from '@/db/schema';
+import RegEvalCriteriaDeleteDialog from './reg-eval-criteria-delete-dialog';
+import RegEvalCriteriaFormSheet from './reg-eval-criteria-form-sheet';
+import RegEvalCriteriaTableToolbarActions from './reg-eval-criteria-table-toolbar-actions';
+import {
+ type DataTableFilterField,
+ type DataTableRowAction,
+ type DataTableAdvancedFilterField,
+} from '@/types/table';
+import { useDataTable } from '@/hooks/use-data-table';
+import { use, useCallback, useMemo, useState } from 'react';
+import { useRouter } from 'next/navigation';
+
+// ----------------------------------------------------------------------------------------------------
+
+/* TYPES */
+interface RegEvalCriteriaTableProps {
+ promises: Promise<[
+ Awaited<ReturnType<typeof getRegEvalCriteria>>,
+ ]>,
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* TABLE COMPONENT */
+function RegEvalCriteriaTable({ promises }: RegEvalCriteriaTableProps) {
+ const router = useRouter();
+ const [rowAction, setRowAction] = useState<DataTableRowAction<RegEvalCriteriaView> | null>(null);
+ const [isCreateFormOpen, setIsCreateFormOpen] = useState<boolean>(false);
+ const [promiseData] = use(promises);
+ const tableData = promiseData;
+
+ const columns = useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction],
+ );
+
+ const filterFields: DataTableFilterField<RegEvalCriteriaView>[] = [
+ {
+ id: 'category',
+ label: '평가부문',
+ placeholder: '평가부문 선택...',
+ },
+ {
+ id: 'scoreCategory',
+ label: '점수구분',
+ placeholder: '점수구분 선택...',
+ },
+ {
+ id: 'item',
+ label: '항목',
+ placeholder: '항목 선택...',
+ },
+ ]
+ const advancedFilterFields: DataTableAdvancedFilterField<RegEvalCriteriaView>[] = [
+ {
+ id: 'category',
+ label: '평가부문',
+ type: 'select',
+ options: REG_EVAL_CRITERIA_CATEGORY,
+ },
+ {
+ id: 'scoreCategory',
+ label: '점수구분',
+ type: 'select',
+ options: REG_EVAL_CRITERIA_CATEGORY2,
+ },
+ {
+ id: 'item',
+ label: '항목',
+ type: 'select',
+ options: REG_EVAL_CRITERIA_ITEM,
+ },
+ { id: 'classification', label: '구분', type: 'text' },
+ { id: 'range', label: '범위', type: 'text' },
+ { id: 'detail', label: '평가내용', type: 'text' },
+ { id: 'scoreEquipShip', label: '조선', type: 'number' },
+ { id: 'scoreEquipMarine', label: '해양', type: 'number' },
+ { id: 'scoreBulkShip', label: '조선', type: 'number' },
+ { id: 'scoreBulkMarine', label: '해양', type: 'number' },
+ { id: 'remarks', label: '비고', type: 'text' },
+ ];
+
+ // Data Table Setting
+ const { table } = useDataTable({
+ data: tableData.data,
+ columns,
+ pageCount: tableData.pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: 'id', desc: false }],
+ columnPinning: { left: ['select'], right: ['actions'] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ });
+
+ const emptyCriteriaViewData: RegEvalCriteriaView = {
+ id: 0,
+ category: '',
+ scoreCategory: '',
+ item: '',
+ classification: '',
+ range: null,
+ remarks: null,
+ criteriaId: 0,
+ detail: '',
+ orderIndex: null,
+ scoreEquipShip: null,
+ scoreEquipMarine: null,
+ scoreBulkShip: null,
+ scoreBulkMarine: null,
+ };
+
+ const refreshData = useCallback(() => {
+ router.refresh();
+ }, [router]);
+ const handleCreateCriteria = () => {
+ setIsCreateFormOpen(true);
+ };
+ const handleCreateSuccess = useCallback(() => {
+ setIsCreateFormOpen(false);
+ refreshData();
+ }, [refreshData]);
+ const handleModifySuccess = useCallback(() => {
+ setRowAction(null);
+ refreshData();
+ }, [refreshData]);
+ const handleDeleteSuccess = useCallback(() => {
+ setRowAction(null);
+ refreshData();
+ }, [refreshData]);
+
+ return (
+ <>
+ <DataTable table={table}>
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <RegEvalCriteriaTableToolbarActions
+ table={table}
+ onCreateCriteria={handleCreateCriteria}
+ onRefresh={refreshData}
+ />
+ </DataTableAdvancedToolbar>
+ </DataTable>
+ <RegEvalCriteriaFormSheet
+ open={isCreateFormOpen}
+ onOpenChange={setIsCreateFormOpen}
+ criteriaViewData={null}
+ onSuccess={handleCreateSuccess}
+ />
+ <RegEvalCriteriaFormSheet
+ open={rowAction?.type === 'update'}
+ onOpenChange={() => setRowAction(null)}
+ criteriaViewData={rowAction?.row.original ?? null}
+ onSuccess={handleModifySuccess}
+ />
+ <RegEvalCriteriaDeleteDialog
+ open={rowAction?.type === 'delete'}
+ onOpenChange={() => setRowAction(null)}
+ criteriaViewData={rowAction?.row.original ?? emptyCriteriaViewData}
+ onSuccess={handleDeleteSuccess}
+ />
+ </>
+ )
+}
+
+// ----------------------------------------------------------------------------------------------------
+
+/* EXPORT */
+export default RegEvalCriteriaTable;
diff --git a/lib/evaluation-criteria/validations.ts b/lib/evaluation-criteria/validations.ts
new file mode 100644
index 00000000..e9d5becc
--- /dev/null
+++ b/lib/evaluation-criteria/validations.ts
@@ -0,0 +1,41 @@
+/* IMPORT */
+import * as z from 'zod';
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from 'nuqs/server';
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { type RegEvalCriteriaView } from "@/db/schema";
+
+// ----------------------------------------------------------------------------------------------------
+
+/* QUERY PARAMETER SCHEMATA */
+const searchParamsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(['advancedTable', 'floatingBar'])).withDefault([]),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<RegEvalCriteriaView>().withDefault([{ id: 'id', desc: true }]),
+ tagTypeLabel: parseAsString.withDefault(''),
+ classLabel: parseAsString.withDefault(''),
+ formCode: parseAsString.withDefault(''),
+ formName: parseAsString.withDefault(''),
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(['and', 'or']).withDefault('and'),
+ search: parseAsString.withDefault(''),
+});
+
+// ----------------------------------------------------------------------------------------------------
+
+/* TYPES */
+type GetRegEvalCriteriaSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>;
+
+// ----------------------------------------------------------------------------------------------------
+
+/* EXPORT */
+export {
+ searchParamsCache,
+ type GetRegEvalCriteriaSchema,
+}; \ No newline at end of file
diff --git a/lib/evaluation-target-list/service.ts b/lib/evaluation-target-list/service.ts
index 572b468d..0da50fa2 100644
--- a/lib/evaluation-target-list/service.ts
+++ b/lib/evaluation-target-list/service.ts
@@ -17,13 +17,16 @@ import {
type DomesticForeign,
EVALUATION_DEPARTMENT_CODES,
EvaluationTargetWithDepartments,
- evaluationTargetsWithDepartments
+ evaluationTargetsWithDepartments,
+ periodicEvaluations,
+ reviewerEvaluations
} from "@/db/schema";
import { GetEvaluationTargetsSchema } from "./validation";
import { PgTransaction } from "drizzle-orm/pg-core";
import { getServerSession } from "next-auth/next"
import { authOptions } from "@/app/api/auth/[...nextauth]/route"
import { sendEmail } from "../mail/sendEmail";
+import type { SQL } from "drizzle-orm"
export async function selectEvaluationTargetsFromView(
tx: PgTransaction<any, any, any>,
@@ -60,76 +63,122 @@ export async function countEvaluationTargetsFromView(
// ============= 메인 서버 액션도 함께 수정 =============
+
export async function getEvaluationTargets(input: GetEvaluationTargetsSchema) {
try {
const offset = (input.page - 1) * input.perPage;
- // 고급 필터링 (View 테이블 기준)
- const advancedWhere = filterColumns({
- table: evaluationTargetsWithDepartments,
- filters: input.filters,
- joinOperator: input.joinOperator,
- });
+ // ✅ getRFQ 방식과 동일한 필터링 처리
+ // 1) 고급 필터 조건
+ let advancedWhere: SQL<unknown> | undefined = undefined;
+ if (input.filters && input.filters.length > 0) {
+ advancedWhere = filterColumns({
+ table: evaluationTargetsWithDepartments,
+ filters: input.filters,
+ joinOperator: input.joinOperator || 'and',
+ });
+ }
- // 베이직 필터링 (커스텀 필터)
- let basicWhere;
+ // 2) 기본 필터 조건
+ let basicWhere: SQL<unknown> | undefined = undefined;
if (input.basicFilters && input.basicFilters.length > 0) {
basicWhere = filterColumns({
table: evaluationTargetsWithDepartments,
filters: input.basicFilters,
- joinOperator: input.basicJoinOperator || "and",
+ joinOperator: input.basicJoinOperator || 'and',
});
}
- // 전역 검색 (View 테이블 기준)
- let globalWhere;
+ // 3) 글로벌 검색 조건
+ let globalWhere: SQL<unknown> | undefined = undefined;
if (input.search) {
const s = `%${input.search}%`;
- globalWhere = or(
- ilike(evaluationTargetsWithDepartments.vendorCode, s),
- ilike(evaluationTargetsWithDepartments.vendorName, s),
- ilike(evaluationTargetsWithDepartments.adminComment, s),
- ilike(evaluationTargetsWithDepartments.consolidatedComment, s),
- // 담당자 이름으로도 검색 가능
- ilike(evaluationTargetsWithDepartments.orderReviewerName, s),
- ilike(evaluationTargetsWithDepartments.procurementReviewerName, s),
- ilike(evaluationTargetsWithDepartments.qualityReviewerName, s),
- ilike(evaluationTargetsWithDepartments.designReviewerName, s),
- ilike(evaluationTargetsWithDepartments.csReviewerName, s)
- );
+
+ const validSearchConditions: SQL<unknown>[] = [];
+
+ const vendorCodeCondition = ilike(evaluationTargetsWithDepartments.vendorCode, s);
+ if (vendorCodeCondition) validSearchConditions.push(vendorCodeCondition);
+
+ const vendorNameCondition = ilike(evaluationTargetsWithDepartments.vendorName, s);
+ if (vendorNameCondition) validSearchConditions.push(vendorNameCondition);
+
+ const adminCommentCondition = ilike(evaluationTargetsWithDepartments.adminComment, s);
+ if (adminCommentCondition) validSearchConditions.push(adminCommentCondition);
+
+ const consolidatedCommentCondition = ilike(evaluationTargetsWithDepartments.consolidatedComment, s);
+ if (consolidatedCommentCondition) validSearchConditions.push(consolidatedCommentCondition);
+
+ // 담당자 이름으로도 검색
+ const orderReviewerCondition = ilike(evaluationTargetsWithDepartments.orderReviewerName, s);
+ if (orderReviewerCondition) validSearchConditions.push(orderReviewerCondition);
+
+ const procurementReviewerCondition = ilike(evaluationTargetsWithDepartments.procurementReviewerName, s);
+ if (procurementReviewerCondition) validSearchConditions.push(procurementReviewerCondition);
+
+ const qualityReviewerCondition = ilike(evaluationTargetsWithDepartments.qualityReviewerName, s);
+ if (qualityReviewerCondition) validSearchConditions.push(qualityReviewerCondition);
+
+ const designReviewerCondition = ilike(evaluationTargetsWithDepartments.designReviewerName, s);
+ if (designReviewerCondition) validSearchConditions.push(designReviewerCondition);
+
+ const csReviewerCondition = ilike(evaluationTargetsWithDepartments.csReviewerName, s);
+ if (csReviewerCondition) validSearchConditions.push(csReviewerCondition);
+
+ if (validSearchConditions.length > 0) {
+ globalWhere = or(...validSearchConditions);
+ }
}
- const finalWhere = and(advancedWhere, basicWhere, globalWhere);
+ // ✅ getRFQ 방식과 동일한 WHERE 조건 생성
+ const whereConditions: SQL<unknown>[] = [];
- // 정렬 (View 테이블 기준)
- const orderBy = input.sort.length > 0
- ? input.sort.map((item) => {
- const column = evaluationTargetsWithDepartments[item.id as keyof typeof evaluationTargetsWithDepartments];
- return item.desc ? desc(column) : asc(column);
- })
- : [desc(evaluationTargetsWithDepartments.createdAt)];
-
- // 데이터 조회 - View 테이블 사용
- const { data, total } = await db.transaction(async (tx) => {
- const data = await selectEvaluationTargetsFromView(tx, {
- where: finalWhere,
- orderBy,
- offset,
- limit: input.perPage,
- });
+ if (advancedWhere) whereConditions.push(advancedWhere);
+ if (basicWhere) whereConditions.push(basicWhere);
+ if (globalWhere) whereConditions.push(globalWhere);
+
+ const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined;
- const total = await countEvaluationTargetsFromView(tx, finalWhere);
- return { data, total };
+ // ✅ getRFQ 방식과 동일한 전체 데이터 수 조회 (Transaction 제거)
+ const totalResult = await db
+ .select({ count: count() })
+ .from(evaluationTargetsWithDepartments)
+ .where(finalWhere);
+
+ const total = totalResult[0]?.count || 0;
+
+ if (total === 0) {
+ return { data: [], pageCount: 0, total: 0 };
+ }
+
+ console.log("Total evaluation targets:", total);
+
+ // ✅ getRFQ 방식과 동일한 정렬 및 페이징 처리된 데이터 조회
+ const orderByColumns = input.sort.map((sort) => {
+ const column = sort.id as keyof typeof evaluationTargetsWithDepartments.$inferSelect;
+ return sort.desc ? desc(evaluationTargetsWithDepartments[column]) : asc(evaluationTargetsWithDepartments[column]);
});
+ if (orderByColumns.length === 0) {
+ orderByColumns.push(desc(evaluationTargetsWithDepartments.createdAt));
+ }
+
+ const evaluationData = await db
+ .select()
+ .from(evaluationTargetsWithDepartments)
+ .where(finalWhere)
+ .orderBy(...orderByColumns)
+ .limit(input.perPage)
+ .offset(offset);
+
const pageCount = Math.ceil(total / input.perPage);
- return { data, pageCount, total };
+
+ return { data: evaluationData, pageCount, total };
} catch (err) {
console.error("Error in getEvaluationTargets:", err);
- return { data: [], pageCount: 0 };
+ // ✅ getRFQ 방식과 동일한 에러 반환 (total 포함)
+ return { data: [], pageCount: 0, total: 0 };
}
}
-
// ============= 개별 조회 함수도 업데이트 =============
export async function getEvaluationTargetById(id: number): Promise<EvaluationTargetWithDepartments | null> {
@@ -377,7 +426,8 @@ export async function updateEvaluationTarget(input: UpdateEvaluationTargetInput)
console.log(input, "update input")
try {
- const session = await auth()
+ const session = await getServerSession(authOptions)
+
if (!session?.user) {
throw new Error("인증이 필요합니다.")
}
@@ -462,8 +512,8 @@ export async function updateEvaluationTarget(input: UpdateEvaluationTargetInput)
.values({
evaluationTargetId: input.id,
departmentCode: update.departmentCode,
- reviewerUserId: user[0].id,
- assignedBy: session.user.id,
+ reviewerUserId: Number(user[0].id),
+ assignedBy:Number( session.user.id),
})
}
}
@@ -553,7 +603,7 @@ export async function updateEvaluationTarget(input: UpdateEvaluationTargetInput)
consensusStatus: hasConsensus,
status: newStatus,
confirmedAt: hasConsensus ? new Date() : null,
- confirmedBy: hasConsensus ? session.user.id : null,
+ confirmedBy: hasConsensus ? Number(session.user.id) : null,
updatedAt: new Date()
})
.where(eq(evaluationTargets.id, input.id))
@@ -649,20 +699,27 @@ export async function getDepartmentInfo() {
}
-export async function confirmEvaluationTargets(targetIds: number[]) {
+export async function confirmEvaluationTargets(
+ targetIds: number[],
+ evaluationPeriod?: string // "상반기", "하반기", "연간" 등
+) {
try {
const session = await getServerSession(authOptions)
-
+
if (!session?.user) {
return { success: false, error: "인증이 필요합니다." }
}
-
+
if (targetIds.length === 0) {
return { success: false, error: "선택된 평가 대상이 없습니다." }
}
+ // 평가 기간이 없으면 현재 날짜 기준으로 자동 결정
+ // const currentPeriod = evaluationPeriod || getCurrentEvaluationPeriod()
+ const currentPeriod ="연간"
+
// 트랜잭션으로 처리
- await db.transaction(async (tx) => {
+ const result = await db.transaction(async (tx) => {
// 확정 가능한 대상들 확인 (PENDING 상태이면서 consensusStatus가 true인 것들)
const eligibleTargets = await tx
.select()
@@ -674,13 +731,14 @@ export async function confirmEvaluationTargets(targetIds: number[]) {
eq(evaluationTargets.consensusStatus, true)
)
)
-
+
if (eligibleTargets.length === 0) {
throw new Error("확정 가능한 평가 대상이 없습니다. (의견 일치 상태인 대기중 항목만 확정 가능)")
}
-
- // 상태를 CONFIRMED로 변경
+
const confirmedTargetIds = eligibleTargets.map(target => target.id)
+
+ // 1. 평가 대상 상태를 CONFIRMED로 변경
await tx
.update(evaluationTargets)
.set({
@@ -690,26 +748,201 @@ export async function confirmEvaluationTargets(targetIds: number[]) {
updatedAt: new Date()
})
.where(inArray(evaluationTargets.id, confirmedTargetIds))
+
+ // 2. 각 확정된 평가 대상에 대해 periodicEvaluations 레코드 생성
+ const periodicEvaluationsToCreate = []
+
+ for (const target of eligibleTargets) {
+ // 이미 해당 기간에 평가가 존재하는지 확인
+ const existingEvaluation = await tx
+ .select({ id: periodicEvaluations.id })
+ .from(periodicEvaluations)
+ .where(
+ and(
+ eq(periodicEvaluations.evaluationTargetId, target.id),
+ // eq(periodicEvaluations.evaluationPeriod, currentPeriod)
+ )
+ )
+ .limit(1)
+
+ // 없으면 생성 목록에 추가
+ if (existingEvaluation.length === 0) {
+ periodicEvaluationsToCreate.push({
+ evaluationTargetId: target.id,
+ evaluationPeriod: currentPeriod,
+ // 평가년도에 따른 제출 마감일 설정 (예: 상반기는 7월 말, 하반기는 1월 말)
+ submissionDeadline: getSubmissionDeadline(target.evaluationYear, currentPeriod),
+ status: "PENDING_SUBMISSION" as const,
+ createdAt: new Date(),
+ updatedAt: new Date()
+ })
+ }
+ }
+
+ // 3. periodicEvaluations 레코드들 일괄 생성
+ let createdEvaluationsCount = 0
+ if (periodicEvaluationsToCreate.length > 0) {
+ const createdEvaluations = await tx
+ .insert(periodicEvaluations)
+ .values(periodicEvaluationsToCreate)
+ .returning({ id: periodicEvaluations.id })
+
+ createdEvaluationsCount = createdEvaluations.length
+ }
+
+ // 4. 평가 항목 수 조회 (evaluationSubmissions 생성을 위해)
+ const [generalItemsCount, esgItemsCount] = await Promise.all([
+ // 활성화된 일반평가 항목 수
+ tx.select({ count: count() })
+ .from(generalEvaluations)
+ .where(eq(generalEvaluations.isActive, true)),
+
+ // 활성화된 ESG 평가항목 수
+ tx.select({ count: count() })
+ .from(esgEvaluationItems)
+ .where(eq(esgEvaluationItems.isActive, true))
+ ])
+
+ const totalGeneralItems = generalItemsCount[0]?.count || 0
+ const totalEsgItems = esgItemsCount[0]?.count || 0
- return confirmedTargetIds
+ // 5. 각 periodicEvaluation에 대해 담당자별 reviewerEvaluations도 생성
+ if (periodicEvaluationsToCreate.length > 0) {
+ // 새로 생성된 periodicEvaluations 조회
+ const newPeriodicEvaluations = await tx
+ .select({
+ id: periodicEvaluations.id,
+ evaluationTargetId: periodicEvaluations.evaluationTargetId
+ })
+ .from(periodicEvaluations)
+ .where(
+ and(
+ inArray(periodicEvaluations.evaluationTargetId, confirmedTargetIds),
+ eq(periodicEvaluations.evaluationPeriod, currentPeriod)
+ )
+ )
+
+ // 각 평가에 대해 담당자별 reviewerEvaluations 생성
+ for (const periodicEval of newPeriodicEvaluations) {
+ // 해당 evaluationTarget의 담당자들 조회
+ const reviewers = await tx
+ .select()
+ .from(evaluationTargetReviewers)
+ .where(eq(evaluationTargetReviewers.evaluationTargetId, periodicEval.evaluationTargetId))
+
+ if (reviewers.length > 0) {
+ const reviewerEvaluationsToCreate = reviewers.map(reviewer => ({
+ periodicEvaluationId: periodicEval.id,
+ evaluationTargetReviewerId: reviewer.id,
+ isCompleted: false,
+ createdAt: new Date(),
+ updatedAt: new Date()
+ }))
+
+ await tx
+ .insert(reviewerEvaluations)
+ .values(reviewerEvaluationsToCreate)
+ }
+ }
+ }
+
+ // 6. 벤더별 evaluationSubmissions 레코드 생성
+ const evaluationSubmissionsToCreate = []
+
+ for (const target of eligibleTargets) {
+ // 이미 해당 년도/기간에 제출 레코드가 있는지 확인
+ const existingSubmission = await tx
+ .select({ id: evaluationSubmissions.id })
+ .from(evaluationSubmissions)
+ .where(
+ and(
+ eq(evaluationSubmissions.companyId, target.vendorId),
+ eq(evaluationSubmissions.evaluationYear, target.evaluationYear),
+ // eq(evaluationSubmissions.evaluationRound, currentPeriod)
+ )
+ )
+ .limit(1)
+
+ // 없으면 생성 목록에 추가
+ if (existingSubmission.length === 0) {
+ evaluationSubmissionsToCreate.push({
+ companyId: target.vendorId,
+ evaluationYear: target.evaluationYear,
+ evaluationRound: currentPeriod,
+ submissionStatus: "draft" as const,
+ totalGeneralItems: totalGeneralItems,
+ completedGeneralItems: 0,
+ totalEsgItems: totalEsgItems,
+ completedEsgItems: 0,
+ isActive: true,
+ createdAt: new Date(),
+ updatedAt: new Date()
+ })
+ }
+ }
+
+ // 7. evaluationSubmissions 레코드들 일괄 생성
+ let createdSubmissionsCount = 0
+ if (evaluationSubmissionsToCreate.length > 0) {
+ const createdSubmissions = await tx
+ .insert(evaluationSubmissions)
+ .values(evaluationSubmissionsToCreate)
+ .returning({ id: evaluationSubmissions.id })
+
+ createdSubmissionsCount = createdSubmissions.length
+ }
+
+ return {
+ confirmedTargetIds,
+ createdEvaluationsCount,
+ createdSubmissionsCount,
+ totalConfirmed: confirmedTargetIds.length
+ }
})
-
-
- return {
- success: true,
- message: `${targetIds.length}개 평가 대상이 확정되었습니다.`,
- confirmedCount: targetIds.length
+
+ return {
+ success: true,
+ message: `${result.totalConfirmed}개 평가 대상이 확정되었습니다. ${result.createdEvaluationsCount}개의 정기평가와 ${result.createdSubmissionsCount}개의 제출 요청이 생성되었습니다.`,
+ confirmedCount: result.totalConfirmed,
+ createdEvaluationsCount: result.createdEvaluationsCount,
+ createdSubmissionsCount: result.createdSubmissionsCount
}
-
+
} catch (error) {
console.error("Error confirming evaluation targets:", error)
- return {
- success: false,
- error: error instanceof Error ? error.message : "확정 처리 중 오류가 발생했습니다."
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "확정 처리 중 오류가 발생했습니다."
}
}
}
+
+// 현재 날짜 기준으로 평가 기간 결정하는 헬퍼 함수
+function getCurrentEvaluationPeriod(): string {
+ const now = new Date()
+ const month = now.getMonth() + 1 // 0-based이므로 +1
+
+ // 1~6월: 상반기, 7~12월: 하반기
+ return month <= 6 ? "상반기" : "하반기"
+}
+
+// 평가년도와 기간에 따른 제출 마감일 설정하는 헬퍼 함수
+function getSubmissionDeadline(evaluationYear: number, period: string): Date {
+ const year = evaluationYear
+
+ if (period === "상반기") {
+ // 상반기 평가는 다음 해 6월 말까지
+ return new Date(year, 5, 31) // 7월은 6 (0-based)
+ } else if (period === "하반기") {
+ // 하반기 평가는 다음 올해 12월 말까지
+ return new Date(year, 11, 31) // 1월은 0 (0-based)
+ } else {
+ // 연간 평가는 올해 6월 말까지
+ return new Date(year, 5, 31) // 3월은 2 (0-based)
+ }
+}
+
export async function excludeEvaluationTargets(targetIds: number[]) {
try {
const session = await getServerSession(authOptions)
@@ -769,7 +1002,8 @@ export async function excludeEvaluationTargets(targetIds: number[]) {
export async function requestEvaluationReview(targetIds: number[], message?: string) {
try {
- const session = await auth()
+ const session = await getServerSession(authOptions)
+
if (!session?.user) {
return { success: false, error: "인증이 필요합니다." }
}
diff --git a/lib/evaluation-target-list/table/evaluation-target-table.tsx b/lib/evaluation-target-list/table/evaluation-target-table.tsx
index fe0b3188..87be3589 100644
--- a/lib/evaluation-target-list/table/evaluation-target-table.tsx
+++ b/lib/evaluation-target-list/table/evaluation-target-table.tsx
@@ -1,76 +1,61 @@
-"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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
-import { Badge } from "@/components/ui/badge"
-import { Skeleton } from "@/components/ui/skeleton"
+// ============================================================================
+// components/evaluation-targets-table.tsx (CLIENT COMPONENT)
+// ─ 완전본 ─ evaluation-targets-columns.tsx 는 별도
+// ============================================================================
+"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 { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
+import { Badge } from "@/components/ui/badge";
+import { Skeleton } from "@/components/ui/skeleton";
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 { getEvaluationTargets, getEvaluationTargetsStats } from "../service"
-import { cn } from "@/lib/utils"
-import { useTablePresets } from "@/components/data-table/use-table-presets"
-import { TablePresetManager } from "@/components/data-table/data-table-preset"
-import { useMemo } from "react"
-import { getEvaluationTargetsColumns } from "./evaluation-targets-columns"
-import { EvaluationTargetsTableToolbarActions } from "./evaluation-targets-toolbar-actions"
-import { EvaluationTargetFilterSheet } from "./evaluation-targets-filter-sheet"
-import { EvaluationTargetWithDepartments } from "@/db/schema"
-import { EditEvaluationTargetSheet } from "./update-evaluation-target"
-
-interface EvaluationTargetsTableProps {
- promises: Promise<[Awaited<ReturnType<typeof getEvaluationTargets>>]>
- evaluationYear: number
- className?: string
-}
-
-// 통계 카드 컴포넌트 (클라이언트 컴포넌트용)
+} 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 { getEvaluationTargets, getEvaluationTargetsStats } from "../service";
+import { cn } from "@/lib/utils";
+import { useTablePresets } from "@/components/data-table/use-table-presets";
+import { TablePresetManager } from "@/components/data-table/data-table-preset";
+import { getEvaluationTargetsColumns } from "./evaluation-targets-columns";
+import { EvaluationTargetsTableToolbarActions } from "./evaluation-targets-toolbar-actions";
+import { EvaluationTargetFilterSheet } from "./evaluation-targets-filter-sheet";
+import { EvaluationTargetWithDepartments } from "@/db/schema";
+import { EditEvaluationTargetSheet } from "./update-evaluation-target";
+
+/* -------------------------------------------------------------------------- */
+/* Stats Card */
+/* -------------------------------------------------------------------------- */
function EvaluationTargetsStats({ evaluationYear }: { evaluationYear: number }) {
- const [stats, setStats] = React.useState<any>(null)
- const [isLoading, setIsLoading] = React.useState(true)
- const [error, setError] = React.useState<string | null>(null)
+ const [stats, setStats] = React.useState<any>(null);
+ const [isLoading, setIsLoading] = React.useState(true);
+ const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
- let isMounted = true
-
- async function fetchStats() {
+ let mounted = true;
+ (async () => {
try {
- setIsLoading(true)
- setError(null)
- const statsData = await getEvaluationTargetsStats(evaluationYear)
-
- if (isMounted) {
- setStats(statsData)
- }
- } catch (err) {
- if (isMounted) {
- setError(err instanceof Error ? err.message : 'Failed to fetch stats')
- console.error('Error fetching evaluation targets stats:', err)
- }
+ setIsLoading(true);
+ const data = await getEvaluationTargetsStats(evaluationYear);
+ mounted && setStats(data);
+ } catch (e) {
+ mounted && setError(e instanceof Error ? e.message : "failed");
} finally {
- if (isMounted) {
- setIsLoading(false)
- }
+ mounted && setIsLoading(false);
}
- }
-
- fetchStats()
-
+ })();
return () => {
- isMounted = false
- }
- }, [])
+ mounted = false;
+ };
+ }, [evaluationYear]);
- if (isLoading) {
+ if (isLoading)
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-6">
{Array.from({ length: 4 }).map((_, i) => (
@@ -84,42 +69,32 @@ function EvaluationTargetsStats({ evaluationYear }: { evaluationYear: number })
</Card>
))}
</div>
- )
- }
-
- if (error) {
+ );
+ if (error)
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-6">
<Card className="col-span-full">
- <CardContent className="pt-6">
- <div className="text-center text-sm text-muted-foreground">
- 통계 데이터를 불러올 수 없습니다: {error}
- </div>
+ <CardContent className="pt-6 text-center text-sm text-muted-foreground">
+ 통계 데이터를 불러올 수 없습니다: {error}
</CardContent>
</Card>
</div>
- )
- }
-
- if (!stats) {
+ );
+ if (!stats)
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-6">
<Card className="col-span-full">
- <CardContent className="pt-6">
- <div className="text-center text-sm text-muted-foreground">
- 통계 데이터가 없습니다.
- </div>
+ <CardContent className="pt-6 text-center text-sm text-muted-foreground">
+ 통계 데이터가 없습니다.
</CardContent>
</Card>
</div>
- )
- }
+ );
- const totalTargets = stats.total || 0
- const pendingTargets = stats.pending || 0
- const confirmedTargets = stats.confirmed || 0
- const excludedTargets = stats.excluded || 0
- const consensusRate = totalTargets > 0 ? Math.round(((stats.consensusTrue || 0) / totalTargets) * 100) : 0
+ const total = stats.total || 0;
+ const pending = stats.pending || 0;
+ const confirmed = stats.confirmed || 0;
+ const consensusRate = total ? Math.round(((stats.consensusTrue || 0) / total) * 100) : 0;
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4 mb-6">
@@ -130,7 +105,7 @@ function EvaluationTargetsStats({ evaluationYear }: { evaluationYear: number })
<Badge variant="outline">{evaluationYear}년</Badge>
</CardHeader>
<CardContent>
- <div className="text-2xl font-bold">{totalTargets.toLocaleString()}</div>
+ <div className="text-2xl font-bold">{total.toLocaleString()}</div>
<div className="text-xs text-muted-foreground mt-1">
해양 {stats.oceanDivision || 0}개 | 조선 {stats.shipyardDivision || 0}개
</div>
@@ -144,9 +119,9 @@ function EvaluationTargetsStats({ evaluationYear }: { evaluationYear: number })
<Badge variant="secondary">대기</Badge>
</CardHeader>
<CardContent>
- <div className="text-2xl font-bold text-orange-600">{pendingTargets.toLocaleString()}</div>
+ <div className="text-2xl font-bold text-orange-600">{pending.toLocaleString()}</div>
<div className="text-xs text-muted-foreground mt-1">
- {totalTargets > 0 ? Math.round((pendingTargets / totalTargets) * 100) : 0}% of total
+ {total ? Math.round((pending / total) * 100) : 0}% of total
</div>
</CardContent>
</Card>
@@ -155,12 +130,12 @@ function EvaluationTargetsStats({ evaluationYear }: { evaluationYear: number })
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">확정</CardTitle>
- <Badge variant="default" className="bg-green-600">완료</Badge>
+ <Badge variant="default">완료</Badge>
</CardHeader>
<CardContent>
- <div className="text-2xl font-bold text-green-600">{confirmedTargets.toLocaleString()}</div>
+ <div className="text-2xl font-bold text-green-600">{confirmed.toLocaleString()}</div>
<div className="text-xs text-muted-foreground mt-1">
- {totalTargets > 0 ? Math.round((confirmedTargets / totalTargets) * 100) : 0}% of total
+ {total ? Math.round((confirmed / total) * 100) : 0}% of total
</div>
</CardContent>
</Card>
@@ -169,9 +144,7 @@ function EvaluationTargetsStats({ evaluationYear }: { evaluationYear: number })
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">의견 일치율</CardTitle>
- <Badge variant={consensusRate >= 80 ? "default" : consensusRate >= 60 ? "secondary" : "destructive"}>
- {consensusRate}%
- </Badge>
+ <Badge variant={consensusRate >= 80 ? "default" : consensusRate >= 60 ? "secondary" : "destructive"}>{consensusRate}%</Badge>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{consensusRate}%</div>
@@ -181,83 +154,92 @@ function EvaluationTargetsStats({ evaluationYear }: { evaluationYear: number })
</CardContent>
</Card>
</div>
- )
+ );
+}
+
+/* -------------------------------------------------------------------------- */
+/* EvaluationTargetsTable */
+/* -------------------------------------------------------------------------- */
+interface EvaluationTargetsTableProps {
+ promises: Promise<[Awaited<ReturnType<typeof getEvaluationTargets>>]>;
+ evaluationYear: number;
+ className?: string;
}
export function EvaluationTargetsTable({ promises, evaluationYear, className }: EvaluationTargetsTableProps) {
- const [rowAction, setRowAction] = React.useState<DataTableRowAction<EvaluationTargetWithDepartments> | null>(null)
- const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false)
- const router = useRouter()
- const searchParams = useSearchParams()
+ const [rowAction, setRowAction] = React.useState<DataTableRowAction<EvaluationTargetWithDepartments> | null>(null);
+ const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false);
+ const searchParams = useSearchParams();
- const containerRef = React.useRef<HTMLDivElement>(null)
- const [containerTop, setContainerTop] = React.useState(0)
+ /* --------------------------- layout refs --------------------------- */
+ const containerRef = React.useRef<HTMLDivElement>(null);
+ const [containerTop, setContainerTop] = React.useState(0);
- // ✅ 스크롤 이벤트 throttling으로 성능 최적화
+ // RFQ 패턴으로 변경: State를 통한 위치 관리
const updateContainerBounds = React.useCallback(() => {
if (containerRef.current) {
- const rect = containerRef.current.getBoundingClientRect()
- const newTop = rect.top
-
- // ✅ 값이 실제로 변경될 때만 상태 업데이트
- setContainerTop(prevTop => {
- if (Math.abs(prevTop - newTop) > 1) { // 1px 이상 차이날 때만 업데이트
- return newTop
- }
- return prevTop
- })
+ const rect = containerRef.current.getBoundingClientRect();
+ setContainerTop(rect.top);
}
- }, [])
-
- // ✅ throttle 함수 추가
- const throttledUpdateBounds = React.useCallback(() => {
- let timeoutId: NodeJS.Timeout
- return () => {
- clearTimeout(timeoutId)
- timeoutId = setTimeout(updateContainerBounds, 16) // ~60fps
- }
- }, [updateContainerBounds])
+ }, []);
React.useEffect(() => {
- updateContainerBounds()
-
- const throttledHandler = throttledUpdateBounds()
-
+ updateContainerBounds();
+
const handleResize = () => {
- updateContainerBounds()
- }
-
- window.addEventListener('resize', handleResize)
- window.addEventListener('scroll', throttledHandler) // ✅ throttled 함수 사용
-
+ updateContainerBounds();
+ };
+
+ window.addEventListener('resize', handleResize);
+ window.addEventListener('scroll', updateContainerBounds);
+
return () => {
- window.removeEventListener('resize', handleResize)
- window.removeEventListener('scroll', throttledHandler)
+ window.removeEventListener('resize', handleResize);
+ window.removeEventListener('scroll', updateContainerBounds);
+ };
+ }, [updateContainerBounds]);
+
+ /* ---------------------- 데이터 프리패치 ---------------------- */
+ const [promiseData] = React.use(promises);
+ const tableData = promiseData;
+
+ /* ---------------------- 검색 파라미터 안전 처리 ---------------------- */
+ const getSearchParam = React.useCallback((key: string, defaultValue?: string): string => {
+ return searchParams?.get(key) ?? defaultValue ?? "";
+ }, [searchParams]);
+
+ // 제네릭 함수는 useCallback 밖에서 정의
+ const parseSearchParamHelper = React.useCallback((key: string, defaultValue: any): any => {
+ try {
+ const value = getSearchParam(key);
+ return value ? JSON.parse(value) : defaultValue;
+ } catch {
+ return defaultValue;
}
- }, [updateContainerBounds, throttledUpdateBounds])
+ }, [getSearchParam]);
- const [promiseData] = React.use(promises)
- const tableData = promiseData
-
- console.log(tableData)
+const parseSearchParam = <T,>(key: string, defaultValue: T): T => {
+ return parseSearchParamHelper(key, defaultValue);
+};
+ /* ---------------------- 초기 설정 ---------------------------- */
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: "createdAt", desc: true }],
- filters: searchParams.get('filters') ? JSON.parse(searchParams.get('filters')!) : [],
- joinOperator: (searchParams.get('joinOperator') as "and" | "or") || "and",
- basicFilters: searchParams.get('basicFilters') ?
- JSON.parse(searchParams.get('basicFilters')!) : [],
- basicJoinOperator: (searchParams.get('basicJoinOperator') as "and" | "or") || "and",
- search: searchParams.get('search') || '',
+ page: parseInt(getSearchParam("page", "1")),
+ perPage: parseInt(getSearchParam("perPage", "10")),
+ sort: parseSearchParam("sort", [{ id: "createdAt", desc: true }]),
+ filters: parseSearchParam("filters", []),
+ joinOperator: (getSearchParam("joinOperator") as "and" | "or") || "and",
+ basicFilters: parseSearchParam("basicFilters", []),
+ basicJoinOperator: (getSearchParam("basicJoinOperator") as "and" | "or") || "and",
+ search: getSearchParam("search", ""),
columnVisibility: {},
columnOrder: [],
pinnedColumns: { left: [], right: ["actions"] },
groupBy: [],
- expandedRows: []
- }), [searchParams])
+ expandedRows: [],
+ }), [getSearchParam, parseSearchParamHelper]);
+ /* --------------------- 프리셋 훅 ------------------------------ */
const {
presets,
activePresetId,
@@ -269,83 +251,59 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }:
deletePreset,
setDefaultPreset,
renamePreset,
- updateClientState,
getCurrentSettings,
- } = useTablePresets<EvaluationTargetWithDepartments>('evaluation-targets-table', initialSettings)
-
- const columns = React.useMemo(
- () => getEvaluationTargetsColumns({ setRowAction }),
- [setRowAction]
- )
-
+ } = useTablePresets<EvaluationTargetWithDepartments>(
+ "evaluation-targets-table",
+ initialSettings
+ );
+
+ /* --------------------- 컬럼 ------------------------------ */
+ const columns = React.useMemo(() => getEvaluationTargetsColumns({ setRowAction }), [setRowAction]);
+// const columns =[
+// { accessorKey: "vendorCode", header: "벤더 코드" },
+// { accessorKey: "vendorName", header: "벤더명" },
+// { accessorKey: "status", header: "상태" },
+// { accessorKey: "evaluationYear", header: "평가년도" },
+// { accessorKey: "division", header: "구분" }
+// ];
+
+window.addEventListener('beforeunload', () => {
+ console.trace('[beforeunload] 문서가 통째로 사라지려 합니다!');
+});
+
+ /* 기본 필터 */
const filterFields: DataTableFilterField<EvaluationTargetWithDepartments>[] = [
{ id: "vendorCode", label: "벤더 코드" },
{ id: "vendorName", label: "벤더명" },
{ id: "status", label: "상태" },
- ]
+ ];
+ /* 고급 필터 */
const advancedFilterFields: DataTableAdvancedFilterField<EvaluationTargetWithDepartments>[] = [
{ id: "evaluationYear", label: "평가년도", type: "number" },
- {
- id: "division", label: "구분", type: "select", options: [
- { label: "해양", value: "OCEAN" },
- { label: "조선", value: "SHIPYARD" },
- ]
- },
+ { id: "division", label: "구분", type: "select", options: [ { label: "해양", value: "OCEAN" }, { label: "조선", value: "SHIPYARD" } ] },
{ id: "vendorCode", label: "벤더 코드", type: "text" },
{ id: "vendorName", label: "벤더명", type: "text" },
- {
- id: "domesticForeign", label: "내외자", type: "select", options: [
- { label: "내자", value: "DOMESTIC" },
- { label: "외자", value: "FOREIGN" },
- ]
- },
- {
- id: "materialType", label: "자재구분", type: "select", options: [
- { label: "기자재", value: "EQUIPMENT" },
- { label: "벌크", value: "BULK" },
- { label: "기자재/벌크", value: "EQUIPMENT_BULK" },
- ]
- },
- {
- id: "status", label: "상태", type: "select", options: [
- { label: "검토 중", value: "PENDING" },
- { label: "확정", value: "CONFIRMED" },
- { label: "제외", value: "EXCLUDED" },
- ]
- },
- {
- id: "consensusStatus", label: "의견 일치", type: "select", options: [
- { label: "의견 일치", value: "true" },
- { label: "의견 불일치", value: "false" },
- { label: "검토 중", value: "null" },
- ]
- },
+ { id: "domesticForeign", label: "내외자", type: "select", options: [ { label: "내자", value: "DOMESTIC" }, { label: "외자", value: "FOREIGN" } ] },
+ { id: "materialType", label: "자재구분", type: "select", options: [ { label: "기자재", value: "EQUIPMENT" }, { label: "벌크", value: "BULK" }, { label: "기/벌", value: "EQUIPMENT_BULK" } ] },
+ { id: "status", label: "상태", type: "select", options: [ { label: "검토 중", value: "PENDING" }, { label: "확정", value: "CONFIRMED" }, { label: "제외", value: "EXCLUDED" } ] },
+ { id: "consensusStatus", label: "의견 일치", type: "select", options: [ { label: "일치", value: "true" }, { label: "불일치", value: "false" }, { label: "검토 중", value: "null" } ] },
{ id: "adminComment", label: "관리자 의견", type: "text" },
{ id: "consolidatedComment", label: "종합 의견", type: "text" },
{ id: "confirmedAt", label: "확정일", type: "date" },
{ id: "createdAt", label: "생성일", type: "date" },
- ]
-
- const currentSettings = useMemo(() => {
- return getCurrentSettings()
- }, [getCurrentSettings])
-
- function getColKey<T>(c: ColumnDef<T>): string | undefined {
- if ("accessorKey" in c && c.accessorKey) return c.accessorKey as string
- if ("id" in c && c.id) return c.id as string
- return undefined
- }
-
- const initialState = useMemo(() => {
- return {
- sorting: initialSettings.sort.filter(s =>
- columns.some(c => getColKey(c) === s.id)),
- columnVisibility: currentSettings.columnVisibility,
- columnPinning: currentSettings.pinnedColumns,
- }
- }, [columns, currentSettings, initialSettings.sort])
+ ];
+
+ /* current settings */
+ const currentSettings = React.useMemo(() => getCurrentSettings(), [getCurrentSettings]);
+
+ const initialState = React.useMemo(() => ({
+ sorting: initialSettings.sort.filter((s: any) => columns.some((c: any) => ("accessorKey" in c ? c.accessorKey : c.id) === s.id)),
+ columnVisibility: currentSettings.columnVisibility,
+ columnPinning: currentSettings.pinnedColumns,
+ }), [columns, currentSettings, initialSettings.sort]);
+ /* ----------------------- useDataTable ------------------------ */
const { table } = useDataTable({
data: tableData.data,
columns,
@@ -355,134 +313,119 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }:
enablePinning: true,
enableAdvancedFilter: true,
initialState,
- getRowId: (originalRow) => String(originalRow.id),
+ getRowId: (row) => String(row.id),
shallow: false,
clearOnDefault: true,
- })
+ });
- const handleSearch = () => {
- setIsFilterPanelOpen(false)
- }
-
- const getActiveBasicFilterCount = () => {
+ /* ---------------------- helper ------------------------------ */
+ const getActiveBasicFilterCount = React.useCallback(() => {
try {
- const basicFilters = searchParams.get('basicFilters')
- return basicFilters ? JSON.parse(basicFilters).length : 0
- } catch (e) {
- return 0
+ const f = getSearchParam("basicFilters");
+ return f ? JSON.parse(f).length : 0;
+ } catch {
+ return 0;
}
- }
+ }, [getSearchParam]);
const FILTER_PANEL_WIDTH = 400;
+ /* ---------------------------- JSX ---------------------------- */
return (
- <>
+ <>
{/* Filter Panel */}
<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',
+ style={{
+ width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : "0px",
top: `${containerTop}px`,
height: `calc(100vh - ${containerTop}px)`
}}
>
- <div className="h-full">
- <EvaluationTargetFilterSheet
- isOpen={isFilterPanelOpen}
- onClose={() => setIsFilterPanelOpen(false)}
- onSearch={handleSearch}
- isLoading={false}
- />
- </div>
+ <EvaluationTargetFilterSheet
+ isOpen={isFilterPanelOpen}
+ onClose={() => setIsFilterPanelOpen(false)}
+ onSearch={() => setIsFilterPanelOpen(false)}
+ isLoading={false}
+ />
</div>
- {/* Main Content Container */}
- <div
- ref={containerRef}
- className={cn("relative w-full overflow-hidden", className)}
- >
+ {/* Main Container */}
+ <div ref={containerRef} className={cn("relative w-full overflow-hidden", className)}>
<div className="flex w-full h-full">
<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'
+ width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : "100%",
+ marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : "0px",
}}
>
- {/* Header Bar */}
+ {/* Header */}
<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>
-
- <div className="text-sm text-muted-foreground">
- {tableData && (
- <span>총 {tableData.total || tableData.data.length}건</span>
+ <Button
+ variant="outline"
+ size="sm"
+ 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 className="text-sm text-muted-foreground">
+ 총 {tableData.total || tableData.data.length}건
</div>
</div>
- {/* 통계 카드들 */}
+ {/* Stats */}
<div className="px-4">
<EvaluationTargetsStats evaluationYear={evaluationYear} />
</div>
- {/* Table Content Area */}
- <div className="flex-1 overflow-hidden" style={{ height: 'calc(100vh - 500px)' }}>
- <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">
- <TablePresetManager<EvaluationTargetWithDepartments>
- presets={presets}
- activePresetId={activePresetId}
- currentSettings={currentSettings}
- hasUnsavedChanges={hasUnsavedChanges}
- isLoading={presetsLoading}
- onCreatePreset={createPreset}
- onUpdatePreset={updatePreset}
- onDeletePreset={deletePreset}
- onApplyPreset={applyPreset}
- onSetDefaultPreset={setDefaultPreset}
- onRenamePreset={renamePreset}
- />
-
- <EvaluationTargetsTableToolbarActions table={table} />
- </div>
- </DataTableAdvancedToolbar>
- </DataTable>
-
- <EditEvaluationTargetSheet
- open={rowAction?.type === "update"}
- onOpenChange={() => setRowAction(null)}
- evaluationTarget={rowAction?.row.original ?? null}
- />
-
- </div>
+ {/* Table */}
+ <div className="flex-1 overflow-hidden" style={{ height: "calc(100vh - 500px)" }}>
+ <DataTable table={table} className="h-full">
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <div className="flex items-center gap-2">
+ <TablePresetManager<EvaluationTargetWithDepartments>
+ presets={presets}
+ activePresetId={activePresetId}
+ currentSettings={currentSettings}
+ hasUnsavedChanges={hasUnsavedChanges}
+ isLoading={presetsLoading}
+ onCreatePreset={createPreset}
+ onUpdatePreset={updatePreset}
+ onDeletePreset={deletePreset}
+ onApplyPreset={applyPreset}
+ onSetDefaultPreset={setDefaultPreset}
+ onRenamePreset={renamePreset}
+ />
+
+ <EvaluationTargetsTableToolbarActions table={table} />
+ </div>
+ </DataTableAdvancedToolbar>
+ </DataTable>
+
+ {/* 편집 다이얼로그 */}
+ <EditEvaluationTargetSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ evaluationTarget={rowAction?.row.original ?? null}
+ />
</div>
</div>
</div>
</div>
</>
- )
+ );
} \ No newline at end of file
diff --git a/lib/evaluation-target-list/table/evaluation-targets-columns.tsx b/lib/evaluation-target-list/table/evaluation-targets-columns.tsx
index 93807ef9..e2163cad 100644
--- a/lib/evaluation-target-list/table/evaluation-targets-columns.tsx
+++ b/lib/evaluation-target-list/table/evaluation-targets-columns.tsx
@@ -4,16 +4,17 @@ import { type ColumnDef } from "@tanstack/react-table";
import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
-import { Pencil, Eye, MessageSquare, Check, X } from "lucide-react";
+import { Pencil, Check, X } from "lucide-react";
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header";
import { EvaluationTargetWithDepartments } from "@/db/schema";
-import { EditEvaluationTargetSheet } from "./update-evaluation-target";
+import type { DataTableRowAction } from "@/types/table";
+import { formatDate } from "@/lib/utils";
interface GetColumnsProps {
setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<EvaluationTargetWithDepartments> | null>>;
}
-// 상태별 색상 매핑
+// ✅ 모든 헬퍼 함수들을 컴포넌트 외부로 이동 (매번 재생성 방지)
const getStatusBadgeVariant = (status: string) => {
switch (status) {
case "PENDING":
@@ -27,7 +28,15 @@ const getStatusBadgeVariant = (status: string) => {
}
};
-// 의견 일치 여부 배지
+const getStatusText = (status: string) => {
+ const statusMap = {
+ PENDING: "검토 중",
+ CONFIRMED: "확정",
+ EXCLUDED: "제외"
+ };
+ return statusMap[status] || status;
+};
+
const getConsensusBadge = (consensusStatus: boolean | null) => {
if (consensusStatus === null) {
return <Badge variant="outline">검토 중</Badge>;
@@ -38,16 +47,14 @@ const getConsensusBadge = (consensusStatus: boolean | null) => {
return <Badge variant="destructive">의견 불일치</Badge>;
};
-// 구분 배지
const getDivisionBadge = (division: string) => {
return (
- <Badge variant={division === "PLANT" ? "default" : "secondary"}>
- {division === "PLANT" ? "해양" : "조선"}
+ <Badge variant={division === "OCEAN" ? "default" : "secondary"}>
+ {division === "OCEAN" ? "해양" : "조선"}
</Badge>
);
};
-// 자재구분 배지
const getMaterialTypeBadge = (materialType: string) => {
const typeMap = {
EQUIPMENT: "기자재",
@@ -57,7 +64,6 @@ const getMaterialTypeBadge = (materialType: string) => {
return <Badge variant="outline">{typeMap[materialType] || materialType}</Badge>;
};
-// 내외자 배지
const getDomesticForeignBadge = (domesticForeign: string) => {
return (
<Badge variant={domesticForeign === "DOMESTIC" ? "default" : "secondary"}>
@@ -66,24 +72,27 @@ const getDomesticForeignBadge = (domesticForeign: string) => {
);
};
-// 평가 상태 배지
-const getApprovalBadge = (isApproved: boolean | null) => {
- if (isApproved === null) {
- return <Badge variant="outline" className="text-xs">대기중</Badge>;
- }
- if (isApproved === true) {
- return <Badge variant="default" className="bg-green-600 text-xs">승인</Badge>;
+// ✅ 평가 대상 여부 표시 함수
+const getEvaluationTargetBadge = (isTarget: boolean | null) => {
+ if (isTarget === null) {
+ return <Badge variant="outline">미정</Badge>;
}
- return <Badge variant="destructive" className="text-xs">거부</Badge>;
+ return isTarget ? (
+ <Badge variant="default" className="bg-blue-600">
+ <Check className="size-3 mr-1" />
+ 평가 대상
+ </Badge>
+ ) : (
+ <Badge variant="secondary">
+ <X className="size-3 mr-1" />
+ 평가 제외
+ </Badge>
+ );
};
-export function getEvaluationTargetsColumns({setRowAction}:GetColumnsProps): ColumnDef<EvaluationTargetWithDepartments>[] {
+export function getEvaluationTargetsColumns({ setRowAction }: GetColumnsProps): ColumnDef<EvaluationTargetWithDepartments>[] {
return [
- // ═══════════════════════════════════════════════════════════════
- // 기본 정보
- // ═══════════════════════════════════════════════════════════════
-
- // Checkbox
+ // ✅ Checkbox
{
id: "select",
header: ({ table }) => (
@@ -107,15 +116,13 @@ export function getEvaluationTargetsColumns({setRowAction}:GetColumnsProps): Col
enableHiding: false,
},
- // ░░░ 평가년도 ░░░
+ // ✅ 기본 정보
{
accessorKey: "evaluationYear",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가년도" />,
cell: ({ row }) => <span className="font-medium">{row.getValue("evaluationYear")}</span>,
size: 100,
},
-
- // ░░░ 구분 ░░░
{
accessorKey: "division",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="구분" />,
@@ -127,24 +134,25 @@ export function getEvaluationTargetsColumns({setRowAction}:GetColumnsProps): Col
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="상태" />,
cell: ({ row }) => {
const status = row.getValue<string>("status");
- const statusMap = {
- PENDING: "검토 중",
- CONFIRMED: "확정",
- EXCLUDED: "제외"
- };
return (
<Badge variant={getStatusBadgeVariant(status)}>
- {statusMap[status] || status}
+ {getStatusText(status)}
</Badge>
);
},
size: 100,
},
+ {
+ accessorKey: "consensusStatus",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="의견 일치" />,
+ cell: ({ row }) => getConsensusBadge(row.getValue("consensusStatus")),
+ size: 100,
+ },
- // ░░░ 벤더 코드 ░░░
-
+ // ✅ 벤더 정보 그룹
{
- header: "협력업체 정보",
+ id: "vendorInfo",
+ header: "벤더 정보",
columns: [
{
accessorKey: "vendorCode",
@@ -154,8 +162,6 @@ export function getEvaluationTargetsColumns({setRowAction}:GetColumnsProps): Col
),
size: 120,
},
-
- // ░░░ 벤더명 ░░░
{
accessorKey: "vendorName",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="벤더명" />,
@@ -166,267 +172,182 @@ export function getEvaluationTargetsColumns({setRowAction}:GetColumnsProps): Col
),
size: 200,
},
-
- // ░░░ 내외자 ░░░
{
accessorKey: "domesticForeign",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="내외자" />,
cell: ({ row }) => getDomesticForeignBadge(row.getValue("domesticForeign")),
size: 80,
},
-
+ {
+ accessorKey: "materialType",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재구분" />,
+ cell: ({ row }) => getMaterialTypeBadge(row.getValue("materialType")),
+ size: 120,
+ },
]
},
- // ░░░ 자재구분 ░░░
- {
- accessorKey: "materialType",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재구분" />,
- cell: ({ row }) => getMaterialTypeBadge(row.getValue("materialType")),
- size: 120,
- },
-
- // ░░░ 상태 ░░░
-
-
- // ░░░ 의견 일치 여부 ░░░
- {
- accessorKey: "consensusStatus",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="의견 일치" />,
- cell: ({ row }) => getConsensusBadge(row.getValue("consensusStatus")),
- size: 100,
- },
-
- // ═══════════════════════════════════════════════════════════════
- // 주문 부서 그룹
- // ═══════════════════════════════════════════════════════════════
+ // ✅ 발주 담당자
{
- header: "발주 평가 담당자",
+ id: "orderReviewer",
+ header: "발주 담당자",
columns: [
{
- accessorKey: "orderDepartmentName",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="부서명" />,
- cell: ({ row }) => {
- const departmentName = row.getValue<string>("orderDepartmentName");
- return departmentName ? (
- <div className="truncate max-w-[120px]" title={departmentName}>
- {departmentName}
- </div>
- ) : (
- <span className="text-muted-foreground">-</span>
- );
- },
- size: 120,
- },
- {
accessorKey: "orderReviewerName",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자" />,
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자명" />,
cell: ({ row }) => {
const reviewerName = row.getValue<string>("orderReviewerName");
return reviewerName ? (
- <div className="truncate max-w-[100px]" title={reviewerName}>
+ <div className="truncate max-w-[120px]" title={reviewerName}>
{reviewerName}
</div>
) : (
<span className="text-muted-foreground">-</span>
);
},
- size: 100,
+ size: 120,
},
{
accessorKey: "orderIsApproved",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가" />,
- cell: ({ row }) => getApprovalBadge(row.getValue("orderIsApproved")),
- size: 80,
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가 대상" />,
+ cell: ({ row }) => {
+ const isApproved = row.getValue<boolean>("orderIsApproved");
+ return getEvaluationTargetBadge(isApproved);
+ },
+ size: 120,
},
- ],
+ ]
},
- // ═══════════════════════════════════════════════════════════════
- // 조달 부서 그룹
- // ═══════════════════════════════════════════════════════════════
+ // ✅ 조달 담당자
{
- header: "조달 평가 담당자",
+ id: "procurementReviewer",
+ header: "조달 담당자",
columns: [
{
- accessorKey: "procurementDepartmentName",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="부서명" />,
- cell: ({ row }) => {
- const departmentName = row.getValue<string>("procurementDepartmentName");
- return departmentName ? (
- <div className="truncate max-w-[120px]" title={departmentName}>
- {departmentName}
- </div>
- ) : (
- <span className="text-muted-foreground">-</span>
- );
- },
- size: 120,
- },
- {
accessorKey: "procurementReviewerName",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자" />,
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자명" />,
cell: ({ row }) => {
const reviewerName = row.getValue<string>("procurementReviewerName");
return reviewerName ? (
- <div className="truncate max-w-[100px]" title={reviewerName}>
+ <div className="truncate max-w-[120px]" title={reviewerName}>
{reviewerName}
</div>
) : (
<span className="text-muted-foreground">-</span>
);
},
- size: 100,
+ size: 120,
},
{
accessorKey: "procurementIsApproved",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가" />,
- cell: ({ row }) => getApprovalBadge(row.getValue("procurementIsApproved")),
- size: 80,
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가 대상" />,
+ cell: ({ row }) => {
+ const isApproved = row.getValue<boolean>("procurementIsApproved");
+ return getEvaluationTargetBadge(isApproved);
+ },
+ size: 120,
},
- ],
+ ]
},
- // ═══════════════════════════════════════════════════════════════
- // 품질 부서 그룹
- // ═══════════════════════════════════════════════════════════════
+ // ✅ 품질 담당자
{
- header: "품질 평가 담당자",
+ id: "qualityReviewer",
+ header: "품질 담당자",
columns: [
{
- accessorKey: "qualityDepartmentName",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="부서명" />,
- cell: ({ row }) => {
- const departmentName = row.getValue<string>("qualityDepartmentName");
- return departmentName ? (
- <div className="truncate max-w-[120px]" title={departmentName}>
- {departmentName}
- </div>
- ) : (
- <span className="text-muted-foreground">-</span>
- );
- },
- size: 120,
- },
- {
accessorKey: "qualityReviewerName",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자" />,
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자명" />,
cell: ({ row }) => {
const reviewerName = row.getValue<string>("qualityReviewerName");
return reviewerName ? (
- <div className="truncate max-w-[100px]" title={reviewerName}>
+ <div className="truncate max-w-[120px]" title={reviewerName}>
{reviewerName}
</div>
) : (
<span className="text-muted-foreground">-</span>
);
},
- size: 100,
+ size: 120,
},
{
accessorKey: "qualityIsApproved",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가" />,
- cell: ({ row }) => getApprovalBadge(row.getValue("qualityIsApproved")),
- size: 80,
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가 대상" />,
+ cell: ({ row }) => {
+ const isApproved = row.getValue<boolean>("qualityIsApproved");
+ return getEvaluationTargetBadge(isApproved);
+ },
+ size: 120,
},
- ],
+ ]
},
- // ═══════════════════════════════════════════════════════════════
- // 설계 부서 그룹
- // ═══════════════════════════════════════════════════════════════
+ // ✅ 설계 담당자
{
- header: "설계 평가 담당자",
+ id: "designReviewer",
+ header: "설계 담당자",
columns: [
{
- accessorKey: "designDepartmentName",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="부서명" />,
- cell: ({ row }) => {
- const departmentName = row.getValue<string>("designDepartmentName");
- return departmentName ? (
- <div className="truncate max-w-[120px]" title={departmentName}>
- {departmentName}
- </div>
- ) : (
- <span className="text-muted-foreground">-</span>
- );
- },
- size: 120,
- },
- {
accessorKey: "designReviewerName",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자" />,
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자명" />,
cell: ({ row }) => {
const reviewerName = row.getValue<string>("designReviewerName");
return reviewerName ? (
- <div className="truncate max-w-[100px]" title={reviewerName}>
+ <div className="truncate max-w-[120px]" title={reviewerName}>
{reviewerName}
</div>
) : (
<span className="text-muted-foreground">-</span>
);
},
- size: 100,
+ size: 120,
},
{
accessorKey: "designIsApproved",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가" />,
- cell: ({ row }) => getApprovalBadge(row.getValue("designIsApproved")),
- size: 80,
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가 대상" />,
+ cell: ({ row }) => {
+ const isApproved = row.getValue<boolean>("designIsApproved");
+ return getEvaluationTargetBadge(isApproved);
+ },
+ size: 120,
},
- ],
+ ]
},
- // ═══════════════════════════════════════════════════════════════
- // CS 부서 그룹
- // ═══════════════════════════════════════════════════════════════
+ // ✅ CS 담당자
{
- header: "CS 평가 담당자",
+ id: "csReviewer",
+ header: "CS 담당자",
columns: [
{
- accessorKey: "csDepartmentName",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="부서명" />,
- cell: ({ row }) => {
- const departmentName = row.getValue<string>("csDepartmentName");
- return departmentName ? (
- <div className="truncate max-w-[120px]" title={departmentName}>
- {departmentName}
- </div>
- ) : (
- <span className="text-muted-foreground">-</span>
- );
- },
- size: 120,
- },
- {
accessorKey: "csReviewerName",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자" />,
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자명" />,
cell: ({ row }) => {
const reviewerName = row.getValue<string>("csReviewerName");
return reviewerName ? (
- <div className="truncate max-w-[100px]" title={reviewerName}>
+ <div className="truncate max-w-[120px]" title={reviewerName}>
{reviewerName}
</div>
) : (
<span className="text-muted-foreground">-</span>
);
},
- size: 100,
+ size: 120,
},
{
accessorKey: "csIsApproved",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가" />,
- cell: ({ row }) => getApprovalBadge(row.getValue("csIsApproved")),
- size: 80,
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가 대상" />,
+ cell: ({ row }) => {
+ const isApproved = row.getValue<boolean>("csIsApproved");
+ return getEvaluationTargetBadge(isApproved);
+ },
+ size: 120,
},
- ],
+ ]
},
- // ═══════════════════════════════════════════════════════════════
- // 관리 정보
- // ═══════════════════════════════════════════════════════════════
-
- // ░░░ 관리자 의견 ░░░
+ // ✅ 의견 및 결과
{
accessorKey: "adminComment",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="관리자 의견" />,
@@ -442,8 +363,6 @@ export function getEvaluationTargetsColumns({setRowAction}:GetColumnsProps): Col
},
size: 150,
},
-
- // ░░░ 종합 의견 ░░░
{
accessorKey: "consolidatedComment",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="종합 의견" />,
@@ -459,69 +378,49 @@ export function getEvaluationTargetsColumns({setRowAction}:GetColumnsProps): Col
},
size: 150,
},
-
- // ░░░ 확정일 ░░░
{
accessorKey: "confirmedAt",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="확정일" />,
cell: ({ row }) => {
const confirmedAt = row.getValue<Date>("confirmedAt");
- return confirmedAt ? (
- <span className="text-sm">
- {new Intl.DateTimeFormat("ko-KR", {
- year: "numeric",
- month: "2-digit",
- day: "2-digit",
- }).format(new Date(confirmedAt))}
- </span>
- ) : (
- <span className="text-muted-foreground">-</span>
- );
+ return <span className="text-sm">{formatDate(confirmedAt, "KR")}</span>;
},
size: 100,
},
-
- // ░░░ 생성일 ░░░
{
accessorKey: "createdAt",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="생성일" />,
cell: ({ row }) => {
const createdAt = row.getValue<Date>("createdAt");
- return createdAt ? (
- <span className="text-sm">
- {new Intl.DateTimeFormat("ko-KR", {
- year: "numeric",
- month: "2-digit",
- day: "2-digit",
- }).format(new Date(createdAt))}
- </span>
- ) : (
- <span className="text-muted-foreground">-</span>
- );
+ return <span className="text-sm">{formatDate(createdAt, "KR")}</span>;
},
size: 100,
},
- // ░░░ Actions ░░░
+ // ✅ Actions - 가장 안전하게 처리
{
id: "actions",
enableHiding: false,
size: 40,
minSize: 40,
cell: ({ row }) => {
- return (
+ // ✅ 함수를 직접 정의해서 매번 새로 생성되지 않도록 처리
+ const handleEdit = () => {
+ setRowAction({ row, type: "update" });
+ };
+
+ return (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-8"
- onClick={() => setRowAction({ row, type: "update" })}
+ onClick={handleEdit}
aria-label="수정"
title="수정"
>
<Pencil className="size-4" />
</Button>
-
</div>
);
},
diff --git a/lib/evaluation-target-list/table/evaluation-targets-filter-sheet.tsx b/lib/evaluation-target-list/table/evaluation-targets-filter-sheet.tsx
index 502ee974..c37258ae 100644
--- a/lib/evaluation-target-list/table/evaluation-targets-filter-sheet.tsx
+++ b/lib/evaluation-target-list/table/evaluation-targets-filter-sheet.tsx
@@ -365,7 +365,7 @@ export function EvaluationTargetFilterSheet({
setIsInitializing(true);
form.reset({
- evaluationYear: new Date().getFullYear().toString(),
+ evaluationYear: "",
division: "",
status: "",
domesticForeign: "",
diff --git a/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx b/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx
index 9043c588..7ea2e0ec 100644
--- a/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx
+++ b/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx
@@ -239,7 +239,7 @@ export function EvaluationTargetsTableToolbarActions({
/>
{/* 선택 정보 표시 */}
- {hasSelection && (
+ {/* {hasSelection && (
<div className="text-xs text-muted-foreground">
선택된 {selectedRows.length}개 항목:
대기중 {selectedStats.pending}개,
@@ -248,7 +248,7 @@ export function EvaluationTargetsTableToolbarActions({
{selectedStats.consensusTrue > 0 && ` | 의견일치 ${selectedStats.consensusTrue}개`}
{selectedStats.consensusFalse > 0 && ` | 의견불일치 ${selectedStats.consensusFalse}개`}
</div>
- )}
+ )} */}
</>
)
} \ No newline at end of file
diff --git a/lib/evaluation-target-list/table/update-evaluation-target.tsx b/lib/evaluation-target-list/table/update-evaluation-target.tsx
index 0d56addb..9f9b7af4 100644
--- a/lib/evaluation-target-list/table/update-evaluation-target.tsx
+++ b/lib/evaluation-target-list/table/update-evaluation-target.tsx
@@ -498,7 +498,7 @@ export function EditEvaluationTargetSheet({
{/* 각 부서별 평가 */}
<div className="grid grid-cols-1 gap-4">
{[
- { key: "orderIsApproved", label: "주문 부서 평가", email: evaluationTarget.orderReviewerEmail },
+ { key: "orderIsApproved", label: "발주 부서 평가", email: evaluationTarget.orderReviewerEmail },
{ key: "procurementIsApproved", label: "조달 부서 평가", email: evaluationTarget.procurementReviewerEmail },
{ key: "qualityIsApproved", label: "품질 부서 평가", email: evaluationTarget.qualityReviewerEmail },
{ key: "designIsApproved", label: "설계 부서 평가", email: evaluationTarget.designReviewerEmail },
@@ -608,7 +608,7 @@ export function EditEvaluationTargetSheet({
</CardHeader>
<CardContent className="space-y-4">
{[
- { key: "orderReviewerEmail", label: "주문 부서 담당자", current: evaluationTarget.orderReviewerEmail },
+ { key: "orderReviewerEmail", label: "발주 부서 담당자", current: evaluationTarget.orderReviewerEmail },
{ key: "procurementReviewerEmail", label: "조달 부서 담당자", current: evaluationTarget.procurementReviewerEmail },
{ key: "qualityReviewerEmail", label: "품질 부서 담당자", current: evaluationTarget.qualityReviewerEmail },
{ key: "designReviewerEmail", label: "설계 부서 담당자", current: evaluationTarget.designReviewerEmail },
diff --git a/lib/evaluation/service.ts b/lib/evaluation/service.ts
index e69de29b..3cc4ca7d 100644
--- a/lib/evaluation/service.ts
+++ b/lib/evaluation/service.ts
@@ -0,0 +1,125 @@
+import db from "@/db/db"
+import {
+ periodicEvaluationsView,
+ type PeriodicEvaluationView
+} from "@/db/schema"
+import {
+ and,
+ asc,
+ count,
+ desc,
+ ilike,
+ or,
+ type SQL
+} from "drizzle-orm"
+import { filterColumns } from "@/lib/filter-columns"
+import { GetEvaluationTargetsSchema } from "../evaluation-target-list/validation";
+
+export async function getPeriodicEvaluations(input: GetEvaluationTargetsSchema) {
+ try {
+ const offset = (input.page - 1) * input.perPage;
+
+ // ✅ getEvaluationTargets 방식과 동일한 필터링 처리
+ // 1) 고급 필터 조건
+ let advancedWhere: SQL<unknown> | undefined = undefined;
+ if (input.filters && input.filters.length > 0) {
+ advancedWhere = filterColumns({
+ table: periodicEvaluationsView,
+ filters: input.filters,
+ joinOperator: input.joinOperator || 'and',
+ });
+ }
+
+ // 2) 기본 필터 조건
+ let basicWhere: SQL<unknown> | undefined = undefined;
+ if (input.basicFilters && input.basicFilters.length > 0) {
+ basicWhere = filterColumns({
+ table: periodicEvaluationsView,
+ filters: input.basicFilters,
+ joinOperator: input.basicJoinOperator || 'and',
+ });
+ }
+
+ // 3) 글로벌 검색 조건
+ let globalWhere: SQL<unknown> | undefined = undefined;
+ if (input.search) {
+ const s = `%${input.search}%`;
+
+ const validSearchConditions: SQL<unknown>[] = [];
+
+ // 벤더 정보로 검색
+ const vendorCodeCondition = ilike(periodicEvaluationsView.vendorCode, s);
+ if (vendorCodeCondition) validSearchConditions.push(vendorCodeCondition);
+
+ const vendorNameCondition = ilike(periodicEvaluationsView.vendorName, s);
+ if (vendorNameCondition) validSearchConditions.push(vendorNameCondition);
+
+ // 평가 관련 코멘트로 검색
+ const evaluationNoteCondition = ilike(periodicEvaluationsView.evaluationNote, s);
+ if (evaluationNoteCondition) validSearchConditions.push(evaluationNoteCondition);
+
+ const adminCommentCondition = ilike(periodicEvaluationsView.evaluationTargetAdminComment, s);
+ if (adminCommentCondition) validSearchConditions.push(adminCommentCondition);
+
+ const consolidatedCommentCondition = ilike(periodicEvaluationsView.evaluationTargetConsolidatedComment, s);
+ if (consolidatedCommentCondition) validSearchConditions.push(consolidatedCommentCondition);
+
+ // 최종 확정자 이름으로 검색
+ const finalizedByUserNameCondition = ilike(periodicEvaluationsView.finalizedByUserName, s);
+ if (finalizedByUserNameCondition) validSearchConditions.push(finalizedByUserNameCondition);
+
+ if (validSearchConditions.length > 0) {
+ globalWhere = or(...validSearchConditions);
+ }
+ }
+
+ // ✅ getEvaluationTargets 방식과 동일한 WHERE 조건 생성
+ const whereConditions: SQL<unknown>[] = [];
+
+ if (advancedWhere) whereConditions.push(advancedWhere);
+ if (basicWhere) whereConditions.push(basicWhere);
+ if (globalWhere) whereConditions.push(globalWhere);
+
+ const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined;
+
+ // ✅ getEvaluationTargets 방식과 동일한 전체 데이터 수 조회
+ const totalResult = await db
+ .select({ count: count() })
+ .from(periodicEvaluationsView)
+ .where(finalWhere);
+
+ const total = totalResult[0]?.count || 0;
+
+ if (total === 0) {
+ return { data: [], pageCount: 0, total: 0 };
+ }
+
+ console.log("Total periodic evaluations:", total);
+
+ // ✅ getEvaluationTargets 방식과 동일한 정렬 및 페이징 처리된 데이터 조회
+ const orderByColumns = input.sort.map((sort) => {
+ const column = sort.id as keyof typeof periodicEvaluationsView.$inferSelect;
+ return sort.desc ? desc(periodicEvaluationsView[column]) : asc(periodicEvaluationsView[column]);
+ });
+
+ if (orderByColumns.length === 0) {
+ orderByColumns.push(desc(periodicEvaluationsView.createdAt));
+ }
+
+ const periodicEvaluationsData = await db
+ .select()
+ .from(periodicEvaluationsView)
+ .where(finalWhere)
+ .orderBy(...orderByColumns)
+ .limit(input.perPage)
+ .offset(offset);
+
+ const pageCount = Math.ceil(total / input.perPage);
+
+ return { data: periodicEvaluationsData, pageCount, total };
+ } catch (err) {
+ console.error("Error in getPeriodicEvaluations:", err);
+ // ✅ getEvaluationTargets 방식과 동일한 에러 반환 (total 포함)
+ return { data: [], pageCount: 0, total: 0 };
+ }
+ } \ No newline at end of file
diff --git a/lib/evaluation/table/evaluation-columns.tsx b/lib/evaluation/table/evaluation-columns.tsx
index 0c207a53..821e8182 100644
--- a/lib/evaluation/table/evaluation-columns.tsx
+++ b/lib/evaluation/table/evaluation-columns.tsx
@@ -11,6 +11,7 @@ import { Button } from "@/components/ui/button";
import { Pencil, Eye, MessageSquare, Check, X, Clock, FileText } from "lucide-react";
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header";
import { PeriodicEvaluationView } from "@/db/schema";
+import { DataTableRowAction } from "@/types/table";
@@ -104,7 +105,7 @@ const getProgressBadge = (completed: number, total: number) => {
return <Badge variant={variant}>{completed}/{total} ({percentage}%)</Badge>;
};
-export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): ColumnDef<PeriodicEvaluationWithRelations>[] {
+export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps): ColumnDef<PeriodicEvaluationView>[] {
return [
// ═══════════════════════════════════════════════════════════════
// 선택 및 기본 정보
@@ -136,9 +137,9 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
// ░░░ 평가년도 ░░░
{
- accessorKey: "evaluationTarget.evaluationYear",
+ accessorKey: "evaluationYear",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가년도" />,
- cell: ({ row }) => <span className="font-medium">{row.original.evaluationTarget?.evaluationYear}</span>,
+ cell: ({ row }) => <span className="font-medium">{row.original.evaluationYear}</span>,
size: 100,
},
@@ -154,9 +155,9 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
// ░░░ 구분 ░░░
{
- accessorKey: "evaluationTarget.division",
+ accessorKey: "division",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="구분" />,
- cell: ({ row }) => getDivisionBadge(row.original.evaluationTarget?.division || ""),
+ cell: ({ row }) => getDivisionBadge(row.original.division || ""),
size: 80,
},
@@ -167,36 +168,36 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
header: "협력업체 정보",
columns: [
{
- accessorKey: "evaluationTarget.vendorCode",
+ accessorKey: "vendorCode",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="벤더 코드" />,
cell: ({ row }) => (
- <span className="font-mono text-sm">{row.original.evaluationTarget?.vendorCode}</span>
+ <span className="font-mono text-sm">{row.original.vendorCode}</span>
),
size: 120,
},
{
- accessorKey: "evaluationTarget.vendorName",
+ accessorKey: "vendorName",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="벤더명" />,
cell: ({ row }) => (
- <div className="truncate max-w-[200px]" title={row.original.evaluationTarget?.vendorName}>
- {row.original.evaluationTarget?.vendorName}
+ <div className="truncate max-w-[200px]" title={row.original.vendorName}>
+ {row.original.vendorName}
</div>
),
size: 200,
},
{
- accessorKey: "evaluationTarget.domesticForeign",
+ accessorKey: "domesticForeign",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="내외자" />,
- cell: ({ row }) => getDomesticForeignBadge(row.original.evaluationTarget?.domesticForeign || ""),
+ cell: ({ row }) => getDomesticForeignBadge(row.original.domesticForeign || ""),
size: 80,
},
{
- accessorKey: "evaluationTarget.materialType",
+ accessorKey: "materialType",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재구분" />,
- cell: ({ row }) => getMaterialTypeBadge(row.original.evaluationTarget?.materialType || ""),
+ cell: ({ row }) => getMaterialTypeBadge(row.original.materialType || ""),
size: 120,
},
]
@@ -355,10 +356,10 @@ export function getPeriodicEvaluationsColumns({setRowAction}: GetColumnsProps):
id: "reviewProgress",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="리뷰진행" />,
cell: ({ row }) => {
- const stats = row.original.reviewerStats;
- if (!stats) return <span className="text-muted-foreground">-</span>;
+ const totalReviewers = row.original.totalReviewers || 0;
+ const completedReviewers = row.original.completedReviewers || 0;
- return getProgressBadge(stats.completedReviewers, stats.totalReviewers);
+ return getProgressBadge(completedReviewers, totalReviewers);
},
size: 120,
},
diff --git a/lib/evaluation/table/evaluation-filter-sheet.tsx b/lib/evaluation/table/evaluation-filter-sheet.tsx
index 7cda4989..7c1e93d8 100644
--- a/lib/evaluation/table/evaluation-filter-sheet.tsx
+++ b/lib/evaluation/table/evaluation-filter-sheet.tsx
@@ -394,7 +394,7 @@ export function PeriodicEvaluationFilterSheet({
}
return (
- <div className="flex flex-col h-full max-h-full bg-[#F5F7FB] px-6 sm:px-8">
+ <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>
diff --git a/lib/evaluation/table/evaluation-table.tsx b/lib/evaluation/table/evaluation-table.tsx
index 16f70592..a628475d 100644
--- a/lib/evaluation/table/evaluation-table.tsx
+++ b/lib/evaluation/table/evaluation-table.tsx
@@ -23,6 +23,7 @@ import { useMemo } from "react"
import { PeriodicEvaluationFilterSheet } from "./evaluation-filter-sheet"
import { getPeriodicEvaluationsColumns } from "./evaluation-columns"
import { PeriodicEvaluationView } from "@/db/schema"
+import { getPeriodicEvaluations } from "../service"
interface PeriodicEvaluationsTableProps {
promises: Promise<[Awaited<ReturnType<typeof getPeriodicEvaluations>>]>
@@ -229,16 +230,33 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className }
const [promiseData] = React.use(promises)
const tableData = promiseData
+ const getSearchParam = React.useCallback((key: string, defaultValue?: string): string => {
+ return searchParams?.get(key) ?? defaultValue ?? "";
+ }, [searchParams]);
+
+ const parseSearchParamHelper = React.useCallback((key: string, defaultValue: any): any => {
+ try {
+ const value = getSearchParam(key);
+ return value ? JSON.parse(value) : defaultValue;
+ } catch {
+ return defaultValue;
+ }
+ }, [getSearchParam]);
+
+ const parseSearchParam = <T,>(key: string, defaultValue: T): T => {
+ return parseSearchParamHelper(key, defaultValue);
+ };
+
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: "createdAt", desc: true }],
- filters: searchParams.get('filters') ? JSON.parse(searchParams.get('filters')!) : [],
- joinOperator: (searchParams.get('joinOperator') as "and" | "or") || "and",
- basicFilters: searchParams.get('basicFilters') ?
- JSON.parse(searchParams.get('basicFilters')!) : [],
- basicJoinOperator: (searchParams.get('basicJoinOperator') as "and" | "or") || "and",
- search: searchParams.get('search') || '',
+ page: parseInt(getSearchParam('page') || '1'),
+ perPage: parseInt(getSearchParam('perPage') || '10'),
+ sort: getSearchParam('sort') ? JSON.parse(getSearchParam('sort')!) : [{ id: "createdAt", desc: true }],
+ filters: getSearchParam('filters') ? JSON.parse(getSearchParam('filters')!) : [],
+ joinOperator: (getSearchParam('joinOperator') as "and" | "or") || "and",
+ basicFilters: getSearchParam('basicFilters') ?
+ JSON.parse(getSearchParam('basicFilters')!) : [],
+ basicJoinOperator: (getSearchParam('basicJoinOperator') as "and" | "or") || "and",
+ search: getSearchParam('search') || '',
columnVisibility: {},
columnOrder: [],
pinnedColumns: { left: [], right: ["actions"] },
@@ -267,22 +285,22 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className }
)
const filterFields: DataTableFilterField<PeriodicEvaluationView>[] = [
- { id: "evaluationTarget.vendorCode", label: "벤더 코드" },
- { id: "evaluationTarget.vendorName", label: "벤더명" },
+ { id: "vendorCode", label: "벤더 코드" },
+ { id: "vendorName", label: "벤더명" },
{ id: "status", label: "진행상태" },
]
const advancedFilterFields: DataTableAdvancedFilterField<PeriodicEvaluationView>[] = [
- { id: "evaluationTarget.evaluationYear", label: "평가년도", type: "number" },
+ { id: "evaluationYear", label: "평가년도", type: "number" },
{ id: "evaluationPeriod", label: "평가기간", type: "text" },
{
- id: "evaluationTarget.division", label: "구분", type: "select", options: [
+ id: "division", label: "구분", type: "select", options: [
{ label: "해양", value: "PLANT" },
{ label: "조선", value: "SHIP" },
]
},
- { id: "evaluationTarget.vendorCode", label: "벤더 코드", type: "text" },
- { id: "evaluationTarget.vendorName", label: "벤더명", type: "text" },
+ { id: "vendorCode", label: "벤더 코드", type: "text" },
+ { id: "vendorName", label: "벤더명", type: "text" },
{
id: "status", label: "진행상태", type: "select", options: [
{ label: "제출대기", value: "PENDING_SUBMISSION" },
@@ -305,24 +323,14 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className }
{ id: "finalizedAt", label: "최종확정일", type: "date" },
]
- const currentSettings = useMemo(() => {
- return getCurrentSettings()
- }, [getCurrentSettings])
+ const currentSettings = React.useMemo(() => getCurrentSettings(), [getCurrentSettings]);
- function getColKey<T>(c: ColumnDef<T>): string | undefined {
- if ("accessorKey" in c && c.accessorKey) return c.accessorKey as string
- if ("id" in c && c.id) return c.id as string
- return undefined
- }
+ const initialState = React.useMemo(() => ({
+ sorting: initialSettings.sort.filter((s: any) => columns.some((c: any) => ("accessorKey" in c ? c.accessorKey : c.id) === s.id)),
+ columnVisibility: currentSettings.columnVisibility,
+ columnPinning: currentSettings.pinnedColumns,
+ }), [columns, currentSettings, initialSettings.sort]);
- const initialState = useMemo(() => {
- return {
- sorting: initialSettings.sort.filter(s =>
- columns.some(c => getColKey(c) === s.id)),
- columnVisibility: currentSettings.columnVisibility,
- columnPinning: currentSettings.pinnedColumns,
- }
- }, [columns, currentSettings, initialSettings.sort])
const { table } = useDataTable({
data: tableData.data,
@@ -344,7 +352,7 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className }
const getActiveBasicFilterCount = () => {
try {
- const basicFilters = searchParams.get('basicFilters')
+ const basicFilters = getSearchParam('basicFilters')
return basicFilters ? JSON.parse(basicFilters).length : 0
} catch (e) {
return 0
diff --git a/lib/spread-js/fns.ts b/lib/spread-js/fns.ts
new file mode 100644
index 00000000..8a2b23a0
--- /dev/null
+++ b/lib/spread-js/fns.ts
@@ -0,0 +1,353 @@
+'use client'
+
+import GC from "@mescius/spread-sheets";
+import { Spread } from "@/types/spread-js";
+
+//시트 추가
+export const addSheet = (spread: Spread | null): void => {
+ if (!spread) return;
+
+ const activeIndex = spread.getActiveSheetIndex();
+
+ if (activeIndex >= 0) {
+ spread.addSheet(activeIndex + 1);
+ spread.setActiveSheetIndex(activeIndex + 1);
+ } else {
+ spread.addSheet(0);
+ spread.setActiveSheetIndex(0);
+ }
+};
+
+//시트 제거
+export const removeSheet = (spread: Spread | null): void => {
+ if (!spread) return;
+
+ if (spread.getSheetCount() > 0) {
+ spread.removeSheet(spread.getActiveSheetIndex());
+ }
+};
+
+//전체 시트 제거
+export const clearSheet = (spread: Spread | null) => {
+ if (!spread) return;
+
+ spread.clearSheets();
+};
+
+//Table Sheet 추가
+export const createTableSheet = (spread: Spread | null) => {
+ if (!spread) return;
+
+ const activeIndex = spread.getActiveSheetIndex();
+
+ if (activeIndex >= 0) {
+ spread.addSheet(
+ activeIndex + 1,
+ new GC.Spread.Sheets.Worksheet(`Table Sheet ${activeIndex + 1}`)
+ );
+ spread.setActiveSheetIndex(activeIndex + 1);
+ } else {
+ spread.addSheet(0, new GC.Spread.Sheets.Worksheet(`Table Sheet 0`));
+ spread.setActiveSheetIndex(0);
+ }
+
+ const sheet = spread.getActiveSheet();
+};
+
+/**
+ * Sample Report Form를 만드는 함수
+ * @param spread
+ * @returns
+ */
+export const createSampleReportForm = (spread: Spread | null) => {
+ if (!spread) return;
+
+ const sheet = spread.getActiveSheet();
+
+ sheet.reset();
+
+ sheet.suspendPaint();
+
+ sheet.name("Inspection Report");
+
+ let bindingPathCellType = new BindingPathCellType();
+
+ //기본 테두리 설정
+ const defaultLineStyle = GC.Spread.Sheets.LineStyle.thin;
+ const defaultLineBorder = new GC.Spread.Sheets.LineBorder(
+ "black",
+ defaultLineStyle
+ );
+
+ //행 높이 설정
+ sheet.setRowHeight(1, 50);
+
+ //Cell Data 삽입
+ sheet
+ .getCell(1, 1)
+ .value("TEST")
+ .vAlign(GC.Spread.Sheets.VerticalAlign.bottom)
+ .hAlign(GC.Spread.Sheets.HorizontalAlign.left);
+
+ //Cell Data Binding
+
+ sheet
+ .getCell(1, 2)
+ .bindingPath("test")
+ .cellType(bindingPathCellType)
+ .vAlign(GC.Spread.Sheets.VerticalAlign.bottom);
+
+ sheet
+ .getCell(1, 7)
+ .bindingPath("test2")
+ .cellType(bindingPathCellType)
+ .vAlign(GC.Spread.Sheets.VerticalAlign.bottom);
+
+ const bindingData = { test: "testData", test2: "testData2" };
+ const bindingDataSource = new GC.Spread.Sheets.Bindings.CellBindingSource(
+ bindingData
+ );
+
+ sheet.setDataSource(bindingDataSource);
+
+ //Cell Merge
+ sheet.addSpan(1, 2, 1, 4);
+
+ //Cell Border Setting
+ sheet
+ .getRange(1, 1, 1, 5)
+ .setBorder(defaultLineBorder, { outline: true, inside: true });
+
+ //Table 생성
+ const tableFields = [
+ { field: "id", name: "ID" },
+ { field: "name", name: "DESCRIPTION" },
+ { field: "date", name: "Date", formatter: "dd/mm/yyyy" },
+ { field: "qty", name: "QUANTITY" },
+ { field: "amount", name: "AMOUNT" },
+ ];
+
+ const table = sheet.tables.add("sampleTable", 3, 1, 1, tableFields.length);
+
+ const tableColumns = tableFields.map((c, i) => {
+ const { field, name, formatter } = c;
+ const tableColumn = new GC.Spread.Sheets.Tables.TableColumn(i);
+
+ tableColumn.name(name);
+ tableColumn.dataField(field);
+
+ if (formatter) {
+ tableColumn.formatter(formatter);
+ }
+
+ return tableColumn;
+ });
+ table.autoGenerateColumns(false);
+ table.bindColumns(tableColumns);
+
+ table.bindingPath("tableData");
+
+ //이미 있는 속성으로 테이블 스타일을 적용 시키는 법
+ // table.style(GC.Spread.Sheets.Tables.TableThemes.light1.name());
+
+ //테이블 속성을 임의로 만들어서 적용 시키는 법 1
+ const tableBorder = new GC.Spread.Sheets.LineBorder(
+ "black",
+ defaultLineStyle
+ );
+
+ const baseTableStyleInfo = new GC.Spread.Sheets.Tables.TableStyle(
+ "#fff",
+ "#000000",
+ undefined,
+ tableBorder,
+ tableBorder,
+ tableBorder,
+ tableBorder,
+ tableBorder,
+ tableBorder
+ );
+
+ const headerStyle = new GC.Spread.Sheets.Tables.TableStyle(
+ "#F9FAFB",
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ undefined,
+ "900",
+ "14px"
+ );
+
+ var customTableStyle = new GC.Spread.Sheets.Tables.TableTheme();
+
+ // customTableStyle.headerRowStyle(tableStyleInfo);
+ customTableStyle.wholeTableStyle(baseTableStyleInfo);
+
+ customTableStyle.headerRowStyle(headerStyle);
+ customTableStyle.footerRowStyle(headerStyle);
+
+ // customTableStyle.wholeTableStyle(new GC.Spread.Sheets.Tables.TableStyle('#e0f2f1'));
+ // customTableStyle.headerRowStyle(new GC.Spread.Sheets.Tables.TableStyle('#26a69a', '#fff'));
+ // customTableStyle.firstRowStripStyle(new GC.Spread.Sheets.Tables.TableStyle('#b2dfdb'));
+ // customTableStyle.firstColumnStripStyle(new GC.Spread.Sheets.Tables.TableStyle('#b2dfdb'));
+ // customTableStyle.footerRowStyle(new GC.Spread.Sheets.Tables.TableStyle('#26a69a', '#fff'));
+ // customTableStyle.highlightFirstColumnStyle(new GC.Spread.Sheets.Tables.TableStyle('#26a69a', '#fff'));
+ // customTableStyle.highlightLastColumnStyle(new GC.Spread.Sheets.Tables.TableStyle('#26a69a', '#fff'));
+
+ customTableStyle.name("customTableStyle1");
+
+ spread.customTableThemes.add(customTableStyle);
+
+ table.style("customTableStyle1");
+
+ table.showFooter(true, false /*isFooterInserted*/);
+ table.useFooterDropDownList(true);
+
+ sheet.resumePaint();
+};
+
+export const setSampleReportData = (
+ spread: Spread | null,
+ reportData: { [key: string]: any } = {
+ test: "test",
+ tableData: [
+ {
+ id: "1",
+ name: "2",
+ date: new Date(),
+ qty: 3,
+ amount: 4000,
+ },
+ {
+ id: "1",
+ name: "2",
+ date: new Date(),
+ qty: 3,
+ amount: 4000,
+ },
+ ],
+ }
+) => {
+ if (!spread) return;
+
+ const sheet = spread.getActiveSheet();
+
+ const dataSource = new GC.Spread.Sheets.Bindings.CellBindingSource(
+ reportData
+ );
+
+ sheet.setDataSource(dataSource);
+};
+
+export const exportJSON = (spread: Spread | null) => {
+ if (!spread) return;
+
+ const spreadJSON = spread.toJSON()
+
+ const jsonString = JSON.stringify(spreadJSON, null, 2); // 예쁘게 포맷
+ const blob = new Blob([jsonString], { type: "application/json" });
+
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+
+ a.href = url;
+ a.download = "spreadjs-form.json"; // 저장될 파일명
+ document.body.appendChild(a);
+ a.click();
+ document.body.removeChild(a);
+
+ URL.revokeObjectURL(url); // 메모리 해제
+};
+
+export function handleFileImport(spread: Spread) {
+ const input = document.createElement("input");
+ input.type = "file";
+ input.accept = ".json";
+
+ input.onchange = async (e: any) => {
+ const file = e.target.files[0];
+ if (!file) return;
+
+ const text = await file.text();
+ const json = JSON.parse(text);
+
+ spread.fromJSON(json)
+
+ // const targetSheet = spread.getSheetFromName("Inspection Report");
+
+ const sheets = spread.sheets
+
+ sheets.forEach(sheet => {
+ const sheetName = sheet.name()
+ const bindingData = { test: "testData", test2: "testData2" };
+ const bindingDataSource = new GC.Spread.Sheets.Bindings.CellBindingSource(
+ bindingData
+ );
+
+
+
+ sheet.setDataSource(bindingDataSource);
+ })
+
+ };
+
+ input.click();
+}
+
+
+class BindingPathCellType extends GC.Spread.Sheets.CellTypes.Text {
+ constructor() {
+ super();
+ }
+
+ paint(
+ ctx: any,
+ value: any,
+ x: number,
+ y: number,
+ w: number,
+ h: number,
+ style: any,
+ context: any
+ ) {
+ if (value === null || value === undefined) {
+ let sheet = context.sheet,
+ row = context.row,
+ col = context.col;
+ if (sheet && (row === 0 || !!row) && (col === 0 || !!col)) {
+ let bindingPath = sheet.getBindingPath(context.row, context.col);
+ if (bindingPath) {
+ value = "[" + bindingPath + "]";
+ }
+ }
+ }
+ super.paint(ctx, value, x, y, w, h, style, context);
+ }
+}
+
+type TableStyle = {
+ backColor?:
+ | string
+ | GC.Spread.Sheets.IPatternFill
+ | GC.Spread.Sheets.IGradientFill
+ | GC.Spread.Sheets.IGradientPathFill;
+ foreColor?: string;
+ font?: string;
+ borderLeft?: GC.Spread.Sheets.LineBorder;
+ borderTop?: GC.Spread.Sheets.LineBorder;
+ borderRight?: GC.Spread.Sheets.LineBorder;
+ borderBottom?: GC.Spread.Sheets.LineBorder;
+ borderHorizontal?: GC.Spread.Sheets.LineBorder;
+ borderVertical?: GC.Spread.Sheets.LineBorder;
+ textDecoration?: GC.Spread.Sheets.TextDecorationType;
+ fontStyle?: string;
+ fontWeight?: string;
+ fontSize?: string;
+ fontFamily?: string;
+};
diff --git a/lib/tech-project-avl/table/accepted-quotations-table-columns.tsx b/lib/tech-project-avl/table/accepted-quotations-table-columns.tsx
new file mode 100644
index 00000000..68a61f0a
--- /dev/null
+++ b/lib/tech-project-avl/table/accepted-quotations-table-columns.tsx
@@ -0,0 +1,324 @@
+"use client"
+
+import * as React from "react"
+import { ColumnDef } from "@tanstack/react-table"
+import { formatDate } from "@/lib/utils"
+import { Checkbox } from "@/components/ui/checkbox"
+import { Badge } from "@/components/ui/badge"
+import {
+} from "@/components/ui/dropdown-menu"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+
+// Accepted Quotation 타입 정의
+export interface AcceptedQuotationItem {
+ id: number
+ rfqId: number
+ vendorId: number
+ quotationCode: string | null
+ quotationVersion: number | null
+ totalPrice: string | null
+ currency: string | null
+ validUntil: Date | null
+ status: string
+ remark: string | null
+ submittedAt: Date | null
+ acceptedAt: Date | null
+ createdAt: Date
+ updatedAt: Date
+
+ // RFQ 정보
+ rfqCode: string | null
+ rfqType: string | null
+ description: string | null
+ dueDate: Date | null
+ rfqStatus: string | null
+ materialCode: string | null
+
+ // Vendor 정보
+ vendorName: string
+ vendorCode: string | null
+ vendorEmail: string | null
+ vendorCountry: string | null
+
+ // Project 정보
+ projNm: string | null
+ pspid: string | null
+ sector: string | null
+}
+
+export function getColumns(): ColumnDef<AcceptedQuotationItem>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<AcceptedQuotationItem> = {
+ 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) actions 컬럼 (Dropdown 메뉴)
+ // ----------------------------------------------------------------
+ // const actionsColumn: ColumnDef<AcceptedQuotationItem> = {
+ // id: "actions",
+ // cell: ({ row }) => (
+ // <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={() => setRowAction({ row, type: "open" })}
+ // >
+ // 견적서 보기
+ // </DropdownMenuItem>
+ // </DropdownMenuContent>
+ // </DropdownMenu>
+ // ),
+ // size: 40,
+ // enableSorting: false,
+ // enableHiding: false,
+ // }
+
+ // ----------------------------------------------------------------
+ // 3) 데이터 컬럼들 정의
+ // ----------------------------------------------------------------
+ const dataColumns: ColumnDef<AcceptedQuotationItem>[] = [
+ {
+ accessorKey: "rfqCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ 코드" />
+ ),
+ cell: ({ row }) => (
+ <div className="font-medium">
+ {row.original.rfqCode || "-"}
+ </div>
+ ),
+ enableSorting: true,
+ enableHiding: true,
+ meta: {
+ excelHeader: "RFQ 코드",
+ },
+ },
+ {
+ accessorKey: "description",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ 설명" />
+ ),
+ cell: ({ row }) => (
+ <div className="max-w-32 truncate">
+ {row.original.description || "-"}
+ </div>
+ ),
+ enableSorting: true,
+ enableHiding: true,
+ meta: {
+ excelHeader: "RFQ 설명",
+ },
+ },
+ {
+ accessorKey: "rfqType",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ 타입" />
+ ),
+ cell: ({ row }) => (
+ <div>
+ {row.original.rfqType || "-"}
+ </div>
+ ),
+ enableSorting: true,
+ },
+ {
+ accessorKey: "vendorName",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="업체명" />
+ ),
+ cell: ({ row }) => (
+ <div className="font-medium">
+ {row.original.vendorName}
+ </div>
+ ),
+ enableSorting: true,
+ enableHiding: true,
+ meta: {
+ excelHeader: "업체명",
+ },
+ },
+ {
+ accessorKey: "vendorCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="업체 코드" />
+ ),
+ cell: ({ row }) => (
+ <div>
+ {row.original.vendorCode || "-"}
+ </div>
+ ),
+ enableSorting: true,
+ enableHiding: true,
+ meta: {
+ excelHeader: "업체 코드",
+ },
+ },
+ {
+ accessorKey: "quotationCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="견적서 코드" />
+ ),
+ cell: ({ row }) => (
+ <div>
+ {row.original.quotationCode || "-"}
+ </div>
+ ),
+ enableSorting: true,
+ enableHiding: true,
+ meta: {
+ excelHeader: "견적서 코드",
+ },
+ },
+ {
+ accessorKey: "totalPrice",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="총 금액" />
+ ),
+ cell: ({ row }) => {
+ const price = row.original.totalPrice;
+ const currency = row.original.currency || "USD";
+ return (
+ <div className="text-right font-medium">
+ {price ? `${Number(price).toLocaleString()} ${currency}` : "-"}
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ meta: {
+ excelHeader: "총 금액",
+ },
+ },
+ {
+ accessorKey: "status",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="상태" />
+ ),
+ cell: ({ row }) => (
+ <Badge variant="default" className="bg-green-100 text-green-800">
+ {row.original.status}
+ </Badge>
+ ),
+ enableSorting: true,
+ enableHiding: true,
+ meta: {
+ excelHeader: "상태",
+ },
+ },
+ {
+ accessorKey: "projNm",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="프로젝트명" />
+ ),
+ cell: ({ row }) => (
+ <div className="max-w-32 truncate">
+ {row.original.projNm || "-"}
+ </div>
+ ),
+ enableSorting: true,
+ enableHiding: true,
+ meta: {
+ excelHeader: "프로젝트명",
+ },
+ },
+ {
+ accessorKey: "materialCode",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="자재 코드" />
+ ),
+ cell: ({ row }) => (
+ <div className="max-w-32 truncate">
+ {row.original.materialCode || "-"}
+ </div>
+ ),
+ enableSorting: true,
+ enableHiding: true,
+ meta: {
+ excelHeader: "자재 코드",
+ },
+ },
+ {
+ accessorKey: "vendorCountry",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="국가" />
+ ),
+ cell: ({ row }) => (
+ <div>
+ {row.original.vendorCountry || "-"}
+ </div>
+ ),
+ enableSorting: true,
+ enableHiding: true,
+ meta: {
+ excelHeader: "국가",
+ },
+ },
+ {
+ accessorKey: "dueDate",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="마감일" />
+ ),
+ cell: ({ row }) => (
+ <div>
+ {row.original.dueDate ? formatDate(row.original.dueDate) : "-"}
+ </div>
+ ),
+ enableSorting: true,
+ enableHiding: true,
+ meta: {
+ excelHeader: "마감일",
+ },
+ },
+ {
+ accessorKey: "acceptedAt",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="승인일" />
+ ),
+ cell: ({ row }) => (
+ <div>
+ {row.original.acceptedAt ? formatDate(row.original.acceptedAt) : "-"}
+ </div>
+ ),
+ enableSorting: true,
+ enableHiding: true,
+ meta: {
+ excelHeader: "승인일",
+ },
+ },
+ ]
+
+ return [selectColumn, ...dataColumns]
+} \ No newline at end of file
diff --git a/lib/tech-project-avl/table/accepted-quotations-table-toolbar-actions.tsx b/lib/tech-project-avl/table/accepted-quotations-table-toolbar-actions.tsx
new file mode 100644
index 00000000..ae9aea60
--- /dev/null
+++ b/lib/tech-project-avl/table/accepted-quotations-table-toolbar-actions.tsx
@@ -0,0 +1,51 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download, RefreshCcw } from "lucide-react"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { AcceptedQuotationItem } from "./accepted-quotations-table-columns"
+
+interface AcceptedQuotationsTableToolbarActionsProps {
+ table: Table<AcceptedQuotationItem>
+ onRefresh?: () => void
+}
+
+export function AcceptedQuotationsTableToolbarActions({
+ table,
+ onRefresh
+}: AcceptedQuotationsTableToolbarActionsProps) {
+
+ return (
+ <div className="flex items-center gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "accepted-tech-sales-quotations",
+ excludeColumns: ["select", "actions"],
+ })
+ }
+ className="gap-2"
+ >
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Excel 내보내기</span>
+ </Button>
+
+ {onRefresh && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={onRefresh}
+ className="gap-2"
+ >
+ <RefreshCcw className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">새로고침</span>
+ </Button>
+ )}
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/tech-project-avl/table/accepted-quotations-table.tsx b/lib/tech-project-avl/table/accepted-quotations-table.tsx
new file mode 100644
index 00000000..da33d0d5
--- /dev/null
+++ b/lib/tech-project-avl/table/accepted-quotations-table.tsx
@@ -0,0 +1,117 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableAdvancedFilterField } 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 { getColumns, type AcceptedQuotationItem } from "./accepted-quotations-table-columns"
+import { AcceptedQuotationsTableToolbarActions } from "./accepted-quotations-table-toolbar-actions"
+
+interface AcceptedQuotationsTableProps {
+ data: AcceptedQuotationItem[]
+ pageCount: number
+ onRefresh?: () => void
+}
+
+export function AcceptedQuotationsTable({
+ data,
+ pageCount,
+ onRefresh,
+}: AcceptedQuotationsTableProps) {
+
+ // 필터 필드 정의
+ const filterFields: DataTableAdvancedFilterField<AcceptedQuotationItem>[] = [
+ {
+ id: "rfqCode",
+ label: "RFQ 코드",
+ type: "text",
+ placeholder: "RFQ 코드로 필터...",
+ },
+ {
+ id: "vendorName",
+ label: "업체명",
+ type: "text",
+ placeholder: "업체명으로 필터...",
+ },
+ {
+ id: "vendorCode",
+ label: "업체 코드",
+ type: "text",
+ placeholder: "업체 코드로 필터...",
+ },
+ {
+ id: "projNm",
+ label: "프로젝트명",
+ type: "text",
+ placeholder: "프로젝트명으로 필터...",
+ },
+ {
+ id: "vendorCountry",
+ label: "국가",
+ type: "text",
+ placeholder: "국가로 필터...",
+ },
+ {
+ id: "currency",
+ label: "통화",
+ type: "select",
+ options: [
+ { label: "USD", value: "USD" },
+ { label: "EUR", value: "EUR" },
+ { label: "KRW", value: "KRW" },
+ { label: "JPY", value: "JPY" },
+ { label: "CNY", value: "CNY" },
+ ],
+ },
+ {
+ id: "rfqType",
+ label: "RFQ 타입",
+ type: "select",
+ options: [
+ { label: "TOP", value: "TOP" },
+ { label: "HULL", value: "HULL" },
+ ],
+ },
+ {
+ id: "dueDate",
+ label: "마감일",
+ type: "date",
+ },
+ {
+ id: "acceptedAt",
+ label: "승인일",
+ type: "date",
+ },
+ ]
+
+ const columns = React.useMemo(
+ () => getColumns(),
+ []
+ )
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ initialState: {
+ sorting: [{ id: "acceptedAt", desc: true }],
+ columnPinning: { left: ["select"] },
+ },
+ getRowId: (originalRow) => `${originalRow.id}`,
+ })
+
+ return (
+ <div className="w-full space-y-2.5 overflow-auto">
+ <DataTableAdvancedToolbar table={table} filterFields={filterFields}>
+ <AcceptedQuotationsTableToolbarActions
+ table={table}
+ onRefresh={onRefresh}
+ />
+ </DataTableAdvancedToolbar>
+ <DataTable table={table} />
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/tech-project-avl/validations.ts b/lib/tech-project-avl/validations.ts
new file mode 100644
index 00000000..3e08b641
--- /dev/null
+++ b/lib/tech-project-avl/validations.ts
@@ -0,0 +1,41 @@
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+import { AcceptedQuotationItem } from "./table/accepted-quotations-table-columns"
+
+export const searchParamsCache = createSearchParamsCache({
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault(
+ []
+ ),
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+ sort: getSortingStateParser<AcceptedQuotationItem>().withDefault([
+ { id: "acceptedAt", desc: true },
+ ]),
+
+ // 검색 필드
+ rfqCode: parseAsString.withDefault(""),
+ vendorName: parseAsString.withDefault(""),
+ vendorCode: parseAsString.withDefault(""),
+ projNm: parseAsString.withDefault(""),
+
+ // 필터 필드
+ rfqType: parseAsStringEnum(["SHIP", "TOP", "HULL"]),
+ currency: parseAsStringEnum(["USD", "EUR", "KRW", "JPY", "CNY"]),
+
+ // 날짜 범위
+ from: parseAsString.withDefault(""),
+ to: parseAsString.withDefault(""),
+
+ // advanced filter
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+ search: parseAsString.withDefault(""),
+}) \ No newline at end of file
diff --git a/lib/tech-vendor-candidates/service.ts b/lib/tech-vendor-candidates/service.ts
new file mode 100644
index 00000000..47832236
--- /dev/null
+++ b/lib/tech-vendor-candidates/service.ts
@@ -0,0 +1,395 @@
+"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택)
+
+import { asc, desc, ilike, inArray, and, or, gte, lte, eq, count } from "drizzle-orm";
+import { revalidateTag } from "next/cache";
+import { filterColumns } from "@/lib/filter-columns";
+import { unstable_cache } from "@/lib/unstable-cache";
+import { getErrorMessage } from "@/lib/handle-error";
+import db from "@/db/db";
+import { sendEmail } from "../mail/sendEmail";
+import { CreateVendorCandidateSchema, createVendorCandidateSchema, GetTechVendorsCandidateSchema, RemoveTechCandidatesInput, removeTechCandidatesSchema, updateVendorCandidateSchema, UpdateVendorCandidateSchema } from "./validations";
+import { PgTransaction } from "drizzle-orm/pg-core";
+import { techVendorCandidates, techVendorCandidatesWithVendorInfo } from "@/db/schema/techVendors";
+import { headers } from 'next/headers';
+
+export async function getVendorCandidates(input: GetTechVendorsCandidateSchema) {
+ return unstable_cache(
+ async () => {
+ try {
+ const offset = (input.page - 1) * input.perPage
+ const fromDate = input.from ? new Date(input.from) : undefined;
+ const toDate = input.to ? new Date(input.to) : undefined;
+
+ // 1) Advanced filters
+ const advancedWhere = filterColumns({
+ table: techVendorCandidatesWithVendorInfo,
+ filters: input.filters,
+ joinOperator: input.joinOperator,
+ })
+
+ // 2) Global search
+ let globalWhere
+ if (input.search) {
+ const s = `%${input.search}%`
+ globalWhere = or(
+ ilike(techVendorCandidatesWithVendorInfo.companyName, s),
+ ilike(techVendorCandidatesWithVendorInfo.contactEmail, s),
+ ilike(techVendorCandidatesWithVendorInfo.contactPhone, s),
+ ilike(techVendorCandidatesWithVendorInfo.country, s),
+ ilike(techVendorCandidatesWithVendorInfo.source, s),
+ ilike(techVendorCandidatesWithVendorInfo.status, s),
+ ilike(techVendorCandidatesWithVendorInfo.taxId, s),
+ ilike(techVendorCandidatesWithVendorInfo.items, s),
+ ilike(techVendorCandidatesWithVendorInfo.remark, s),
+ ilike(techVendorCandidatesWithVendorInfo.address, s),
+ // etc.
+ )
+ }
+
+ // 3) Combine finalWhere
+ const finalWhere = and(
+ advancedWhere,
+ globalWhere,
+ fromDate ? gte(techVendorCandidatesWithVendorInfo.createdAt, fromDate) : undefined,
+ toDate ? lte(techVendorCandidatesWithVendorInfo.createdAt, toDate) : undefined
+ )
+
+ // 5) Sorting
+ const orderBy =
+ input.sort && input.sort.length > 0
+ ? input.sort.map((item) =>
+ item.desc
+ ? desc(techVendorCandidatesWithVendorInfo[item.id])
+ : asc(techVendorCandidatesWithVendorInfo[item.id])
+ )
+ : [desc(techVendorCandidatesWithVendorInfo.createdAt)]
+
+ // 6) Query & count
+ const { data, total } = await db.transaction(async (tx) => {
+ // a) Select from the view
+ const candidatesData = await tx
+ .select()
+ .from(techVendorCandidatesWithVendorInfo)
+ .where(finalWhere)
+ .orderBy(...orderBy)
+ .offset(offset)
+ .limit(input.perPage)
+
+ // b) Count total
+ const resCount = await tx
+ .select({ count: count() })
+ .from(techVendorCandidatesWithVendorInfo)
+ .where(finalWhere)
+
+ return { data: candidatesData, total: resCount[0]?.count }
+ })
+
+ // 7) Calculate pageCount
+ const pageCount = Math.ceil(total / input.perPage)
+
+ return { data, pageCount }
+ } catch (err) {
+ console.error(err)
+ return { data: [], pageCount: 0 }
+ }
+ },
+ // Cache key
+ [JSON.stringify(input)],
+ {
+ revalidate: 3600,
+ tags: ["tech-vendor-candidates"],
+ }
+ )()
+}
+
+export async function createVendorCandidate(input: CreateVendorCandidateSchema) {
+ try {
+ // Validate input
+ const validated = createVendorCandidateSchema.parse(input);
+
+ // 트랜잭션으로 데이터 삽입
+ const result = await db.transaction(async (tx) => {
+ // Insert into database
+ const [newCandidate] = await tx
+ .insert(techVendorCandidates)
+ .values({
+ companyName: validated.companyName,
+ contactEmail: validated.contactEmail,
+ contactPhone: validated.contactPhone || null,
+ taxId: validated.taxId || "",
+ address: validated.address || null,
+ country: validated.country || null,
+ source: validated.source || null,
+ status: validated.status || "COLLECTED",
+ remark: validated.remark || null,
+ items: validated.items || "", // items가 필수 필드이므로 빈 문자열이라도 제공
+ vendorId: validated.vendorId || null,
+ updatedAt: new Date(),
+ })
+ .returning();
+
+ return newCandidate;
+ });
+
+ // Invalidate cache
+ revalidateTag("tech-vendor-candidates");
+
+ return { success: true, data: result };
+ } catch (error) {
+ console.error("Failed to create tech vendor candidate:", error);
+ return { success: false, error: getErrorMessage(error) };
+ }
+}
+
+
+// Helper function to group tech vendor candidates by status
+async function groupVendorCandidatesByStatus(tx: PgTransaction<Record<string, never>, Record<string, never>, Record<string, never>>) {
+ return tx
+ .select({
+ status: techVendorCandidates.status,
+ count: count(),
+ })
+ .from(techVendorCandidates)
+ .groupBy(techVendorCandidates.status);
+}
+
+/**
+ * Get count of tech vendor candidates grouped by status
+ */
+export async function getVendorCandidateCounts() {
+ return unstable_cache(
+ async () => {
+ try {
+ // Initialize counts object with all possible statuses set to 0
+ const initial: Record<"COLLECTED" | "INVITED" | "DISCARDED", number> = {
+ COLLECTED: 0,
+ INVITED: 0,
+ DISCARDED: 0,
+ };
+
+ // Execute query within transaction and transform results
+ const result = await db.transaction(async (tx) => {
+ const rows = await groupVendorCandidatesByStatus(tx);
+ return rows.reduce<Record<string, number>>((acc, { status, count }) => {
+ if (status in acc) {
+ acc[status] = count;
+ }
+ return acc;
+ }, initial);
+ });
+
+ return result;
+ } catch (err) {
+ console.error("Failed to get tech vendor candidate counts:", err);
+ return {
+ COLLECTED: 0,
+ INVITED: 0,
+ DISCARDED: 0,
+ };
+ }
+ },
+ ["tech-vendor-candidate-status-counts"], // Cache key
+ {
+ revalidate: 3600, // Revalidate every hour
+ }
+ )();
+}
+
+
+/**
+ * Update a vendor candidate
+ */
+export async function updateVendorCandidate(input: UpdateVendorCandidateSchema) {
+ try {
+ // Validate input
+ const validated = updateVendorCandidateSchema.parse(input);
+
+ // Prepare update data (excluding id)
+ const { id, ...updateData } = validated;
+
+ const headersList = await headers();
+ const host = headersList.get('host') || 'localhost:3000';
+
+ const baseUrl = `http://${host}`
+
+ // Add updatedAt timestamp
+ const dataToUpdate = {
+ ...updateData,
+ updatedAt: new Date(),
+ };
+
+ const result = await db.transaction(async (tx) => {
+ // 현재 데이터 조회 (상태 변경 감지를 위해)
+ const [existingCandidate] = await tx
+ .select()
+ .from(techVendorCandidates)
+ .where(eq(techVendorCandidates.id, id));
+
+ if (!existingCandidate) {
+ throw new Error("Tech vendor candidate not found");
+ }
+
+ // Update database
+ const [updatedCandidate] = await tx
+ .update(techVendorCandidates)
+ .set(dataToUpdate)
+ .where(eq(techVendorCandidates.id, id))
+ .returning();
+
+ // 로그 작성
+ const statusChanged =
+ updateData.status &&
+ existingCandidate.status !== updateData.status;
+
+ // If status was updated to "INVITED", send email
+ if (statusChanged && updateData.status === "INVITED" && updatedCandidate.contactEmail) {
+ await sendEmail({
+ to: updatedCandidate.contactEmail,
+ subject: "Invitation to Register as a Vendor",
+ template: "vendor-invitation",
+ context: {
+ companyName: updatedCandidate.companyName,
+ language: "en",
+ registrationLink: `${baseUrl}/en/partners`,
+ }
+ });
+ }
+
+ return updatedCandidate;
+ });
+
+ // Invalidate cache
+ revalidateTag("vendor-candidates");
+
+ return { success: true, data: result };
+ } catch (error) {
+ console.error("Failed to update vendor candidate:", error);
+ return { success: false, error: getErrorMessage(error) };
+ }
+}
+
+export async function bulkUpdateVendorCandidateStatus({
+ ids,
+ status,
+}: {
+ ids: number[],
+ status: "COLLECTED" | "INVITED" | "DISCARDED",
+}) {
+ try {
+ // Validate inputs
+ if (!ids.length) {
+ return { success: false, error: "No IDs provided" };
+ }
+
+ if (!["COLLECTED", "INVITED", "DISCARDED"].includes(status)) {
+ return { success: false, error: "Invalid status" };
+ }
+
+ const headersList = await headers();
+ const host = headersList.get('host') || 'localhost:3000';
+
+ const baseUrl = `http://${host}`
+
+ const result = await db.transaction(async (tx) => {
+ // Update all records
+ const updatedCandidates = await tx
+ .update(techVendorCandidates)
+ .set({
+ status,
+ updatedAt: new Date(),
+ })
+ .where(inArray(techVendorCandidates.id, ids))
+ .returning();
+
+ // If status is "INVITED", send emails to all updated candidates
+ if (status === "INVITED") {
+ const emailPromises = updatedCandidates
+ .filter(candidate => candidate.contactEmail)
+ .map(async (candidate) => {
+ await sendEmail({
+ to: candidate.contactEmail!,
+ subject: "Invitation to Register as a Vendor",
+ template: "vendor-invitation",
+ context: {
+ companyName: candidate.companyName,
+ language: "en",
+ registrationLink: `${baseUrl}/en/partners`,
+ }
+ });
+ });
+
+ // Wait for all emails to be sent
+ await Promise.all(emailPromises);
+ }
+
+ return updatedCandidates;
+ });
+
+ // Invalidate cache
+ revalidateTag("vendor-candidates");
+
+ return {
+ success: true,
+ data: result,
+ count: result.length
+ };
+ } catch (error) {
+ console.error("Failed to bulk update vendor candidates:", error);
+ return { success: false, error: getErrorMessage(error) };
+ }
+}
+
+// 4. 후보자 삭제 함수 업데이트
+export async function removeCandidates(input: RemoveTechCandidatesInput) {
+ try {
+ // Validate input
+ const validated = removeTechCandidatesSchema.parse(input);
+
+ const result = await db.transaction(async (tx) => {
+ // Get candidates before deletion (for logging purposes)
+ const candidatesBeforeDelete = await tx
+ .select()
+ .from(techVendorCandidates)
+ .where(inArray(techVendorCandidates.id, validated.ids));
+
+ // Delete the candidates
+ const deletedCandidates = await tx
+ .delete(techVendorCandidates)
+ .where(inArray(techVendorCandidates.id, validated.ids))
+ .returning({ id: techVendorCandidates.id });
+
+ return {
+ deletedCandidates,
+ candidatesBeforeDelete
+ };
+ });
+
+ // If no candidates were deleted, return an error
+ if (!result.deletedCandidates.length) {
+ return {
+ success: false,
+ error: "No candidates were found with the provided IDs",
+ };
+ }
+
+ // Log deletion for audit purposes
+ console.log(
+ `Deleted ${result.deletedCandidates.length} vendor candidates:`,
+ result.candidatesBeforeDelete.map(c => `${c.id} (${c.companyName})`)
+ );
+
+ // Invalidate cache
+ revalidateTag("vendor-candidates");
+ revalidateTag("vendor-candidate-status-counts");
+ revalidateTag("vendor-candidate-total-count");
+
+ return {
+ success: true,
+ count: result.deletedCandidates.length,
+ deletedIds: result.deletedCandidates.map(c => c.id),
+ };
+ } catch (error) {
+ console.error("Failed to remove vendor candidates:", error);
+ return { success: false, error: getErrorMessage(error) };
+ }
+} \ No newline at end of file
diff --git a/lib/tech-vendor-candidates/table/add-candidates-dialog.tsx b/lib/tech-vendor-candidates/table/add-candidates-dialog.tsx
new file mode 100644
index 00000000..31c39137
--- /dev/null
+++ b/lib/tech-vendor-candidates/table/add-candidates-dialog.tsx
@@ -0,0 +1,394 @@
+"use client"
+
+import * as React from "react"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { Check, ChevronsUpDown } from "lucide-react"
+import i18nIsoCountries from "i18n-iso-countries"
+import enLocale from "i18n-iso-countries/langs/en.json"
+import koLocale from "i18n-iso-countries/langs/ko.json"
+import { cn } from "@/lib/utils"
+import { useSession } from "next-auth/react" // next-auth 세션 훅 추가
+
+import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import { useToast } from "@/hooks/use-toast"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command"
+
+// react-hook-form + shadcn/ui Form
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+
+
+import { createVendorCandidateSchema, CreateVendorCandidateSchema } from "../validations"
+import { createVendorCandidate } from "../service"
+
+// Register locales for countries
+i18nIsoCountries.registerLocale(enLocale)
+i18nIsoCountries.registerLocale(koLocale)
+
+// Generate country array
+const locale = "ko"
+const countryMap = i18nIsoCountries.getNames(locale, { select: "official" })
+const countryArray = Object.entries(countryMap).map(([code, label]) => ({
+ code,
+ label,
+}))
+
+export function AddCandidateDialog() {
+ const [open, setOpen] = React.useState(false)
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+ const { toast } = useToast()
+ const { data: session, status } = useSession()
+
+ // react-hook-form 세팅
+ const form = useForm<CreateVendorCandidateSchema>({
+ resolver: zodResolver(createVendorCandidateSchema),
+ defaultValues: {
+ companyName: "",
+ contactEmail: "", // 필수 입력값
+ contactPhone: "",
+ taxId: "",
+ address: "",
+ country: "",
+ source: "",
+ items: "",
+ remark: "",
+ status: "COLLECTED",
+ },
+ });
+
+ async function onSubmit(data: CreateVendorCandidateSchema) {
+ setIsSubmitting(true)
+ try {
+ // 세션 유효성 검사
+ if (!session || !session.user || !session.user.id) {
+ toast({
+ title: "인증 오류",
+ description: "로그인 정보를 찾을 수 없습니다. 다시 로그인해주세요.",
+ variant: "destructive",
+ })
+ return
+ }
+
+ // userId 추출 (세션 구조에 따라 조정 필요)
+ const userId = session.user.id
+
+ const result = await createVendorCandidate(data, Number(userId))
+ if (result.error) {
+ toast({
+ title: "오류 발생",
+ description: result.error,
+ variant: "destructive",
+ })
+ return
+ }
+ // 성공 시 모달 닫고 폼 리셋
+ toast({
+ title: "등록 완료",
+ description: "협력업체 후보가 성공적으로 등록되었습니다.",
+ })
+ form.reset()
+ setOpen(false)
+ } catch (error) {
+ console.error("Failed to create vendor candidate:", error)
+ toast({
+ title: "오류 발생",
+ description: "예상치 못한 오류가 발생했습니다.",
+ variant: "destructive",
+ })
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ function handleDialogOpenChange(nextOpen: boolean) {
+ if (!nextOpen) {
+ form.reset()
+ }
+ setOpen(nextOpen)
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={handleDialogOpenChange}>
+ {/* 모달을 열기 위한 버튼 */}
+ <DialogTrigger asChild>
+ <Button variant="default" size="sm">
+ Add Vendor Candidate
+ </Button>
+ </DialogTrigger>
+
+ <DialogContent className="sm:max-w-[525px]">
+ <DialogHeader>
+ <DialogTitle>Create New Vendor Candidate</DialogTitle>
+ <DialogDescription>
+ 새 Vendor Candidate 정보를 입력하고 <b>Create</b> 버튼을 누르세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* shadcn/ui Form을 이용해 react-hook-form과 연결 */}
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ {/* Company Name 필드 */}
+ <FormField
+ control={form.control}
+ name="companyName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Company Name <span className="text-red-500">*</span></FormLabel>
+ <FormControl>
+ <Input
+ placeholder="Enter company name"
+ {...field}
+ disabled={isSubmitting}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Tax ID 필드 (새로 추가) */}
+ <FormField
+ control={form.control}
+ name="taxId"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Tax ID</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="Tax identification number"
+ {...field}
+ disabled={isSubmitting}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Contact Email 필드 */}
+ <FormField
+ control={form.control}
+ name="contactEmail"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Contact Email<span className="text-red-500">*</span></FormLabel>
+ <FormControl>
+ <Input
+ placeholder="email@example.com"
+ type="email"
+ {...field}
+ disabled={isSubmitting}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Contact Phone 필드 */}
+ <FormField
+ control={form.control}
+ name="contactPhone"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Contact Phone</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="+82-10-1234-5678"
+ {...field}
+ disabled={isSubmitting}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Address 필드 */}
+ <FormField
+ control={form.control}
+ name="address"
+ render={({ field }) => (
+ <FormItem className="col-span-full">
+ <FormLabel>Address</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="Company address"
+ {...field}
+ disabled={isSubmitting}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Country 필드 */}
+ <FormField
+ control={form.control}
+ name="country"
+ render={({ field }) => {
+ const selectedCountry = countryArray.find(
+ (c) => c.code === field.value
+ )
+ return (
+ <FormItem>
+ <FormLabel>Country</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "w-full justify-between",
+ !field.value && "text-muted-foreground"
+ )}
+ disabled={isSubmitting}
+ >
+ {selectedCountry
+ ? selectedCountry.label
+ : "Select a country"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-[300px] p-0">
+ <Command>
+ <CommandInput placeholder="Search country..." />
+ <CommandList>
+ <CommandEmpty>No country found.</CommandEmpty>
+ <CommandGroup className="max-h-[300px] overflow-y-auto">
+ {countryArray.map((country) => (
+ <CommandItem
+ key={country.code}
+ value={country.label}
+ onSelect={() =>
+ field.onChange(country.code)
+ }
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ country.code === field.value
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {country.label}
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )
+ }}
+ />
+
+ {/* Source 필드 */}
+ <FormField
+ control={form.control}
+ name="source"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Source <span className="text-red-500">*</span></FormLabel>
+ <FormControl>
+ <Input
+ placeholder="Where this candidate was found"
+ {...field}
+ disabled={isSubmitting}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+
+ {/* Items 필드 (새로 추가) */}
+ <FormField
+ control={form.control}
+ name="items"
+ render={({ field }) => (
+ <FormItem className="col-span-full">
+ <FormLabel>Items <span className="text-red-500">*</span></FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="List of items or products this vendor provides"
+ className="min-h-[80px]"
+ {...field}
+ disabled={isSubmitting}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Remark 필드 (새로 추가) */}
+ <FormField
+ control={form.control}
+ name="remark"
+ render={({ field }) => (
+ <FormItem className="col-span-full">
+ <FormLabel>Remarks</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="Additional notes or comments"
+ className="min-h-[80px]"
+ {...field}
+ disabled={isSubmitting}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <DialogFooter>
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setOpen(false)}
+ disabled={isSubmitting}
+ >
+ Cancel
+ </Button>
+ <Button type="submit" disabled={isSubmitting}>
+ {isSubmitting ? "Creating..." : "Create"}
+ </Button>
+ </DialogFooter>
+ </form>
+ </Form>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/tech-vendor-candidates/table/candidates-table-columns.tsx b/lib/tech-vendor-candidates/table/candidates-table-columns.tsx
new file mode 100644
index 00000000..113927cf
--- /dev/null
+++ b/lib/tech-vendor-candidates/table/candidates-table-columns.tsx
@@ -0,0 +1,199 @@
+"use client"
+
+import * as React from "react"
+import { type DataTableRowAction } from "@/types/table"
+import { type ColumnDef } from "@tanstack/react-table"
+import { Ellipsis } from "lucide-react"
+import { toast } from "sonner"
+
+import { getErrorMessage } from "@/lib/handle-error"
+import { formatDate, formatDateTime } 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,
+ DropdownMenuRadioGroup,
+ DropdownMenuRadioItem,
+ DropdownMenuSeparator,
+ DropdownMenuShortcut,
+ DropdownMenuSub,
+ DropdownMenuSubContent,
+ DropdownMenuSubTrigger,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+
+import { getCandidateStatusIcon } from "@/lib/vendor-candidates/utils"
+import { candidateColumnsConfig } from "@/config/candidatesColumnsConfig"
+import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
+import { VendorCandidatesWithVendorInfo } from "@/db/schema"
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<VendorCandidatesWithVendorInfo> | null>>
+}
+
+/**
+ * tanstack table 컬럼 정의 (중첩 헤더 버전)
+ */
+export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<VendorCandidatesWithVendorInfo>[] {
+ // ----------------------------------------------------------------
+ // 1) select 컬럼 (체크박스)
+ // ----------------------------------------------------------------
+ const selectColumn: ColumnDef<VendorCandidatesWithVendorInfo> = {
+ 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) actions 컬럼 (Dropdown 메뉴)
+ // ----------------------------------------------------------------
+// "actions" 컬럼 예시
+const actionsColumn: ColumnDef<VendorCandidatesWithVendorInfo> = {
+ id: "actions",
+ enableHiding: false,
+ cell: function Cell({ row }) {
+ 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={() => setRowAction({ row, type: "update" })}
+ >
+ 편집
+ </DropdownMenuItem>
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "delete" })}
+ >
+ 삭제
+ <DropdownMenuShortcut>⌘⌫</DropdownMenuShortcut>
+ </DropdownMenuItem>
+
+ {/* 여기서 Log 보기 액션 추가 */}
+ <DropdownMenuItem
+ onSelect={() => setRowAction({ row, type: "log" })}
+ >
+ 감사 로그 보기
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ )
+ },
+ size: 40,
+}
+
+
+ // ----------------------------------------------------------------
+ // 3) 일반 컬럼들을 "그룹"별로 묶어 중첩 columns 생성
+ // ----------------------------------------------------------------
+ // 3-1) groupMap: { [groupName]: ColumnDef<VendorCandidatesWithVendorInfo>[] }
+ const groupMap: Record<string, ColumnDef<VendorCandidatesWithVendorInfo>[]> = {}
+
+ candidateColumnsConfig.forEach((cfg) => {
+ // 만약 group가 없으면 "_noGroup" 처리
+ const groupName = cfg.group || "_noGroup"
+
+ if (!groupMap[groupName]) {
+ groupMap[groupName] = []
+ }
+
+ // child column 정의
+ const childCol: ColumnDef<VendorCandidatesWithVendorInfo> = {
+ accessorKey: cfg.id,
+ enableResizing: true,
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title={cfg.label} />
+ ),
+ meta: {
+ excelHeader: cfg.excelHeader,
+ group: cfg.group,
+ type: cfg.type,
+ },
+ cell: ({ row, cell }) => {
+
+ if (cfg.id === "status") {
+ const statusVal = row.original.status
+ if (!statusVal) return null
+ const Icon = getCandidateStatusIcon(statusVal)
+ return (
+ <div className="flex w-[6.25rem] items-center">
+ <Icon className="mr-2 size-4 text-muted-foreground" aria-hidden="true" />
+ <span className="capitalize">{statusVal}</span>
+ </div>
+ )
+ }
+
+
+ if (cfg.id === "createdAt" ||cfg.id === "updatedAt" ) {
+ const dateVal = cell.getValue() as Date
+ return formatDateTime(dateVal)
+ }
+
+ // code etc...
+ return row.getValue(cfg.id) ?? ""
+ },
+ }
+
+ groupMap[groupName].push(childCol)
+ })
+
+ // ----------------------------------------------------------------
+ // 3-2) groupMap에서 실제 상위 컬럼(그룹)을 만들기
+ // ----------------------------------------------------------------
+ const nestedColumns: ColumnDef<VendorCandidatesWithVendorInfo>[] = []
+
+ // 순서를 고정하고 싶다면 group 순서를 미리 정의하거나 sort해야 함
+ // 여기서는 그냥 Object.entries 순서
+ Object.entries(groupMap).forEach(([groupName, colDefs]) => {
+ if (groupName === "_noGroup") {
+ // 그룹 없음 → 그냥 최상위 레벨 컬럼
+ nestedColumns.push(...colDefs)
+ } else {
+ // 상위 컬럼
+ nestedColumns.push({
+ id: groupName,
+ header: groupName, // "Basic Info", "Metadata" 등
+ columns: colDefs,
+ })
+ }
+ })
+
+ // ----------------------------------------------------------------
+ // 4) 최종 컬럼 배열: select, nestedColumns, actions
+ // ----------------------------------------------------------------
+ return [
+ selectColumn,
+ ...nestedColumns,
+ actionsColumn,
+ ]
+} \ No newline at end of file
diff --git a/lib/tech-vendor-candidates/table/candidates-table-floating-bar.tsx b/lib/tech-vendor-candidates/table/candidates-table-floating-bar.tsx
new file mode 100644
index 00000000..baf4a583
--- /dev/null
+++ b/lib/tech-vendor-candidates/table/candidates-table-floating-bar.tsx
@@ -0,0 +1,395 @@
+"use client"
+
+import * as React from "react"
+import { SelectTrigger } from "@radix-ui/react-select"
+import { type Table } from "@tanstack/react-table"
+import {
+ ArrowUp,
+ CheckCircle2,
+ Download,
+ Loader,
+ Trash2,
+ X,
+ Mail,
+} from "lucide-react"
+import { toast } from "sonner"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import { Portal } from "@/components/ui/portal"
+import {
+ Select,
+ SelectContent,
+ SelectGroup,
+ SelectItem,
+} from "@/components/ui/select"
+import { Separator } from "@/components/ui/separator"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+import { Kbd } from "@/components/kbd"
+import { useSession } from "next-auth/react" // next-auth 세션 훅
+
+import { ActionConfirmDialog } from "@/components/ui/action-dialog"
+import { VendorCandidatesWithVendorInfo, vendorCandidates } from "@/db/schema/vendors"
+import {
+ bulkUpdateVendorCandidateStatus,
+ removeCandidates,
+} from "../service"
+
+/**
+ * 테이블 상단/하단에 고정되는 Floating Bar
+ * 상태 일괄 변경, 초대, 삭제, Export 등을 수행
+ */
+interface CandidatesTableFloatingBarProps {
+ table: Table<VendorCandidatesWithVendorInfo>
+}
+
+export function VendorCandidateTableFloatingBar({
+ table,
+}: CandidatesTableFloatingBarProps) {
+ const rows = table.getFilteredSelectedRowModel().rows
+ const { data: session, status } = useSession()
+
+ // React 18의 startTransition 사용 (isPending으로 트랜지션 상태 확인)
+ const [isPending, startTransition] = React.useTransition()
+ const [action, setAction] = React.useState<
+ "update-status" | "export" | "delete" | "invite"
+ >()
+ const [popoverOpen, setPopoverOpen] = React.useState(false)
+
+ // ESC 키로 selection 해제
+ React.useEffect(() => {
+ function handleKeyDown(event: KeyboardEvent) {
+ if (event.key === "Escape") {
+ table.toggleAllRowsSelected(false)
+ }
+ }
+ window.addEventListener("keydown", handleKeyDown)
+ return () => window.removeEventListener("keydown", handleKeyDown)
+ }, [table])
+
+ // 공용 Confirm Dialog (ActionConfirmDialog) 제어
+ const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false)
+ const [confirmProps, setConfirmProps] = React.useState<{
+ title: string
+ description?: string
+ onConfirm: () => Promise<void> | void
+ }>({
+ title: "",
+ description: "",
+ onConfirm: () => {},
+ })
+
+ /**
+ * 1) 삭제 버튼 클릭 시 Confirm Dialog 열기
+ */
+ function handleDeleteConfirm() {
+ setAction("delete")
+
+ setConfirmProps({
+ title: `Delete ${rows.length} candidate${
+ rows.length > 1 ? "s" : ""
+ }?`,
+ description: "This action cannot be undone.",
+ onConfirm: async () => {
+ startTransition(async () => {
+ if (!session?.user?.id) {
+ toast.error("인증 오류. 로그인 정보를 찾을 수 없습니다.")
+ return
+ }
+ const userId = Number(session.user.id)
+
+ // removeCandidates 호출 시 userId를 넘긴다고 가정
+ const { error } = await removeCandidates(
+ {
+ ids: rows.map((row) => row.original.id),
+ },
+ userId
+ )
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+ toast.success("Candidates deleted successfully")
+ table.toggleAllRowsSelected(false)
+ setConfirmDialogOpen(false)
+ })
+ },
+ })
+ setConfirmDialogOpen(true)
+ }
+
+ /**
+ * 2) 선택된 후보들의 상태 일괄 업데이트
+ */
+ function handleSelectStatus(newStatus: VendorCandidatesWithVendorInfo["status"]) {
+ setAction("update-status")
+
+ setConfirmProps({
+ title: `Update ${rows.length} candidate${
+ rows.length > 1 ? "s" : ""
+ } with status: ${newStatus}?`,
+ description: "This action will override their current status.",
+ onConfirm: async () => {
+ startTransition(async () => {
+ if (!session?.user?.id) {
+ toast.error("인증 오류. 로그인 정보를 찾을 수 없습니다.")
+ return
+ }
+ const userId = Number(session.user.id)
+
+ const { error } = await bulkUpdateVendorCandidateStatus({
+ ids: rows.map((row) => row.original.id),
+ status: newStatus,
+ userId,
+ comment: `Bulk status update to ${newStatus}`,
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+ toast.success("Candidates updated")
+ setConfirmDialogOpen(false)
+ table.toggleAllRowsSelected(false)
+ })
+ },
+ })
+ setConfirmDialogOpen(true)
+ }
+
+ /**
+ * 3) 초대하기 (status = "INVITED" + 이메일 발송)
+ */
+ function handleInvite() {
+ setAction("invite")
+ setConfirmProps({
+ title: `Invite ${rows.length} candidate${
+ rows.length > 1 ? "s" : ""
+ }?`,
+ description:
+ "This will change their status to INVITED and send invitation emails.",
+ onConfirm: async () => {
+ startTransition(async () => {
+ if (!session?.user?.id) {
+ toast.error("인증 오류. 로그인 정보를 찾을 수 없습니다.")
+ return
+ }
+ const userId = Number(session.user.id)
+
+ const { error } = await bulkUpdateVendorCandidateStatus({
+ ids: rows.map((row) => row.original.id),
+ status: "INVITED",
+ userId,
+ comment: "Bulk invite action",
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+ toast.success("Invitation emails sent successfully")
+ table.toggleAllRowsSelected(false)
+ setConfirmDialogOpen(false)
+ })
+ },
+ })
+ setConfirmDialogOpen(true)
+ }
+
+ return (
+ <>
+ {/* 선택된 row가 있을 때 표시되는 Floating Bar */}
+ <div className="flex justify-center w-full my-4">
+ <div className="flex items-center gap-2 rounded-md border bg-background p-2 text-foreground shadow">
+ {/* 선택된 갯수 표시 + Clear selection 버튼 */}
+ <div className="flex h-7 items-center rounded-md border border-dashed pl-2.5 pr-1">
+ <span className="whitespace-nowrap text-xs">
+ {rows.length} selected
+ </span>
+ <Separator orientation="vertical" className="ml-2 mr-1" />
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="ghost"
+ size="icon"
+ className="size-5 hover:border"
+ onClick={() => table.toggleAllRowsSelected(false)}
+ >
+ <X className="size-3.5 shrink-0" aria-hidden="true" />
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent className="flex items-center border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-zinc-900">
+ <p className="mr-2">Clear selection</p>
+ <Kbd abbrTitle="Escape" variant="outline">
+ Esc
+ </Kbd>
+ </TooltipContent>
+ </Tooltip>
+ </div>
+
+ <Separator orientation="vertical" className="hidden h-5 sm:block" />
+
+ {/* 우측 액션들: 초대, 상태변경, Export, 삭제 */}
+ <div className="flex items-center gap-1.5">
+ {/* 초대하기 */}
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="secondary"
+ size="sm"
+ className="h-7 border"
+ onClick={handleInvite}
+ disabled={isPending}
+ >
+ {isPending && action === "invite" ? (
+ <Loader
+ className="mr-1 size-3.5 animate-spin"
+ aria-hidden="true"
+ />
+ ) : (
+ <Mail className="mr-1 size-3.5" aria-hidden="true" />
+ )}
+ <span>Invite</span>
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900">
+ <p>Send invitation emails</p>
+ </TooltipContent>
+ </Tooltip>
+
+ {/* 상태 업데이트 (Select) */}
+ <Select
+ onValueChange={(value: VendorCandidatesWithVendorInfo["status"]) => {
+ handleSelectStatus(value)
+ }}
+ >
+ <Tooltip>
+ <SelectTrigger asChild>
+ <TooltipTrigger asChild>
+ <Button
+ variant="secondary"
+ size="icon"
+ className="size-7 border data-[state=open]:bg-accent data-[state=open]:text-accent-foreground"
+ disabled={isPending}
+ >
+ {isPending && action === "update-status" ? (
+ <Loader
+ className="size-3.5 animate-spin"
+ aria-hidden="true"
+ />
+ ) : (
+ <CheckCircle2 className="size-3.5" aria-hidden="true" />
+ )}
+ </Button>
+ </TooltipTrigger>
+ </SelectTrigger>
+ <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900">
+ <p>Update status</p>
+ </TooltipContent>
+ </Tooltip>
+ <SelectContent align="center">
+ <SelectGroup>
+ {vendorCandidates.status.enumValues.map((status) => (
+ <SelectItem
+ key={status}
+ value={status}
+ className="capitalize"
+ >
+ {status}
+ </SelectItem>
+ ))}
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+
+ {/* Export 버튼 */}
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="secondary"
+ size="icon"
+ className="size-7 border"
+ onClick={() => {
+ setAction("export")
+ startTransition(() => {
+ exportTableToExcel(table, {
+ excludeColumns: ["select", "actions"],
+ onlySelected: true,
+ })
+ })
+ }}
+ disabled={isPending}
+ >
+ {isPending && action === "export" ? (
+ <Loader
+ className="size-3.5 animate-spin"
+ aria-hidden="true"
+ />
+ ) : (
+ <Download className="size-3.5" aria-hidden="true" />
+ )}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900">
+ <p>Export candidates</p>
+ </TooltipContent>
+ </Tooltip>
+
+ {/* 삭제 버튼 */}
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <Button
+ variant="secondary"
+ size="icon"
+ className="size-7 border"
+ onClick={handleDeleteConfirm}
+ disabled={isPending}
+ >
+ {isPending && action === "delete" ? (
+ <Loader
+ className="size-3.5 animate-spin"
+ aria-hidden="true"
+ />
+ ) : (
+ <Trash2 className="size-3.5" aria-hidden="true" />
+ )}
+ </Button>
+ </TooltipTrigger>
+ <TooltipContent className="border bg-accent font-semibold text-foreground dark:bg-zinc-900">
+ <p>Delete candidates</p>
+ </TooltipContent>
+ </Tooltip>
+ </div>
+ </div>
+ </div>
+
+ {/* 공용 Confirm Dialog */}
+ <ActionConfirmDialog
+ open={confirmDialogOpen}
+ onOpenChange={setConfirmDialogOpen}
+ title={confirmProps.title}
+ description={confirmProps.description}
+ onConfirm={confirmProps.onConfirm}
+ isLoading={
+ isPending &&
+ (action === "delete" || action === "update-status" || action === "invite")
+ }
+ confirmLabel={
+ action === "delete"
+ ? "Delete"
+ : action === "update-status"
+ ? "Update"
+ : action === "invite"
+ ? "Invite"
+ : "Confirm"
+ }
+ confirmVariant={action === "delete" ? "destructive" : "default"}
+ />
+ </>
+ )
+}
diff --git a/lib/tech-vendor-candidates/table/candidates-table-toolbar-actions.tsx b/lib/tech-vendor-candidates/table/candidates-table-toolbar-actions.tsx
new file mode 100644
index 00000000..17462841
--- /dev/null
+++ b/lib/tech-vendor-candidates/table/candidates-table-toolbar-actions.tsx
@@ -0,0 +1,93 @@
+"use client"
+
+import * as React from "react"
+import { type Table } from "@tanstack/react-table"
+import { Download, FileDown, Upload } from "lucide-react"
+
+import { exportTableToExcel } from "@/lib/export"
+import { Button } from "@/components/ui/button"
+import {
+ DropdownMenu,
+ DropdownMenuContent,
+ DropdownMenuItem,
+ DropdownMenuTrigger,
+} from "@/components/ui/dropdown-menu"
+
+import { AddCandidateDialog } from "./add-candidates-dialog"
+import { DeleteCandidatesDialog } from "./delete-candidates-dialog"
+import { InviteCandidatesDialog } from "./invite-candidates-dialog"
+import { ImportVendorCandidatesButton } from "./import-button"
+import { exportVendorCandidateTemplate } from "./excel-template-download"
+import { VendorCandidatesWithVendorInfo } from "@/db/schema/vendors"
+
+
+interface CandidatesTableToolbarActionsProps {
+ table: Table<VendorCandidatesWithVendorInfo>
+}
+
+export function CandidatesTableToolbarActions({ table }: CandidatesTableToolbarActionsProps) {
+ const selectedRows = table.getFilteredSelectedRowModel().rows
+ const hasSelection = selectedRows.length > 0
+ const [refreshKey, setRefreshKey] = React.useState(0)
+
+ // Handler to refresh the table after import
+ const handleImportSuccess = () => {
+ // Trigger a refresh of the table data
+ setRefreshKey(prev => prev + 1)
+ }
+
+ return (
+ <div className="flex items-center gap-2">
+ {/* Show actions only when rows are selected */}
+ {hasSelection ? (
+ <>
+ {/* Invite dialog - new addition */}
+ <InviteCandidatesDialog
+ candidates={selectedRows.map((row) => row.original)}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ />
+
+ {/* Delete dialog */}
+ <DeleteCandidatesDialog
+ candidates={selectedRows.map((row) => row.original)}
+ onSuccess={() => table.toggleAllRowsSelected(false)}
+ />
+ </>
+ ) : null}
+
+ {/* Add new candidate dialog */}
+ <AddCandidateDialog />
+
+ {/* Import Excel button */}
+ <ImportVendorCandidatesButton onSuccess={handleImportSuccess} />
+
+ {/* Export dropdown menu */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <Download className="size-4" aria-hidden="true" />
+ <span className="hidden sm:inline">Export</span>
+ </Button>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent align="end">
+ <DropdownMenuItem
+ onClick={() => {
+ exportTableToExcel(table, {
+ filename: "vendor-candidates",
+ excludeColumns: ["select", "actions"],
+ useGroupHeader: false,
+ })
+ }}
+ >
+ <FileDown className="mr-2 h-4 w-4" />
+ <span>Export Current Data</span>
+ </DropdownMenuItem>
+ <DropdownMenuItem onClick={exportVendorCandidateTemplate}>
+ <FileDown className="mr-2 h-4 w-4" />
+ <span>Download Template</span>
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+ </div>
+ )
+} \ No newline at end of file
diff --git a/lib/tech-vendor-candidates/table/candidates-table.tsx b/lib/tech-vendor-candidates/table/candidates-table.tsx
new file mode 100644
index 00000000..9dab8f6d
--- /dev/null
+++ b/lib/tech-vendor-candidates/table/candidates-table.tsx
@@ -0,0 +1,173 @@
+"use client"
+
+import * as React from "react"
+import type {
+ DataTableAdvancedFilterField,
+ DataTableFilterField,
+ DataTableRowAction,
+} from "@/types/table"
+
+import { toSentenceCase } from "@/lib/utils"
+import { useDataTable } from "@/hooks/use-data-table"
+import { DataTable } from "@/components/data-table/data-table"
+import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
+
+import { useFeatureFlags } from "./feature-flags-provider"
+import { getVendorCandidateCounts, getVendorCandidates } from "../service"
+import { vendorCandidates ,VendorCandidatesWithVendorInfo} from "@/db/schema/vendors"
+import { VendorCandidateTableFloatingBar } from "./candidates-table-floating-bar"
+import { getColumns } from "./candidates-table-columns"
+import { CandidatesTableToolbarActions } from "./candidates-table-toolbar-actions"
+import { DeleteCandidatesDialog } from "./delete-candidates-dialog"
+import { UpdateCandidateSheet } from "./update-candidate-sheet"
+import { getCandidateStatusIcon } from "@/lib/vendor-candidates/utils"
+
+interface VendorCandidatesTableProps {
+ promises: Promise<
+ [
+ Awaited<ReturnType<typeof getVendorCandidates>>,
+ Awaited<ReturnType<typeof getVendorCandidateCounts>>,
+ ]
+ >
+}
+
+export function VendorCandidateTable({ promises }: VendorCandidatesTableProps) {
+ const { featureFlags } = useFeatureFlags()
+
+ const [{ data, pageCount }, statusCounts] =
+ React.use(promises)
+
+
+
+ const [rowAction, setRowAction] =
+ React.useState<DataTableRowAction<VendorCandidatesWithVendorInfo> | null>(null)
+
+ const columns = React.useMemo(
+ () => getColumns({ setRowAction }),
+ [setRowAction]
+ )
+
+ /**
+ * This component can render either a faceted filter or a search filter based on the `options` prop.
+ *
+ * @prop options - An array of objects, each representing a filter option. If provided, a faceted filter is rendered. If not, a search filter is rendered.
+ *
+ * Each `option` object has the following properties:
+ * @prop {string} label - The label for the filter option.
+ * @prop {string} value - The value for the filter option.
+ * @prop {React.ReactNode} [icon] - An optional icon to display next to the label.
+ * @prop {boolean} [withCount] - An optional boolean to display the count of the filter option.
+ */
+ const filterFields: DataTableFilterField<VendorCandidatesWithVendorInfo>[] = [
+
+ {
+ id: "status",
+ label: "Status",
+ options: vendorCandidates.status.enumValues.map((status) => ({
+ label: toSentenceCase(status),
+ value: status,
+ count: statusCounts[status],
+ })),
+ },
+
+ ]
+
+ /**
+ * Advanced filter fields for the data table.
+ * These fields provide more complex filtering options compared to the regular filterFields.
+ *
+ * Key differences from regular filterFields:
+ * 1. More field types: Includes 'text', 'multi-select', 'date', and 'boolean'.
+ * 2. Enhanced flexibility: Allows for more precise and varied filtering options.
+ * 3. Used with DataTableAdvancedToolbar: Enables a more sophisticated filtering UI.
+ * 4. Date and boolean types: Adds support for filtering by date ranges and boolean values.
+ */
+ const advancedFilterFields: DataTableAdvancedFilterField<VendorCandidatesWithVendorInfo>[] = [
+ {
+ id: "companyName",
+ label: "Company Name",
+ type: "text",
+ },
+ {
+ id: "contactEmail",
+ label: "Contact Email",
+ type: "text",
+ },
+ {
+ id: "contactPhone",
+ label: "Contact Phone",
+ type: "text",
+ },
+ {
+ id: "source",
+ label: "source",
+ type: "text",
+ },
+ {
+ id: "status",
+ label: "Status",
+ type: "multi-select",
+ options: vendorCandidates.status.enumValues.map((status) => ({
+ label: (status),
+ value: status,
+ icon: getCandidateStatusIcon(status),
+ count: statusCounts[status],
+ })),
+ },
+
+ {
+ id: "createdAt",
+ label: "수집일",
+ type: "date",
+ },
+ ]
+
+
+ const { table } = useDataTable({
+ data,
+ columns,
+ pageCount,
+ filterFields,
+ enablePinning: true,
+ enableAdvancedFilter: true,
+ initialState: {
+ sorting: [{ id: "createdAt", desc: true }],
+ columnPinning: { right: ["actions"] },
+ },
+ getRowId: (originalRow) => String(originalRow.id),
+ shallow: false,
+ clearOnDefault: true,
+ })
+
+ return (
+ <>
+ <DataTable
+ table={table}
+ floatingBar={<VendorCandidateTableFloatingBar table={table} />}
+ >
+
+ <DataTableAdvancedToolbar
+ table={table}
+ filterFields={advancedFilterFields}
+ shallow={false}
+ >
+ <CandidatesTableToolbarActions table={table} />
+ </DataTableAdvancedToolbar>
+
+ </DataTable>
+ <UpdateCandidateSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ candidate={rowAction?.row.original ?? null}
+ />
+ <DeleteCandidatesDialog
+ open={rowAction?.type === "delete"}
+ onOpenChange={() => setRowAction(null)}
+ candidates={rowAction?.row.original ? [rowAction?.row.original] : []}
+ showTrigger={false}
+ onSuccess={() => rowAction?.row.toggleSelected(false)}
+ />
+
+ </>
+ )
+}
diff --git a/lib/tech-vendor-candidates/table/delete-candidates-dialog.tsx b/lib/tech-vendor-candidates/table/delete-candidates-dialog.tsx
new file mode 100644
index 00000000..bc231109
--- /dev/null
+++ b/lib/tech-vendor-candidates/table/delete-candidates-dialog.tsx
@@ -0,0 +1,159 @@
+"use client"
+
+import * as React from "react"
+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 { removeCandidates } from "../service"
+import { VendorCandidatesWithVendorInfo } from "@/db/schema"
+import { useSession } from "next-auth/react" // next-auth 세션 훅
+
+interface DeleteCandidatesDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ candidates: Row<VendorCandidatesWithVendorInfo>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function DeleteCandidatesDialog({
+ candidates,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: DeleteCandidatesDialogProps) {
+ const [isDeletePending, startDeleteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+ const { data: session, status } = useSession()
+
+ function onDelete() {
+ startDeleteTransition(async () => {
+
+ if (!session?.user?.id) {
+ toast.error("인증 오류. 로그인 정보를 찾을 수 없습니다.")
+ return
+ }
+
+ const userId = Number(session.user.id)
+
+ const { error } = await removeCandidates({
+ ids: candidates.map((candidate) => candidate.id),
+ }, userId)
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("Candidates deleted")
+ onSuccess?.()
+ })
+ }
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ Delete ({candidates.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Are you absolutely sure?</DialogTitle>
+ <DialogDescription>
+ This action cannot be undone. This will permanently delete your{" "}
+ <span className="font-medium">{candidates.length}</span>
+ {candidates.length === 1 ? " candidate" : " candidates"} from our servers.
+ </DialogDescription>
+ </DialogHeader>
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DialogClose>
+ <Button
+ aria-label="Delete selected rows"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Delete
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm">
+ <Trash className="mr-2 size-4" aria-hidden="true" />
+ Delete ({candidates.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>Are you absolutely sure?</DrawerTitle>
+ <DrawerDescription>
+ This action cannot be undone. This will permanently delete your{" "}
+ <span className="font-medium">{candidates.length}</span>
+ {candidates.length === 1 ? " candidate" : " candidates"} from our servers.
+ </DrawerDescription>
+ </DrawerHeader>
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DrawerClose>
+ <Button
+ aria-label="Delete selected rows"
+ variant="destructive"
+ onClick={onDelete}
+ disabled={isDeletePending}
+ >
+ {isDeletePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ Delete
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+}
diff --git a/lib/tech-vendor-candidates/table/excel-template-download.tsx b/lib/tech-vendor-candidates/table/excel-template-download.tsx
new file mode 100644
index 00000000..673680db
--- /dev/null
+++ b/lib/tech-vendor-candidates/table/excel-template-download.tsx
@@ -0,0 +1,128 @@
+"use client"
+
+import { type Table } from "@tanstack/react-table"
+import ExcelJS from "exceljs"
+import { VendorCandidates } from "@/db/schema/vendors"
+
+/**
+ * Export an empty template for vendor candidates with column headers
+ * matching the expected import format
+ */
+export async function exportVendorCandidateTemplate() {
+ // Create a new workbook and worksheet
+ const workbook = new ExcelJS.Workbook()
+ const worksheet = workbook.addWorksheet("Vendor Candidates")
+
+ // Define the columns with expected headers
+ const columns = [
+ { header: "Company Name", key: "companyName", width: 30 },
+ { header: "Tax ID", key: "taxId", width: 20 },
+ { header: "Contact Email", key: "contactEmail", width: 30 },
+ { header: "Contact Phone", key: "contactPhone", width: 20 },
+ { header: "Address", key: "address", width: 40 },
+ { header: "Country", key: "country", width: 20 },
+ { header: "Source", key: "source", width: 20 },
+ { header: "Items", key: "items", width: 40 },
+ { header: "Remark", key: "remark", width: 40 },
+ { header: "Status", key: "status", width: 15 },
+ ]
+
+ // Add columns to the worksheet
+ worksheet.columns = columns
+
+ // Style the header row
+ const headerRow = worksheet.getRow(2)
+ headerRow.font = { bold: true }
+ headerRow.alignment = { horizontal: "center" }
+ headerRow.eachCell((cell) => {
+ cell.fill = {
+ type: "pattern",
+ pattern: "solid",
+ fgColor: { argb: "FFCCCCCC" },
+ }
+
+ // Mark required fields with a red asterisk
+ const requiredFields = ["Company Name", "Source", "Items"]
+ if (requiredFields.includes(cell.value as string)) {
+ cell.value = `${cell.value} *`
+ cell.font = { bold: true, color: { argb: "FFFF0000" } }
+ }
+ })
+
+ // Add example data rows
+ const exampleData = [
+ {
+ companyName: "ABC Corporation",
+ taxId: "123-45-6789",
+ contactEmail: "contact@abc.com",
+ contactPhone: "+1-123-456-7890",
+ address: "123 Business Ave, Suite 100, New York, NY 10001",
+ country: "US",
+ source: "Website",
+ items: "Electronic components, Circuit boards, Sensors",
+ remark: "Potential supplier for Project X",
+ status: "COLLECTED",
+ },
+ {
+ companyName: "XYZ Ltd.",
+ taxId: "GB987654321",
+ contactEmail: "info@xyz.com",
+ contactPhone: "+44-987-654-3210",
+ address: "45 Industrial Park, London, EC2A 4PX",
+ country: "GB",
+ source: "Referral",
+ items: "Steel components, Metal frames, Industrial hardware",
+ remark: "Met at trade show in March",
+ status: "COLLECTED",
+ },
+ ]
+
+ // Add the example rows to the worksheet
+ exampleData.forEach((data) => {
+ worksheet.addRow(data)
+ })
+
+ // Add data validation for Status column
+ const statusValues = ["COLLECTED", "INVITED", "DISCARDED"]
+ const statusColumn = columns.findIndex(col => col.key === "status") + 1
+ const statusColLetter = String.fromCharCode(64 + statusColumn)
+
+ for (let i = 4; i <= 100; i++) { // Apply to rows 4-100 (after example data)
+ worksheet.getCell(`${statusColLetter}${i}`).dataValidation = {
+ type: 'list',
+ allowBlank: true,
+ formulae: [`"${statusValues.join(',')}"`]
+ }
+ }
+
+ // Add instructions row
+ worksheet.insertRow(1, ["Please fill in the data below. Required fields are marked with an asterisk (*): Company Name, Source, Items"])
+ worksheet.mergeCells(`A1:${String.fromCharCode(64 + columns.length)}1`)
+ const instructionRow = worksheet.getRow(1)
+ instructionRow.font = { bold: true, color: { argb: "FF0000FF" } }
+ instructionRow.alignment = { horizontal: "center" }
+ instructionRow.height = 30
+
+ // Auto-width columns based on content
+ worksheet.columns.forEach(column => {
+ if (column.key) { // Check that column.key is defined
+ const dataMax = Math.max(...worksheet.getColumn(column.key).values
+ .filter(value => value !== null && value !== undefined)
+ .map(value => String(value).length)
+ )
+ column.width = Math.max(column.width || 10, dataMax + 2)
+ }
+ })
+
+ // Download the workbook
+ const buffer = await workbook.xlsx.writeBuffer()
+ const blob = new Blob([buffer], {
+ type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
+ })
+ const url = URL.createObjectURL(blob)
+ const link = document.createElement("a")
+ link.href = url
+ link.download = "vendor-candidates-template.xlsx"
+ link.click()
+ URL.revokeObjectURL(url)
+} \ No newline at end of file
diff --git a/lib/tech-vendor-candidates/table/feature-flags-provider.tsx b/lib/tech-vendor-candidates/table/feature-flags-provider.tsx
new file mode 100644
index 00000000..81131894
--- /dev/null
+++ b/lib/tech-vendor-candidates/table/feature-flags-provider.tsx
@@ -0,0 +1,108 @@
+"use client"
+
+import * as React from "react"
+import { useQueryState } from "nuqs"
+
+import { dataTableConfig, type DataTableConfig } from "@/config/data-table"
+import { cn } from "@/lib/utils"
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
+
+interface FeatureFlagsContextProps {
+ featureFlags: FeatureFlagValue[]
+ setFeatureFlags: (value: FeatureFlagValue[]) => void
+}
+
+const FeatureFlagsContext = React.createContext<FeatureFlagsContextProps>({
+ featureFlags: [],
+ setFeatureFlags: () => {},
+})
+
+export function useFeatureFlags() {
+ const context = React.useContext(FeatureFlagsContext)
+ if (!context) {
+ throw new Error(
+ "useFeatureFlags must be used within a FeatureFlagsProvider"
+ )
+ }
+ return context
+}
+
+interface FeatureFlagsProviderProps {
+ children: React.ReactNode
+}
+
+export function FeatureFlagsProvider({ children }: FeatureFlagsProviderProps) {
+ const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
+ "flags",
+ {
+ defaultValue: [],
+ parse: (value) => value.split(",") as FeatureFlagValue[],
+ serialize: (value) => value.join(","),
+ eq: (a, b) =>
+ a.length === b.length && a.every((value, index) => value === b[index]),
+ clearOnDefault: true,
+ shallow: false,
+ }
+ )
+
+ return (
+ <FeatureFlagsContext.Provider
+ value={{
+ featureFlags,
+ setFeatureFlags: (value) => void setFeatureFlags(value),
+ }}
+ >
+ <div className="w-full overflow-x-auto">
+ <ToggleGroup
+ type="multiple"
+ variant="outline"
+ size="sm"
+ value={featureFlags}
+ onValueChange={(value: FeatureFlagValue[]) => setFeatureFlags(value)}
+ className="w-fit gap-0"
+ >
+ {dataTableConfig.featureFlags.map((flag, index) => (
+ <Tooltip key={flag.value}>
+ <ToggleGroupItem
+ value={flag.value}
+ className={cn(
+ "gap-2 whitespace-nowrap rounded-none px-3 text-xs data-[state=on]:bg-accent/70 data-[state=on]:hover:bg-accent/90",
+ {
+ "rounded-l-sm border-r-0": index === 0,
+ "rounded-r-sm":
+ index === dataTableConfig.featureFlags.length - 1,
+ }
+ )}
+ asChild
+ >
+ <TooltipTrigger>
+ <flag.icon className="size-3.5 shrink-0" aria-hidden="true" />
+ {flag.label}
+ </TooltipTrigger>
+ </ToggleGroupItem>
+ <TooltipContent
+ align="start"
+ side="bottom"
+ sideOffset={6}
+ className="flex max-w-60 flex-col space-y-1.5 border bg-background py-2 font-semibold text-foreground"
+ >
+ <div>{flag.tooltipTitle}</div>
+ <div className="text-xs text-muted-foreground">
+ {flag.tooltipDescription}
+ </div>
+ </TooltipContent>
+ </Tooltip>
+ ))}
+ </ToggleGroup>
+ </div>
+ {children}
+ </FeatureFlagsContext.Provider>
+ )
+}
diff --git a/lib/tech-vendor-candidates/table/feature-flags.tsx b/lib/tech-vendor-candidates/table/feature-flags.tsx
new file mode 100644
index 00000000..aaae6af2
--- /dev/null
+++ b/lib/tech-vendor-candidates/table/feature-flags.tsx
@@ -0,0 +1,96 @@
+"use client"
+
+import * as React from "react"
+import { useQueryState } from "nuqs"
+
+import { dataTableConfig, type DataTableConfig } from "@/config/data-table"
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipTrigger,
+} from "@/components/ui/tooltip"
+
+type FeatureFlagValue = DataTableConfig["featureFlags"][number]["value"]
+
+interface TasksTableContextProps {
+ featureFlags: FeatureFlagValue[]
+ setFeatureFlags: (value: FeatureFlagValue[]) => void
+}
+
+const TasksTableContext = React.createContext<TasksTableContextProps>({
+ featureFlags: [],
+ setFeatureFlags: () => {},
+})
+
+export function useTasksTable() {
+ const context = React.useContext(TasksTableContext)
+ if (!context) {
+ throw new Error("useTasksTable must be used within a TasksTableProvider")
+ }
+ return context
+}
+
+export function TasksTableProvider({ children }: React.PropsWithChildren) {
+ const [featureFlags, setFeatureFlags] = useQueryState<FeatureFlagValue[]>(
+ "featureFlags",
+ {
+ 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,
+ }
+ )
+
+ return (
+ <TasksTableContext.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"
+ >
+ {dataTableConfig.featureFlags.map((flag) => (
+ <Tooltip key={flag.value}>
+ <ToggleGroupItem
+ value={flag.value}
+ className="whitespace-nowrap px-3 text-xs"
+ asChild
+ >
+ <TooltipTrigger>
+ <flag.icon
+ className="mr-2 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}
+ </TasksTableContext.Provider>
+ )
+}
diff --git a/lib/tech-vendor-candidates/table/import-button.tsx b/lib/tech-vendor-candidates/table/import-button.tsx
new file mode 100644
index 00000000..ad1e6862
--- /dev/null
+++ b/lib/tech-vendor-candidates/table/import-button.tsx
@@ -0,0 +1,233 @@
+"use client"
+
+import React, { useRef } from 'react'
+import ExcelJS from 'exceljs'
+import { toast } from 'sonner'
+import { Button } from '@/components/ui/button'
+import { Upload, Loader } from 'lucide-react'
+import { createVendorCandidate } from '../service'
+import { Input } from '@/components/ui/input'
+import { useSession } from "next-auth/react" // next-auth 세션 훅 추가
+import { decryptWithServerAction } from '@/components/drm/drmUtils' // DRM 복호화 함수 import
+
+interface ImportExcelProps {
+ onSuccess?: () => void
+}
+
+export function ImportVendorCandidatesButton({ onSuccess }: ImportExcelProps) {
+ const fileInputRef = useRef<HTMLInputElement>(null)
+ const [isImporting, setIsImporting] = React.useState(false)
+ const { data: session } = useSession() // status 제거 (사용하지 않음)
+
+ // Helper function to get cell value as string
+ const getCellValueAsString = (cell: ExcelJS.Cell): string => {
+ if (!cell || cell.value === undefined || cell.value === null) return '';
+
+ if (typeof cell.value === 'string') return cell.value.trim();
+ if (typeof cell.value === 'number') return cell.value.toString();
+
+ // Handle rich text
+ if (typeof cell.value === 'object' && 'richText' in cell.value) {
+ return cell.value.richText.map((rt: { text: string }) => rt.text).join('');
+ }
+
+ // Handle dates
+ if (cell.value instanceof Date) {
+ return cell.value.toISOString().split('T')[0];
+ }
+
+ // Fallback
+ return String(cell.value);
+ }
+
+ const handleImport = async (event: React.ChangeEvent<HTMLInputElement>) => {
+ const file = event.target.files?.[0]
+ if (!file) return
+
+ setIsImporting(true)
+
+ try {
+ // DRM 복호화 시도 (실패시 원본 파일 사용)
+ let data: ArrayBuffer;
+ try {
+ data = await decryptWithServerAction(file);
+ console.log('[Import] DRM 복호화 성공');
+ } catch (decryptError) {
+ console.warn('[Import] DRM 복호화 실패, 원본 파일 사용:', decryptError);
+ data = await file.arrayBuffer();
+ }
+
+ // Read the Excel file using ExcelJS
+ const workbook = new ExcelJS.Workbook()
+ await workbook.xlsx.load(data)
+
+ // Get the first worksheet
+ const worksheet = workbook.getWorksheet(1)
+ if (!worksheet) {
+ toast.error("No worksheet found in the spreadsheet")
+ return
+ }
+
+ // Check if there's an instruction row
+ const hasInstructionRow = worksheet.getRow(1).getCell(1).value !== null &&
+ worksheet.getRow(1).getCell(2).value === null;
+
+ // Get header row index (row 2 if there's an instruction row, otherwise row 1)
+ const headerRowIndex = hasInstructionRow ? 2 : 1;
+
+ // Get column headers and their indices
+ const headerRow = worksheet.getRow(headerRowIndex);
+ const headers: Record<number, string> = {};
+ const columnIndices: Record<string, number> = {};
+
+ headerRow.eachCell((cell, colNumber) => {
+ const header = getCellValueAsString(cell);
+ headers[colNumber] = header;
+ columnIndices[header] = colNumber;
+ });
+
+ // Process data rows
+ const rows: Record<string, string>[] = [];
+ const startRow = headerRowIndex + 1;
+
+ for (let i = startRow; i <= worksheet.rowCount; i++) {
+ const row = worksheet.getRow(i);
+
+ // Skip empty rows
+ if (row.cellCount === 0) continue;
+
+ // Check if this is likely an example row
+ const isExample = i === startRow && worksheet.getRow(i + 1).values?.length === 0;
+ if (isExample) continue;
+
+ const rowData: Record<string, string> = {};
+ let hasData = false;
+
+ // Map the data using header indices
+ Object.entries(columnIndices).forEach(([header, colIndex]) => {
+ const value = getCellValueAsString(row.getCell(colIndex));
+ if (value) {
+ rowData[header] = value;
+ hasData = true;
+ }
+ });
+
+ if (hasData) {
+ rows.push(rowData);
+ }
+ }
+
+ if (rows.length === 0) {
+ toast.error("No data found in the spreadsheet")
+ setIsImporting(false)
+ return
+ }
+
+ // Process each row
+ let successCount = 0;
+ let errorCount = 0;
+
+ // Create promises for all vendor candidate creation operations
+ const promises = rows.map(async (row) => {
+ try {
+ // Map Excel columns to our data model
+ const candidateData = {
+ companyName: String(row['Company Name'] || ''),
+ contactEmail: String(row['Contact Email'] || ''),
+ contactPhone: String(row['Contact Phone'] || ''),
+ taxId: String(row['Tax ID'] || ''),
+ address: String(row['Address'] || ''),
+ country: String(row['Country'] || ''),
+ source: String(row['Source'] || ''),
+ items: String(row['Items'] || ''),
+ remark: String(row['Remark'] || row['Remarks'] || ''),
+ // Default to COLLECTED if not specified
+ status: (row['Status'] || 'COLLECTED') as "COLLECTED" | "INVITED" | "DISCARDED"
+ };
+
+ // Validate required fields
+ if (!candidateData.companyName || !candidateData.source ||
+ !candidateData.items) {
+ console.error("Missing required fields", candidateData);
+ errorCount++;
+ return null;
+ }
+
+ if (!session || !session.user || !session.user.id) {
+ toast.error("인증 오류. 로그인 정보를 찾을 수 없습니다.")
+ return
+ }
+
+ // Create the vendor candidate (userId는 이미 number 타입이므로 변환 불필요)
+ const result = await createVendorCandidate(candidateData)
+
+ if (result.error) {
+ console.error(`Failed to import row: ${result.error}`, candidateData);
+ errorCount++;
+ return null;
+ }
+
+ successCount++;
+ return result.data;
+ } catch (error) {
+ console.error("Error processing row:", error, row);
+ errorCount++;
+ return null;
+ }
+ });
+
+ // Wait for all operations to complete
+ await Promise.all(promises);
+
+ // Show results
+ if (successCount > 0) {
+ toast.success(`Successfully imported ${successCount} vendor candidates`);
+ if (errorCount > 0) {
+ toast.warning(`Failed to import ${errorCount} rows due to errors`);
+ }
+ // Call the success callback to refresh data
+ onSuccess?.();
+ } else if (errorCount > 0) {
+ toast.error(`Failed to import all ${errorCount} rows due to errors`);
+ }
+
+ } catch (error) {
+ console.error("Import error:", error);
+ toast.error("Error importing data. Please check file format.");
+ } finally {
+ setIsImporting(false);
+ // Reset the file input
+ if (fileInputRef.current) {
+ fileInputRef.current.value = '';
+ }
+ }
+ }
+
+ return (
+ <>
+ <Input
+ type="file"
+ ref={fileInputRef}
+ onChange={handleImport}
+ accept=".xlsx,.xls"
+ className="hidden"
+ />
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={() => fileInputRef.current?.click()}
+ disabled={isImporting}
+ className="gap-2"
+ >
+ {isImporting ? (
+ <Loader className="size-4 animate-spin" aria-hidden="true" />
+ ) : (
+ <Upload className="size-4" aria-hidden="true" />
+ )}
+ <span className="hidden sm:inline">
+ {isImporting ? "Importing..." : "Import"}
+ </span>
+ </Button>
+ </>
+ )
+} \ No newline at end of file
diff --git a/lib/tech-vendor-candidates/table/invite-candidates-dialog.tsx b/lib/tech-vendor-candidates/table/invite-candidates-dialog.tsx
new file mode 100644
index 00000000..570cf96a
--- /dev/null
+++ b/lib/tech-vendor-candidates/table/invite-candidates-dialog.tsx
@@ -0,0 +1,230 @@
+"use client"
+
+import * as React from "react"
+import { type Row } from "@tanstack/react-table"
+import { Loader, Mail, AlertCircle, XCircle } 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 {
+ Alert,
+ AlertTitle,
+ AlertDescription
+} from "@/components/ui/alert"
+
+import { VendorCandidates } from "@/db/schema/vendors"
+import { bulkUpdateVendorCandidateStatus } from "../service"
+import { useSession } from "next-auth/react" // next-auth 세션 훅
+
+interface InviteCandidatesDialogProps
+ extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ candidates: Row<VendorCandidates>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+export function InviteCandidatesDialog({
+ candidates,
+ showTrigger = true,
+ onSuccess,
+ ...props
+}: InviteCandidatesDialogProps) {
+ const [isInvitePending, startInviteTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+ const { data: session, status } = useSession()
+
+ // 후보자를 상태별로 분류
+ const discardedCandidates = candidates.filter(candidate => candidate.status === "DISCARDED")
+ const nonDiscardedCandidates = candidates.filter(candidate => candidate.status !== "DISCARDED")
+
+ // 이메일 유무에 따라 초대 가능한 후보자 분류 (DISCARDED가 아닌 후보자 중에서)
+ const candidatesWithEmail = nonDiscardedCandidates.filter(candidate => candidate.contactEmail)
+ const candidatesWithoutEmail = nonDiscardedCandidates.filter(candidate => !candidate.contactEmail)
+
+ // 각 카테고리 수
+ const invitableCount = candidatesWithEmail.length
+ const hasUninvitableCandidates = candidatesWithoutEmail.length > 0
+ const hasDiscardedCandidates = discardedCandidates.length > 0
+
+ function onInvite() {
+ startInviteTransition(async () => {
+ // 이메일이 있고 DISCARDED가 아닌 후보자만 상태 업데이트
+
+ if (!session?.user?.id) {
+ toast.error("인증 오류. 로그인 정보를 찾을 수 없습니다.")
+ return
+ }
+
+ const { error } = await bulkUpdateVendorCandidateStatus({
+ ids: candidatesWithEmail.map((candidate) => candidate.id),
+ status: "INVITED",
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ props.onOpenChange?.(false)
+
+ if (invitableCount === 0) {
+ toast.warning("No invitation sent - no eligible candidates with email addresses")
+ } else {
+ let skipMessage = ""
+
+ if (hasUninvitableCandidates && hasDiscardedCandidates) {
+ skipMessage = ` ${candidatesWithoutEmail.length} candidates without email and ${discardedCandidates.length} discarded candidates were skipped.`
+ } else if (hasUninvitableCandidates) {
+ skipMessage = ` ${candidatesWithoutEmail.length} candidates without email were skipped.`
+ } else if (hasDiscardedCandidates) {
+ skipMessage = ` ${discardedCandidates.length} discarded candidates were skipped.`
+ }
+
+ toast.success(`Invitation emails sent to ${invitableCount} candidates.${skipMessage}`)
+ }
+
+ onSuccess?.()
+ })
+ }
+
+ // 초대 버튼 비활성화 조건
+ const disableInviteButton = isInvitePending || invitableCount === 0
+
+ const DialogComponent = (
+ <>
+ <div className="space-y-4">
+ {/* 이메일 없는 후보자 알림 */}
+ {hasUninvitableCandidates && (
+ <Alert>
+ <AlertCircle className="h-4 w-4" />
+ <AlertTitle>Missing Email Addresses</AlertTitle>
+ <AlertDescription>
+ {candidatesWithoutEmail.length} candidate{candidatesWithoutEmail.length > 1 ? 's' : ''} {candidatesWithoutEmail.length > 1 ? 'don&apos;t' : 'doesn&apos;t'} have email addresses and won&apos;t receive invitations.
+ </AlertDescription>
+ </Alert>
+ )}
+
+ {/* 폐기된 후보자 알림 */}
+ {hasDiscardedCandidates && (
+ <Alert variant="destructive">
+ <XCircle className="h-4 w-4" />
+ <AlertTitle>Discarded Candidates</AlertTitle>
+ <AlertDescription>
+ {discardedCandidates.length} candidate{discardedCandidates.length > 1 ? 's have' : ' has'} been discarded and won&apos;t receive invitations.
+ </AlertDescription>
+ </Alert>
+ )}
+
+ <DialogDescription>
+ {invitableCount > 0 ? (
+ <>
+ This will send invitation emails to{" "}
+ <span className="font-medium">{invitableCount}</span>
+ {invitableCount === 1 ? " candidate" : " candidates"} and change their status to INVITED.
+ </>
+ ) : (
+ <>
+ No candidates can be invited because none of the selected candidates have valid email addresses or they have been discarded.
+ </>
+ )}
+ </DialogDescription>
+ </div>
+ </>
+ )
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger ? (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <Mail className="size-4" aria-hidden="true" />
+ Invite ({candidates.length})
+ </Button>
+ </DialogTrigger>
+ ) : null}
+ <DialogContent>
+ <DialogHeader>
+ <DialogTitle>Send invitations?</DialogTitle>
+ </DialogHeader>
+ {DialogComponent}
+ <DialogFooter className="gap-2 sm:space-x-0">
+ <DialogClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DialogClose>
+ <Button
+ aria-label="Invite selected vendors"
+ variant="default"
+ onClick={onInvite}
+ disabled={disableInviteButton}
+ >
+ {isInvitePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Send Invitations
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger ? (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <Mail className="size-4" aria-hidden="true" />
+ Invite ({candidates.length})
+ </Button>
+ </DrawerTrigger>
+ ) : null}
+ <DrawerContent>
+ <DrawerHeader>
+ <DrawerTitle>Send invitations?</DrawerTitle>
+ </DrawerHeader>
+ {DialogComponent}
+ <DrawerFooter className="gap-2 sm:space-x-0">
+ <DrawerClose asChild>
+ <Button variant="outline">Cancel</Button>
+ </DrawerClose>
+ <Button
+ aria-label="Invite selected vendors"
+ variant="default"
+ onClick={onInvite}
+ disabled={disableInviteButton}
+ >
+ {isInvitePending && (
+ <Loader className="mr-2 size-4 animate-spin" aria-hidden="true" />
+ )}
+ Send Invitations
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+} \ No newline at end of file
diff --git a/lib/tech-vendor-candidates/table/update-candidate-sheet.tsx b/lib/tech-vendor-candidates/table/update-candidate-sheet.tsx
new file mode 100644
index 00000000..3d278126
--- /dev/null
+++ b/lib/tech-vendor-candidates/table/update-candidate-sheet.tsx
@@ -0,0 +1,437 @@
+"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 i18nIsoCountries from "i18n-iso-countries"
+import enLocale from "i18n-iso-countries/langs/en.json"
+import koLocale from "i18n-iso-countries/langs/ko.json"
+import { cn } from "@/lib/utils"
+
+import { Button } from "@/components/ui/button"
+import {
+ Form,
+ FormControl,
+ 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 { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command"
+import { useSession } from "next-auth/react" // next-auth 세션 훅
+
+import { updateVendorCandidateSchema, UpdateVendorCandidateSchema } from "../validations"
+import { updateVendorCandidate } from "../service"
+import { vendorCandidates,VendorCandidatesWithVendorInfo} from "@/db/schema"
+
+// Register locales for countries
+i18nIsoCountries.registerLocale(enLocale)
+i18nIsoCountries.registerLocale(koLocale)
+
+// Generate country array
+const locale = "ko"
+const countryMap = i18nIsoCountries.getNames(locale, { select: "official" })
+const countryArray = Object.entries(countryMap).map(([code, label]) => ({
+ code,
+ label,
+}))
+
+interface UpdateCandidateSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ candidate: VendorCandidatesWithVendorInfo | null
+}
+
+export function UpdateCandidateSheet({ candidate, ...props }: UpdateCandidateSheetProps) {
+ const [isUpdatePending, startUpdateTransition] = React.useTransition()
+ const { data: session, status } = useSession()
+
+ // Set default values from candidate data when the component receives a new candidate
+
+ React.useEffect(() => {
+ if (candidate) {
+ form.reset({
+ id: candidate.id,
+ companyName: candidate.companyName,
+ taxId: candidate.taxId,
+ contactEmail: candidate.contactEmail || "", // null을 빈 문자열로 변환
+ contactPhone: candidate.contactPhone || "",
+ address: candidate.address || "",
+ country: candidate.country || "",
+ source: candidate.source || "",
+ items: candidate.items,
+ remark: candidate.remark || "",
+ status: candidate.status,
+ })
+ }
+ }, [candidate])
+
+
+ const form = useForm<UpdateVendorCandidateSchema>({
+ resolver: zodResolver(updateVendorCandidateSchema),
+ defaultValues: {
+ id: candidate?.id || 0,
+ companyName: candidate?.companyName || "",
+ taxId: candidate?.taxId || "",
+ contactEmail: candidate?.contactEmail || "",
+ contactPhone: candidate?.contactPhone || "",
+ address: candidate?.address || "",
+ country: candidate?.country || "",
+ source: candidate?.source || "",
+ items: candidate?.items || "",
+ remark: candidate?.remark || "",
+ status: candidate?.status || "COLLECTED",
+ },
+ })
+
+ function onSubmit(input: UpdateVendorCandidateSchema) {
+ startUpdateTransition(async () => {
+
+ if (!session?.user?.id) {
+ toast.error("인증 오류. 로그인 정보를 찾을 수 없습니다.")
+ return
+ }
+ const userId = Number(session.user.id)
+
+ if (!candidate) return
+
+ const { error } = await updateVendorCandidate({
+ ...input,
+ }, userId)
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ form.reset()
+ props.onOpenChange?.(false)
+ toast.success("Vendor candidate updated")
+ })
+ }
+
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md overflow-y-auto">
+ <SheetHeader className="text-left">
+ <SheetTitle>Update Vendor Candidate</SheetTitle>
+ <SheetDescription>
+ Update the vendor candidate details and save the changes
+ </SheetDescription>
+ </SheetHeader>
+ <Form {...form}>
+ <form
+ onSubmit={form.handleSubmit(onSubmit)}
+ className="flex flex-col gap-4"
+ >
+ {/* Company Name Field */}
+ <FormField
+ control={form.control}
+ name="companyName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Company Name <span className="text-red-500">*</span></FormLabel>
+ <FormControl>
+ <Input
+ placeholder="Enter company name"
+ {...field}
+ disabled={isUpdatePending}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Tax ID Field */}
+ <FormField
+ control={form.control}
+ name="taxId"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Tax ID</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="Enter tax ID"
+ {...field}
+ disabled={isUpdatePending}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Contact Email Field */}
+ <FormField
+ control={form.control}
+ name="contactEmail"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Contact Email</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="email@example.com"
+ type="email"
+ {...field}
+ disabled={isUpdatePending}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Contact Phone Field */}
+ <FormField
+ control={form.control}
+ name="contactPhone"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Contact Phone</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="+82-10-1234-5678"
+ {...field}
+ disabled={isUpdatePending}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Address Field */}
+ <FormField
+ control={form.control}
+ name="address"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Address</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="Enter company address"
+ {...field}
+ disabled={isUpdatePending}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Country Field */}
+ <FormField
+ control={form.control}
+ name="country"
+ render={({ field }) => {
+ const selectedCountry = countryArray.find(
+ (c) => c.code === field.value
+ )
+ return (
+ <FormItem>
+ <FormLabel>Country</FormLabel>
+ <Popover>
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ role="combobox"
+ className={cn(
+ "w-full justify-between",
+ !field.value && "text-muted-foreground"
+ )}
+ disabled={isUpdatePending}
+ >
+ {selectedCountry
+ ? selectedCountry.label
+ : "Select a country"}
+ <ChevronsUpDown className="ml-2 h-4 w-4 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-[300px] p-0">
+ <Command>
+ <CommandInput placeholder="Search country..." />
+ <CommandList>
+ <CommandEmpty>No country found.</CommandEmpty>
+ <CommandGroup className="max-h-[300px] overflow-y-auto">
+ {countryArray.map((country) => (
+ <CommandItem
+ key={country.code}
+ value={country.label}
+ onSelect={() =>
+ field.onChange(country.code)
+ }
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ country.code === field.value
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ {country.label}
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )
+ }}
+ />
+
+ {/* Source Field */}
+ <FormField
+ control={form.control}
+ name="source"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Source <span className="text-red-500">*</span></FormLabel>
+ <FormControl>
+ <Input
+ placeholder="Where this candidate was found"
+ {...field}
+ disabled={isUpdatePending}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Items Field */}
+ <FormField
+ control={form.control}
+ name="items"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Items <span className="text-red-500">*</span></FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="List of items or products this vendor provides"
+ className="min-h-[80px]"
+ {...field}
+ disabled={isUpdatePending}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Remark Field */}
+ <FormField
+ control={form.control}
+ name="remark"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Remark</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="Additional notes or comments"
+ className="min-h-[80px]"
+ {...field}
+ disabled={isUpdatePending}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* Status Field */}
+ <FormField
+ control={form.control}
+ name="status"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>Status</FormLabel>
+ <Select
+ onValueChange={field.onChange}
+ defaultValue={field.value}
+ disabled={isUpdatePending}
+ >
+ <FormControl>
+ <SelectTrigger className="capitalize">
+ <SelectValue placeholder="Select a status" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectGroup>
+ {vendorCandidates.status.enumValues.map((item) => (
+ <SelectItem
+ key={item}
+ value={item}
+ className="capitalize"
+ >
+ {item}
+ </SelectItem>
+ ))}
+ </SelectGroup>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <SheetFooter className="gap-2 pt-2 sm:space-x-0">
+ <SheetClose asChild>
+ <Button type="button" variant="outline" disabled={isUpdatePending}>
+ Cancel
+ </Button>
+ </SheetClose>
+ <Button disabled={isUpdatePending}>
+ {isUpdatePending && (
+ <Loader
+ className="mr-2 size-4 animate-spin"
+ aria-hidden="true"
+ />
+ )}
+ Save
+ </Button>
+ </SheetFooter>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/tech-vendor-candidates/utils.ts b/lib/tech-vendor-candidates/utils.ts
new file mode 100644
index 00000000..4c21b873
--- /dev/null
+++ b/lib/tech-vendor-candidates/utils.ts
@@ -0,0 +1,40 @@
+import {
+ Activity,
+ AlertCircle,
+ AlertTriangle,
+ ArrowDownIcon,
+ ArrowRightIcon,
+ ArrowUpIcon,
+ AwardIcon,
+ BadgeCheck,
+ CheckCircle2,
+ CircleHelp,
+ CircleIcon,
+ CircleX,
+ ClipboardCheck,
+ ClipboardList,
+ FileCheck2,
+ FilePenLine,
+ FileX2,
+ MailCheck,
+ PencilIcon,
+ SearchIcon,
+ SendIcon,
+ Timer,
+ Trash2,
+ XCircle,
+} from "lucide-react"
+
+import { TechVendorCandidate } from "@/db/schema/techVendors"
+
+
+export function getCandidateStatusIcon(status: TechVendorCandidate["status"]) {
+ const statusIcons = {
+ COLLECTED: ClipboardList, // Data collection icon
+ INVITED: MailCheck, // Email sent and checked icon
+ DISCARDED: Trash2, // Trashed/discarded icon
+ }
+
+ return statusIcons[status] || CircleIcon
+}
+
diff --git a/lib/tech-vendor-candidates/validations.ts b/lib/tech-vendor-candidates/validations.ts
new file mode 100644
index 00000000..d2ef4d53
--- /dev/null
+++ b/lib/tech-vendor-candidates/validations.ts
@@ -0,0 +1,148 @@
+import { techVendorCandidatesWithVendorInfo } from "@/db/schema/techVendors"
+import {
+ createSearchParamsCache,
+ parseAsArrayOf,
+ parseAsInteger,
+ parseAsString,
+ parseAsStringEnum,
+} from "nuqs/server"
+import * as z from "zod"
+import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers"
+
+export const searchParamsTechCandidateCache = createSearchParamsCache({
+ // Common flags
+ flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]),
+ from: parseAsString.withDefault(""),
+ to: parseAsString.withDefault(""),
+ // Paging
+ page: parseAsInteger.withDefault(1),
+ perPage: parseAsInteger.withDefault(10),
+
+ // Sorting - adjusting for vendorInvestigationsView
+ sort: getSortingStateParser<typeof techVendorCandidatesWithVendorInfo.$inferSelect>().withDefault([
+ { id: "createdAt", desc: true },
+ ]),
+
+ // Advanced filter
+ filters: getFiltersStateParser().withDefault([]),
+ joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"),
+
+ // Global search
+ search: parseAsString.withDefault(""),
+
+ // -----------------------------------------------------------------
+ // Fields specific to vendor investigations
+ // -----------------------------------------------------------------
+
+ // investigationStatus: PLANNED, IN_PROGRESS, COMPLETED, CANCELED
+ status: parseAsStringEnum(["COLLECTED", "INVITED", "DISCARDED"]),
+
+ // In case you also want to filter by vendorName, vendorCode, etc.
+ companyName: parseAsString.withDefault(""),
+ contactEmail: parseAsString.withDefault(""),
+ contactPhone: parseAsString.withDefault(""),
+ country: parseAsString.withDefault(""),
+ source: parseAsString.withDefault(""),
+
+
+})
+
+// Finally, export the type you can use in your server action:
+export type GetTechVendorsCandidateSchema = Awaited<ReturnType<typeof searchParamsTechCandidateCache.parse>>
+
+
+// Updated version of the updateVendorCandidateSchema
+export const updateVendorCandidateSchema = z.object({
+ id: z.number(),
+ // 필수 필드
+ companyName: z.string().min(1, "회사명은 필수입니다").max(255),
+ // null을 명시적으로 처리
+ contactEmail: z.union([
+ z.string().email("유효한 이메일 형식이 아닙니다").max(255),
+ z.literal(''),
+ z.null()
+ ]).optional().transform(val => val === null ? '' : val),
+ contactPhone: z.union([z.string().max(50), z.literal(''), z.null()]).optional()
+ .transform(val => val === null ? '' : val),
+ country: z.union([z.string().max(100), z.literal(''), z.null()]).optional()
+ .transform(val => val === null ? '' : val),
+ // 필수 필드
+ source: z.string().min(1, "출처는 필수입니다").max(100),
+ address: z.union([z.string(), z.literal(''), z.null()]).optional()
+ .transform(val => val === null ? '' : val),
+ taxId: z.union([z.string(), z.literal(''), z.null()]).optional()
+ .transform(val => val === null ? '' : val),
+ // 필수 필드
+ items: z.string().min(1, "항목은 필수입니다"),
+ remark: z.union([z.string(), z.literal(''), z.null()]).optional()
+ .transform(val => val === null ? '' : val),
+ status: z.enum(["COLLECTED", "INVITED", "DISCARDED"]),
+ updatedAt: z.date().optional().default(() => new Date()),
+});;
+
+// Create schema for vendor candidates
+export const createVendorCandidateSchema = z.object({
+ companyName: z.string().min(1, "회사명은 필수입니다").max(255),
+ // contactEmail을 필수값으로 변경
+ contactEmail: z.string().min(1, "이메일은 필수입니다").email("유효한 이메일 형식이 아닙니다").max(255),
+ contactPhone: z.string().max(50).optional(),
+ country: z.string().max(100).optional(),
+ source: z.string().min(1, "출처는 필수입니다").max(100),
+ address: z.string().optional(),
+ taxId: z.string().optional(),
+ items: z.string().min(1, "항목은 필수입니다"),
+ remark: z.string().optional(),
+ vendorId: z.number().optional(),
+ status: z.enum(["COLLECTED", "INVITED", "DISCARDED"]).default("COLLECTED"),
+});
+
+// Export types for both schemas
+export type UpdateVendorCandidateSchema = z.infer<typeof updateVendorCandidateSchema>;
+export type CreateVendorCandidateSchema = z.infer<typeof createVendorCandidateSchema>;
+
+
+export const removeCandidatesSchema = z.object({
+ ids: z.array(z.number()).min(1, "At least one candidate ID must be provided"),
+});
+
+export type RemoveCandidatesInput = z.infer<typeof removeCandidatesSchema>;
+
+// 기술영업 벤더 후보 생성 스키마
+export const createTechVendorCandidateSchema = z.object({
+ companyName: z.string().min(1, "Company name is required"),
+ contactEmail: z.string().email("Invalid email").optional(),
+ contactPhone: z.string().optional(),
+ taxId: z.string().optional(),
+ address: z.string().optional(),
+ country: z.string().optional(),
+ source: z.string().optional(),
+ status: z.enum(["COLLECTED", "INVITED", "DISCARDED"]).default("COLLECTED"),
+ remark: z.string().optional(),
+ items: z.string().min(1, "Items are required"),
+ vendorId: z.number().optional(),
+});
+
+// 기술영업 벤더 후보 업데이트 스키마
+export const updateTechVendorCandidateSchema = z.object({
+ id: z.number(),
+ companyName: z.string().min(1, "Company name is required").optional(),
+ contactEmail: z.string().email("Invalid email").optional(),
+ contactPhone: z.string().optional(),
+ taxId: z.string().optional(),
+ address: z.string().optional(),
+ country: z.string().optional(),
+ source: z.string().optional(),
+ status: z.enum(["COLLECTED", "INVITED", "DISCARDED"]).optional(),
+ remark: z.string().optional(),
+ items: z.string().optional(),
+ vendorId: z.number().optional(),
+});
+
+// 기술영업 벤더 후보 삭제 스키마
+export const removeTechCandidatesSchema = z.object({
+ ids: z.array(z.number()).min(1, "At least one ID is required"),
+});
+
+export type CreateTechVendorCandidateSchema = z.infer<typeof createTechVendorCandidateSchema>
+export type UpdateTechVendorCandidateSchema = z.infer<typeof updateTechVendorCandidateSchema>
+export type RemoveTechCandidatesInput = z.infer<typeof removeTechCandidatesSchema> \ No newline at end of file
diff --git a/lib/tech-vendors/service.ts b/lib/tech-vendors/service.ts
index 05ec1178..5fd5ef02 100644
--- a/lib/tech-vendors/service.ts
+++ b/lib/tech-vendors/service.ts
@@ -2,7 +2,7 @@
import { revalidateTag, unstable_noStore } from "next/cache";
import db from "@/db/db";
-import { techVendorAttachments, techVendorContacts, techVendorPossibleItems, techVendors, techVendorItemsView, type TechVendor } from "@/db/schema/techVendors";
+import { techVendorAttachments, techVendorContacts, techVendorPossibleItems, techVendors, techVendorItemsView, type TechVendor, techVendorCandidates } from "@/db/schema/techVendors";
import { items, itemShipbuilding, itemOffshoreTop, itemOffshoreHull } from "@/db/schema/items";
import { filterColumns } from "@/lib/filter-columns";
@@ -78,6 +78,25 @@ export async function getTechVendors(input: GetTechVendorsSchema) {
// 최종 where 결합
const finalWhere = and(advancedWhere, globalWhere);
+ // 벤더 타입 필터링 로직 추가
+ let vendorTypeWhere;
+ if (input.vendorType) {
+ // URL의 vendorType 파라미터를 실제 벤더 타입으로 매핑
+ const vendorTypeMap = {
+ "ship": "조선",
+ "top": "해양TOP",
+ "hull": "해양HULL"
+ };
+
+ const actualVendorType = input.vendorType in vendorTypeMap
+ ? vendorTypeMap[input.vendorType as keyof typeof vendorTypeMap]
+ : undefined;
+ if (actualVendorType) {
+ // techVendorType 필드는 콤마로 구분된 문자열이므로 LIKE 사용
+ vendorTypeWhere = ilike(techVendors.techVendorType, `%${actualVendorType}%`);
+ }
+ }
+
// 간단 검색 (advancedTable=false) 시 예시
const simpleWhere = and(
input.vendorName
@@ -89,8 +108,8 @@ export async function getTechVendors(input: GetTechVendorsSchema) {
: undefined
);
- // 실제 사용될 where
- const where = finalWhere;
+ // 실제 사용될 where (vendorType 필터링 추가)
+ const where = and(finalWhere, vendorTypeWhere);
// 정렬
const orderBy =
@@ -272,7 +291,7 @@ export async function createTechVendor(input: CreateTechVendorSchema) {
phone: input.phone || null,
email: input.email,
website: input.website || null,
- techVendorType: input.techVendorType as "조선" | "해양TOP" | "해양HULL",
+ techVendorType: Array.isArray(input.techVendorType) ? input.techVendorType.join(',') : input.techVendorType,
representativeName: input.representativeName || null,
representativeBirth: input.representativeBirth || null,
representativeEmail: input.representativeEmail || null,
@@ -1066,9 +1085,9 @@ export async function exportTechVendorDetails(vendorIds: number[]) {
}
/**
- * 기술영업 벤더 상세 정보 조회
+ * 기술영업 벤더 상세 정보 조회 (연락처, 첨부파일 포함)
*/
-async function getTechVendorDetailById(id: number) {
+export async function getTechVendorDetailById(id: number) {
try {
const vendor = await db.select().from(techVendors).where(eq(techVendors.id, id)).limit(1);
@@ -1255,7 +1274,7 @@ export async function importTechVendorsFromExcel(
phone: vendor.phone || null,
email: vendor.email,
website: vendor.website || null,
- techVendorType: vendor.techVendorType as "조선" | "해양TOP" | "해양HULL",
+ techVendorType: vendor.techVendorType,
status: "ACTIVE",
representativeName: vendor.representativeName || null,
representativeEmail: vendor.representativeEmail || null,
@@ -1345,6 +1364,149 @@ export async function findTechVendorById(id: number): Promise<TechVendor | null>
}
/**
+ * 회원가입 폼을 통한 기술영업 벤더 생성 (초대 토큰 기반)
+ */
+export async function createTechVendorFromSignup(params: {
+ vendorData: {
+ vendorName: string
+ vendorCode?: string
+ items: string
+ website?: string
+ taxId: string
+ address?: string
+ email: string
+ phone?: string
+ country: string
+ techVendorType: "조선" | "해양TOP" | "해양HULL"
+ representativeName?: string
+ representativeBirth?: string
+ representativeEmail?: string
+ representativePhone?: string
+ }
+ files?: File[]
+ contacts: {
+ contactName: string
+ contactPosition?: string
+ contactEmail: string
+ contactPhone?: string
+ isPrimary?: boolean
+ }[]
+ invitationToken?: string // 초대 토큰
+}) {
+ unstable_noStore();
+
+ try {
+ console.log("기술영업 벤더 회원가입 시작:", params.vendorData.vendorName);
+
+ const result = await db.transaction(async (tx) => {
+ // 1. 이메일 중복 체크
+ const existingVendor = await tx.query.techVendors.findFirst({
+ where: eq(techVendors.email, params.vendorData.email),
+ columns: { id: true, vendorName: true }
+ });
+
+ if (existingVendor) {
+ throw new Error(`이미 등록된 이메일입니다: ${params.vendorData.email}`);
+ }
+
+ // 2. 벤더 생성
+ const [newVendor] = await tx.insert(techVendors).values({
+ vendorName: params.vendorData.vendorName,
+ vendorCode: params.vendorData.vendorCode || null,
+ taxId: params.vendorData.taxId,
+ country: params.vendorData.country,
+ address: params.vendorData.address || null,
+ phone: params.vendorData.phone || null,
+ email: params.vendorData.email,
+ website: params.vendorData.website || null,
+ techVendorType: params.vendorData.techVendorType,
+ status: "ACTIVE",
+ representativeName: params.vendorData.representativeName || null,
+ representativeEmail: params.vendorData.representativeEmail || null,
+ representativePhone: params.vendorData.representativePhone || null,
+ representativeBirth: params.vendorData.representativeBirth || null,
+ items: params.vendorData.items,
+ }).returning();
+
+ console.log("기술영업 벤더 생성 성공:", newVendor.id);
+
+ // 3. 연락처 생성
+ if (params.contacts && params.contacts.length > 0) {
+ for (const [index, contact] of params.contacts.entries()) {
+ await tx.insert(techVendorContacts).values({
+ vendorId: newVendor.id,
+ contactName: contact.contactName,
+ contactPosition: contact.contactPosition || null,
+ contactEmail: contact.contactEmail,
+ contactPhone: contact.contactPhone || null,
+ isPrimary: index === 0, // 첫 번째 연락처를 primary로 설정
+ });
+ }
+ console.log("연락처 생성 완료:", params.contacts.length, "개");
+ }
+
+ // 4. 첨부파일 처리
+ if (params.files && params.files.length > 0) {
+ await storeTechVendorFiles(tx, newVendor.id, params.files, "GENERAL");
+ console.log("첨부파일 저장 완료:", params.files.length, "개");
+ }
+
+ // 5. 유저 생성 (techCompanyId 설정)
+ console.log("유저 생성 시도:", params.vendorData.email);
+
+ const existingUser = await tx.query.users.findFirst({
+ where: eq(users.email, params.vendorData.email),
+ columns: { id: true, techCompanyId: true }
+ });
+
+ let userId = null;
+ if (!existingUser) {
+ const [newUser] = await tx.insert(users).values({
+ name: params.vendorData.vendorName,
+ email: params.vendorData.email,
+ techCompanyId: newVendor.id, // 중요: techCompanyId 설정
+ domain: "partners",
+ }).returning();
+ userId = newUser.id;
+ console.log("유저 생성 성공:", userId);
+ } else {
+ // 기존 유저의 techCompanyId 업데이트
+ if (!existingUser.techCompanyId) {
+ await tx.update(users)
+ .set({ techCompanyId: newVendor.id })
+ .where(eq(users.id, existingUser.id));
+ console.log("기존 유저의 techCompanyId 업데이트:", existingUser.id);
+ }
+ userId = existingUser.id;
+ }
+
+ // 6. 후보에서 해당 이메일이 있으면 vendorId 업데이트 및 상태 변경
+ if (params.vendorData.email) {
+ await tx.update(techVendorCandidates)
+ .set({
+ vendorId: newVendor.id,
+ status: "INVITED"
+ })
+ .where(eq(techVendorCandidates.contactEmail, params.vendorData.email));
+ }
+
+ return { vendor: newVendor, userId };
+ });
+
+ // 캐시 무효화
+ revalidateTag("tech-vendors");
+ revalidateTag("tech-vendor-candidates");
+ revalidateTag("users");
+
+ console.log("기술영업 벤더 회원가입 완료:", result);
+ return { success: true, data: result };
+ } catch (error) {
+ console.error("기술영업 벤더 회원가입 실패:", error);
+ return { success: false, error: getErrorMessage(error) };
+ }
+}
+
+/**
* 단일 기술영업 벤더 추가 (사용자 계정도 함께 생성)
*/
export async function addTechVendor(input: {
@@ -1361,7 +1523,7 @@ export async function addTechVendor(input: {
address?: string | null;
phone?: string | null;
website?: string | null;
- techVendorType: "조선" | "해양TOP" | "해양HULL";
+ techVendorType: string;
representativeName?: string | null;
representativeEmail?: string | null;
representativePhone?: string | null;
@@ -1404,7 +1566,7 @@ export async function addTechVendor(input: {
phone: input.phone || null,
email: input.email,
website: input.website || null,
- techVendorType: input.techVendorType,
+ techVendorType: Array.isArray(input.techVendorType) ? input.techVendorType.join(',') : input.techVendorType,
status: "ACTIVE",
representativeName: input.representativeName || null,
representativeEmail: input.representativeEmail || null,
diff --git a/lib/tech-vendors/table/add-vendor-dialog.tsx b/lib/tech-vendors/table/add-vendor-dialog.tsx
index bc260d51..da9880d4 100644
--- a/lib/tech-vendors/table/add-vendor-dialog.tsx
+++ b/lib/tech-vendors/table/add-vendor-dialog.tsx
@@ -25,13 +25,7 @@ import {
FormMessage,
} from "@/components/ui/form"
import { Input } from "@/components/ui/input"
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select"
+
import { Textarea } from "@/components/ui/textarea"
import { Plus, Loader2 } from "lucide-react"
@@ -52,9 +46,7 @@ const addVendorSchema = z.object({
address: z.string().optional(),
phone: z.string().optional(),
website: z.string().optional(),
- techVendorType: z.enum(["조선", "해양TOP", "해양HULL"], {
- required_error: "벤더 타입을 선택해주세요",
- }),
+ techVendorType: z.array(z.enum(["조선", "해양TOP", "해양HULL"])).min(1, "최소 하나의 벤더 타입을 선택해주세요"),
representativeName: z.string().optional(),
representativeEmail: z.string().email("올바른 이메일 주소를 입력해주세요").optional().or(z.literal("")),
representativePhone: z.string().optional(),
@@ -87,7 +79,7 @@ export function AddVendorDialog({ onSuccess }: AddVendorDialogProps) {
address: "",
phone: "",
website: "",
- techVendorType: undefined,
+ techVendorType: [],
representativeName: "",
representativeEmail: "",
representativePhone: "",
@@ -110,6 +102,7 @@ export function AddVendorDialog({ onSuccess }: AddVendorDialogProps) {
address: data.address || null,
phone: data.phone || null,
website: data.website || null,
+ techVendorType: data.techVendorType.join(','),
representativeName: data.representativeName || null,
representativeEmail: data.representativeEmail || null,
representativePhone: data.representativePhone || null,
@@ -218,18 +211,29 @@ export function AddVendorDialog({ onSuccess }: AddVendorDialogProps) {
render={({ field }) => (
<FormItem>
<FormLabel>벤더 타입 *</FormLabel>
- <Select onValueChange={field.onChange} defaultValue={field.value}>
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="벤더 타입을 선택하세요" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- <SelectItem value="조선">조선</SelectItem>
- <SelectItem value="해양TOP">해양TOP</SelectItem>
- <SelectItem value="해양HULL">해양HULL</SelectItem>
- </SelectContent>
- </Select>
+ <div className="space-y-2">
+ {["조선", "해양TOP", "해양HULL"].map((type) => (
+ <div key={type} className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id={type}
+ checked={field.value?.includes(type as "조선" | "해양TOP" | "해양HULL")}
+ onChange={(e) => {
+ const currentValue = field.value || [];
+ if (e.target.checked) {
+ field.onChange([...currentValue, type]);
+ } else {
+ field.onChange(currentValue.filter((v) => v !== type));
+ }
+ }}
+ className="w-4 h-4"
+ />
+ <label htmlFor={type} className="text-sm font-medium cursor-pointer">
+ {type}
+ </label>
+ </div>
+ ))}
+ </div>
<FormMessage />
</FormItem>
)}
diff --git a/lib/tech-vendors/table/excel-template-download.tsx b/lib/tech-vendors/table/excel-template-download.tsx
index db2c5fb5..b6011e2c 100644
--- a/lib/tech-vendors/table/excel-template-download.tsx
+++ b/lib/tech-vendors/table/excel-template-download.tsx
@@ -72,7 +72,7 @@ export async function exportTechVendorTemplate() {
phone: '02-1234-5678',
email: 'sample1@example.com',
website: 'https://example1.com',
- techVendorType: '조선',
+ techVendorType: '조선,해양TOP',
representativeName: '홍길동',
representativeEmail: 'ceo1@example.com',
representativePhone: '010-1234-5678',
@@ -93,7 +93,7 @@ export async function exportTechVendorTemplate() {
phone: '051-234-5678',
email: 'sample2@example.com',
website: 'https://example2.com',
- techVendorType: '해양TOP',
+ techVendorType: '해양HULL',
representativeName: '김철수',
representativeEmail: 'ceo2@example.com',
representativePhone: '010-2345-6789',
diff --git a/lib/tech-vendors/table/tech-vendors-table-columns.tsx b/lib/tech-vendors/table/tech-vendors-table-columns.tsx
index e586a667..093b5547 100644
--- a/lib/tech-vendors/table/tech-vendors-table-columns.tsx
+++ b/lib/tech-vendors/table/tech-vendors-table-columns.tsx
@@ -131,11 +131,6 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
>
상세보기(새창)
</DropdownMenuItem>
- <DropdownMenuItem
- onSelect={() => setRowAction({ row, type: "log" })}
- >
- 감사 로그 보기
- </DropdownMenuItem>
<Separator />
<DropdownMenuSub>
diff --git a/lib/tech-vendors/table/update-vendor-sheet.tsx b/lib/tech-vendors/table/update-vendor-sheet.tsx
index cc6b4003..774299f1 100644
--- a/lib/tech-vendors/table/update-vendor-sheet.tsx
+++ b/lib/tech-vendors/table/update-vendor-sheet.tsx
@@ -65,24 +65,6 @@ type StatusConfig = {
// 상태 표시 유틸리티 함수
const getStatusConfig = (status: StatusType): StatusConfig => {
switch(status) {
- case "PENDING_REVIEW":
- return {
- Icon: ClipboardList,
- className: "text-yellow-600",
- label: "가입 신청 중"
- };
- case "IN_REVIEW":
- return {
- Icon: FilePenLine,
- className: "text-blue-600",
- label: "심사 중"
- };
- case "REJECTED":
- return {
- Icon: XCircle,
- className: "text-red-600",
- label: "심사 거부됨"
- };
case "ACTIVE":
return {
Icon: Activity,
@@ -127,6 +109,7 @@ export function UpdateVendorSheet({ vendor, ...props }: UpdateVendorSheetProps)
phone: vendor?.phone ?? "",
email: vendor?.email ?? "",
website: vendor?.website ?? "",
+ techVendorType: vendor?.techVendorType ? vendor.techVendorType.split(',').filter(Boolean) : [],
status: vendor?.status ?? "ACTIVE",
},
})
@@ -141,6 +124,7 @@ export function UpdateVendorSheet({ vendor, ...props }: UpdateVendorSheetProps)
phone: vendor?.phone ?? "",
email: vendor?.email ?? "",
website: vendor?.website ?? "",
+ techVendorType: vendor?.techVendorType ? vendor.techVendorType.split(',').filter(Boolean) : [],
status: vendor?.status ?? "ACTIVE",
});
@@ -172,7 +156,8 @@ export function UpdateVendorSheet({ vendor, ...props }: UpdateVendorSheetProps)
id: String(vendor.id),
userId: Number(session.user.id), // Add user ID from session
comment: statusComment, // Add comment for status changes
- ...data // 모든 데이터 전달 - 서비스 함수에서 필요한 필드만 처리
+ ...data, // 모든 데이터 전달 - 서비스 함수에서 필요한 필드만 처리
+ techVendorType: data.techVendorType ? data.techVendorType.join(',') : undefined,
})
if (error) throw new Error(error)
@@ -312,6 +297,41 @@ export function UpdateVendorSheet({ vendor, ...props }: UpdateVendorSheetProps)
)}
/>
+ {/* techVendorType */}
+ <FormField
+ control={form.control}
+ name="techVendorType"
+ render={({ field }) => (
+ <FormItem className="md:col-span-2">
+ <FormLabel>벤더 타입 *</FormLabel>
+ <div className="space-y-2">
+ {["조선", "해양TOP", "해양HULL"].map((type) => (
+ <div key={type} className="flex items-center space-x-2">
+ <input
+ type="checkbox"
+ id={`update-${type}`}
+ checked={field.value?.includes(type as "조선" | "해양TOP" | "해양HULL")}
+ onChange={(e) => {
+ const currentValue = field.value || [];
+ if (e.target.checked) {
+ field.onChange([...currentValue, type]);
+ } else {
+ field.onChange(currentValue.filter((v) => v !== type));
+ }
+ }}
+ className="w-4 h-4"
+ />
+ <label htmlFor={`update-${type}`} className="text-sm font-medium cursor-pointer">
+ {type}
+ </label>
+ </div>
+ ))}
+ </div>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
{/* status with icons */}
<FormField
control={form.control}
diff --git a/lib/tech-vendors/table/vendor-all-export.ts b/lib/tech-vendors/table/vendor-all-export.ts
index a1ad4fd1..f2650102 100644
--- a/lib/tech-vendors/table/vendor-all-export.ts
+++ b/lib/tech-vendors/table/vendor-all-export.ts
@@ -108,6 +108,7 @@ function createBasicInfoSheet(
address: vendor.address,
representativeName: vendor.representativeName,
createdAt: vendor.createdAt ? formatDate(vendor.createdAt) : "",
+ techVendorType: vendor.techVendorType?.split(',').join(', ') || vendor.techVendorType,
});
});
}
diff --git a/lib/tech-vendors/validations.ts b/lib/tech-vendors/validations.ts
index bae3e5b4..ee076945 100644
--- a/lib/tech-vendors/validations.ts
+++ b/lib/tech-vendors/validations.ts
@@ -46,7 +46,8 @@ export const searchParamsCache = createSearchParamsCache({
// 예) 코드 검색
vendorCode: parseAsString.withDefault(""),
-
+ // 벤더 타입 필터링 (다중 선택 가능)
+ vendorType: parseAsStringEnum(["ship", "top", "hull"]),
// 필요하다면 이메일 검색 / 웹사이트 검색 등 추가 가능
email: parseAsString.withDefault(""),
website: parseAsString.withDefault(""),
@@ -117,6 +118,10 @@ export const updateTechVendorSchema = z.object({
phone: z.string().optional(),
email: z.string().email("유효한 이메일 주소를 입력해주세요").optional(),
website: z.string().url("유효한 URL을 입력해주세요").optional(),
+ techVendorType: z.union([
+ z.array(z.enum(VENDOR_TYPES)).min(1, "최소 하나의 벤더 타입을 선택해주세요"),
+ z.string().min(1, "벤더 타입을 선택해주세요")
+ ]).optional(),
status: z.enum(techVendors.status.enumValues).optional(),
userId: z.number().optional(),
comment: z.string().optional(),
@@ -155,7 +160,10 @@ export const createTechVendorSchema = z
files: z.any().optional(),
status: z.enum(techVendors.status.enumValues).default("ACTIVE"),
- techVendorType: z.enum(VENDOR_TYPES).default("조선"),
+ techVendorType: z.union([
+ z.array(z.enum(VENDOR_TYPES)).min(1, "최소 하나의 벤더 타입을 선택해주세요"),
+ z.string().min(1, "벤더 타입을 선택해주세요")
+ ]).default(["조선"]),
representativeName: z.union([z.string().max(255), z.literal("")]).optional(),
representativeBirth: z.union([z.string().max(20), z.literal("")]).optional(),
diff --git a/lib/techsales-rfq/actions.ts b/lib/techsales-rfq/actions.ts
index 1171271f..5d5d5118 100644
--- a/lib/techsales-rfq/actions.ts
+++ b/lib/techsales-rfq/actions.ts
@@ -2,12 +2,9 @@
import { revalidatePath } from "next/cache"
import {
- acceptTechSalesVendorQuotation,
- rejectTechSalesVendorQuotation
+ acceptTechSalesVendorQuotation
} from "./service"
-// ... existing code ...
-
/**
* 기술영업 벤더 견적 승인 (벤더 선택) Server Action
*/
@@ -32,28 +29,3 @@ export async function acceptTechSalesVendorQuotationAction(quotationId: number)
}
}
}
-
-// /**
-// * 기술영업 벤더 견적 거절 Server Action
-// */
-// export async function rejectTechSalesVendorQuotationAction(quotationId: number, rejectionReason?: string) {
-// try {
-// const result = await rejectTechSalesVendorQuotation(quotationId, rejectionReason)
-
-// if (result.success) {
-// // 관련 페이지들 재검증
-// revalidatePath("/evcp/budgetary-tech-sales-ship")
-// revalidatePath("/partners/techsales")
-
-// return { success: true, message: "견적이 성공적으로 거절되었습니다" }
-// } else {
-// return { success: false, error: result.error }
-// }
-// } catch (error) {
-// console.error("견적 거절 액션 오류:", error)
-// return {
-// success: false,
-// error: error instanceof Error ? error.message : "견적 거절에 실패했습니다"
-// }
-// }
-// } \ No newline at end of file
diff --git a/lib/techsales-rfq/repository.ts b/lib/techsales-rfq/repository.ts
index e9ad3925..1aaf4b3d 100644
--- a/lib/techsales-rfq/repository.ts
+++ b/lib/techsales-rfq/repository.ts
@@ -117,11 +117,24 @@ export async function selectTechSalesRfqsWithJoin(
projMsrm: biddingProjects.projMsrm,
ptypeNm: biddingProjects.ptypeNm,
- // 첨부파일 개수
+ // 첨부파일 개수 (타입별로 분리)
attachmentCount: sql<number>`(
SELECT COUNT(*)
FROM tech_sales_attachments
WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id}
+ AND tech_sales_attachments.attachment_type = 'RFQ_COMMON'
+ )`,
+ hasTbeAttachments: sql<boolean>`(
+ SELECT CASE WHEN COUNT(*) > 0 THEN TRUE ELSE FALSE END
+ FROM tech_sales_attachments
+ WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id}
+ AND tech_sales_attachments.attachment_type = 'TBE_RESULT'
+ )`,
+ hasCbeAttachments: sql<boolean>`(
+ SELECT CASE WHEN COUNT(*) > 0 THEN TRUE ELSE FALSE END
+ FROM tech_sales_attachments
+ WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id}
+ AND tech_sales_attachments.attachment_type = 'CBE_RESULT'
)`,
// 벤더 견적 개수
@@ -258,6 +271,20 @@ export async function selectTechSalesVendorQuotationsWithJoin(
WHERE tech_sales_attachments.tech_sales_rfq_id = ${techSalesRfqs.id}
)`,
+ // 견적서 첨부파일 개수
+ quotationAttachmentCount: sql<number>`(
+ SELECT COUNT(*)
+ FROM tech_sales_vendor_quotation_attachments
+ WHERE tech_sales_vendor_quotation_attachments.quotation_id = ${techSalesVendorQuotations.id}
+ )`,
+
+ // RFQ 아이템 개수
+ itemCount: sql<number>`(
+ SELECT COUNT(*)
+ FROM tech_sales_rfq_items
+ WHERE tech_sales_rfq_items.rfq_id = ${techSalesRfqs.id}
+ )`,
+
})
.from(techSalesVendorQuotations)
.leftJoin(techSalesRfqs, sql`${techSalesVendorQuotations.rfqId} = ${techSalesRfqs.id}`)
diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts
index 96d6a3c9..25e1f379 100644
--- a/lib/techsales-rfq/service.ts
+++ b/lib/techsales-rfq/service.ts
@@ -5,7 +5,9 @@ import db from "@/db/db";
import {
techSalesRfqs,
techSalesVendorQuotations,
+ techSalesVendorQuotationRevisions,
techSalesAttachments,
+ techSalesVendorQuotationAttachments,
users,
techSalesRfqComments,
techSalesRfqItems,
@@ -30,6 +32,7 @@ import { authOptions } from "@/app/api/auth/[...nextauth]/route";
import { sendEmail } from "../mail/sendEmail";
import { formatDate } from "../utils";
import { techVendors, techVendorPossibleItems } from "@/db/schema/techVendors";
+import { decryptWithServerAction } from "@/components/drm/drmUtils";
// 정렬 타입 정의
// 의도적으로 any 사용 - drizzle ORM의 orderBy 타입이 복잡함
@@ -79,16 +82,6 @@ async function generateRfqCodes(tx: any, count: number, year?: number): Promise<
return codes;
}
-/**
- * 기술영업 조선 RFQ 생성 액션
- *
- * 받을 파라미터 (생성시 입력하는 것)
- * 1. RFQ 관련
- * 2. 프로젝트 관련
- * 3. 자재 관련 (자재그룹)
- *
- * 나머지 벤더, 첨부파일 등은 생성 이후 처리
- */
/**
* 직접 조인을 사용하여 RFQ 데이터 조회하는 함수
@@ -309,8 +302,29 @@ export async function getTechSalesVendorQuotationsWithJoin(input: {
limit: input.perPage,
});
+ // 각 견적서의 첨부파일 정보 조회
+ const dataWithAttachments = await Promise.all(
+ data.map(async (quotation) => {
+ const attachments = await db.query.techSalesVendorQuotationAttachments.findMany({
+ where: eq(techSalesVendorQuotationAttachments.quotationId, quotation.id),
+ orderBy: [desc(techSalesVendorQuotationAttachments.createdAt)],
+ });
+
+ return {
+ ...quotation,
+ quotationAttachments: attachments.map(att => ({
+ id: att.id,
+ fileName: att.fileName,
+ fileSize: att.fileSize,
+ filePath: att.filePath,
+ description: att.description,
+ }))
+ };
+ })
+ );
+
const total = await countTechSalesVendorQuotationsWithJoin(tx, finalWhere);
- return { data, total };
+ return { data: dataWithAttachments, total };
});
const pageCount = Math.ceil(total / input.perPage);
@@ -414,160 +428,6 @@ export async function getTechSalesDashboardWithJoin(input: {
}
}
-
-
-/**
- * 기술영업 RFQ에서 벤더 제거 (Draft 상태 체크 포함)
- */
-export async function removeVendorFromTechSalesRfq(input: {
- rfqId: number;
- vendorId: number;
-}) {
- unstable_noStore();
- try {
- // 먼저 해당 벤더의 견적서 상태 확인
- const existingQuotation = await db
- .select()
- .from(techSalesVendorQuotations)
- .where(
- and(
- eq(techSalesVendorQuotations.rfqId, input.rfqId),
- eq(techSalesVendorQuotations.vendorId, input.vendorId)
- )
- )
- .limit(1);
-
- if (existingQuotation.length === 0) {
- return {
- data: null,
- error: "해당 벤더가 이 RFQ에 존재하지 않습니다."
- };
- }
-
- // Draft 상태가 아닌 경우 삭제 불가
- if (existingQuotation[0].status !== "Draft") {
- return {
- data: null,
- error: "Draft 상태의 벤더만 삭제할 수 있습니다."
- };
- }
-
- // 해당 벤더의 견적서 삭제
- const deletedQuotations = await db
- .delete(techSalesVendorQuotations)
- .where(
- and(
- eq(techSalesVendorQuotations.rfqId, input.rfqId),
- eq(techSalesVendorQuotations.vendorId, input.vendorId)
- )
- )
- .returning();
-
- // RFQ 타입 조회 및 캐시 무효화
- const rfqForCache = await db.query.techSalesRfqs.findFirst({
- where: eq(techSalesRfqs.id, input.rfqId),
- columns: { rfqType: true }
- });
-
- revalidateTag("techSalesVendorQuotations");
- revalidateTag(`techSalesRfq-${input.rfqId}`);
- revalidateTag(`vendor-${input.vendorId}-quotations`);
- revalidatePath(getTechSalesRevalidationPath(rfqForCache?.rfqType || "SHIP"));
-
- return { data: deletedQuotations[0], error: null };
- } catch (err) {
- console.error("Error removing vendor from RFQ:", err);
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-/**
- * 기술영업 RFQ에서 여러 벤더 일괄 제거 (Draft 상태 체크 포함)
- */
-export async function removeVendorsFromTechSalesRfq(input: {
- rfqId: number;
- vendorIds: number[];
-}) {
- unstable_noStore();
- try {
- const results: typeof techSalesVendorQuotations.$inferSelect[] = [];
- const errors: string[] = [];
-
- // 트랜잭션으로 처리
- await db.transaction(async (tx) => {
- for (const vendorId of input.vendorIds) {
- try {
- // 먼저 해당 벤더의 견적서 상태 확인
- const existingQuotation = await tx
- .select()
- .from(techSalesVendorQuotations)
- .where(
- and(
- eq(techSalesVendorQuotations.rfqId, input.rfqId),
- eq(techSalesVendorQuotations.vendorId, vendorId)
- )
- )
- .limit(1);
-
- if (existingQuotation.length === 0) {
- errors.push(`벤더 ID ${vendorId}가 이 RFQ에 존재하지 않습니다.`);
- continue;
- }
-
- // Draft 상태가 아닌 경우 삭제 불가
- if (existingQuotation[0].status !== "Draft") {
- errors.push(`벤더 ID ${vendorId}는 Draft 상태가 아니므로 삭제할 수 없습니다.`);
- continue;
- }
-
- // 해당 벤더의 견적서 삭제
- const deletedQuotations = await tx
- .delete(techSalesVendorQuotations)
- .where(
- and(
- eq(techSalesVendorQuotations.rfqId, input.rfqId),
- eq(techSalesVendorQuotations.vendorId, vendorId)
- )
- )
- .returning();
-
- if (deletedQuotations.length > 0) {
- results.push(deletedQuotations[0]);
- }
- } catch (vendorError) {
- console.error(`Error removing vendor ${vendorId}:`, vendorError);
- errors.push(`벤더 ID ${vendorId} 삭제 중 오류가 발생했습니다.`);
- }
- }
- });
-
- // RFQ 타입 조회 및 캐시 무효화
- const rfqForCache2 = await db.query.techSalesRfqs.findFirst({
- where: eq(techSalesRfqs.id, input.rfqId),
- columns: { rfqType: true }
- });
-
- revalidateTag("techSalesVendorQuotations");
- revalidateTag(`techSalesRfq-${input.rfqId}`);
- revalidatePath(getTechSalesRevalidationPath(rfqForCache2?.rfqType || "SHIP"));
-
- // 벤더별 캐시도 무효화
- for (const vendorId of input.vendorIds) {
- revalidateTag(`vendor-${vendorId}-quotations`);
- }
-
- return {
- data: results,
- error: errors.length > 0 ? errors.join(", ") : null,
- successCount: results.length,
- errorCount: errors.length
- };
- } catch (err) {
- console.error("Error removing vendors from RFQ:", err);
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
/**
* 특정 RFQ의 벤더 목록 조회
*/
@@ -716,6 +576,19 @@ export async function sendTechSalesRfqToVendors(input: {
.set(updateData)
.where(eq(techSalesRfqs.id, input.rfqId));
+ // 2. 선택된 벤더들의 견적서 상태를 "Assigned"에서 "Draft"로 변경
+ for (const quotation of vendorQuotations) {
+ if (quotation.status === "Assigned") {
+ await tx.update(techSalesVendorQuotations)
+ .set({
+ status: "Draft",
+ updatedBy: Number(session.user.id),
+ updatedAt: new Date(),
+ })
+ .where(eq(techSalesVendorQuotations.id, quotation.id));
+ }
+ }
+
// 2. 각 벤더에 대해 이메일 발송 처리
for (const quotation of vendorQuotations) {
if (!quotation.vendorId || !quotation.vendor) continue;
@@ -847,6 +720,12 @@ export async function getTechSalesVendorQuotation(quotationId: number) {
const itemsResult = await getTechSalesRfqItems(quotation.rfqId);
const items = itemsResult.data || [];
+ // 견적서 첨부파일 조회
+ const quotationAttachments = await db.query.techSalesVendorQuotationAttachments.findMany({
+ where: eq(techSalesVendorQuotationAttachments.quotationId, quotationId),
+ orderBy: [desc(techSalesVendorQuotationAttachments.createdAt)],
+ });
+
// 기존 구조와 호환되도록 데이터 재구성
const formattedQuotation = {
id: quotation.id,
@@ -911,7 +790,16 @@ export async function getTechSalesVendorQuotation(quotationId: number) {
country: quotation.vendorCountry,
email: quotation.vendorEmail,
phone: quotation.vendorPhone,
- }
+ },
+
+ // 첨부파일 정보
+ quotationAttachments: quotationAttachments.map(attachment => ({
+ id: attachment.id,
+ fileName: attachment.fileName,
+ fileSize: attachment.fileSize,
+ filePath: attachment.filePath,
+ description: attachment.description,
+ }))
};
return { data: formattedQuotation, error: null };
@@ -922,7 +810,8 @@ export async function getTechSalesVendorQuotation(quotationId: number) {
}
/**
- * 기술영업 벤더 견적서 업데이트 (임시저장)
+ * 기술영업 벤더 견적서 업데이트 (임시저장),
+ * 현재는 submit으로 처리, revision 을 아래의 함수로 사용가능함.
*/
export async function updateTechSalesVendorQuotation(data: {
id: number
@@ -931,46 +820,78 @@ export async function updateTechSalesVendorQuotation(data: {
validUntil: Date
remark?: string
updatedBy: number
+ changeReason?: string
}) {
try {
- // 현재 견적서 상태 및 벤더 ID 확인
- const currentQuotation = await db.query.techSalesVendorQuotations.findFirst({
- where: eq(techSalesVendorQuotations.id, data.id),
- columns: {
- status: true,
- vendorId: true,
+ return await db.transaction(async (tx) => {
+ // 현재 견적서 전체 데이터 조회 (revision 저장용)
+ const currentQuotation = await tx.query.techSalesVendorQuotations.findFirst({
+ where: eq(techSalesVendorQuotations.id, data.id),
+ });
+
+ if (!currentQuotation) {
+ return { data: null, error: "견적서를 찾을 수 없습니다." };
}
- });
- if (!currentQuotation) {
- return { data: null, error: "견적서를 찾을 수 없습니다." };
- }
+ // Accepted나 Rejected 상태가 아니면 수정 가능
+ if (["Rejected"].includes(currentQuotation.status)) {
+ return { data: null, error: "승인되거나 거절된 견적서는 수정할 수 없습니다." };
+ }
- // Draft 또는 Revised 상태에서만 수정 가능
- if (!["Draft", "Revised"].includes(currentQuotation.status)) {
- return { data: null, error: "현재 상태에서는 견적서를 수정할 수 없습니다." };
- }
+ // 실제 변경사항이 있는지 확인
+ const hasChanges =
+ currentQuotation.currency !== data.currency ||
+ currentQuotation.totalPrice !== data.totalPrice ||
+ currentQuotation.validUntil?.getTime() !== data.validUntil.getTime() ||
+ currentQuotation.remark !== (data.remark || null);
- const result = await db
- .update(techSalesVendorQuotations)
- .set({
- currency: data.currency,
- totalPrice: data.totalPrice,
- validUntil: data.validUntil,
- remark: data.remark || null,
- updatedAt: new Date(),
- })
- .where(eq(techSalesVendorQuotations.id, data.id))
- .returning()
+ if (!hasChanges) {
+ return { data: currentQuotation, error: null };
+ }
- // 캐시 무효화
- revalidateTag("techSalesVendorQuotations")
- revalidatePath(`/partners/techsales/rfq-ship/${data.id}`)
+ // 현재 버전을 revision history에 저장
+ await tx.insert(techSalesVendorQuotationRevisions).values({
+ quotationId: data.id,
+ version: currentQuotation.quotationVersion || 1,
+ snapshot: {
+ currency: currentQuotation.currency,
+ totalPrice: currentQuotation.totalPrice,
+ validUntil: currentQuotation.validUntil,
+ remark: currentQuotation.remark,
+ status: currentQuotation.status,
+ quotationVersion: currentQuotation.quotationVersion,
+ submittedAt: currentQuotation.submittedAt,
+ acceptedAt: currentQuotation.acceptedAt,
+ updatedAt: currentQuotation.updatedAt,
+ },
+ changeReason: data.changeReason || "견적서 수정",
+ revisedBy: data.updatedBy,
+ });
- return { data: result[0], error: null }
+ // 새로운 버전으로 업데이트
+ const result = await tx
+ .update(techSalesVendorQuotations)
+ .set({
+ currency: data.currency,
+ totalPrice: data.totalPrice,
+ validUntil: data.validUntil,
+ remark: data.remark || null,
+ quotationVersion: (currentQuotation.quotationVersion || 1) + 1,
+ status: "Revised", // 수정된 상태로 변경
+ updatedAt: new Date(),
+ })
+ .where(eq(techSalesVendorQuotations.id, data.id))
+ .returning();
+
+ return { data: result[0], error: null };
+ });
} catch (error) {
- console.error("Error updating tech sales vendor quotation:", error)
- return { data: null, error: "견적서 업데이트 중 오류가 발생했습니다" }
+ console.error("Error updating tech sales vendor quotation:", error);
+ return { data: null, error: "견적서 업데이트 중 오류가 발생했습니다" };
+ } finally {
+ // 캐시 무효화
+ revalidateTag("techSalesVendorQuotations");
+ revalidatePath(`/partners/techsales/rfq-ship/${data.id}`);
}
}
@@ -983,63 +904,134 @@ export async function submitTechSalesVendorQuotation(data: {
totalPrice: string
validUntil: Date
remark?: string
+ attachments?: Array<{
+ fileName: string
+ filePath: string
+ fileSize: number
+ }>
updatedBy: number
}) {
try {
- // 현재 견적서 상태 확인
- const currentQuotation = await db.query.techSalesVendorQuotations.findFirst({
- where: eq(techSalesVendorQuotations.id, data.id),
- columns: {
- status: true,
- vendorId: true,
+ return await db.transaction(async (tx) => {
+ // 현재 견적서 전체 데이터 조회 (revision 저장용)
+ const currentQuotation = await tx.query.techSalesVendorQuotations.findFirst({
+ where: eq(techSalesVendorQuotations.id, data.id),
+ });
+
+ if (!currentQuotation) {
+ return { data: null, error: "견적서를 찾을 수 없습니다." };
}
- });
- if (!currentQuotation) {
- return { data: null, error: "견적서를 찾을 수 없습니다." };
- }
+ // Rejected 상태에서는 제출 불가
+ if (["Rejected"].includes(currentQuotation.status)) {
+ return { data: null, error: "거절된 견적서는 제출할 수 없습니다." };
+ }
+
+ // // 실제 변경사항이 있는지 확인
+ // const hasChanges =
+ // currentQuotation.currency !== data.currency ||
+ // currentQuotation.totalPrice !== data.totalPrice ||
+ // currentQuotation.validUntil?.getTime() !== data.validUntil.getTime() ||
+ // currentQuotation.remark !== (data.remark || null);
+
+ // // 변경사항이 있거나 처음 제출하는 경우 revision 저장
+ // if (hasChanges || currentQuotation.status === "Draft") {
+ // await tx.insert(techSalesVendorQuotationRevisions).values({
+ // quotationId: data.id,
+ // version: currentQuotation.quotationVersion || 1,
+ // snapshot: {
+ // currency: currentQuotation.currency,
+ // totalPrice: currentQuotation.totalPrice,
+ // validUntil: currentQuotation.validUntil,
+ // remark: currentQuotation.remark,
+ // status: currentQuotation.status,
+ // quotationVersion: currentQuotation.quotationVersion,
+ // submittedAt: currentQuotation.submittedAt,
+ // acceptedAt: currentQuotation.acceptedAt,
+ // updatedAt: currentQuotation.updatedAt,
+ // },
+ // changeReason: "견적서 제출",
+ // revisedBy: data.updatedBy,
+ // });
+ // }
+
+ // 항상 revision 저장 (변경사항 여부와 관계없이)
+ await tx.insert(techSalesVendorQuotationRevisions).values({
+ quotationId: data.id,
+ version: currentQuotation.quotationVersion || 1,
+ snapshot: {
+ currency: currentQuotation.currency,
+ totalPrice: currentQuotation.totalPrice,
+ validUntil: currentQuotation.validUntil,
+ remark: currentQuotation.remark,
+ status: currentQuotation.status,
+ quotationVersion: currentQuotation.quotationVersion,
+ submittedAt: currentQuotation.submittedAt,
+ acceptedAt: currentQuotation.acceptedAt,
+ updatedAt: currentQuotation.updatedAt,
+ },
+ changeReason: "견적서 제출",
+ revisedBy: data.updatedBy,
+ });
- // Draft 또는 Revised 상태에서만 제출 가능
- if (!["Draft", "Revised"].includes(currentQuotation.status)) {
- return { data: null, error: "현재 상태에서는 견적서를 제출할 수 없습니다." };
- }
+ // 새로운 버전 번호 계산 (항상 1 증가)
+ const newRevisionId = (currentQuotation.quotationVersion || 1) + 1;
- const result = await db
- .update(techSalesVendorQuotations)
- .set({
- currency: data.currency,
- totalPrice: data.totalPrice,
- validUntil: data.validUntil,
- remark: data.remark || null,
- status: "Submitted",
- submittedAt: new Date(),
- updatedAt: new Date(),
- })
- .where(eq(techSalesVendorQuotations.id, data.id))
- .returning()
+ // 새로운 버전으로 업데이트
+ const result = await tx
+ .update(techSalesVendorQuotations)
+ .set({
+ currency: data.currency,
+ totalPrice: data.totalPrice,
+ validUntil: data.validUntil,
+ remark: data.remark || null,
+ quotationVersion: newRevisionId,
+ status: "Submitted",
+ submittedAt: new Date(),
+ updatedAt: new Date(),
+ })
+ .where(eq(techSalesVendorQuotations.id, data.id))
+ .returning();
- // 메일 발송 (백그라운드에서 실행)
- if (result[0]) {
- // 벤더에게 견적 제출 확인 메일 발송
- sendQuotationSubmittedNotificationToVendor(data.id).catch(error => {
- console.error("벤더 견적 제출 확인 메일 발송 실패:", error);
- });
+ // 첨부파일 처리 (새로운 revisionId 사용)
+ if (data.attachments && data.attachments.length > 0) {
+ for (const attachment of data.attachments) {
+ await tx.insert(techSalesVendorQuotationAttachments).values({
+ quotationId: data.id,
+ revisionId: newRevisionId, // 새로운 리비전 ID 사용
+ fileName: attachment.fileName,
+ originalFileName: attachment.fileName,
+ fileSize: attachment.fileSize,
+ filePath: attachment.filePath,
+ fileType: attachment.fileName.split('.').pop() || 'unknown',
+ uploadedBy: data.updatedBy,
+ isVendorUpload: true,
+ });
+ }
+ }
- // 담당자에게 견적 접수 알림 메일 발송
- sendQuotationSubmittedNotificationToManager(data.id).catch(error => {
- console.error("담당자 견적 접수 알림 메일 발송 실패:", error);
- });
- }
+ // 메일 발송 (백그라운드에서 실행)
+ if (result[0]) {
+ // 벤더에게 견적 제출 확인 메일 발송
+ sendQuotationSubmittedNotificationToVendor(data.id).catch(error => {
+ console.error("벤더 견적 제출 확인 메일 발송 실패:", error);
+ });
- // 캐시 무효화
- revalidateTag("techSalesVendorQuotations")
- revalidateTag(`vendor-${currentQuotation.vendorId}-quotations`)
- revalidatePath(`/partners/techsales/rfq-ship/${data.id}`)
+ // 담당자에게 견적 접수 알림 메일 발송
+ sendQuotationSubmittedNotificationToManager(data.id).catch(error => {
+ console.error("담당자 견적 접수 알림 메일 발송 실패:", error);
+ });
+ }
- return { data: result[0], error: null }
+ return { data: result[0], error: null };
+ });
} catch (error) {
- console.error("Error submitting tech sales vendor quotation:", error)
- return { data: null, error: "견적서 제출 중 오류가 발생했습니다" }
+ console.error("Error submitting tech sales vendor quotation:", error);
+ return { data: null, error: "견적서 제출 중 오류가 발생했습니다" };
+ } finally {
+ // 캐시 무효화
+ revalidateTag("techSalesVendorQuotations");
+ revalidatePath(`/partners/techsales/rfq-ship`);
}
}
@@ -1095,14 +1087,17 @@ export async function getVendorQuotations(input: {
const offset = (page - 1) * perPage;
const limit = perPage;
- // 기본 조건: 해당 벤더의 견적서만 조회
+ // 기본 조건: 해당 벤더의 견적서만 조회 (Assigned 상태 제외)
const vendorIdNum = parseInt(vendorId);
if (isNaN(vendorIdNum)) {
console.error('❌ [getVendorQuotations] Invalid vendorId:', vendorId);
return { data: [], pageCount: 0, total: 0 };
}
- const baseConditions = [eq(techSalesVendorQuotations.vendorId, vendorIdNum)];
+ const baseConditions = [
+ eq(techSalesVendorQuotations.vendorId, vendorIdNum),
+ sql`${techSalesVendorQuotations.status} != 'Assigned'` // Assigned 상태 제외
+ ];
// rfqType 필터링 추가
if (input.rfqType) {
@@ -1210,9 +1205,13 @@ export async function getVendorQuotations(input: {
description: techSalesRfqs.description,
// 프로젝트 정보 (직접 조인)
projNm: biddingProjects.projNm,
- // 아이템 정보 추가 (임시로 description 사용)
- // itemName: techSalesRfqs.description,
- // 첨부파일 개수
+ // 아이템 개수
+ itemCount: sql<number>`(
+ SELECT COUNT(*)
+ FROM tech_sales_rfq_items
+ WHERE tech_sales_rfq_items.rfq_id = ${techSalesRfqs.id}
+ )`,
+ // RFQ 첨부파일 개수
attachmentCount: sql<number>`(
SELECT COUNT(*)
FROM tech_sales_attachments
@@ -1221,6 +1220,7 @@ export async function getVendorQuotations(input: {
})
.from(techSalesVendorQuotations)
.leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id))
+ .leftJoin(techSalesAttachments, eq(techSalesRfqs.id, techSalesAttachments.techSalesRfqId))
.leftJoin(biddingProjects, eq(techSalesRfqs.biddingProjectId, biddingProjects.id))
.where(finalWhere)
.orderBy(...orderBy)
@@ -1256,48 +1256,6 @@ export async function getVendorQuotations(input: {
}
/**
- * 벤더용 기술영업 견적서 상태별 개수 조회
- */
-export async function getQuotationStatusCounts(vendorId: string, rfqType?: "SHIP" | "TOP" | "HULL") {
- return unstable_cache(
- async () => {
- try {
- const query = db
- .select({
- status: techSalesVendorQuotations.status,
- count: sql<number>`count(*)`,
- })
- .from(techSalesVendorQuotations)
- .leftJoin(techSalesRfqs, eq(techSalesVendorQuotations.rfqId, techSalesRfqs.id));
-
- // 조건 설정
- const conditions = [eq(techSalesVendorQuotations.vendorId, parseInt(vendorId))];
- if (rfqType) {
- conditions.push(eq(techSalesRfqs.rfqType, rfqType));
- }
-
- const result = await query
- .where(and(...conditions))
- .groupBy(techSalesVendorQuotations.status);
-
- return { data: result, error: null };
- } catch (err) {
- console.error("Error fetching quotation status counts:", err);
- return { data: null, error: getErrorMessage(err) };
- }
- },
- [vendorId], // 캐싱 키
- {
- revalidate: 60, // 1분간 캐시
- tags: [
- "techSalesVendorQuotations",
- `vendor-${vendorId}-quotations`
- ],
- }
- )();
-}
-
-/**
* 기술영업 벤더 견적 승인 (벤더 선택)
*/
export async function acceptTechSalesVendorQuotation(quotationId: number) {
@@ -1358,6 +1316,9 @@ export async function acceptTechSalesVendorQuotation(quotationId: number) {
for (const vendorQuotation of allVendorsInRfq) {
revalidateTag(`vendor-${vendorQuotation.vendorId}-quotations`);
}
+ revalidatePath("/evcp/budgetary-tech-sales-ship")
+ revalidatePath("/partners/techsales")
+
return { success: true, data: result }
} catch (error) {
@@ -1370,7 +1331,7 @@ export async function acceptTechSalesVendorQuotation(quotationId: number) {
}
/**
- * 기술영업 RFQ 첨부파일 생성 (파일 업로드)
+ * 기술영업 RFQ 첨부파일 생성 (파일 업로드), 사용x
*/
export async function createTechSalesRfqAttachments(params: {
techSalesRfqId: number
@@ -1415,8 +1376,7 @@ export async function createTechSalesRfqAttachments(params: {
await fs.mkdir(rfqDir, { recursive: true });
for (const file of files) {
- const ab = await file.arrayBuffer();
- const buffer = Buffer.from(ab);
+ const decryptedBuffer = await decryptWithServerAction(file);
// 고유 파일명 생성
const uniqueName = `${randomUUID()}-${file.name}`;
@@ -1424,7 +1384,7 @@ export async function createTechSalesRfqAttachments(params: {
const absolutePath = path.join(process.cwd(), "public", relativePath);
// 파일 저장
- await fs.writeFile(absolutePath, buffer);
+ await fs.writeFile(absolutePath, Buffer.from(decryptedBuffer));
// DB에 첨부파일 레코드 생성
const [newAttachment] = await tx.insert(techSalesAttachments).values({
@@ -1488,6 +1448,39 @@ export async function getTechSalesRfqAttachments(techSalesRfqId: number) {
}
/**
+ * RFQ 첨부파일 타입별 조회
+ */
+export async function getTechSalesRfqAttachmentsByType(
+ techSalesRfqId: number,
+ attachmentType: "RFQ_COMMON" | "VENDOR_SPECIFIC" | "TBE_RESULT" | "CBE_RESULT"
+) {
+ unstable_noStore();
+ try {
+ const attachments = await db.query.techSalesAttachments.findMany({
+ where: and(
+ eq(techSalesAttachments.techSalesRfqId, techSalesRfqId),
+ eq(techSalesAttachments.attachmentType, attachmentType)
+ ),
+ orderBy: [desc(techSalesAttachments.createdAt)],
+ with: {
+ createdByUser: {
+ columns: {
+ id: true,
+ name: true,
+ email: true,
+ }
+ }
+ }
+ });
+
+ return { data: attachments, error: null };
+ } catch (err) {
+ console.error(`기술영업 RFQ ${attachmentType} 첨부파일 조회 오류:`, err);
+ return { data: [], error: getErrorMessage(err) };
+ }
+}
+
+/**
* 기술영업 RFQ 첨부파일 삭제
*/
export async function deleteTechSalesRfqAttachment(attachmentId: number) {
@@ -1561,7 +1554,7 @@ export async function deleteTechSalesRfqAttachment(attachmentId: number) {
*/
export async function processTechSalesRfqAttachments(params: {
techSalesRfqId: number
- newFiles: { file: File; attachmentType: "RFQ_COMMON" | "VENDOR_SPECIFIC"; description?: string }[]
+ newFiles: { file: File; attachmentType: "RFQ_COMMON" | "VENDOR_SPECIFIC" | "TBE_RESULT" | "CBE_RESULT"; description?: string }[]
deleteAttachmentIds: number[]
createdBy: number
}) {
@@ -1623,16 +1616,16 @@ export async function processTechSalesRfqAttachments(params: {
await fs.mkdir(rfqDir, { recursive: true });
for (const { file, attachmentType, description } of newFiles) {
- const ab = await file.arrayBuffer();
- const buffer = Buffer.from(ab);
+ // 파일 복호화
+ const decryptedBuffer = await decryptWithServerAction(file);
// 고유 파일명 생성
const uniqueName = `${randomUUID()}-${file.name}`;
const relativePath = path.join("techsales-rfq", String(techSalesRfqId), uniqueName);
const absolutePath = path.join(process.cwd(), "public", relativePath);
- // 파일 저장
- await fs.writeFile(absolutePath, buffer);
+ // 복호화된 파일 저장
+ await fs.writeFile(absolutePath, Buffer.from(decryptedBuffer));
// DB에 첨부파일 레코드 생성
const [newAttachment] = await tx.insert(techSalesAttachments).values({
@@ -2213,6 +2206,8 @@ export async function markTechSalesMessagesAsRead(rfqId: number, vendorId?: numb
}
}
+// ==================== RFQ 조선/해양 관련 ====================
+
/**
* 기술영업 조선 RFQ 생성 (1:N 관계)
*/
@@ -2223,9 +2218,7 @@ export async function createTechSalesShipRfq(input: {
description?: string;
createdBy: number;
}) {
- unstable_noStore();
- console.log('🔍 createTechSalesShipRfq 호출됨:', input);
-
+ unstable_noStore();
try {
return await db.transaction(async (tx) => {
// 프로젝트 정보 조회 (유효성 검증)
@@ -2474,46 +2467,7 @@ export async function getTechSalesHullVendorQuotationsWithJoin(input: {
return getTechSalesVendorQuotationsWithJoin({ ...input, rfqType: "HULL" });
}
-/**
- * 조선 대시보드 전용 조회 함수
- */
-export async function getTechSalesShipDashboardWithJoin(input: {
- search?: string;
- filters?: Filter<typeof techSalesRfqs>[];
- sort?: { id: string; desc: boolean }[];
- page: number;
- perPage: number;
-}) {
- return getTechSalesDashboardWithJoin({ ...input, rfqType: "SHIP" });
-}
-
-/**
- * 해양 TOP 대시보드 전용 조회 함수
- */
-export async function getTechSalesTopDashboardWithJoin(input: {
- search?: string;
- filters?: Filter<typeof techSalesRfqs>[];
- sort?: { id: string; desc: boolean }[];
- page: number;
- perPage: number;
-}) {
- return getTechSalesDashboardWithJoin({ ...input, rfqType: "TOP" });
-}
-
-/**
- * 해양 HULL 대시보드 전용 조회 함수
- */
-export async function getTechSalesHullDashboardWithJoin(input: {
- search?: string;
- filters?: Filter<typeof techSalesRfqs>[];
- sort?: { id: string; desc: boolean }[];
- page: number;
- perPage: number;
-}) {
- return getTechSalesDashboardWithJoin({ ...input, rfqType: "HULL" });
-}
-
-/**
+/**
* 기술영업 RFQ의 아이템 목록 조회
*/
export async function getTechSalesRfqItems(rfqId: number) {
@@ -2700,53 +2654,6 @@ export async function getTechSalesRfqCandidateVendors(rfqId: number) {
}
/**
- * 기술영업 RFQ에 벤더 추가 (techVendors 기반)
- */
-export async function addTechVendorToTechSalesRfq(input: {
- rfqId: number;
- vendorId: number;
- createdBy: number;
-}) {
- unstable_noStore();
-
- try {
- return await db.transaction(async (tx) => {
- // 벤더가 이미 추가되어 있는지 확인
- const existingQuotation = await tx.query.techSalesVendorQuotations.findFirst({
- where: and(
- eq(techSalesVendorQuotations.rfqId, input.rfqId),
- eq(techSalesVendorQuotations.vendorId, input.vendorId)
- )
- });
-
- if (existingQuotation) {
- return { data: null, error: "이미 추가된 벤더입니다." };
- }
-
- // 새로운 견적서 레코드 생성
- const [quotation] = await tx
- .insert(techSalesVendorQuotations)
- .values({
- rfqId: input.rfqId,
- vendorId: input.vendorId,
- status: "Draft",
- createdBy: input.createdBy,
- updatedBy: input.createdBy,
- })
- .returning({ id: techSalesVendorQuotations.id });
-
- // 캐시 무효화
- revalidateTag("techSalesRfqs");
-
- return { data: quotation, error: null };
- });
- } catch (err) {
- console.error("Error adding tech vendor to RFQ:", err);
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
-/**
* RFQ 타입에 따른 캐시 무효화 경로 반환
*/
function getTechSalesRevalidationPath(rfqType: "SHIP" | "TOP" | "HULL"): string {
@@ -2764,6 +2671,7 @@ function getTechSalesRevalidationPath(rfqType: "SHIP" | "TOP" | "HULL"): string
/**
* 기술영업 RFQ에 여러 벤더 추가 (techVendors 기반)
+ * 벤더 추가 시에는 견적서를 생성하지 않고, RFQ 전송 시에 견적서를 생성
*/
export async function addTechVendorsToTechSalesRfq(input: {
rfqId: number;
@@ -2783,7 +2691,7 @@ export async function addTechVendorsToTechSalesRfq(input: {
columns: {
id: true,
status: true,
- rfqType: true
+ rfqType: true,
}
});
@@ -2791,10 +2699,10 @@ export async function addTechVendorsToTechSalesRfq(input: {
throw new Error("RFQ를 찾을 수 없습니다");
}
- // 2. 각 벤더에 대해 처리
+ // 2. 각 벤더에 대해 처리 (이미 추가된 벤더는 견적서가 있는지 확인)
for (const vendorId of input.vendorIds) {
try {
- // 벤더가 이미 추가되어 있는지 확인
+ // 이미 추가된 벤더인지 확인 (견적서 존재 여부로 확인)
const existingQuotation = await tx.query.techSalesVendorQuotations.findFirst({
where: and(
eq(techSalesVendorQuotations.rfqId, input.rfqId),
@@ -2807,19 +2715,30 @@ export async function addTechVendorsToTechSalesRfq(input: {
continue;
}
- // 새로운 견적서 레코드 생성
+ // 벤더가 실제로 존재하는지 확인
+ const vendor = await tx.query.techVendors.findFirst({
+ where: eq(techVendors.id, vendorId),
+ columns: { id: true, vendorName: true }
+ });
+
+ if (!vendor) {
+ errors.push(`벤더 ID ${vendorId}를 찾을 수 없습니다.`);
+ continue;
+ }
+
+ // 🔥 중요: 벤더 추가 시에는 견적서를 생성하지 않고, "Assigned" 상태로만 생성
const [quotation] = await tx
.insert(techSalesVendorQuotations)
.values({
rfqId: input.rfqId,
vendorId: vendorId,
- status: "Draft",
+ status: "Assigned", // Draft가 아닌 Assigned 상태로 생성
createdBy: input.createdBy,
updatedBy: input.createdBy,
})
.returning({ id: techSalesVendorQuotations.id });
-
- results.push(quotation);
+
+ results.push({ id: quotation.id, vendorId, vendorName: vendor.vendorName });
} catch (vendorError) {
console.error(`Error adding vendor ${vendorId}:`, vendorError);
errors.push(`벤더 ID ${vendorId} 추가 중 오류가 발생했습니다.`);
@@ -2843,11 +2762,6 @@ export async function addTechVendorsToTechSalesRfq(input: {
revalidateTag(`techSalesRfq-${input.rfqId}`);
revalidatePath(getTechSalesRevalidationPath(rfq.rfqType || "SHIP"));
- // 벤더별 캐시도 무효화
- for (const vendorId of input.vendorIds) {
- revalidateTag(`vendor-${vendorId}-quotations`);
- }
-
return {
data: results,
error: errors.length > 0 ? errors.join(", ") : null,
@@ -2921,9 +2835,9 @@ export async function removeTechVendorFromTechSalesRfq(input: {
return { data: null, error: "해당 벤더가 이 RFQ에 존재하지 않습니다." };
}
- // Draft 상태가 아닌 경우 삭제 불가
- if (existingQuotation.status !== "Draft") {
- return { data: null, error: "Draft 상태의 벤더만 삭제할 수 있습니다." };
+ // Assigned 상태가 아닌 경우 삭제 불가
+ if (existingQuotation.status !== "Assigned") {
+ return { data: null, error: "Assigned 상태의 벤더만 삭제할 수 있습니다." };
}
// 해당 벤더의 견적서 삭제
@@ -2977,9 +2891,9 @@ export async function removeTechVendorsFromTechSalesRfq(input: {
continue;
}
- // Draft 상태가 아닌 경우 삭제 불가
- if (existingQuotation.status !== "Draft") {
- errors.push(`벤더 ID ${vendorId}는 Draft 상태가 아니므로 삭제할 수 없습니다.`);
+ // Assigned 상태가 아닌 경우 삭제 불가
+ if (existingQuotation.status !== "Assigned") {
+ errors.push(`벤더 ID ${vendorId}는 Assigned 상태가 아니므로 삭제할 수 없습니다.`);
continue;
}
@@ -3060,6 +2974,242 @@ export async function searchTechVendors(searchTerm: string, limit = 100, rfqType
}
}
+
+/**
+ * 벤더 견적서 거절 처리 (벤더가 직접 거절)
+ */
+export async function rejectTechSalesVendorQuotations(input: {
+ quotationIds: number[];
+ rejectionReason?: string;
+}) {
+ try {
+ const session = await getServerSession(authOptions);
+ if (!session?.user?.id) {
+ throw new Error("인증이 필요합니다.");
+ }
+
+ const result = await db.transaction(async (tx) => {
+ // 견적서들이 존재하고 벤더가 권한이 있는지 확인
+ const quotations = await tx
+ .select({
+ id: techSalesVendorQuotations.id,
+ status: techSalesVendorQuotations.status,
+ vendorId: techSalesVendorQuotations.vendorId,
+ })
+ .from(techSalesVendorQuotations)
+ .where(inArray(techSalesVendorQuotations.id, input.quotationIds));
+
+ if (quotations.length !== input.quotationIds.length) {
+ throw new Error("일부 견적서를 찾을 수 없습니다.");
+ }
+
+ // 이미 거절된 견적서가 있는지 확인
+ const alreadyRejected = quotations.filter(q => q.status === "Rejected");
+ if (alreadyRejected.length > 0) {
+ throw new Error("이미 거절된 견적서가 포함되어 있습니다.");
+ }
+
+ // 승인된 견적서가 있는지 확인
+ const alreadyAccepted = quotations.filter(q => q.status === "Accepted");
+ if (alreadyAccepted.length > 0) {
+ throw new Error("이미 승인된 견적서는 거절할 수 없습니다.");
+ }
+
+ // 견적서 상태를 거절로 변경
+ await tx
+ .update(techSalesVendorQuotations)
+ .set({
+ status: "Rejected",
+ rejectionReason: input.rejectionReason || null,
+ updatedBy: parseInt(session.user.id),
+ updatedAt: new Date(),
+ })
+ .where(inArray(techSalesVendorQuotations.id, input.quotationIds));
+
+ return { success: true, updatedCount: quotations.length };
+ });
+ revalidateTag("techSalesRfqs");
+ revalidateTag("techSalesVendorQuotations");
+ revalidatePath("/partners/techsales/rfq-ship", "page");
+ return {
+ success: true,
+ message: `${result.updatedCount}개의 견적서가 거절되었습니다.`,
+ data: result
+ };
+ } catch (error) {
+ console.error("견적서 거절 오류:", error);
+ return {
+ success: false,
+ error: getErrorMessage(error)
+ };
+ }
+}
+
+// ==================== Revision 관련 ====================
+
+/**
+ * 견적서 revision 히스토리 조회
+ */
+export async function getTechSalesVendorQuotationRevisions(quotationId: number) {
+ try {
+ const revisions = await db
+ .select({
+ id: techSalesVendorQuotationRevisions.id,
+ version: techSalesVendorQuotationRevisions.version,
+ snapshot: techSalesVendorQuotationRevisions.snapshot,
+ changeReason: techSalesVendorQuotationRevisions.changeReason,
+ revisionNote: techSalesVendorQuotationRevisions.revisionNote,
+ revisedBy: techSalesVendorQuotationRevisions.revisedBy,
+ revisedAt: techSalesVendorQuotationRevisions.revisedAt,
+ // 수정자 정보 조인
+ revisedByName: users.name,
+ })
+ .from(techSalesVendorQuotationRevisions)
+ .leftJoin(users, eq(techSalesVendorQuotationRevisions.revisedBy, users.id))
+ .where(eq(techSalesVendorQuotationRevisions.quotationId, quotationId))
+ .orderBy(desc(techSalesVendorQuotationRevisions.version));
+
+ return { data: revisions, error: null };
+ } catch (error) {
+ console.error("견적서 revision 히스토리 조회 오류:", error);
+ return { data: null, error: "견적서 히스토리를 조회하는 중 오류가 발생했습니다." };
+ }
+}
+
+/**
+ * 견적서의 현재 버전과 revision 히스토리를 함께 조회 (각 리비전의 첨부파일 포함)
+ */
+export async function getTechSalesVendorQuotationWithRevisions(quotationId: number) {
+ try {
+ // 먼저 현재 견적서 조회
+ const currentQuotation = await db.query.techSalesVendorQuotations.findFirst({
+ where: eq(techSalesVendorQuotations.id, quotationId),
+ with: {
+ // 벤더 정보와 RFQ 정보도 함께 조회 (필요한 경우)
+ }
+ });
+
+ if (!currentQuotation) {
+ return { data: null, error: "견적서를 찾을 수 없습니다." };
+ }
+
+ // 이제 현재 견적서의 정보를 알고 있으므로 병렬로 나머지 정보 조회
+ const [revisionsResult, currentAttachments] = await Promise.all([
+ getTechSalesVendorQuotationRevisions(quotationId),
+ getTechSalesVendorQuotationAttachmentsByRevision(quotationId, currentQuotation.quotationVersion || 0)
+ ]);
+
+ // 현재 견적서에 첨부파일 정보 추가
+ const currentWithAttachments = {
+ ...currentQuotation,
+ attachments: currentAttachments.data || []
+ };
+
+ // 각 리비전의 첨부파일 정보 추가
+ const revisionsWithAttachments = await Promise.all(
+ (revisionsResult.data || []).map(async (revision) => {
+ const attachmentsResult = await getTechSalesVendorQuotationAttachmentsByRevision(quotationId, revision.version);
+ return {
+ ...revision,
+ attachments: attachmentsResult.data || []
+ };
+ })
+ );
+
+ return {
+ data: {
+ current: currentWithAttachments,
+ revisions: revisionsWithAttachments
+ },
+ error: null
+ };
+ } catch (error) {
+ console.error("견적서 전체 히스토리 조회 오류:", error);
+ return { data: null, error: "견적서 정보를 조회하는 중 오류가 발생했습니다." };
+ }
+}
+
+/**
+ * 견적서 첨부파일 조회 (리비전 ID 기준 오름차순 정렬)
+ */
+export async function getTechSalesVendorQuotationAttachments(quotationId: number) {
+ return unstable_cache(
+ async () => {
+ try {
+ const attachments = await db
+ .select({
+ id: techSalesVendorQuotationAttachments.id,
+ quotationId: techSalesVendorQuotationAttachments.quotationId,
+ revisionId: techSalesVendorQuotationAttachments.revisionId,
+ fileName: techSalesVendorQuotationAttachments.fileName,
+ originalFileName: techSalesVendorQuotationAttachments.originalFileName,
+ fileSize: techSalesVendorQuotationAttachments.fileSize,
+ fileType: techSalesVendorQuotationAttachments.fileType,
+ filePath: techSalesVendorQuotationAttachments.filePath,
+ description: techSalesVendorQuotationAttachments.description,
+ uploadedBy: techSalesVendorQuotationAttachments.uploadedBy,
+ vendorId: techSalesVendorQuotationAttachments.vendorId,
+ isVendorUpload: techSalesVendorQuotationAttachments.isVendorUpload,
+ createdAt: techSalesVendorQuotationAttachments.createdAt,
+ updatedAt: techSalesVendorQuotationAttachments.updatedAt,
+ })
+ .from(techSalesVendorQuotationAttachments)
+ .where(eq(techSalesVendorQuotationAttachments.quotationId, quotationId))
+ .orderBy(desc(techSalesVendorQuotationAttachments.createdAt));
+
+ return { data: attachments };
+ } catch (error) {
+ console.error("견적서 첨부파일 조회 오류:", error);
+ return { error: "견적서 첨부파일 조회 중 오류가 발생했습니다." };
+ }
+ },
+ [`quotation-attachments-${quotationId}`],
+ {
+ revalidate: 60,
+ tags: [`quotation-${quotationId}`, "quotation-attachments"],
+ }
+ )();
+}
+
+/**
+ * 특정 리비전의 견적서 첨부파일 조회
+ */
+export async function getTechSalesVendorQuotationAttachmentsByRevision(quotationId: number, revisionId: number) {
+ try {
+ const attachments = await db
+ .select({
+ id: techSalesVendorQuotationAttachments.id,
+ quotationId: techSalesVendorQuotationAttachments.quotationId,
+ revisionId: techSalesVendorQuotationAttachments.revisionId,
+ fileName: techSalesVendorQuotationAttachments.fileName,
+ originalFileName: techSalesVendorQuotationAttachments.originalFileName,
+ fileSize: techSalesVendorQuotationAttachments.fileSize,
+ fileType: techSalesVendorQuotationAttachments.fileType,
+ filePath: techSalesVendorQuotationAttachments.filePath,
+ description: techSalesVendorQuotationAttachments.description,
+ uploadedBy: techSalesVendorQuotationAttachments.uploadedBy,
+ vendorId: techSalesVendorQuotationAttachments.vendorId,
+ isVendorUpload: techSalesVendorQuotationAttachments.isVendorUpload,
+ createdAt: techSalesVendorQuotationAttachments.createdAt,
+ updatedAt: techSalesVendorQuotationAttachments.updatedAt,
+ })
+ .from(techSalesVendorQuotationAttachments)
+ .where(and(
+ eq(techSalesVendorQuotationAttachments.quotationId, quotationId),
+ eq(techSalesVendorQuotationAttachments.revisionId, revisionId)
+ ))
+ .orderBy(desc(techSalesVendorQuotationAttachments.createdAt));
+
+ return { data: attachments };
+ } catch (error) {
+ console.error("리비전별 견적서 첨부파일 조회 오류:", error);
+ return { error: "첨부파일 조회 중 오류가 발생했습니다." };
+ }
+}
+
+
+// ==================== Project AVL 관련 ====================
+
/**
* Accepted 상태의 Tech Sales Vendor Quotations 조회 (RFQ, Vendor 정보 포함)
*/
@@ -3076,9 +3226,10 @@ export async function getAcceptedTechSalesVendorQuotations(input: {
try {
const offset = (input.page - 1) * input.perPage;
- // 기본 WHERE 조건: status = 'Accepted'만 조회
+ // 기본 WHERE 조건: status = 'Accepted'만 조회, rfqType이 'SHIP'이 아닌 것만
const baseConditions = [
- eq(techSalesVendorQuotations.status, 'Accepted')
+ eq(techSalesVendorQuotations.status, 'Accepted'),
+ sql`${techSalesRfqs.rfqType} != 'SHIP'` // 조선 RFQ 타입 제외
];
// 검색 조건 추가
@@ -3126,10 +3277,10 @@ export async function getAcceptedTechSalesVendorQuotations(input: {
// 필터 조건 추가
const filterConditions = [];
if (input.filters?.length) {
- const { filterWhere, joinOperator } = filterColumns({
+ const filterWhere = filterColumns({
table: techSalesVendorQuotations,
filters: input.filters,
- joinOperator: input.joinOperator ?? "and",
+ joinOperator: "and",
});
if (filterWhere) {
filterConditions.push(filterWhere);
@@ -3221,74 +3372,4 @@ export async function getAcceptedTechSalesVendorQuotations(input: {
console.error("getAcceptedTechSalesVendorQuotations 오류:", error);
throw new Error(`Accepted quotations 조회 실패: ${getErrorMessage(error)}`);
}
-}
-
-/**
- * 벤더 견적서 거절 처리 (벤더가 직접 거절)
- */
-export async function rejectTechSalesVendorQuotations(input: {
- quotationIds: number[];
- rejectionReason?: string;
-}) {
- try {
- const session = await getServerSession(authOptions);
- if (!session?.user?.id) {
- throw new Error("인증이 필요합니다.");
- }
-
- const result = await db.transaction(async (tx) => {
- // 견적서들이 존재하고 벤더가 권한이 있는지 확인
- const quotations = await tx
- .select({
- id: techSalesVendorQuotations.id,
- status: techSalesVendorQuotations.status,
- vendorId: techSalesVendorQuotations.vendorId,
- })
- .from(techSalesVendorQuotations)
- .where(inArray(techSalesVendorQuotations.id, input.quotationIds));
-
- if (quotations.length !== input.quotationIds.length) {
- throw new Error("일부 견적서를 찾을 수 없습니다.");
- }
-
- // 이미 거절된 견적서가 있는지 확인
- const alreadyRejected = quotations.filter(q => q.status === "Rejected");
- if (alreadyRejected.length > 0) {
- throw new Error("이미 거절된 견적서가 포함되어 있습니다.");
- }
-
- // 승인된 견적서가 있는지 확인
- const alreadyAccepted = quotations.filter(q => q.status === "Accepted");
- if (alreadyAccepted.length > 0) {
- throw new Error("이미 승인된 견적서는 거절할 수 없습니다.");
- }
-
- // 견적서 상태를 거절로 변경
- await tx
- .update(techSalesVendorQuotations)
- .set({
- status: "Rejected",
- rejectionReason: input.rejectionReason || null,
- updatedBy: parseInt(session.user.id),
- updatedAt: new Date(),
- })
- .where(inArray(techSalesVendorQuotations.id, input.quotationIds));
-
- return { success: true, updatedCount: quotations.length };
- });
- revalidateTag("techSalesRfqs");
- revalidateTag("techSalesVendorQuotations");
- revalidatePath("/partners/techsales/rfq-ship", "page");
- return {
- success: true,
- message: `${result.updatedCount}개의 견적서가 거절되었습니다.`,
- data: result
- };
- } catch (error) {
- console.error("견적서 거절 오류:", error);
- return {
- success: false,
- error: getErrorMessage(error)
- };
- }
} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/create-rfq-hull-dialog.tsx b/lib/techsales-rfq/table/create-rfq-hull-dialog.tsx
index 4ba98cc7..7bbbfa75 100644
--- a/lib/techsales-rfq/table/create-rfq-hull-dialog.tsx
+++ b/lib/techsales-rfq/table/create-rfq-hull-dialog.tsx
@@ -362,18 +362,16 @@ export function CreateHullRfqDialog({ onCreated }: CreateHullRfqDialogProps) {
)}
/>
- <Separator className="my-4" />
-
{/* RFQ 설명 */}
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
- <FormLabel>RFQ 설명</FormLabel>
+ <FormLabel>RFQ Title</FormLabel>
<FormControl>
<Input
- placeholder="RFQ 설명을 입력하세요 (선택사항)"
+ placeholder="RFQ Title을 입력하세요 (선택사항)"
{...field}
/>
</FormControl>
@@ -381,9 +379,7 @@ export function CreateHullRfqDialog({ onCreated }: CreateHullRfqDialogProps) {
</FormItem>
)}
/>
-
<Separator className="my-4" />
-
{/* 마감일 설정 */}
<FormField
control={form.control}
diff --git a/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx b/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx
index 8a66f26e..b616f526 100644
--- a/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx
+++ b/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx
@@ -385,10 +385,10 @@ export function CreateShipRfqDialog({ onCreated }: CreateShipRfqDialogProps) {
name="description"
render={({ field }) => (
<FormItem>
- <FormLabel>RFQ 설명</FormLabel>
+ <FormLabel>RFQ Title</FormLabel>
<FormControl>
<Input
- placeholder="RFQ 설명을 입력하세요 (선택사항)"
+ placeholder="RFQ Title을 입력하세요 (선택사항)"
{...field}
/>
</FormControl>
diff --git a/lib/techsales-rfq/table/create-rfq-top-dialog.tsx b/lib/techsales-rfq/table/create-rfq-top-dialog.tsx
index 70f56ebd..6536e230 100644
--- a/lib/techsales-rfq/table/create-rfq-top-dialog.tsx
+++ b/lib/techsales-rfq/table/create-rfq-top-dialog.tsx
@@ -3,7 +3,6 @@
import * as React from "react"
import { toast } from "sonner"
import { ArrowUpDown, CheckSquare, Plus, Search, Square, X, Loader2 } from "lucide-react"
-import { Input } from "@/components/ui/input"
import { Calendar } from "@/components/ui/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { CalendarIcon } from "lucide-react"
@@ -43,6 +42,7 @@ import {
} from "@/components/ui/dropdown-menu"
import { cn } from "@/lib/utils"
import { ScrollArea } from "@/components/ui/scroll-area"
+import { Input } from "@/components/ui/input"
// 공종 타입 import
import {
@@ -354,7 +354,24 @@ export function CreateTopRfqDialog({ onCreated }: CreateTopRfqDialogProps) {
/>
<Separator className="my-4" />
-
+ {/* RFQ 설명 */}
+ <FormField
+ control={form.control}
+ name="description"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>RFQ Title</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="RFQ Title을 입력하세요 (선택사항)"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <Separator className="my-4" />
{/* 마감일 설정 */}
<FormField
control={form.control}
diff --git a/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx b/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx
index 3574111f..8f2fe948 100644
--- a/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx
+++ b/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx
@@ -29,6 +29,8 @@ type VendorFormValues = z.infer<typeof vendorFormSchema>
type TechSalesRfq = {
id: number
rfqCode: string | null
+ rfqType: "SHIP" | "TOP" | "HULL" | null
+ ptypeNm: string | null // 프로젝트 타입명 추가
status: string
[key: string]: any // eslint-disable-line @typescript-eslint/no-explicit-any
}
@@ -118,10 +120,8 @@ export function AddVendorDialog({
setIsSearching(true)
try {
// 선택된 RFQ의 타입을 기반으로 벤더 검색
- const rfqType = selectedRfq?.rfqCode?.includes("SHIP") ? "SHIP" :
- selectedRfq?.rfqCode?.includes("TOP") ? "TOP" :
- selectedRfq?.rfqCode?.includes("HULL") ? "HULL" : undefined;
-
+ const rfqType = selectedRfq?.rfqType || undefined;
+ console.log("rfqType", rfqType) // 디버깅용
const results = await searchTechVendors(term, 100, rfqType)
// 이미 추가된 벤더 제외
@@ -136,7 +136,7 @@ export function AddVendorDialog({
setIsSearching(false)
}
},
- [existingVendorIds]
+ [existingVendorIds, selectedRfq?.rfqType]
)
// 검색어 변경 시 디바운스 적용
diff --git a/lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx b/lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx
new file mode 100644
index 00000000..7832fa2b
--- /dev/null
+++ b/lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx
@@ -0,0 +1,312 @@
+"use client"
+
+import * as React from "react"
+import { useState, useEffect } from "react"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Badge } from "@/components/ui/badge"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { Separator } from "@/components/ui/separator"
+import { Skeleton } from "@/components/ui/skeleton"
+import { Clock, User, FileText, AlertCircle, Paperclip } from "lucide-react"
+import { formatDate } from "@/lib/utils"
+import { toast } from "sonner"
+
+interface QuotationAttachment {
+ id: number
+ quotationId: number
+ revisionId: number
+ fileName: string
+ originalFileName: string
+ fileSize: number
+ fileType: string | null
+ filePath: string
+ description: string | null
+ isVendorUpload: boolean
+ createdAt: Date
+ updatedAt: Date
+}
+
+interface QuotationSnapshot {
+ currency: string | null
+ totalPrice: string | null
+ validUntil: Date | null
+ remark: string | null
+ status: string | null
+ quotationVersion: number | null
+ submittedAt: Date | null
+ acceptedAt: Date | null
+ updatedAt: Date | null
+}
+
+interface QuotationRevision {
+ id: number
+ version: number
+ snapshot: QuotationSnapshot
+ changeReason: string | null
+ revisionNote: string | null
+ revisedBy: number | null
+ revisedAt: Date
+ revisedByName: string | null
+ attachments: QuotationAttachment[]
+}
+
+interface QuotationHistoryData {
+ current: {
+ id: number
+ currency: string | null
+ totalPrice: string | null
+ validUntil: Date | null
+ remark: string | null
+ status: string
+ quotationVersion: number | null
+ submittedAt: Date | null
+ acceptedAt: Date | null
+ updatedAt: Date | null
+ attachments: QuotationAttachment[]
+ }
+ revisions: QuotationRevision[]
+}
+
+interface QuotationHistoryDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ quotationId: number | null
+}
+
+const statusConfig = {
+ "Draft": { label: "초안", color: "bg-yellow-100 text-yellow-800" },
+ "Submitted": { label: "제출됨", color: "bg-blue-100 text-blue-800" },
+ "Revised": { label: "수정됨", color: "bg-purple-100 text-purple-800" },
+ "Accepted": { label: "승인됨", color: "bg-green-100 text-green-800" },
+ "Rejected": { label: "거절됨", color: "bg-red-100 text-red-800" },
+}
+
+function QuotationCard({
+ data,
+ version,
+ isCurrent = false,
+ changeReason,
+ revisedBy,
+ revisedAt,
+ attachments
+}: {
+ data: QuotationSnapshot | QuotationHistoryData["current"]
+ version: number
+ isCurrent?: boolean
+ changeReason?: string | null
+ revisedBy?: string | null
+ revisedAt?: Date
+ attachments?: QuotationAttachment[]
+}) {
+ const statusInfo = statusConfig[data.status as keyof typeof statusConfig] ||
+ { label: data.status || "알 수 없음", color: "bg-gray-100 text-gray-800" }
+
+ return (
+ <Card className={`${isCurrent ? "border-blue-500 shadow-md" : "border-gray-200"}`}>
+ <CardHeader className="pb-3">
+ <div className="flex items-center justify-between">
+ <CardTitle className="text-lg flex items-center gap-2">
+ <span>버전 {version}</span>
+ {isCurrent && <Badge variant="default">현재</Badge>}
+ </CardTitle>
+ <Badge className={statusInfo.color}>
+ {statusInfo.label}
+ </Badge>
+ </div>
+ {changeReason && (
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <FileText className="size-4" />
+ <span>{changeReason}</span>
+ </div>
+ )}
+ </CardHeader>
+ <CardContent className="space-y-3">
+ <div className="grid grid-cols-2 gap-4">
+ <div>
+ <p className="text-sm font-medium text-muted-foreground">견적 금액</p>
+ <p className="text-lg font-semibold">
+ {data.totalPrice ? `${data.currency} ${Number(data.totalPrice).toLocaleString()}` : "미입력"}
+ </p>
+ </div>
+ <div>
+ <p className="text-sm font-medium text-muted-foreground">유효 기한</p>
+ <p className="text-sm">
+ {data.validUntil ? formatDate(data.validUntil) : "미설정"}
+ </p>
+ </div>
+ </div>
+
+ {data.remark && (
+ <div>
+ <p className="text-sm font-medium text-muted-foreground">비고</p>
+ <p className="text-sm bg-gray-50 p-2 rounded">{data.remark}</p>
+ </div>
+ )}
+
+ {/* 첨부파일 섹션 */}
+ {attachments && attachments.length > 0 && (
+ <div>
+ <p className="text-sm font-medium text-muted-foreground mb-2 flex items-center gap-1">
+ <Paperclip className="size-3" />
+ 첨부파일 ({attachments.length}개)
+ </p>
+ <div className="space-y-1">
+ {attachments.map((attachment) => (
+ <div key={attachment.id} className="flex items-center justify-between p-2 bg-gray-50 rounded text-xs">
+ <div className="flex items-center gap-2 min-w-0 flex-1">
+ <div className="min-w-0 flex-1">
+ <p className="font-medium truncate" title={attachment.originalFileName}>
+ {attachment.originalFileName}
+ </p>
+ {attachment.description && (
+ <p className="text-muted-foreground truncate" title={attachment.description}>
+ {attachment.description}
+ </p>
+ )}
+ </div>
+ </div>
+ <div className="text-muted-foreground whitespace-nowrap ml-2">
+ {(attachment.fileSize / 1024 / 1024).toFixed(2)} MB
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ )}
+
+ <Separator />
+
+ <div className="flex items-center justify-between text-xs text-muted-foreground">
+ <div className="flex items-center gap-1">
+ <Clock className="size-3" />
+ <span>
+ {isCurrent
+ ? `수정: ${data.updatedAt ? formatDate(data.updatedAt) : "N/A"}`
+ : `변경: ${revisedAt ? formatDate(revisedAt) : "N/A"}`
+ }
+ </span>
+ </div>
+ {revisedBy && (
+ <div className="flex items-center gap-1">
+ <User className="size-3" />
+ <span>{revisedBy}</span>
+ </div>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+ )
+}
+
+export function QuotationHistoryDialog({
+ open,
+ onOpenChange,
+ quotationId
+}: QuotationHistoryDialogProps) {
+ const [data, setData] = useState<QuotationHistoryData | null>(null)
+ const [isLoading, setIsLoading] = useState(false)
+
+ useEffect(() => {
+ if (open && quotationId) {
+ loadQuotationHistory()
+ }
+ }, [open, quotationId])
+
+ const loadQuotationHistory = async () => {
+ if (!quotationId) return
+
+ try {
+ setIsLoading(true)
+ const { getTechSalesVendorQuotationWithRevisions } = await import("@/lib/techsales-rfq/service")
+
+ const result = await getTechSalesVendorQuotationWithRevisions(quotationId)
+
+ if (result.error) {
+ toast.error(result.error)
+ return
+ }
+
+ setData(result.data as QuotationHistoryData)
+ } catch (error) {
+ console.error("견적 히스토리 로드 오류:", error)
+ toast.error("견적 히스토리를 불러오는 중 오류가 발생했습니다")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ const handleOpenChange = (newOpen: boolean) => {
+ onOpenChange(newOpen)
+ if (!newOpen) {
+ setData(null) // 다이얼로그 닫을 때 데이터 초기화
+ }
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={handleOpenChange}>
+ <DialogContent className=" max-h-[80vh] overflow-y-auto">
+ <DialogHeader>
+ <DialogTitle>견적서 수정 히스토리</DialogTitle>
+ <DialogDescription>
+ 견적서의 변경 이력을 확인할 수 있습니다. 최신 버전부터 순서대로 표시됩니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ {isLoading ? (
+ <div className="space-y-4">
+ {[1, 2, 3].map((i) => (
+ <div key={i} className="space-y-3">
+ <Skeleton className="h-6 w-32" />
+ <Skeleton className="h-32 w-full" />
+ </div>
+ ))}
+ </div>
+ ) : data ? (
+ <>
+ {/* 현재 버전 */}
+ <QuotationCard
+ data={data.current}
+ version={data.current.quotationVersion || 1}
+ isCurrent={true}
+ attachments={data.current.attachments}
+ />
+
+ {/* 이전 버전들 */}
+ {data.revisions.length > 0 ? (
+ data.revisions.map((revision) => (
+ <QuotationCard
+ key={revision.id}
+ data={revision.snapshot}
+ version={revision.version}
+ changeReason={revision.changeReason}
+ revisedBy={revision.revisedByName}
+ revisedAt={revision.revisedAt}
+ attachments={revision.attachments}
+ />
+ ))
+ ) : (
+ <div className="text-center py-8 text-muted-foreground">
+ <AlertCircle className="size-12 mx-auto mb-2 opacity-50" />
+ <p>수정 이력이 없습니다.</p>
+ <p className="text-sm">이 견적서는 아직 수정되지 않았습니다.</p>
+ </div>
+ )}
+ </>
+ ) : (
+ <div className="text-center py-8 text-muted-foreground">
+ <AlertCircle className="size-12 mx-auto mb-2 opacity-50" />
+ <p>견적서 정보를 불러올 수 없습니다.</p>
+ </div>
+ )}
+ </div>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx
index 3e50a516..e921fcaa 100644
--- a/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx
+++ b/lib/techsales-rfq/table/detail-table/rfq-detail-column.tsx
@@ -5,7 +5,7 @@ import type { ColumnDef, Row } from "@tanstack/react-table";
import { formatDate } from "@/lib/utils"
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header"
import { Checkbox } from "@/components/ui/checkbox";
-import { MessageCircle, MoreHorizontal, Trash2 } from "lucide-react";
+import { MessageCircle, MoreHorizontal, Trash2, Paperclip } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import {
@@ -38,6 +38,24 @@ export interface RfqDetailView {
createdAt: Date | null
updatedAt: Date | null
createdByName: string | null
+ quotationCode?: string | null
+ rfqCode?: string | null
+ quotationAttachments?: Array<{
+ id: number
+ revisionId: number
+ fileName: string
+ fileSize: number
+ filePath: string
+ description?: string | null
+ }>
+}
+
+// 견적서 정보 타입 (Sheet용)
+export interface QuotationInfo {
+ id: number
+ quotationCode: string | null
+ vendorName?: string
+ rfqCode?: string
}
interface GetColumnsProps<TData> {
@@ -45,11 +63,15 @@ interface GetColumnsProps<TData> {
React.SetStateAction<DataTableRowAction<TData> | null>
>;
unreadMessages?: Record<number, number>; // 읽지 않은 메시지 개수
+ onQuotationClick?: (quotationId: number) => void; // 견적 클릭 핸들러
+ openQuotationAttachmentsSheet?: (quotationId: number, quotationInfo: QuotationInfo) => void; // 견적서 첨부파일 sheet 열기
}
export function getRfqDetailColumns({
setRowAction,
- unreadMessages = {}
+ unreadMessages = {},
+ onQuotationClick,
+ openQuotationAttachmentsSheet
}: GetColumnsProps<RfqDetailView>): ColumnDef<RfqDetailView>[] {
return [
{
@@ -66,15 +88,15 @@ export function getRfqDetailColumns({
),
cell: ({ row }) => {
const status = row.original.status;
- const isDraft = status === "Draft";
+ const isSelectable = status ? !["Accepted", "Rejected"].includes(status) : true;
return (
<Checkbox
checked={row.getIsSelected()}
onCheckedChange={(value) => row.toggleSelected(!!value)}
- disabled={!isDraft}
+ disabled={!isSelectable}
aria-label="행 선택"
- className={!isDraft ? "opacity-50 cursor-not-allowed" : ""}
+ className={!isSelectable ? "opacity-50 cursor-not-allowed" : ""}
/>
);
},
@@ -163,15 +185,31 @@ export function getRfqDetailColumns({
cell: ({ row }) => {
const value = row.getValue("totalPrice") as string | number | null;
const currency = row.getValue("currency") as string | null;
+ const quotationId = row.original.id;
if (value === null || value === undefined) return "-";
// 숫자로 변환 시도
const numValue = typeof value === 'string' ? parseFloat(value) : value;
+ const displayValue = isNaN(numValue) ? value : numValue.toLocaleString();
+
+ // 견적값이 있고 클릭 핸들러가 있는 경우 클릭 가능한 버튼으로 표시
+ if (onQuotationClick && quotationId) {
+ return (
+ <Button
+ variant="link"
+ className="p-0 h-auto font-medium text-left justify-start hover:underline"
+ onClick={() => onQuotationClick(quotationId)}
+ title="견적 히스토리 보기"
+ >
+ {displayValue} {currency}
+ </Button>
+ );
+ }
return (
<div className="font-medium">
- {isNaN(numValue) ? value : numValue.toLocaleString()} {currency}
+ {displayValue} {currency}
</div>
);
},
@@ -182,6 +220,57 @@ export function getRfqDetailColumns({
size: 140,
},
{
+ accessorKey: "quotationAttachments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="첨부파일" />
+ ),
+ cell: ({ row }) => {
+ const attachments = row.original.quotationAttachments || [];
+ const attachmentCount = attachments.length;
+
+ if (attachmentCount === 0) {
+ return <div className="text-muted-foreground">-</div>;
+ }
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={() => {
+ // 견적서 첨부파일 sheet 열기
+ if (openQuotationAttachmentsSheet) {
+ const quotation = row.original;
+ openQuotationAttachmentsSheet(quotation.id, {
+ id: quotation.id,
+ quotationCode: quotation.quotationCode || null,
+ vendorName: quotation.vendorName || undefined,
+ rfqCode: quotation.rfqCode || undefined,
+ });
+ }
+ }}
+ title={
+ attachmentCount === 1
+ ? `${attachments[0].fileName} (${(attachments[0].fileSize / 1024 / 1024).toFixed(2)} MB)`
+ : `${attachmentCount}개의 첨부파일:\n${attachments.map(att => att.fileName).join('\n')}`
+ }
+ >
+ <Paperclip className="h-4 w-4 text-muted-foreground group-hover:text-primary transition-colors" />
+ {attachmentCount > 0 && (
+ <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-4 min-w-[1rem] items-center justify-center rounded-full bg-primary px-1 text-[0.625rem] font-medium leading-none text-primary-foreground">
+ {attachmentCount}
+ </span>
+ )}
+ </Button>
+ );
+ },
+ meta: {
+ excelHeader: "첨부파일"
+ },
+ enableResizing: false,
+ size: 80,
+ },
+ {
accessorKey: "currency",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="통화" />
diff --git a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
index f2eda8d9..1d701bd5 100644
--- a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
+++ b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
@@ -12,12 +12,14 @@ import { toast } from "sonner"
import { Skeleton } from "@/components/ui/skeleton"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
-import { Loader2, UserPlus, BarChart2, Send, Trash2 } from "lucide-react"
+import { Loader2, UserPlus, Send, Trash2, CheckCircle } from "lucide-react"
import { ClientDataTable } from "@/components/client-data-table/data-table"
import { AddVendorDialog } from "./add-vendor-dialog"
import { VendorCommunicationDrawer } from "./vendor-communication-drawer"
-import { VendorQuotationComparisonDialog } from "./vendor-quotation-comparison-dialog"
import { DeleteVendorsDialog } from "../delete-vendors-dialog"
+import { QuotationHistoryDialog } from "@/lib/techsales-rfq/table/detail-table/quotation-history-dialog"
+import { TechSalesQuotationAttachmentsSheet, type QuotationAttachment } from "../tech-sales-quotation-attachments-sheet"
+import type { QuotationInfo } from "./rfq-detail-column"
// 기본적인 RFQ 타입 정의
interface TechSalesRfq {
@@ -30,6 +32,8 @@ interface TechSalesRfq {
rfqSendDate?: Date | null
dueDate?: Date | null
createdByName?: string | null
+ rfqType: "SHIP" | "TOP" | "HULL" | null
+ ptypeNm?: string | null
}
// 프로퍼티 정의
@@ -58,9 +62,6 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
// 읽지 않은 메시지 개수
const [unreadMessages, setUnreadMessages] = useState<Record<number, number>>({})
- // 견적 비교 다이얼로그 상태 관리
- const [comparisonDialogOpen, setComparisonDialogOpen] = useState(false)
-
// 테이블 선택 상태 관리
const [selectedRows, setSelectedRows] = useState<RfqDetailView[]>([])
const [isSendingRfq, setIsSendingRfq] = useState(false)
@@ -69,6 +70,16 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
// 벤더 삭제 확인 다이얼로그 상태 추가
const [deleteConfirmDialogOpen, setDeleteConfirmDialogOpen] = useState(false)
+ // 견적 히스토리 다이얼로그 상태 관리
+ const [historyDialogOpen, setHistoryDialogOpen] = useState(false)
+ const [selectedQuotationId, setSelectedQuotationId] = useState<number | null>(null)
+
+ // 견적서 첨부파일 sheet 상태 관리
+ const [quotationAttachmentsSheetOpen, setQuotationAttachmentsSheetOpen] = useState(false)
+ const [selectedQuotationInfo, setSelectedQuotationInfo] = useState<QuotationInfo | null>(null)
+ const [quotationAttachments, setQuotationAttachments] = useState<QuotationAttachment[]>([])
+ const [isLoadingAttachments, setIsLoadingAttachments] = useState(false)
+
// selectedRfq ID 메모이제이션 (객체 참조 변경 방지)
const selectedRfqId = useMemo(() => selectedRfq?.id, [selectedRfq?.id])
@@ -108,6 +119,8 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
detailId: item.id,
rfqId: selectedRfqId,
rfqCode: selectedRfq?.rfqCode || null,
+ rfqType: selectedRfq?.rfqType || null,
+ ptypeNm: selectedRfq?.ptypeNm || null,
vendorId: item.vendorId ? Number(item.vendorId) : undefined,
})) || []
@@ -121,7 +134,7 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
console.error("데이터 새로고침 오류:", error)
toast.error("데이터를 새로고침하는 중 오류가 발생했습니다")
}
- }, [selectedRfqId, selectedRfq?.rfqCode, loadUnreadMessages])
+ }, [selectedRfqId, selectedRfq?.rfqCode, selectedRfq?.rfqType, selectedRfq?.ptypeNm, loadUnreadMessages])
// 벤더 추가 핸들러 메모이제이션
const handleAddVendor = useCallback(async () => {
@@ -180,6 +193,54 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
}
}, [selectedRows, selectedRfqId, handleRefreshData]);
+ // 벤더 선택 핸들러 추가
+ const [isAcceptingVendors, setIsAcceptingVendors] = useState(false);
+
+ const handleAcceptVendors = useCallback(async () => {
+ if (selectedRows.length === 0) {
+ toast.warning("선택할 벤더를 선택해주세요.");
+ return;
+ }
+
+ if (selectedRows.length > 1) {
+ toast.warning("하나의 벤더만 선택할 수 있습니다.");
+ return;
+ }
+
+ const selectedQuotation = selectedRows[0];
+ if (selectedQuotation.status !== "Submitted") {
+ toast.warning("제출된 견적서만 선택할 수 있습니다.");
+ return;
+ }
+
+ try {
+ setIsAcceptingVendors(true);
+
+ // 벤더 견적 승인 서비스 함수 호출
+ const { acceptTechSalesVendorQuotationAction } = await import("@/lib/techsales-rfq/actions");
+
+ const result = await acceptTechSalesVendorQuotationAction(selectedQuotation.id);
+
+ if (result.success) {
+ toast.success(result.message || "벤더가 성공적으로 선택되었습니다.");
+ } else {
+ toast.error(result.error || "벤더 선택 중 오류가 발생했습니다.");
+ }
+
+ // 선택 해제
+ setSelectedRows([]);
+
+ // 데이터 새로고침
+ await handleRefreshData();
+
+ } catch (error) {
+ console.error("벤더 선택 오류:", error);
+ toast.error("벤더 선택 중 오류가 발생했습니다.");
+ } finally {
+ setIsAcceptingVendors(false);
+ }
+ }, [selectedRows, handleRefreshData]);
+
// 벤더 삭제 핸들러 메모이제이션
const handleDeleteVendors = useCallback(async () => {
if (selectedRows.length === 0) {
@@ -246,27 +307,47 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
await handleDeleteVendors();
}, [handleDeleteVendors]);
- // 견적 비교 다이얼로그 열기 핸들러 메모이제이션
- const handleOpenComparisonDialog = useCallback(() => {
- // 제출된 견적이 있는 벤더가 최소 1개 이상 있는지 확인
- const hasSubmittedQuotations = details.some(detail =>
- detail.status === "Submitted" // RfqDetailView의 실제 필드 사용
- );
- if (!hasSubmittedQuotations) {
- toast.warning("제출된 견적이 없습니다.");
- return;
- }
+ // 견적 히스토리 다이얼로그 열기 핸들러 메모이제이션
+ const handleOpenHistoryDialog = useCallback((quotationId: number) => {
+ setSelectedQuotationId(quotationId);
+ setHistoryDialogOpen(true);
+ }, [])
- setComparisonDialogOpen(true);
- }, [details])
+ // 견적서 첨부파일 sheet 열기 핸들러 메모이제이션
+ const handleOpenQuotationAttachmentsSheet = useCallback(async (quotationId: number, quotationInfo: QuotationInfo) => {
+ try {
+ setIsLoadingAttachments(true);
+ setSelectedQuotationInfo(quotationInfo);
+ setQuotationAttachmentsSheetOpen(true);
+
+ // 견적서 첨부파일 조회
+ const { getTechSalesVendorQuotationAttachments } = await import("@/lib/techsales-rfq/service");
+ const result = await getTechSalesVendorQuotationAttachments(quotationId);
+
+ if (result.error) {
+ toast.error(result.error);
+ setQuotationAttachments([]);
+ } else {
+ setQuotationAttachments(result.data || []);
+ }
+ } catch (error) {
+ console.error("견적서 첨부파일 조회 오류:", error);
+ toast.error("견적서 첨부파일을 불러오는 중 오류가 발생했습니다.");
+ setQuotationAttachments([]);
+ } finally {
+ setIsLoadingAttachments(false);
+ }
+ }, [])
// 칼럼 정의 - unreadMessages 상태 전달 (메모이제이션)
const columns = useMemo(() =>
getRfqDetailColumns({
setRowAction,
- unreadMessages
- }), [unreadMessages])
+ unreadMessages,
+ onQuotationClick: handleOpenHistoryDialog,
+ openQuotationAttachmentsSheet: handleOpenQuotationAttachmentsSheet
+ }), [unreadMessages, handleOpenHistoryDialog, handleOpenQuotationAttachmentsSheet])
// 필터 필드 정의 (메모이제이션)
const advancedFilterFields = useMemo(
@@ -493,6 +574,22 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
)}
</div>
<div className="flex gap-2">
+ {/* 벤더 선택 버튼 */}
+ <Button
+ variant="default"
+ size="sm"
+ onClick={handleAcceptVendors}
+ disabled={selectedRows.length === 0 || isAcceptingVendors}
+ className="gap-2"
+ >
+ {isAcceptingVendors ? (
+ <Loader2 className="size-4 animate-spin" aria-hidden="true" />
+ ) : (
+ <CheckCircle className="size-4" aria-hidden="true" />
+ )}
+ <span>벤더 선택</span>
+ </Button>
+
{/* RFQ 발송 버튼 */}
<Button
variant="outline"
@@ -525,22 +622,6 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
<span>벤더 삭제</span>
</Button>
- {/* 견적 비교 버튼 */}
- <Button
- variant="outline"
- size="sm"
- onClick={handleOpenComparisonDialog}
- className="gap-2"
- disabled={
- !selectedRfq ||
- details.length === 0 ||
- vendorsWithQuotations === 0
- }
- >
- <BarChart2 className="size-4" aria-hidden="true" />
- <span>견적 비교/선택</span>
- </Button>
-
{/* 벤더 추가 버튼 */}
<Button
variant="outline"
@@ -586,7 +667,7 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
<AddVendorDialog
open={vendorDialogOpen}
onOpenChange={setVendorDialogOpen}
- selectedRfq={selectedRfq}
+ selectedRfq={selectedRfq as unknown as TechSalesRfq}
existingVendorIds={existingVendorIds}
onSuccess={handleRefreshData}
/>
@@ -600,13 +681,6 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
onSuccess={handleRefreshData}
/>
- {/* 견적 비교 다이얼로그 */}
- <VendorQuotationComparisonDialog
- open={comparisonDialogOpen}
- onOpenChange={setComparisonDialogOpen}
- selectedRfq={selectedRfq}
- />
-
{/* 다중 벤더 삭제 확인 다이얼로그 */}
<DeleteVendorsDialog
open={deleteConfirmDialogOpen}
@@ -615,6 +689,22 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
onConfirm={executeDeleteVendors}
isLoading={isDeletingVendors}
/>
+
+ {/* 견적 히스토리 다이얼로그 */}
+ <QuotationHistoryDialog
+ open={historyDialogOpen}
+ onOpenChange={setHistoryDialogOpen}
+ quotationId={selectedQuotationId}
+ />
+
+ {/* 견적서 첨부파일 Sheet */}
+ <TechSalesQuotationAttachmentsSheet
+ open={quotationAttachmentsSheetOpen}
+ onOpenChange={setQuotationAttachmentsSheetOpen}
+ quotation={selectedQuotationInfo}
+ attachments={quotationAttachments}
+ isLoading={isLoadingAttachments}
+ />
</div>
)
} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/detail-table/vendor-quotation-comparison-dialog.tsx b/lib/techsales-rfq/table/detail-table/vendor-quotation-comparison-dialog.tsx
deleted file mode 100644
index 0a6caa5c..00000000
--- a/lib/techsales-rfq/table/detail-table/vendor-quotation-comparison-dialog.tsx
+++ /dev/null
@@ -1,341 +0,0 @@
-"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"
-import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle } from "@/components/ui/alert-dialog"
-
-// Lucide 아이콘
-import { Plus, Minus, CheckCircle, Loader2 } from "lucide-react"
-
-import { getTechSalesVendorQuotationsWithJoin } from "@/lib/techsales-rfq/service"
-import { acceptTechSalesVendorQuotationAction } from "@/lib/techsales-rfq/actions"
-import { formatCurrency, formatDate } from "@/lib/utils"
-import { techSalesVendorQuotations } from "@/db/schema/techSales"
-
-// 기술영업 견적 정보 타입
-interface TechSalesVendorQuotation {
- id: number
- rfqId: number
- vendorId: number
- vendorName?: string | null
- totalPrice: string | null
- currency: string | null
- validUntil: Date | null
- status: string
- remark: string | null
- submittedAt: Date | null
- acceptedAt: Date | null
- createdAt: Date
- updatedAt: Date
-}
-
-interface VendorQuotationComparisonDialogProps {
- open: boolean
- onOpenChange: (open: boolean) => void
- selectedRfq: {
- id: number;
- rfqCode: string | null;
- status: string;
- [key: string]: any; // eslint-disable-line @typescript-eslint/no-explicit-any
- } | null
-}
-
-export function VendorQuotationComparisonDialog({
- open,
- onOpenChange,
- selectedRfq,
-}: VendorQuotationComparisonDialogProps) {
- const [isLoading, setIsLoading] = useState(false)
- const [quotations, setQuotations] = useState<TechSalesVendorQuotation[]>([])
- const [selectedVendorId, setSelectedVendorId] = useState<number | null>(null)
- const [isAccepting, setIsAccepting] = useState(false)
- const [showConfirmDialog, setShowConfirmDialog] = useState(false)
-
- useEffect(() => {
- async function loadQuotationData() {
- if (!open || !selectedRfq?.id) return
-
- try {
- setIsLoading(true)
- // 기술영업 견적 목록 조회 (제출된 견적만)
- const result = await getTechSalesVendorQuotationsWithJoin({
- rfqId: selectedRfq.id,
- page: 1,
- perPage: 100,
- filters: [
- {
- id: "status" as keyof typeof techSalesVendorQuotations,
- value: "Submitted",
- type: "select" as const,
- operator: "eq" as const,
- rowId: "status"
- }
- ]
- })
-
- setQuotations(result.data || [])
- } 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"
- }
- }
-
- // 벤더 선택 핸들러
- const handleSelectVendor = (vendorId: number) => {
- setSelectedVendorId(vendorId)
- setShowConfirmDialog(true)
- }
-
- // 벤더 선택 확정
- const handleConfirmSelection = async () => {
- if (!selectedVendorId) return
-
- try {
- setIsAccepting(true)
-
- // 선택된 견적의 ID 찾기
- const selectedQuotation = quotations.find(q => q.vendorId === selectedVendorId)
- if (!selectedQuotation) {
- toast.error("선택된 견적을 찾을 수 없습니다")
- return
- }
-
- // 벤더 선택 API 호출
- const result = await acceptTechSalesVendorQuotationAction(selectedQuotation.id)
-
- if (result.success) {
- toast.success(result.message || "벤더가 선택되었습니다")
- setShowConfirmDialog(false)
- onOpenChange(false)
-
- // 페이지 새로고침 또는 데이터 재로드
- window.location.reload()
- } else {
- toast.error(result.error || "벤더 선택에 실패했습니다")
- }
- } catch (error) {
- console.error("벤더 선택 오류:", error)
- toast.error("벤더 선택에 실패했습니다")
- } finally {
- setIsAccepting(false)
- }
- }
-
- const selectedVendor = quotations.find(q => q.vendorId === selectedVendorId)
-
- return (
- <>
- <Dialog open={open} onOpenChange={onOpenChange}>
- <DialogContent className="max-w-[90vw] lg:max-w-5xl max-h-[90vh]">
- <DialogHeader>
- <DialogTitle>벤더 견적 비교 및 선택</DialogTitle>
- <DialogDescription>
- {selectedRfq
- ? `RFQ ${selectedRfq.rfqCode} - 제출된 견적을 비교하고 벤더를 선택하세요`
- : ""}
- </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>
- ) : (
- <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 w-32">
- 항목
- </TableHead>
- {quotations.map((q) => (
- <TableHead key={q.id} className="p-2 text-center whitespace-nowrap w-48">
- <div className="flex flex-col items-center gap-2">
- <span>{q.vendorName || `벤더 ID: ${q.vendorId}`}</span>
- <Button
- size="sm"
- variant={q.status === "Accepted" ? "default" : "outline"}
- onClick={() => handleSelectVendor(q.vendorId)}
- disabled={q.status === "Accepted"}
- className="gap-1"
- >
- {q.status === "Accepted" ? (
- <>
- <CheckCircle className="h-4 w-4" />
- 선택됨
- </>
- ) : (
- "선택"
- )}
- </Button>
- </div>
- </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 text-center">
- <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={`total-${q.id}`} className="p-2 font-semibold text-center">
- {q.totalPrice ? formatCurrency(Number(q.totalPrice), q.currency || 'USD') : '-'}
- </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 text-center">
- {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 text-center">
- {q.validUntil ? 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={`submitted-${q.id}`} className="p-2 text-center">
- {q.submittedAt ? 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 text-center"
- >
- {q.remark || "-"}
- </TableCell>
- ))}
- </TableRow>
- </tbody>
- </table>
- </div>
- )}
-
- <DialogFooter>
- <Button variant="outline" onClick={() => onOpenChange(false)}>
- 닫기
- </Button>
- </DialogFooter>
- </DialogContent>
- </Dialog>
-
- {/* 벤더 선택 확인 다이얼로그 */}
- <AlertDialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}>
- <AlertDialogContent>
- <AlertDialogHeader>
- <AlertDialogTitle>벤더 선택 확인</AlertDialogTitle>
- <AlertDialogDescription>
- <strong>{selectedVendor?.vendorName || `벤더 ID: ${selectedVendorId}`}</strong>를 선택하시겠습니까?
- <br />
- <br />
- 선택된 벤더의 견적이 승인되며, 다른 벤더들의 견적은 자동으로 거절됩니다.
- 이 작업은 되돌릴 수 없습니다.
- </AlertDialogDescription>
- </AlertDialogHeader>
- <AlertDialogFooter>
- <AlertDialogCancel disabled={isAccepting}>취소</AlertDialogCancel>
- <AlertDialogAction
- onClick={handleConfirmSelection}
- disabled={isAccepting}
- className="gap-2"
- >
- {isAccepting && <Loader2 className="h-4 w-4 animate-spin" />}
- 확인
- </AlertDialogAction>
- </AlertDialogFooter>
- </AlertDialogContent>
- </AlertDialog>
- </>
- )
-}
diff --git a/lib/techsales-rfq/table/rfq-items-view-dialog.tsx b/lib/techsales-rfq/table/rfq-items-view-dialog.tsx
index 10bc9f1f..289ad312 100644
--- a/lib/techsales-rfq/table/rfq-items-view-dialog.tsx
+++ b/lib/techsales-rfq/table/rfq-items-view-dialog.tsx
@@ -30,10 +30,10 @@ interface RfqItemsViewDialogProps {
onOpenChange: (open: boolean) => void;
rfq: {
id: number;
- rfqCode?: string;
+ rfqCode?: string | null;
status?: string;
description?: string;
- rfqType?: "SHIP" | "TOP" | "HULL";
+ rfqType?: "SHIP" | "TOP" | "HULL" | null;
} | null;
}
diff --git a/lib/techsales-rfq/table/rfq-table-column.tsx b/lib/techsales-rfq/table/rfq-table-column.tsx
index 51c143a4..3009e036 100644
--- a/lib/techsales-rfq/table/rfq-table-column.tsx
+++ b/lib/techsales-rfq/table/rfq-table-column.tsx
@@ -6,13 +6,14 @@ 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 { Paperclip, Package } from "lucide-react"
+import { Paperclip, Package, FileText, BarChart3 } from "lucide-react"
import { Button } from "@/components/ui/button"
// 기본적인 RFQ 타입 정의 (rfq-table.tsx 파일과 일치해야 함)
type TechSalesRfq = {
id: number
rfqCode: string | null
+ description: string | null
dueDate: Date
rfqSendDate: Date | null
status: "RFQ Created" | "RFQ Vendor Assignned" | "RFQ Sent" | "Quotation Analysis" | "Closed"
@@ -33,6 +34,8 @@ type TechSalesRfq = {
projMsrm: number
ptypeNm: string
attachmentCount: number
+ hasTbeAttachments: boolean
+ hasCbeAttachments: boolean
quotationCount: number
itemCount: number
// 나머지 필드는 사용할 때마다 추가
@@ -41,7 +44,7 @@ type TechSalesRfq = {
interface GetColumnsProps {
setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TechSalesRfq> | null>>;
- openAttachmentsSheet: (rfqId: number) => void;
+ openAttachmentsSheet: (rfqId: number, attachmentType?: 'RFQ_COMMON' | 'TBE_RESULT' | 'CBE_RESULT') => void;
openItemsDialog: (rfq: TechSalesRfq) => void;
}
@@ -110,6 +113,18 @@ export function getColumns({
size: 120,
},
{
+ accessorKey: "description",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ Title" />
+ ),
+ cell: ({ row }) => <div>{row.getValue("description")}</div>,
+ meta: {
+ excelHeader: "RFQ Title"
+ },
+ enableResizing: true,
+ size: 200,
+ },
+ {
accessorKey: "projNm",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="프로젝트명" />
@@ -286,14 +301,14 @@ export function getColumns({
{
id: "attachments",
header: ({ column }) => (
- <DataTableColumnHeaderSimple column={column} title="첨부파일" />
+ <DataTableColumnHeaderSimple column={column} title="RFQ 첨부파일" />
),
cell: ({ row }) => {
const rfq = row.original
const attachmentCount = rfq.attachmentCount || 0
const handleClick = () => {
- openAttachmentsSheet(rfq.id)
+ openAttachmentsSheet(rfq.id, 'RFQ_COMMON')
}
return (
@@ -325,5 +340,81 @@ export function getColumns({
excelHeader: "첨부파일"
},
},
+ {
+ id: "tbe-attachments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="TBE 결과" />
+ ),
+ cell: ({ row }) => {
+ const rfq = row.original
+ const hasTbeAttachments = rfq.hasTbeAttachments
+
+ const handleClick = () => {
+ openAttachmentsSheet(rfq.id, 'TBE_RESULT')
+ }
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={hasTbeAttachments ? "TBE 첨부파일 있음" : "TBE 첨부파일 추가"}
+ >
+ <FileText className="h-4 w-4 text-muted-foreground group-hover:text-green-600 transition-colors" />
+ {hasTbeAttachments && (
+ <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-3 w-3 rounded-full bg-red-500"></span>
+ )}
+ <span className="sr-only">
+ {hasTbeAttachments ? "TBE 첨부파일 있음" : "TBE 첨부파일 추가"}
+ </span>
+ </Button>
+ )
+ },
+ enableSorting: false,
+ enableResizing: false,
+ size: 80,
+ meta: {
+ excelHeader: "TBE 결과"
+ },
+ },
+ {
+ id: "cbe-attachments",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="CBE 결과" />
+ ),
+ cell: ({ row }) => {
+ const rfq = row.original
+ const hasCbeAttachments = rfq.hasCbeAttachments
+
+ const handleClick = () => {
+ openAttachmentsSheet(rfq.id, 'CBE_RESULT')
+ }
+
+ return (
+ <Button
+ variant="ghost"
+ size="sm"
+ className="relative h-8 w-8 p-0 group"
+ onClick={handleClick}
+ aria-label={hasCbeAttachments ? "CBE 첨부파일 있음" : "CBE 첨부파일 추가"}
+ >
+ <BarChart3 className="h-4 w-4 text-muted-foreground group-hover:text-blue-600 transition-colors" />
+ {hasCbeAttachments && (
+ <span className="pointer-events-none absolute -top-1 -right-1 inline-flex h-3 w-3 rounded-full bg-red-500"></span>
+ )}
+ <span className="sr-only">
+ {hasCbeAttachments ? "CBE 첨부파일 있음" : "CBE 첨부파일 추가"}
+ </span>
+ </Button>
+ )
+ },
+ enableSorting: false,
+ enableResizing: false,
+ size: 80,
+ meta: {
+ excelHeader: "CBE 결과"
+ },
+ },
]
} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/rfq-table.tsx b/lib/techsales-rfq/table/rfq-table.tsx
index 424ca70e..615753cd 100644
--- a/lib/techsales-rfq/table/rfq-table.tsx
+++ b/lib/techsales-rfq/table/rfq-table.tsx
@@ -57,6 +57,7 @@ interface TechSalesRfq {
ptypeNm: string
attachmentCount: number
quotationCount: number
+ rfqType: "SHIP" | "TOP" | "HULL" | null
// 필요에 따라 다른 필드들 추가
[key: string]: unknown
}
@@ -135,7 +136,7 @@ export function RFQListTable({
to: searchParams?.get('to') || undefined,
columnVisibility: {},
columnOrder: [],
- pinnedColumns: { left: [], right: ["items", "attachments"] },
+ pinnedColumns: { left: [], right: ["items", "attachments", "tbe-attachments", "cbe-attachments"] },
groupBy: [],
expandedRows: []
}), [searchParams])
@@ -170,6 +171,7 @@ export function RFQListTable({
setSelectedRfq({
id: rfqData.id,
rfqCode: rfqData.rfqCode,
+ rfqType: rfqData.rfqType, // 빠뜨린 rfqType 필드 추가
biddingProjectId: rfqData.biddingProjectId,
materialCode: rfqData.materialCode,
dueDate: rfqData.dueDate,
@@ -201,6 +203,7 @@ export function RFQListTable({
setProjectDetailRfq({
id: projectRfqData.id,
rfqCode: projectRfqData.rfqCode,
+ rfqType: projectRfqData.rfqType, // 빠뜨린 rfqType 필드 추가
biddingProjectId: projectRfqData.biddingProjectId,
materialCode: projectRfqData.materialCode,
dueDate: projectRfqData.dueDate,
@@ -238,8 +241,11 @@ export function RFQListTable({
}
}, [rowAction])
+ // 첨부파일 시트 상태에 타입 추가
+ const [attachmentType, setAttachmentType] = React.useState<"RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT">("RFQ_COMMON")
+
// 첨부파일 시트 열기 함수
- const openAttachmentsSheet = React.useCallback(async (rfqId: number) => {
+ const openAttachmentsSheet = React.useCallback(async (rfqId: number, attachmentType: 'RFQ_COMMON' | 'TBE_RESULT' | 'CBE_RESULT' = 'RFQ_COMMON') => {
try {
// 선택된 RFQ 찾기
const rfq = tableData?.data?.find(r => r.id === rfqId)
@@ -248,6 +254,9 @@ export function RFQListTable({
return
}
+ // attachmentType을 RFQ_COMMON, TBE_RESULT, CBE_RESULT 중 하나로 변환
+ const validAttachmentType=attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT"
+
// 실제 첨부파일 목록 조회 API 호출
const result = await getTechSalesRfqAttachments(rfqId)
@@ -256,8 +265,11 @@ export function RFQListTable({
return
}
+ // 해당 타입의 첨부파일만 필터링
+ const filteredAttachments = result.data.filter(att => att.attachmentType === validAttachmentType)
+
// API 응답을 ExistingTechSalesAttachment 형식으로 변환
- const attachments: ExistingTechSalesAttachment[] = result.data.map(att => ({
+ const attachments: ExistingTechSalesAttachment[] = filteredAttachments.map(att => ({
id: att.id,
techSalesRfqId: att.techSalesRfqId || rfqId, // null인 경우 rfqId 사용
fileName: att.fileName,
@@ -265,12 +277,13 @@ export function RFQListTable({
filePath: att.filePath,
fileSize: att.fileSize || undefined,
fileType: att.fileType || undefined,
- attachmentType: att.attachmentType as "RFQ_COMMON" | "VENDOR_SPECIFIC",
+ attachmentType: att.attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT",
description: att.description || undefined,
createdBy: att.createdBy,
createdAt: att.createdAt,
}))
+ setAttachmentType(validAttachmentType)
setAttachmentsDefault(attachments)
setSelectedRfqForAttachments(rfq as unknown as TechSalesRfq)
setAttachmentsOpen(true)
@@ -561,6 +574,7 @@ export function RFQListTable({
onOpenChange={setAttachmentsOpen}
defaultAttachments={attachmentsDefault}
rfq={selectedRfqForAttachments}
+ attachmentType={attachmentType}
onAttachmentsUpdated={handleAttachmentsUpdated}
/>
diff --git a/lib/techsales-rfq/table/tech-sales-quotation-attachments-sheet.tsx b/lib/techsales-rfq/table/tech-sales-quotation-attachments-sheet.tsx
new file mode 100644
index 00000000..21c61773
--- /dev/null
+++ b/lib/techsales-rfq/table/tech-sales-quotation-attachments-sheet.tsx
@@ -0,0 +1,231 @@
+"use client"
+
+import * as React from "react"
+import {
+ Sheet,
+ SheetContent,
+ SheetHeader,
+ SheetTitle,
+ SheetDescription,
+} from "@/components/ui/sheet"
+import { Button } from "@/components/ui/button"
+import { Download, FileText, File, ImageIcon, AlertCircle } from "lucide-react"
+import { Badge } from "@/components/ui/badge"
+import { Separator } from "@/components/ui/separator"
+import { formatDate } from "@/lib/utils"
+import prettyBytes from "pretty-bytes"
+
+// 견적서 첨부파일 타입 정의
+export interface QuotationAttachment {
+ id: number
+ quotationId: number
+ revisionId: number
+ fileName: string
+ originalFileName: string
+ fileSize: number
+ fileType: string | null
+ filePath: string
+ description: string | null
+ uploadedBy: number
+ vendorId: number
+ isVendorUpload: boolean
+ createdAt: Date
+ updatedAt: Date
+}
+
+// 견적서 정보 타입
+interface QuotationInfo {
+ id: number
+ quotationCode: string | null
+ vendorName?: string
+ rfqCode?: string
+}
+
+interface TechSalesQuotationAttachmentsSheetProps
+ extends React.ComponentPropsWithRef<typeof Sheet> {
+ quotation: QuotationInfo | null
+ attachments: QuotationAttachment[]
+ isLoading?: boolean
+}
+
+export function TechSalesQuotationAttachmentsSheet({
+ quotation,
+ attachments,
+ isLoading = false,
+ ...props
+}: TechSalesQuotationAttachmentsSheetProps) {
+
+ // 파일 아이콘 선택 함수
+ const getFileIcon = (fileName: string) => {
+ const ext = fileName.split('.').pop()?.toLowerCase();
+ if (!ext) return <File className="h-5 w-5 text-gray-500" />;
+
+ // 이미지 파일
+ if (['jpg', 'jpeg', 'png', 'gif', 'bmp', 'svg', 'webp'].includes(ext)) {
+ return <ImageIcon className="h-5 w-5 text-blue-500" />;
+ }
+ // PDF 파일
+ if (ext === 'pdf') {
+ return <FileText className="h-5 w-5 text-red-500" />;
+ }
+ // Excel 파일
+ if (['xlsx', 'xls', 'csv'].includes(ext)) {
+ return <FileText className="h-5 w-5 text-green-500" />;
+ }
+ // Word 파일
+ if (['docx', 'doc'].includes(ext)) {
+ return <FileText className="h-5 w-5 text-blue-500" />;
+ }
+ // 기본 파일
+ return <File className="h-5 w-5 text-gray-500" />;
+ };
+
+ // 파일 다운로드 처리
+ const handleDownload = (attachment: QuotationAttachment) => {
+ const link = document.createElement('a');
+ link.href = attachment.filePath;
+ link.download = attachment.originalFileName || attachment.fileName;
+ link.target = '_blank';
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ };
+
+ // 리비전별로 첨부파일 그룹핑
+ const groupedAttachments = React.useMemo(() => {
+ const groups = new Map<number, QuotationAttachment[]>();
+
+ attachments.forEach(attachment => {
+ const revisionId = attachment.revisionId;
+ if (!groups.has(revisionId)) {
+ groups.set(revisionId, []);
+ }
+ groups.get(revisionId)!.push(attachment);
+ });
+
+ // 리비전 ID 기준 내림차순 정렬 (최신 버전이 위에)
+ return Array.from(groups.entries())
+ .sort(([a], [b]) => b - a)
+ .map(([revisionId, files]) => ({
+ revisionId,
+ files: files.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
+ }));
+ }, [attachments]);
+ return (
+ <Sheet {...props}>
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetHeader className="text-left">
+ <SheetTitle>견적서 첨부파일</SheetTitle>
+ <SheetDescription>
+ <div className="space-y-1">
+ <div>견적서: {quotation?.quotationCode || "N/A"}</div>
+ {quotation?.vendorName && (
+ <div>벤더: {quotation.vendorName}</div>
+ )}
+ {quotation?.rfqCode && (
+ <div>RFQ: {quotation.rfqCode}</div>
+ )}
+ </div>
+ </SheetDescription>
+ </SheetHeader>
+
+ <div className="flex-1 overflow-auto">
+ {isLoading ? (
+ <div className="flex items-center justify-center py-8">
+ <div className="text-center">
+ <div className="animate-spin h-8 w-8 border-2 border-primary border-t-transparent rounded-full mx-auto mb-2" />
+ <p className="text-sm text-muted-foreground">첨부파일 로딩 중...</p>
+ </div>
+ </div>
+ ) : attachments.length === 0 ? (
+ <div className="flex flex-col items-center justify-center py-8 text-center">
+ <AlertCircle className="h-12 w-12 text-muted-foreground mb-4" />
+ <p className="text-muted-foreground mb-2">첨부파일이 없습니다</p>
+ <p className="text-sm text-muted-foreground">
+ 이 견적서에는 첨부된 파일이 없습니다.
+ </p>
+ </div>
+ ) : (
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <h6 className="font-semibold text-sm">
+ 첨부파일 ({attachments.length}개)
+ </h6>
+ </div>
+
+ {groupedAttachments.map((group, groupIndex) => (
+ <div key={group.revisionId} className="space-y-3">
+ {/* 리비전 헤더 */}
+ <div className="flex items-center gap-2">
+ <Badge variant={group.revisionId === 0 ? "secondary" : "outline"} className="text-xs">
+ {group.revisionId === 0 ? "초기 버전" : `버전 ${group.revisionId}`}
+ </Badge>
+ <span className="text-xs text-muted-foreground">
+ ({group.files.length}개 파일)
+ </span>
+ </div>
+
+ {/* 해당 리비전의 첨부파일들 */}
+ {group.files.map((attachment) => (
+ <div
+ key={attachment.id}
+ className="flex items-start gap-3 p-3 border rounded-lg hover:bg-muted/50 transition-colors ml-4"
+ >
+ <div className="mt-1">
+ {getFileIcon(attachment.fileName)}
+ </div>
+
+ <div className="flex-1 min-w-0">
+ <div className="flex items-start justify-between gap-2">
+ <div className="min-w-0 flex-1">
+ <p className="text-sm font-medium break-words leading-tight">
+ {attachment.originalFileName || attachment.fileName}
+ </p>
+ <div className="flex items-center gap-2 mt-1">
+ <span className="text-xs text-muted-foreground">
+ {prettyBytes(attachment.fileSize)}
+ </span>
+ <Badge variant="outline" className="text-xs">
+ {attachment.isVendorUpload ? "벤더 업로드" : "시스템"}
+ </Badge>
+ </div>
+ <p className="text-xs text-muted-foreground mt-1">
+ {formatDate(attachment.createdAt)}
+ </p>
+ {attachment.description && (
+ <p className="text-xs text-muted-foreground mt-1 break-words">
+ {attachment.description}
+ </p>
+ )}
+ </div>
+ </div>
+ </div>
+
+ <div className="flex flex-col gap-1">
+ {/* 다운로드 버튼 */}
+ <Button
+ variant="ghost"
+ size="icon"
+ className="h-8 w-8"
+ onClick={() => handleDownload(attachment)}
+ title="다운로드"
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ </div>
+ </div>
+ ))}
+
+ {/* 그룹 간 구분선 (마지막 그룹 제외) */}
+ {groupIndex < groupedAttachments.length - 1 && (
+ <Separator className="my-4" />
+ )}
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx b/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx
index ecdf6d81..a7b487e1 100644
--- a/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx
+++ b/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx
@@ -27,7 +27,6 @@ import {
import { Loader, Download, X, Eye, AlertCircle } from "lucide-react"
import { toast } from "sonner"
import { Badge } from "@/components/ui/badge"
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
import {
Dropzone,
@@ -63,7 +62,7 @@ export interface ExistingTechSalesAttachment {
filePath: string
fileSize?: number
fileType?: string
- attachmentType: "RFQ_COMMON" | "VENDOR_SPECIFIC"
+ attachmentType: "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT"
description?: string
createdBy: number
createdAt: Date
@@ -72,7 +71,7 @@ export interface ExistingTechSalesAttachment {
/** 새로 업로드할 파일 */
const newUploadSchema = z.object({
fileObj: z.any().optional(), // 실제 File
- attachmentType: z.enum(["RFQ_COMMON", "VENDOR_SPECIFIC"]).default("RFQ_COMMON"),
+ attachmentType: z.enum(["RFQ_COMMON", "TBE_RESULT", "CBE_RESULT"]).default("RFQ_COMMON"),
description: z.string().optional(),
})
@@ -85,7 +84,7 @@ const existingAttachSchema = z.object({
filePath: z.string(),
fileSize: z.number().optional(),
fileType: z.string().optional(),
- attachmentType: z.enum(["RFQ_COMMON", "VENDOR_SPECIFIC"]),
+ attachmentType: z.enum(["RFQ_COMMON", "TBE_RESULT", "CBE_RESULT"]),
description: z.string().optional(),
createdBy: z.number(),
createdAt: z.custom<Date>(),
@@ -112,27 +111,54 @@ interface TechSalesRfqAttachmentsSheetProps
extends React.ComponentPropsWithRef<typeof Sheet> {
defaultAttachments?: ExistingTechSalesAttachment[]
rfq: TechSalesRfq | null
+ /** 첨부파일 타입 */
+ attachmentType?: "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT"
/** 업로드/삭제 후 상위 테이블에 attachmentCount 등을 업데이트하기 위한 콜백 */
- onAttachmentsUpdated?: (rfqId: number, newAttachmentCount: number) => void
- /** 강제 읽기 전용 모드 (파트너/벤더용) */
- readOnly?: boolean
+ // onAttachmentsUpdated?: (rfqId: number, newAttachmentCount: number) => void
+
}
export function TechSalesRfqAttachmentsSheet({
defaultAttachments = [],
- onAttachmentsUpdated,
+ // onAttachmentsUpdated,
rfq,
- readOnly = false,
+ attachmentType = "RFQ_COMMON",
...props
}: TechSalesRfqAttachmentsSheetProps) {
const [isPending, setIsPending] = React.useState(false)
- // RFQ 상태에 따른 편집 가능 여부 결정 (readOnly prop이 true면 항상 false)
- const isEditable = React.useMemo(() => {
- if (!rfq || readOnly) return false
- // RFQ Created, RFQ Vendor Assignned 상태에서만 편집 가능
- return ["RFQ Created", "RFQ Vendor Assignned"].includes(rfq.status)
- }, [rfq, readOnly])
+ // 첨부파일 타입별 제목과 설명 설정
+ const attachmentConfig = React.useMemo(() => {
+ switch (attachmentType) {
+ case "TBE_RESULT":
+ return {
+ title: "TBE 결과 첨부파일",
+ description: "기술 평가(TBE) 결과 파일을 관리합니다.",
+ fileTypeLabel: "TBE 결과",
+ canEdit: true
+ }
+ case "CBE_RESULT":
+ return {
+ title: "CBE 결과 첨부파일",
+ description: "상업성 평가(CBE) 결과 파일을 관리합니다.",
+ fileTypeLabel: "CBE 결과",
+ canEdit: true
+ }
+ default: // RFQ_COMMON
+ return {
+ title: "RFQ 첨부파일",
+ description: "RFQ 공통 첨부파일을 관리합니다.",
+ fileTypeLabel: "공통",
+ canEdit: true
+ }
+ }
+ }, [attachmentType, rfq?.status])
+
+ // // RFQ 상태에 따른 편집 가능 여부 결정 (readOnly prop이 true면 항상 false)
+ // const isEditable = React.useMemo(() => {
+ // if (!rfq) return false
+ // return attachmentConfig.canEdit
+ // }, [rfq, attachmentConfig.canEdit])
const form = useForm<AttachmentsFormValues>({
resolver: zodResolver(attachmentsFormSchema),
@@ -236,7 +262,7 @@ export function TechSalesRfqAttachmentsSheet({
.filter(upload => upload.fileObj)
.map(upload => ({
file: upload.fileObj as File,
- attachmentType: upload.attachmentType,
+ attachmentType: attachmentType,
description: upload.description,
}))
@@ -268,50 +294,50 @@ export function TechSalesRfqAttachmentsSheet({
toast.success(successMessage)
- // 즉시 첨부파일 목록 새로고침
- const refreshResult = await getTechSalesRfqAttachments(rfq.id)
- if (refreshResult.error) {
- console.error("첨부파일 목록 새로고침 실패:", refreshResult.error)
- toast.warning("첨부파일 목록 새로고침에 실패했습니다. 시트를 다시 열어주세요.")
- } else {
- // 새로운 첨부파일 목록으로 폼 업데이트
- const refreshedAttachments = refreshResult.data.map(att => ({
- id: att.id,
- techSalesRfqId: att.techSalesRfqId || rfq.id,
- fileName: att.fileName,
- originalFileName: att.originalFileName,
- filePath: att.filePath,
- fileSize: att.fileSize,
- fileType: att.fileType,
- attachmentType: att.attachmentType as "RFQ_COMMON" | "VENDOR_SPECIFIC",
- description: att.description,
- createdBy: att.createdBy,
- createdAt: att.createdAt,
- }))
-
- // 폼을 새로운 데이터로 리셋 (새 업로드 목록은 비움)
- form.reset({
- techSalesRfqId: rfq.id,
- existing: refreshedAttachments.map(att => ({
- ...att,
- fileSize: att.fileSize || undefined,
- fileType: att.fileType || undefined,
- description: att.description || undefined,
- })),
- newUploads: [],
- })
+ // // 즉시 첨부파일 목록 새로고침
+ // const refreshResult = await getTechSalesRfqAttachments(rfq.id)
+ // if (refreshResult.error) {
+ // console.error("첨부파일 목록 새로고침 실패:", refreshResult.error)
+ // toast.warning("첨부파일 목록 새로고침에 실패했습니다. 시트를 다시 열어주세요.")
+ // } else {
+ // // 새로운 첨부파일 목록으로 폼 업데이트
+ // const refreshedAttachments = refreshResult.data.map(att => ({
+ // id: att.id,
+ // techSalesRfqId: att.techSalesRfqId || rfq.id,
+ // fileName: att.fileName,
+ // originalFileName: att.originalFileName,
+ // filePath: att.filePath,
+ // fileSize: att.fileSize,
+ // fileType: att.fileType,
+ // attachmentType: att.attachmentType as "RFQ_COMMON" | "TBE_RESULT" | "CBE_RESULT",
+ // description: att.description,
+ // createdBy: att.createdBy,
+ // createdAt: att.createdAt,
+ // }))
+
+ // // 폼을 새로운 데이터로 리셋 (새 업로드 목록은 비움)
+ // form.reset({
+ // techSalesRfqId: rfq.id,
+ // existing: refreshedAttachments.map(att => ({
+ // ...att,
+ // fileSize: att.fileSize || undefined,
+ // fileType: att.fileType || undefined,
+ // description: att.description || undefined,
+ // })),
+ // newUploads: [],
+ // })
- // 즉시 UI 업데이트를 위한 추가 피드백
- if (uploadedCount > 0) {
- toast.success("첨부파일 목록이 업데이트되었습니다.", { duration: 2000 })
- }
- }
+ // // 즉시 UI 업데이트를 위한 추가 피드백
+ // if (uploadedCount > 0) {
+ // toast.success("첨부파일 목록이 업데이트되었습니다.", { duration: 2000 })
+ // }
+ // }
- // 콜백으로 상위 컴포넌트에 변경사항 알림
- const newAttachmentCount = refreshResult.error ?
- (data.existing.length + newFiles.length - deleteAttachmentIds.length) :
- refreshResult.data.length
- onAttachmentsUpdated?.(rfq.id, newAttachmentCount)
+ // // 콜백으로 상위 컴포넌트에 변경사항 알림
+ // const newAttachmentCount = refreshResult.error ?
+ // (data.existing.length + newFiles.length - deleteAttachmentIds.length) :
+ // refreshResult.data.length
+ // onAttachmentsUpdated?.(rfq.id, newAttachmentCount)
} catch (error) {
console.error("첨부파일 저장 오류:", error)
@@ -325,10 +351,11 @@ export function TechSalesRfqAttachmentsSheet({
<Sheet {...props}>
<SheetContent className="flex flex-col gap-6 sm:max-w-md">
<SheetHeader className="text-left">
- <SheetTitle>첨부파일 관리</SheetTitle>
+ <SheetTitle>{attachmentConfig.title}</SheetTitle>
<SheetDescription>
- RFQ: {rfq?.rfqCode || "N/A"}
- {!isEditable && (
+ <div>RFQ: {rfq?.rfqCode || "N/A"}</div>
+ <div className="mt-1">{attachmentConfig.description}</div>
+ {!attachmentConfig.canEdit && (
<div className="mt-2 flex items-center gap-2 text-amber-600">
<AlertCircle className="h-4 w-4" />
<span className="text-sm">현재 상태에서는 편집할 수 없습니다</span>
@@ -345,7 +372,7 @@ export function TechSalesRfqAttachmentsSheet({
기존 첨부파일 ({existingFields.length}개)
</h6>
{existingFields.map((field, index) => {
- const typeLabel = field.attachmentType === "RFQ_COMMON" ? "공통" : "벤더별"
+ const typeLabel = attachmentConfig.fileTypeLabel
const sizeText = field.fileSize ? prettyBytes(field.fileSize) : "알 수 없음"
const dateText = field.createdAt ? formatDate(field.createdAt) : ""
@@ -384,7 +411,7 @@ export function TechSalesRfqAttachmentsSheet({
</a>
)}
{/* Remove button - 편집 가능할 때만 표시 */}
- {isEditable && (
+ {attachmentConfig.canEdit && (
<Button
type="button"
variant="ghost"
@@ -402,7 +429,7 @@ export function TechSalesRfqAttachmentsSheet({
</div>
{/* 2) Dropzone for new uploads - 편집 가능할 때만 표시 */}
- {isEditable ? (
+ {attachmentConfig.canEdit ? (
<>
<Dropzone
maxSize={MAX_FILE_SIZE}
@@ -467,30 +494,6 @@ export function TechSalesRfqAttachmentsSheet({
</FileListAction>
</FileListHeader>
- {/* 파일별 설정 */}
- <div className="px-4 pb-3 space-y-3">
- <FormField
- control={form.control}
- name={`newUploads.${idx}.attachmentType`}
- render={({ field: formField }) => (
- <FormItem>
- <FormLabel className="text-xs">파일 타입</FormLabel>
- <Select onValueChange={formField.onChange} defaultValue={formField.value}>
- <FormControl>
- <SelectTrigger className="h-8">
- <SelectValue placeholder="파일 타입 선택" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- <SelectItem value="RFQ_COMMON">공통 파일</SelectItem>
- {/* <SelectItem value="VENDOR_SPECIFIC">벤더별 파일</SelectItem> */}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
- </div>
</FileListItem>
)
})}
@@ -510,10 +513,10 @@ export function TechSalesRfqAttachmentsSheet({
<SheetFooter className="gap-2 pt-2 sm:space-x-0">
<SheetClose asChild>
<Button type="button" variant="outline">
- {isEditable ? "취소" : "닫기"}
+ {attachmentConfig.canEdit ? "취소" : "닫기"}
</Button>
</SheetClose>
- {isEditable && (
+ {attachmentConfig.canEdit && (
<Button
type="submit"
disabled={
diff --git a/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx b/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx
index 3449dcb6..20b2703c 100644
--- a/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx
+++ b/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx
@@ -1,7 +1,7 @@
"use client"
import * as React from "react"
-import { useState } from "react"
+import { useState, useEffect } from "react"
import { useRouter } from "next/navigation"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
import { Button } from "@/components/ui/button"
@@ -10,7 +10,7 @@ import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge"
import { ScrollArea } from "@/components/ui/scroll-area"
-import { CalendarIcon, Save, Send, AlertCircle } from "lucide-react"
+import { CalendarIcon, Send, AlertCircle, Upload, X, FileText, Download } from "lucide-react"
import { Calendar } from "@/components/ui/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
@@ -26,6 +26,13 @@ interface QuotationResponseTabProps {
currency: string | null
validUntil: Date | null
remark: string | null
+ quotationAttachments?: Array<{
+ id: number
+ fileName: string
+ fileSize: number
+ filePath: string
+ description?: string | null
+ }>
rfq: {
id: number
rfqCode: string | null
@@ -58,38 +65,93 @@ export function QuotationResponseTab({ quotation }: QuotationResponseTabProps) {
)
const [remark, setRemark] = useState(quotation.remark || "")
const [isLoading, setIsLoading] = useState(false)
+ const [attachments, setAttachments] = useState<Array<{
+ id?: number
+ fileName: string
+ fileSize: number
+ filePath: string
+ isNew?: boolean
+ file?: File
+ }>>([])
+ const [isUploadingFiles, setIsUploadingFiles] = useState(false)
const router = useRouter()
+ // // 초기 첨부파일 데이터 로드
+ // useEffect(() => {
+ // if (quotation.quotationAttachments) {
+ // setAttachments(quotation.quotationAttachments.map(att => ({
+ // id: att.id,
+ // fileName: att.fileName,
+ // fileSize: att.fileSize,
+ // filePath: att.filePath,
+ // isNew: false
+ // })))
+ // }
+ // }, [quotation.quotationAttachments])
+
const rfq = quotation.rfq
const isDueDatePassed = rfq?.dueDate ? new Date(rfq.dueDate) < new Date() : false
- const canSubmit = quotation.status === "Draft" && !isDueDatePassed
- const canEdit = ["Draft", "Revised"].includes(quotation.status) && !isDueDatePassed
+ const canSubmit = !["Accepted", "Rejected"].includes(quotation.status) && !isDueDatePassed
+ const canEdit = !["Accepted", "Rejected"].includes(quotation.status) && !isDueDatePassed
+
+ // 파일 업로드 핸들러
+ const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => {
+ const files = event.target.files
+ if (!files) return
+
+ Array.from(files).forEach(file => {
+ setAttachments(prev => [
+ ...prev,
+ {
+ fileName: file.name,
+ fileSize: file.size,
+ filePath: '',
+ isNew: true,
+ file
+ }
+ ])
+ })
+ }
+
+ // 첨부파일 제거
+ const removeAttachment = (index: number) => {
+ setAttachments(prev => prev.filter((_, i) => i !== index))
+ }
+
+ // 파일 업로드 함수
+ const uploadFiles = async () => {
+ const newFiles = attachments.filter(att => att.isNew && att.file)
+ if (newFiles.length === 0) return []
+
+ setIsUploadingFiles(true)
+ const uploadedFiles = []
- const handleSaveDraft = async () => {
- setIsLoading(true)
try {
- const { updateTechSalesVendorQuotation } = await import("@/lib/techsales-rfq/service")
-
- const result = await updateTechSalesVendorQuotation({
- id: quotation.id,
- currency,
- totalPrice,
- validUntil: validUntil!,
- remark,
- updatedBy: 1 // TODO: 실제 사용자 ID로 변경
- })
+ for (const attachment of newFiles) {
+ const formData = new FormData()
+ formData.append('file', attachment.file!)
+
+ const response = await fetch('/api/upload', {
+ method: 'POST',
+ body: formData
+ })
- if (result.error) {
- toast.error(result.error)
- } else {
- toast.success("임시 저장되었습니다.")
- // 페이지 새로고침 대신 router.refresh() 사용
- router.refresh()
+ if (!response.ok) throw new Error('파일 업로드 실패')
+
+ const result = await response.json()
+ uploadedFiles.push({
+ fileName: result.fileName,
+ filePath: result.url,
+ fileSize: attachment.fileSize
+ })
}
- } catch {
- toast.error("저장 중 오류가 발생했습니다.")
+ return uploadedFiles
+ } catch (error) {
+ console.error('파일 업로드 오류:', error)
+ toast.error('파일 업로드 중 오류가 발생했습니다.')
+ return []
} finally {
- setIsLoading(false)
+ setIsUploadingFiles(false)
}
}
@@ -101,6 +163,9 @@ export function QuotationResponseTab({ quotation }: QuotationResponseTabProps) {
setIsLoading(true)
try {
+ // 파일 업로드 먼저 처리
+ const uploadedFiles = await uploadFiles()
+
const { submitTechSalesVendorQuotation } = await import("@/lib/techsales-rfq/service")
const result = await submitTechSalesVendorQuotation({
@@ -109,6 +174,7 @@ export function QuotationResponseTab({ quotation }: QuotationResponseTabProps) {
totalPrice,
validUntil: validUntil!,
remark,
+ attachments: uploadedFiles,
updatedBy: 1 // TODO: 실제 사용자 ID로 변경
})
@@ -116,8 +182,10 @@ export function QuotationResponseTab({ quotation }: QuotationResponseTabProps) {
toast.error(result.error)
} else {
toast.success("견적서가 제출되었습니다.")
- // 페이지 새로고침 대신 router.refresh() 사용
- router.refresh()
+ // // 페이지 새로고침 대신 router.refresh() 사용
+ // router.refresh()
+ // 페이지 새로고침
+ window.location.reload()
}
} catch {
toast.error("제출 중 오류가 발생했습니다.")
@@ -312,28 +380,98 @@ export function QuotationResponseTab({ quotation }: QuotationResponseTabProps) {
/>
</div>
+ {/* 첨부파일 */}
+ <div className="space-y-4">
+ <Label>첨부파일</Label>
+
+ {/* 파일 업로드 버튼 */}
+ {canEdit && (
+ <div className="flex items-center gap-2">
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ disabled={isUploadingFiles}
+ onClick={() => document.getElementById('file-input')?.click()}
+ >
+ <Upload className="h-4 w-4 mr-2" />
+ 파일 선택
+ </Button>
+ <input
+ id="file-input"
+ type="file"
+ multiple
+ onChange={handleFileSelect}
+ className="hidden"
+ accept=".pdf,.doc,.docx,.xls,.xlsx,.ppt,.pptx,.txt,.jpg,.jpeg,.png,.zip"
+ />
+ <span className="text-sm text-muted-foreground">
+ PDF, 문서파일, 이미지파일, 압축파일 등
+ </span>
+ </div>
+ )}
+
+ {/* 첨부파일 목록 */}
+ {attachments.length > 0 && (
+ <div className="space-y-2">
+ {attachments.map((attachment, index) => (
+ <div
+ key={index}
+ className="flex items-center justify-between p-3 border rounded-lg bg-muted/50"
+ >
+ <div className="flex items-center gap-2">
+ <FileText className="h-4 w-4 text-muted-foreground" />
+ <div>
+ <div className="text-sm font-medium">{attachment.fileName}</div>
+ <div className="text-xs text-muted-foreground">
+ {(attachment.fileSize / 1024 / 1024).toFixed(2)} MB
+ {attachment.isNew && (
+ <Badge variant="secondary" className="ml-2">
+ 새 파일
+ </Badge>
+ )}
+ </div>
+ </div>
+ </div>
+ <div className="flex items-center gap-2">
+ {!attachment.isNew && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => window.open(attachment.filePath, '_blank')}
+ >
+ <Download className="h-4 w-4" />
+ </Button>
+ )}
+ {canEdit && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => removeAttachment(index)}
+ >
+ <X className="h-4 w-4" />
+ </Button>
+ )}
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+
{/* 액션 버튼 */}
- {canEdit && (
- <div className="flex gap-2 pt-4">
+ {canEdit && canSubmit && (
+ <div className="flex justify-center pt-4">
<Button
- variant="outline"
- onClick={handleSaveDraft}
- disabled={isLoading}
- className="flex-1"
+ onClick={handleSubmit}
+ disabled={isLoading || !totalPrice || !currency || !validUntil}
+ className="w-full "
>
- <Save className="mr-2 h-4 w-4" />
- 임시 저장
+ <Send className="mr-2 h-4 w-4" />
+ 견적서 제출
</Button>
- {canSubmit && (
- <Button
- onClick={handleSubmit}
- disabled={isLoading || !totalPrice || !currency || !validUntil}
- className="flex-1"
- >
- <Send className="mr-2 h-4 w-4" />
- 견적서 제출
- </Button>
- )}
</div>
)}
</CardContent>
diff --git a/lib/techsales-rfq/vendor-response/quotation-editor.tsx b/lib/techsales-rfq/vendor-response/quotation-editor.tsx
deleted file mode 100644
index 54058214..00000000
--- a/lib/techsales-rfq/vendor-response/quotation-editor.tsx
+++ /dev/null
@@ -1,559 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useState, useEffect } from "react"
-import { useForm } from "react-hook-form"
-import { zodResolver } from "@hookform/resolvers/zod"
-import * as z from "zod"
-import { toast } from "sonner"
-import { useRouter } from "next/navigation"
-import { CalendarIcon, Save, Send, ArrowLeft } from "lucide-react"
-
-import { Button } from "@/components/ui/button"
-import {
- Form,
- FormControl,
- FormField,
- FormItem,
- FormLabel,
- FormMessage,
-} from "@/components/ui/form"
-import { Input } from "@/components/ui/input"
-import { Textarea } from "@/components/ui/textarea"
-import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
-import { Badge } from "@/components/ui/badge"
-import { Separator } from "@/components/ui/separator"
-import { DatePicker } from "@/components/ui/date-picker"
-import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"
-import { Skeleton } from "@/components/ui/skeleton"
-
-import { formatCurrency, formatDate } from "@/lib/utils"
-import {
- updateTechSalesVendorQuotation,
- submitTechSalesVendorQuotation,
- fetchCurrencies
-} from "../service"
-
-// 견적서 폼 스키마 (techsales용 단순화)
-const quotationFormSchema = z.object({
- currency: z.string().min(1, "통화를 선택해주세요"),
- totalPrice: z.string().min(1, "총액을 입력해주세요"),
- validUntil: z.date({
- required_error: "견적 유효기간을 선택해주세요",
- invalid_type_error: "유효한 날짜를 선택해주세요",
- }),
- remark: z.string().optional(),
-})
-
-type QuotationFormValues = z.infer<typeof quotationFormSchema>
-
-// 통화 타입
-interface Currency {
- code: string
- name: string
-}
-
-// 이 컴포넌트에 전달되는 견적서 데이터 타입 (techsales용 단순화)
-interface TechSalesVendorQuotation {
- id: number
- rfqId: number
- vendorId: number
- quotationCode: string | null
- quotationVersion: number | null
- totalPrice: string | null
- currency: string | null
- validUntil: Date | null
- status: "Draft" | "Submitted" | "Revised" | "Rejected" | "Accepted"
- remark: string | null
- rejectionReason: string | null
- submittedAt: Date | null
- acceptedAt: Date | null
- createdAt: Date
- updatedAt: Date
- rfq: {
- id: number
- rfqCode: string | null
- dueDate: Date | null
- status: string | null
- materialCode: string | null
- remark: string | null
- projectSnapshot?: {
- pspid?: string
- projNm?: string
- sector?: string
- projMsrm?: number
- kunnr?: string
- kunnrNm?: string
- ptypeNm?: string
- } | null
- seriesSnapshot?: Array<{
- pspid: string
- sersNo: string
- scDt?: string
- klDt?: string
- lcDt?: string
- dlDt?: string
- dockNo?: string
- dockNm?: string
- projNo?: string
- post1?: string
- }> | null
- item?: {
- id: number
- itemCode: string | null
- itemList: string | null
- } | null
- biddingProject?: {
- id: number
- pspid: string | null
- projNm: string | null
- } | null
- createdByUser?: {
- id: number
- name: string | null
- email: string | null
- } | null
- }
- vendor: {
- id: number
- vendorName: string
- vendorCode: string | null
- }
-}
-
-interface TechSalesQuotationEditorProps {
- quotation: TechSalesVendorQuotation
-}
-
-export default function TechSalesQuotationEditor({ quotation }: TechSalesQuotationEditorProps) {
- const router = useRouter()
- const [isSubmitting, setIsSubmitting] = useState(false)
- const [isSaving, setIsSaving] = useState(false)
- const [currencies, setCurrencies] = useState<Currency[]>([])
- const [loadingCurrencies, setLoadingCurrencies] = useState(true)
-
- // 폼 초기화
- const form = useForm<QuotationFormValues>({
- resolver: zodResolver(quotationFormSchema),
- defaultValues: {
- currency: quotation.currency || "USD",
- totalPrice: quotation.totalPrice || "",
- validUntil: quotation.validUntil || undefined,
- remark: quotation.remark || "",
- },
- })
-
- // 통화 목록 로드
- useEffect(() => {
- const loadCurrencies = async () => {
- try {
- const { data, error } = await fetchCurrencies()
- if (error) {
- toast.error("통화 목록을 불러오는데 실패했습니다")
- return
- }
- setCurrencies(data || [])
- } catch (error) {
- console.error("Error loading currencies:", error)
- toast.error("통화 목록을 불러오는데 실패했습니다")
- } finally {
- setLoadingCurrencies(false)
- }
- }
-
- loadCurrencies()
- }, [])
-
- // 마감일 확인
- const isBeforeDueDate = () => {
- if (!quotation.rfq.dueDate) return true
- return new Date() <= new Date(quotation.rfq.dueDate)
- }
-
- // 편집 가능 여부 확인
- const isEditable = () => {
- return quotation.status === "Draft" || quotation.status === "Rejected"
- }
-
- // 제출 가능 여부 확인
- const isSubmitReady = () => {
- const values = form.getValues()
- return values.currency &&
- values.totalPrice &&
- parseFloat(values.totalPrice) > 0 &&
- values.validUntil &&
- isBeforeDueDate()
- }
-
- // 저장 핸들러
- const handleSave = async () => {
- if (!isEditable()) {
- toast.error("편집할 수 없는 상태입니다")
- return
- }
-
- setIsSaving(true)
- try {
- const values = form.getValues()
- const { data, error } = await updateTechSalesVendorQuotation({
- id: quotation.id,
- currency: values.currency,
- totalPrice: values.totalPrice,
- validUntil: values.validUntil,
- remark: values.remark,
- updatedBy: quotation.vendorId, // 임시로 vendorId 사용
- })
-
- if (error) {
- toast.error(error)
- return
- }
-
- toast.success("견적서가 저장되었습니다")
- router.refresh()
- } catch (error) {
- console.error("Error saving quotation:", error)
- toast.error("견적서 저장 중 오류가 발생했습니다")
- } finally {
- setIsSaving(false)
- }
- }
-
- // 제출 핸들러
- const handleSubmit = async () => {
- if (!isEditable()) {
- toast.error("제출할 수 없는 상태입니다")
- return
- }
-
- if (!isSubmitReady()) {
- toast.error("필수 항목을 모두 입력해주세요")
- return
- }
-
- if (!isBeforeDueDate()) {
- toast.error("마감일이 지났습니다")
- return
- }
-
- setIsSubmitting(true)
- try {
- const values = form.getValues()
- const { data, error } = await submitTechSalesVendorQuotation({
- id: quotation.id,
- currency: values.currency,
- totalPrice: values.totalPrice,
- validUntil: values.validUntil,
- remark: values.remark,
- updatedBy: quotation.vendorId, // 임시로 vendorId 사용
- })
-
- if (error) {
- toast.error(error)
- return
- }
-
- toast.success("견적서가 제출되었습니다")
- router.push("/ko/partners/techsales/rfq-ship")
- } catch (error) {
- console.error("Error submitting quotation:", error)
- toast.error("견적서 제출 중 오류가 발생했습니다")
- } finally {
- setIsSubmitting(false)
- }
- }
-
- // 상태 배지
- const getStatusBadge = (status: string) => {
- const statusConfig = {
- "Draft": { label: "초안", variant: "secondary" as const },
- "Submitted": { label: "제출됨", variant: "default" as const },
- "Revised": { label: "수정됨", variant: "outline" as const },
- "Rejected": { label: "반려됨", variant: "destructive" as const },
- "Accepted": { label: "승인됨", variant: "success" as const },
- }
-
- const config = statusConfig[status as keyof typeof statusConfig] || {
- label: status,
- variant: "secondary" as const
- }
-
- return <Badge variant={config.variant}>{config.label}</Badge>
- }
-
- return (
- <div className="container max-w-4xl mx-auto py-6 space-y-6">
- {/* 헤더 */}
- <div className="flex items-center justify-between">
- <div className="flex items-center space-x-4">
- <Button
- variant="ghost"
- size="sm"
- onClick={() => router.back()}
- >
- <ArrowLeft className="h-4 w-4 mr-2" />
- 뒤로가기
- </Button>
- <div>
- <h1 className="text-2xl font-bold">기술영업 견적서</h1>
- <p className="text-muted-foreground">
- RFQ: {quotation.rfq.rfqCode} | {getStatusBadge(quotation.status)}
- </p>
- </div>
- </div>
- <div className="flex items-center space-x-2">
- {isEditable() && (
- <>
- <Button
- variant="outline"
- onClick={handleSave}
- disabled={isSaving}
- >
- <Save className="h-4 w-4 mr-2" />
- {isSaving ? "저장 중..." : "저장"}
- </Button>
- <Button
- onClick={handleSubmit}
- disabled={isSubmitting || !isSubmitReady()}
- >
- <Send className="h-4 w-4 mr-2" />
- {isSubmitting ? "제출 중..." : "제출"}
- </Button>
- </>
- )}
- </div>
- </div>
-
- <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
- {/* 왼쪽: RFQ 정보 */}
- <div className="lg:col-span-1 space-y-6">
- {/* RFQ 기본 정보 */}
- <Card>
- <CardHeader>
- <CardTitle>RFQ 정보</CardTitle>
- </CardHeader>
- <CardContent className="space-y-4">
- <div>
- <label className="text-sm font-medium text-muted-foreground">RFQ 번호</label>
- <p className="font-mono">{quotation.rfq.rfqCode}</p>
- </div>
- <div>
- <label className="text-sm font-medium text-muted-foreground">자재 그룹</label>
- <p>{quotation.rfq.materialCode || "N/A"}</p>
- </div>
- <div>
- <label className="text-sm font-medium text-muted-foreground">자재명</label>
- <p>{quotation.rfq.item?.itemList || "N/A"}</p>
- </div>
- <div>
- <label className="text-sm font-medium text-muted-foreground">마감일</label>
- <p className={!isBeforeDueDate() ? "text-red-600 font-medium" : ""}>
- {quotation.rfq.dueDate ? formatDate(quotation.rfq.dueDate) : "N/A"}
- </p>
- </div>
- {quotation.rfq.remark && (
- <div>
- <label className="text-sm font-medium text-muted-foreground">비고</label>
- <p className="text-sm">{quotation.rfq.remark}</p>
- </div>
- )}
- </CardContent>
- </Card>
-
- {/* 프로젝트 정보 */}
- {quotation.rfq.projectSnapshot && (
- <Card>
- <CardHeader>
- <CardTitle>프로젝트 정보</CardTitle>
- </CardHeader>
- <CardContent className="space-y-3">
- <div>
- <label className="text-sm font-medium text-muted-foreground">프로젝트 번호</label>
- <p className="font-mono">{quotation.rfq.projectSnapshot.pspid}</p>
- </div>
- <div>
- <label className="text-sm font-medium text-muted-foreground">프로젝트명</label>
- <p>{quotation.rfq.projectSnapshot.projNm || "N/A"}</p>
- </div>
- <div>
- <label className="text-sm font-medium text-muted-foreground">선종</label>
- <p>{quotation.rfq.projectSnapshot.ptypeNm || "N/A"}</p>
- </div>
- <div>
- <label className="text-sm font-medium text-muted-foreground">척수</label>
- <p>{quotation.rfq.projectSnapshot.projMsrm || "N/A"}</p>
- </div>
- <div>
- <label className="text-sm font-medium text-muted-foreground">선주</label>
- <p>{quotation.rfq.projectSnapshot.kunnrNm || "N/A"}</p>
- </div>
- </CardContent>
- </Card>
- )}
-
- {/* 시리즈 정보 */}
- {quotation.rfq.seriesSnapshot && quotation.rfq.seriesSnapshot.length > 0 && (
- <Card>
- <CardHeader>
- <CardTitle>시리즈 일정</CardTitle>
- </CardHeader>
- <CardContent>
- <div className="space-y-3">
- {quotation.rfq.seriesSnapshot.map((series, index) => (
- <div key={index} className="border rounded p-3">
- <div className="font-medium mb-2">시리즈 {series.sersNo}</div>
- <div className="grid grid-cols-2 gap-2 text-sm">
- {series.klDt && (
- <div>
- <span className="text-muted-foreground">K/L:</span> {formatDate(series.klDt)}
- </div>
- )}
- {series.dlDt && (
- <div>
- <span className="text-muted-foreground">인도:</span> {formatDate(series.dlDt)}
- </div>
- )}
- </div>
- </div>
- ))}
- </div>
- </CardContent>
- </Card>
- )}
- </div>
-
- {/* 오른쪽: 견적서 입력 폼 */}
- <div className="lg:col-span-2">
- <Card>
- <CardHeader>
- <CardTitle>견적서 작성</CardTitle>
- <CardDescription>
- 총액 기반으로 견적을 작성해주세요.
- </CardDescription>
- </CardHeader>
- <CardContent>
- <Form {...form}>
- <form className="space-y-6">
- {/* 통화 선택 */}
- <FormField
- control={form.control}
- name="currency"
- render={({ field }) => (
- <FormItem>
- <FormLabel>통화 *</FormLabel>
- <Select
- onValueChange={field.onChange}
- defaultValue={field.value}
- disabled={!isEditable()}
- >
- <FormControl>
- <SelectTrigger>
- <SelectValue placeholder="통화를 선택하세요" />
- </SelectTrigger>
- </FormControl>
- <SelectContent>
- {loadingCurrencies ? (
- <div className="p-2">
- <Skeleton className="h-4 w-full" />
- </div>
- ) : (
- currencies.map((currency) => (
- <SelectItem key={currency.code} value={currency.code}>
- {currency.code} - {currency.name}
- </SelectItem>
- ))
- )}
- </SelectContent>
- </Select>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 총액 입력 */}
- <FormField
- control={form.control}
- name="totalPrice"
- render={({ field }) => (
- <FormItem>
- <FormLabel>총액 *</FormLabel>
- <FormControl>
- <Input
- type="number"
- step="0.01"
- placeholder="총액을 입력하세요"
- disabled={!isEditable()}
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 유효기간 */}
- <FormField
- control={form.control}
- name="validUntil"
- render={({ field }) => (
- <FormItem>
- <FormLabel>견적 유효기간 *</FormLabel>
- <FormControl>
- <DatePicker
- date={field.value}
- onDateChange={field.onChange}
- disabled={!isEditable()}
- placeholder="유효기간을 선택하세요"
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 비고 */}
- <FormField
- control={form.control}
- name="remark"
- render={({ field }) => (
- <FormItem>
- <FormLabel>비고</FormLabel>
- <FormControl>
- <Textarea
- placeholder="추가 설명이나 특이사항을 입력하세요"
- disabled={!isEditable()}
- rows={4}
- {...field}
- />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {/* 반려 사유 (반려된 경우에만 표시) */}
- {quotation.status === "Rejected" && quotation.rejectionReason && (
- <div className="p-4 bg-red-50 border border-red-200 rounded-lg">
- <label className="text-sm font-medium text-red-800">반려 사유</label>
- <p className="text-sm text-red-700 mt-1">{quotation.rejectionReason}</p>
- </div>
- )}
-
- {/* 제출 정보 */}
- {quotation.submittedAt && (
- <div className="p-4 bg-blue-50 border border-blue-200 rounded-lg">
- <label className="text-sm font-medium text-blue-800">제출 정보</label>
- <p className="text-sm text-blue-700 mt-1">
- 제출일: {formatDate(quotation.submittedAt)}
- </p>
- </div>
- )}
- </form>
- </Form>
- </CardContent>
- </Card>
- </div>
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/lib/techsales-rfq/vendor-response/quotation-item-editor.tsx b/lib/techsales-rfq/vendor-response/quotation-item-editor.tsx
deleted file mode 100644
index 92bec96a..00000000
--- a/lib/techsales-rfq/vendor-response/quotation-item-editor.tsx
+++ /dev/null
@@ -1,664 +0,0 @@
-"use client"
-
-import * as React from "react"
-import { useState, useEffect, useRef } from "react"
-import { toast } from "sonner"
-import { format } from "date-fns"
-
-import { Button } from "@/components/ui/button"
-import { Input } from "@/components/ui/input"
-import { Checkbox } from "@/components/ui/checkbox"
-import { DatePicker } from "@/components/ui/date-picker"
-import {
- Table,
- TableBody,
- TableCaption,
- TableCell,
- TableHead,
- TableHeader,
- TableRow
-} from "@/components/ui/table"
-import { Badge } from "@/components/ui/badge"
-import { ScrollArea } from "@/components/ui/scroll-area"
-import {
- Tooltip,
- TooltipContent,
- TooltipProvider,
- TooltipTrigger
-} from "@/components/ui/tooltip"
-import {
- Info,
- Clock,
- CalendarIcon,
- ClipboardCheck,
- AlertTriangle,
- CheckCircle2,
- RefreshCw,
- Save,
- FileText,
- Sparkles
-} from "lucide-react"
-
-import { formatCurrency } from "@/lib/utils"
-import { updateQuotationItem } from "../services"
-import { Textarea } from "@/components/ui/textarea"
-
-// 견적 아이템 타입
-interface QuotationItem {
- id: number
- quotationId: number
- prItemId: number
- materialCode: string | null
- materialDescription: string | null
- quantity: number
- uom: string | null
- unitPrice: number
- totalPrice: number
- currency: string
- vendorMaterialCode: string | null
- vendorMaterialDescription: string | null
- deliveryDate: Date | null
- leadTimeInDays: number | null
- taxRate: number | null
- taxAmount: number | null
- discountRate: number | null
- discountAmount: number | null
- remark: string | null
- isAlternative: boolean
- isRecommended: boolean // 남겨두지만 UI에서는 사용하지 않음
- createdAt: Date
- updatedAt: Date
- prItem?: {
- id: number
- materialCode: string | null
- materialDescription: string | null
- // 기타 필요한 정보
- }
-}
-
-// debounce 함수 구현
-function debounce<T extends (...args: any[]) => any>(
- func: T,
- wait: number
-): (...args: Parameters<T>) => void {
- let timeout: NodeJS.Timeout | null = null;
-
- return function (...args: Parameters<T>) {
- if (timeout) clearTimeout(timeout);
- timeout = setTimeout(() => func(...args), wait);
- };
-}
-
-interface QuotationItemEditorProps {
- items: QuotationItem[]
- onItemsChange: (items: QuotationItem[]) => void
- disabled?: boolean
- currency: string
-}
-
-export function QuotationItemEditor({
- items,
- onItemsChange,
- disabled = false,
- currency
-}: QuotationItemEditorProps) {
- const [editingItem, setEditingItem] = useState<number | null>(null)
- const [isSaving, setIsSaving] = useState(false)
-
- // 저장이 필요한 항목들을 추적
- const [pendingChanges, setPendingChanges] = useState<Set<number>>(new Set())
-
- // 로컬 상태 업데이트 함수 - 화면에 즉시 반영하지만 서버에는 즉시 저장하지 않음
- const updateLocalItem = <K extends keyof QuotationItem>(
- index: number,
- field: K,
- value: QuotationItem[K]
- ) => {
- // 로컬 상태 업데이트
- const updatedItems = [...items]
- const item = { ...updatedItems[index] }
-
- // 필드 업데이트
- item[field] = value
-
- // 대체품 체크 해제 시 관련 필드 초기화
- if (field === 'isAlternative' && value === false) {
- item.vendorMaterialCode = null;
- item.vendorMaterialDescription = null;
- item.remark = null;
- }
-
- // 단가나 수량이 변경되면 총액 계산
- if (field === 'unitPrice' || field === 'quantity') {
- item.totalPrice = Number(item.unitPrice) * Number(item.quantity)
-
- // 세금이 있으면 세액 계산
- if (item.taxRate) {
- item.taxAmount = item.totalPrice * (item.taxRate / 100)
- }
-
- // 할인이 있으면 할인액 계산
- if (item.discountRate) {
- item.discountAmount = item.totalPrice * (item.discountRate / 100)
- }
- }
-
- // 세율이 변경되면 세액 계산
- if (field === 'taxRate') {
- item.taxAmount = item.totalPrice * (value as number / 100)
- }
-
- // 할인율이 변경되면 할인액 계산
- if (field === 'discountRate') {
- item.discountAmount = item.totalPrice * (value as number / 100)
- }
-
- // 변경된 아이템으로 교체
- updatedItems[index] = item
-
- // 미저장 항목으로 표시
- setPendingChanges(prev => new Set(prev).add(item.id))
-
- // 부모 컴포넌트에 변경 사항 알림
- onItemsChange(updatedItems)
-
- // 저장 필요함을 표시
- return item
- }
-
- // 서버에 저장하는 함수
- const saveItemToServer = async (item: QuotationItem, field: keyof QuotationItem, value: any) => {
- if (disabled) return
-
- try {
- setIsSaving(true)
-
- const result = await updateQuotationItem({
- id: item.id,
- [field]: value,
- totalPrice: item.totalPrice,
- taxAmount: item.taxAmount ?? 0,
- discountAmount: item.discountAmount ?? 0
- })
-
- // 저장 완료 후 pendingChanges에서 제거
- setPendingChanges(prev => {
- const newSet = new Set(prev)
- newSet.delete(item.id)
- return newSet
- })
-
- if (!result.success) {
- toast.error(result.message || "항목 저장 중 오류가 발생했습니다")
- }
- } catch (error) {
- console.error("항목 저장 오류:", error)
- toast.error("항목 저장 중 오류가 발생했습니다")
- } finally {
- setIsSaving(false)
- }
- }
-
- // debounce된 저장 함수
- const debouncedSave = useRef(debounce(
- (item: QuotationItem, field: keyof QuotationItem, value: any) => {
- saveItemToServer(item, field, value)
- },
- 800 // 800ms 지연
- )).current
-
- // 견적 항목 업데이트 함수
- const handleItemUpdate = (index: number, field: keyof QuotationItem, value: any) => {
- const updatedItem = updateLocalItem(index, field, value)
-
- // debounce를 통해 서버 저장 지연
- if (!disabled) {
- debouncedSave(updatedItem, field, value)
- }
- }
-
- // 모든 변경 사항 저장
- const saveAllChanges = async () => {
- if (disabled || pendingChanges.size === 0) return
-
- setIsSaving(true)
- toast.info(`${pendingChanges.size}개 항목 저장 중...`)
-
- try {
- // 변경된 모든 항목 저장
- for (const itemId of pendingChanges) {
- const index = items.findIndex(item => item.id === itemId)
- if (index !== -1) {
- const item = items[index]
- await updateQuotationItem({
- id: item.id,
- unitPrice: item.unitPrice,
- totalPrice: item.totalPrice,
- taxRate: item.taxRate ?? 0,
- taxAmount: item.taxAmount ?? 0,
- discountRate: item.discountRate ?? 0,
- discountAmount: item.discountAmount ?? 0,
- deliveryDate: item.deliveryDate,
- leadTimeInDays: item.leadTimeInDays ?? 0,
- vendorMaterialCode: item.vendorMaterialCode ?? "",
- vendorMaterialDescription: item.vendorMaterialDescription ?? "",
- isAlternative: item.isAlternative,
- isRecommended: false, // 항상 false로 설정 (사용하지 않음)
- remark: item.remark ?? ""
- })
- }
- }
-
- // 모든 변경 사항 저장 완료
- setPendingChanges(new Set())
- toast.success("모든 변경 사항이 저장되었습니다")
- } catch (error) {
- console.error("변경 사항 저장 오류:", error)
- toast.error("변경 사항 저장 중 오류가 발생했습니다")
- } finally {
- setIsSaving(false)
- }
- }
-
- // blur 이벤트로 저장 트리거 (사용자가 입력 완료 후)
- const handleBlur = (index: number, field: keyof QuotationItem, value: any) => {
- const itemId = items[index].id
-
- // 해당 항목이 pendingChanges에 있다면 즉시 저장
- if (pendingChanges.has(itemId)) {
- const item = items[index]
- saveItemToServer(item, field, value)
- }
- }
-
- // 전체 단가 업데이트 (일괄 반영)
- const handleBulkUnitPriceUpdate = () => {
- if (items.length === 0) return
-
- // 첫 번째 아이템의 단가 가져오기
- const firstUnitPrice = items[0].unitPrice
-
- if (!firstUnitPrice) {
- toast.error("첫 번째 항목의 단가를 먼저 입력해주세요")
- return
- }
-
- // 모든 아이템에 동일한 단가 적용
- const updatedItems = items.map(item => ({
- ...item,
- unitPrice: firstUnitPrice,
- totalPrice: firstUnitPrice * item.quantity,
- taxAmount: item.taxRate ? (firstUnitPrice * item.quantity) * (item.taxRate / 100) : item.taxAmount,
- discountAmount: item.discountRate ? (firstUnitPrice * item.quantity) * (item.discountRate / 100) : item.discountAmount
- }))
-
- // 모든 아이템을 변경 필요 항목으로 표시
- setPendingChanges(new Set(updatedItems.map(item => item.id)))
-
- // 부모 컴포넌트에 변경 사항 알림
- onItemsChange(updatedItems)
-
- toast.info("모든 항목의 단가가 업데이트되었습니다. 변경 사항을 저장하려면 '저장' 버튼을 클릭하세요.")
- }
-
- // 입력 핸들러
- const handleNumberInputChange = (
- index: number,
- field: keyof QuotationItem,
- e: React.ChangeEvent<HTMLInputElement>
- ) => {
- const value = e.target.value === '' ? 0 : parseFloat(e.target.value)
- handleItemUpdate(index, field, value)
- }
-
- const handleTextInputChange = (
- index: number,
- field: keyof QuotationItem,
- e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>
- ) => {
- handleItemUpdate(index, field, e.target.value)
- }
-
- const handleDateChange = (
- index: number,
- field: keyof QuotationItem,
- date: Date | undefined
- ) => {
- handleItemUpdate(index, field, date || null)
- }
-
- const handleCheckboxChange = (
- index: number,
- field: keyof QuotationItem,
- checked: boolean
- ) => {
- handleItemUpdate(index, field, checked)
- }
-
- // 날짜 형식 지정
- const formatDeliveryDate = (date: Date | null) => {
- if (!date) return "-"
- return format(date, "yyyy-MM-dd")
- }
-
- // 입력 폼 필드 렌더링
- const renderInputField = (item: QuotationItem, index: number, field: keyof QuotationItem) => {
- if (field === 'unitPrice' || field === 'taxRate' || field === 'discountRate' || field === 'leadTimeInDays') {
- return (
- <Input
- type="number"
- min={0}
- step={field === 'unitPrice' ? 0.01 : field === 'taxRate' || field === 'discountRate' ? 0.1 : 1}
- value={item[field] as number || 0}
- onChange={(e) => handleNumberInputChange(index, field, e)}
- onBlur={(e) => handleBlur(index, field, parseFloat(e.target.value) || 0)}
- disabled={disabled || isSaving}
- className="w-full"
- />
- )
- } else if (field === 'vendorMaterialCode' || field === 'vendorMaterialDescription') {
- return (
- <Input
- type="text"
- value={item[field] as string || ''}
- onChange={(e) => handleTextInputChange(index, field, e)}
- onBlur={(e) => handleBlur(index, field, e.target.value)}
- disabled={disabled || isSaving || !item.isAlternative}
- className="w-full"
- placeholder={field === 'vendorMaterialCode' ? "벤더 자재그룹" : "벤더 자재명"}
- />
- )
- } else if (field === 'deliveryDate') {
- return (
- <DatePicker
- date={item.deliveryDate ? new Date(item.deliveryDate) : undefined}
- onSelect={(date) => {
- handleDateChange(index, field, date);
- // DatePicker는 blur 이벤트가 없으므로 즉시 저장 트리거
- if (date) handleBlur(index, field, date);
- }}
- disabled={disabled || isSaving}
- />
- )
- } else if (field === 'isAlternative') {
- return (
- <div className="flex items-center gap-1">
- <Checkbox
- checked={item.isAlternative}
- onCheckedChange={(checked) => {
- handleCheckboxChange(index, field, checked as boolean);
- handleBlur(index, field, checked as boolean);
- }}
- disabled={disabled || isSaving}
- />
- <span className="text-xs">대체품</span>
- </div>
- )
- }
-
- return null
- }
-
- // 대체품 필드 렌더링
- const renderAlternativeFields = (item: QuotationItem, index: number) => {
- if (!item.isAlternative) return null;
-
- return (
- <div className="mt-2 p-3 bg-blue-50 rounded-md space-y-2 text-sm">
- {/* <div className="flex flex-col gap-2">
- <label className="text-xs font-medium text-blue-700">벤더 자재그룹</label>
- <Input
- value={item.vendorMaterialCode || ""}
- onChange={(e) => handleTextInputChange(index, 'vendorMaterialCode', e)}
- onBlur={(e) => handleBlur(index, 'vendorMaterialCode', e.target.value)}
- disabled={disabled || isSaving}
- className="h-8 text-sm"
- placeholder="벤더 자재그룹 입력"
- />
- </div> */}
-
- <div className="flex flex-col gap-2">
- <label className="text-xs font-medium text-blue-700">벤더 자재명</label>
- <Input
- value={item.vendorMaterialDescription || ""}
- onChange={(e) => handleTextInputChange(index, 'vendorMaterialDescription', e)}
- onBlur={(e) => handleBlur(index, 'vendorMaterialDescription', e.target.value)}
- disabled={disabled || isSaving}
- className="h-8 text-sm"
- placeholder="벤더 자재명 입력"
- />
- </div>
-
- <div className="flex flex-col gap-2">
- <label className="text-xs font-medium text-blue-700">대체품 설명</label>
- <Textarea
- value={item.remark || ""}
- onChange={(e) => handleTextInputChange(index, 'remark', e)}
- onBlur={(e) => handleBlur(index, 'remark', e.target.value)}
- disabled={disabled || isSaving}
- className="min-h-[60px] text-sm"
- placeholder="원본과의 차이점, 대체 사유, 장점 등을 설명해주세요"
- />
- </div>
- </div>
- );
- };
-
- // 항목의 저장 상태 아이콘 표시
- const renderSaveStatus = (itemId: number) => {
- if (pendingChanges.has(itemId)) {
- return (
- <TooltipProvider>
- <Tooltip>
- <TooltipTrigger>
- <RefreshCw className="h-4 w-4 text-yellow-500 animate-spin" />
- </TooltipTrigger>
- <TooltipContent>
- <p>저장되지 않은 변경 사항이 있습니다</p>
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
- )
- }
-
- return null
- }
-
- return (
- <div className="space-y-4">
- <div className="flex justify-between items-center">
- <div className="flex items-center gap-2">
- <h3 className="text-lg font-medium">항목 목록 ({items.length}개)</h3>
- {pendingChanges.size > 0 && (
- <Badge variant="outline" className="bg-yellow-50">
- 변경 {pendingChanges.size}개
- </Badge>
- )}
- </div>
-
- <div className="flex items-center gap-2">
- {pendingChanges.size > 0 && !disabled && (
- <Button
- variant="default"
- size="sm"
- onClick={saveAllChanges}
- disabled={isSaving}
- >
- {isSaving ? (
- <RefreshCw className="h-4 w-4 mr-2 animate-spin" />
- ) : (
- <Save className="h-4 w-4 mr-2" />
- )}
- 변경사항 저장 ({pendingChanges.size}개)
- </Button>
- )}
-
- {!disabled && (
- <Button
- variant="outline"
- size="sm"
- onClick={handleBulkUnitPriceUpdate}
- disabled={items.length === 0 || isSaving}
- >
- 첫 항목 단가로 일괄 적용
- </Button>
- )}
- </div>
- </div>
-
- <ScrollArea className="h-[500px] rounded-md border">
- <Table>
- <TableHeader className="sticky top-0 bg-background">
- <TableRow>
- <TableHead className="w-[50px]">번호</TableHead>
- <TableHead>자재그룹</TableHead>
- <TableHead>자재명</TableHead>
- <TableHead>수량</TableHead>
- <TableHead>단위</TableHead>
- <TableHead>단가</TableHead>
- <TableHead>금액</TableHead>
- <TableHead>
- <div className="flex items-center gap-1">
- 세율(%)
- <TooltipProvider>
- <Tooltip>
- <TooltipTrigger>
- <Info className="h-4 w-4" />
- </TooltipTrigger>
- <TooltipContent>
- <p>세율을 입력하면 자동으로 세액이 계산됩니다.</p>
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
- </div>
- </TableHead>
- <TableHead>
- <div className="flex items-center gap-1">
- 납품일
- <TooltipProvider>
- <Tooltip>
- <TooltipTrigger>
- <Info className="h-4 w-4" />
- </TooltipTrigger>
- <TooltipContent>
- <p>납품 가능한 날짜를 선택해주세요.</p>
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
- </div>
- </TableHead>
- <TableHead>리드타임(일)</TableHead>
- <TableHead>
- <div className="flex items-center gap-1">
- 대체품
- <TooltipProvider>
- <Tooltip>
- <TooltipTrigger>
- <Info className="h-4 w-4" />
- </TooltipTrigger>
- <TooltipContent>
- <p>요청된 제품의 대체품을 제안할 경우 선택하세요.</p>
- <p>대체품을 선택하면 추가 정보를 입력할 수 있습니다.</p>
- </TooltipContent>
- </Tooltip>
- </TooltipProvider>
- </div>
- </TableHead>
- <TableHead className="w-[50px]">상태</TableHead>
- </TableRow>
- </TableHeader>
- <TableBody>
- {items.length === 0 ? (
- <TableRow>
- <TableCell colSpan={12} className="text-center py-10">
- 견적 항목이 없습니다
- </TableCell>
- </TableRow>
- ) : (
- items.map((item, index) => (
- <React.Fragment key={item.id}>
- <TableRow className={pendingChanges.has(item.id) ? "bg-yellow-50/30" : ""}>
- <TableCell>
- {index + 1}
- </TableCell>
- <TableCell>
- {item.materialCode || "-"}
- </TableCell>
- <TableCell>
- <div className="font-medium max-w-xs truncate">
- {item.materialDescription || "-"}
- </div>
- </TableCell>
- <TableCell>
- {item.quantity}
- </TableCell>
- <TableCell>
- {item.uom || "-"}
- </TableCell>
- <TableCell>
- {renderInputField(item, index, 'unitPrice')}
- </TableCell>
- <TableCell>
- {formatCurrency(item.totalPrice, currency)}
- </TableCell>
- <TableCell>
- {renderInputField(item, index, 'taxRate')}
- </TableCell>
- <TableCell>
- {renderInputField(item, index, 'deliveryDate')}
- </TableCell>
- <TableCell>
- {renderInputField(item, index, 'leadTimeInDays')}
- </TableCell>
- <TableCell>
- {renderInputField(item, index, 'isAlternative')}
- </TableCell>
- <TableCell>
- {renderSaveStatus(item.id)}
- </TableCell>
- </TableRow>
-
- {/* 대체품으로 선택된 경우 추가 정보 행 표시 */}
- {item.isAlternative && (
- <TableRow className={pendingChanges.has(item.id) ? "bg-blue-50/40" : "bg-blue-50/20"}>
- <TableCell colSpan={1}></TableCell>
- <TableCell colSpan={10}>
- {renderAlternativeFields(item, index)}
- </TableCell>
- <TableCell colSpan={1}></TableCell>
- </TableRow>
- )}
- </React.Fragment>
- ))
- )}
- </TableBody>
- </Table>
- </ScrollArea>
-
- {isSaving && (
- <div className="flex items-center justify-center text-sm text-muted-foreground">
- <Clock className="h-4 w-4 animate-spin mr-2" />
- 변경 사항을 저장 중입니다...
- </div>
- )}
-
- <div className="bg-muted p-4 rounded-md">
- <h4 className="text-sm font-medium mb-2">안내 사항</h4>
- <ul className="text-sm space-y-1 text-muted-foreground">
- <li className="flex items-start gap-2">
- <AlertTriangle className="h-4 w-4 mt-0.5 flex-shrink-0" />
- <span>단가와 납품일은 필수로 입력해야 합니다.</span>
- </li>
- <li className="flex items-start gap-2">
- <ClipboardCheck className="h-4 w-4 mt-0.5 flex-shrink-0" />
- <span>입력 후 다른 필드로 이동하면 자동으로 저장됩니다. 여러 항목을 변경한 후 '저장' 버튼을 사용할 수도 있습니다.</span>
- </li>
- <li className="flex items-start gap-2">
- <FileText className="h-4 w-4 mt-0.5 flex-shrink-0" />
- <span><strong>대체품</strong>으로 제안하는 경우 자재명, 대체품 설명을 입력해주세요.</span>
- </li>
- </ul>
- </div>
- </div>
- )
-} \ No newline at end of file
diff --git a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx
index b89f8953..39de94ed 100644
--- a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx
+++ b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table-columns.tsx
@@ -30,7 +30,6 @@ interface QuotationWithRfqCode extends TechSalesVendorQuotations {
// 아이템 정보
itemName?: string;
-
itemCount?: number;
// 프로젝트 정보
@@ -38,6 +37,9 @@ interface QuotationWithRfqCode extends TechSalesVendorQuotations {
pspid?: string;
sector?: string;
+ // RFQ 정보
+ description?: string;
+
// 벤더 정보
vendorName?: string;
vendorCode?: string;
@@ -194,6 +196,33 @@ export function getColumns({ router, openAttachmentsSheet, openItemsDialog }: Ge
// enableHiding: true,
// },
{
+ accessorKey: "description",
+ header: ({ column }) => (
+ <DataTableColumnHeaderSimple column={column} title="RFQ title" />
+ ),
+ cell: ({ row }) => {
+ const description = row.getValue("description") as string;
+ return (
+ <div className="min-w-48 max-w-64">
+ <TooltipProvider>
+ <Tooltip>
+ <TooltipTrigger asChild>
+ <span className="truncate block text-sm">
+ {description || "N/A"}
+ </span>
+ </TooltipTrigger>
+ <TooltipContent>
+ <p className="max-w-xs">{description || "N/A"}</p>
+ </TooltipContent>
+ </Tooltip>
+ </TooltipProvider>
+ </div>
+ );
+ },
+ enableSorting: true,
+ enableHiding: true,
+ },
+ {
accessorKey: "projNm",
header: ({ column }) => (
<DataTableColumnHeaderSimple column={column} title="프로젝트명" />
@@ -313,7 +342,6 @@ export function getColumns({ router, openAttachmentsSheet, openItemsDialog }: Ge
cell: ({ row }) => {
const quotation = row.original
const attachmentCount = quotation.attachmentCount || 0
-
const handleClick = () => {
openAttachmentsSheet(quotation.rfqId)
}
diff --git a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx
index 5e5d4f39..4c5cdf8e 100644
--- a/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx
+++ b/lib/techsales-rfq/vendor-response/table/vendor-quotations-table.tsx
@@ -38,12 +38,15 @@ interface QuotationWithRfqCode extends TechSalesVendorQuotations {
rfqStatus?: string;
itemName?: string | null;
projNm?: string | null;
- quotationCode?: string | null;
-
- rejectionReason?: string | null;
- acceptedAt?: Date | null;
+ description?: string | null;
attachmentCount?: number;
itemCount?: number;
+ pspid?: string | null;
+ sector?: string | null;
+ vendorName?: string | null;
+ vendorCode?: string | null;
+ createdByName?: string | null;
+ updatedByName?: string | null;
}
interface VendorQuotationsTableProps {
@@ -380,7 +383,7 @@ export function VendorQuotationsTable({ vendorId, rfqType }: VendorQuotationsTab
// useDataTable 훅 사용
const { table } = useDataTable({
data: stableData,
- columns,
+ columns: columns as any,
pageCount,
rowCount: total,
filterFields,
@@ -391,7 +394,7 @@ export function VendorQuotationsTable({ vendorId, rfqType }: VendorQuotationsTab
enableRowSelection: true, // 행 선택 활성화
initialState: {
sorting: initialSettings.sort,
- columnPinning: { right: ["actions"] },
+ columnPinning: { right: ["actions", "items", "attachments"] },
},
getRowId: (originalRow) => String(originalRow.id),
shallow: false,
@@ -417,13 +420,6 @@ export function VendorQuotationsTable({ vendorId, rfqType }: VendorQuotationsTab
<div className="w-full">
<div className="overflow-x-auto">
<div className="relative">
- {/* 로딩 오버레이 (재로딩 시) */}
- {/* {!isInitialLoad && isLoading && (
- <div className="absolute h-full w-full inset-0 bg-background/90 backdrop-blur-md z-10 flex items-center justify-center">
- <CenterLoadingIndicator />
- </div>
- )} */}
-
<DataTable
table={table}
className="min-w-full"