diff options
| author | joonhoekim <26rote@gmail.com> | 2025-06-24 01:51:59 +0000 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-06-24 01:51:59 +0000 |
| commit | 6824e097d768f724cf439b410ccfb1ab9685ac98 (patch) | |
| tree | 1f297313637878e7a4ad6c89b84d5a2c3e9eb650 /lib | |
| parent | f4825dd3853188de4688fb4a56c0f4e847da314b (diff) | |
| parent | 4e63d8427d26d0d1b366ddc53650e15f3481fc75 (diff) | |
(merge) 대표님/최겸 작업사항 머지
Diffstat (limited to 'lib')
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't' : 'doesn't'} have email addresses and won'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'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" |
