summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--app/[lng]/partners/(partners)/evaluation/page.tsx2
-rw-r--r--app/api/upload/basicContract/chunk/route.ts117
-rw-r--r--components/form-data/form-data-table.tsx50
-rw-r--r--components/form-data/spreadJS-dialog.tsx654
-rw-r--r--db/schema/evaluationTarget.ts8
-rw-r--r--lib/admin-users/table/ausers-table.tsx2
-rw-r--r--lib/basic-contract/template/add-basic-contract-template-dialog.tsx406
-rw-r--r--lib/evaluation-submit/service.ts9
-rw-r--r--lib/evaluation-submit/table/evaluation-submissions-table-columns.tsx4
-rw-r--r--lib/evaluation-submit/table/submit-table.tsx3
-rw-r--r--lib/evaluation-target-list/service.ts301
-rw-r--r--lib/evaluation/service.ts18
-rw-r--r--lib/evaluation/table/evaluation-columns.tsx1
-rw-r--r--lib/evaluation/table/evaluation-filter-sheet.tsx14
-rw-r--r--lib/evaluation/table/evaluation-table.tsx1
-rw-r--r--lib/evaluation/table/periodic-evaluation-action-dialogs.tsx2
-rw-r--r--lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx2
-rw-r--r--lib/roles/services.ts104
-rw-r--r--lib/roles/table/add-role-dialog.tsx248
-rw-r--r--lib/roles/table/assign-roles-sheet.tsx168
-rw-r--r--lib/roles/table/roles-table.tsx60
-rw-r--r--lib/roles/table/update-roles-sheet.tsx2
-rw-r--r--lib/roles/userTable/assignedUsers-table.tsx127
-rw-r--r--lib/techsales-rfq/table/tech-sales-quotation-attachments-sheet.tsx4
-rw-r--r--lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx6
-rw-r--r--lib/users/service.ts147
-rw-r--r--lib/users/table/assign-roles-dialog.tsx353
-rw-r--r--lib/users/table/users-table-toolbar-actions.tsx6
-rw-r--r--lib/users/table/users-table.tsx5
-rw-r--r--lib/vendor-document-list/ship/enhanced-doc-table-toolbar-actions.tsx32
-rw-r--r--lib/vendor-document-list/ship/send-to-shi-button.tsx336
-rw-r--r--lib/vendor-evaluation-submit/service.ts42
-rw-r--r--lib/vendor-evaluation-submit/table/esg-evaluation-form-sheet.tsx78
-rw-r--r--lib/vendor-evaluation-submit/table/general-evaluation-form-sheet.tsx1
-rw-r--r--types/table.d.ts2
35 files changed, 2223 insertions, 1092 deletions
diff --git a/app/[lng]/partners/(partners)/evaluation/page.tsx b/app/[lng]/partners/(partners)/evaluation/page.tsx
index cbb4696b..be88ef3e 100644
--- a/app/[lng]/partners/(partners)/evaluation/page.tsx
+++ b/app/[lng]/partners/(partners)/evaluation/page.tsx
@@ -68,8 +68,6 @@ export default async function IndexPage(props: IndexPageProps) {
// Validate vendorId (should be a number)
const idAsNumber = Number(vendorId)
- console.log(idAsNumber)
-
if (isNaN(idAsNumber)) {
// Handle invalid vendor ID (this shouldn't happen if authentication is working properly)
return (
diff --git a/app/api/upload/basicContract/chunk/route.ts b/app/api/upload/basicContract/chunk/route.ts
index 7100988b..e190fca4 100644
--- a/app/api/upload/basicContract/chunk/route.ts
+++ b/app/api/upload/basicContract/chunk/route.ts
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
-import { mkdir, writeFile, appendFile } from 'fs/promises';
+import { mkdir, writeFile, appendFile, readFile, rm } from 'fs/promises';
import path from 'path';
-import crypto from 'crypto';
+import { generateHashedFileName, saveBuffer } from '@/lib/file-stroage';
export async function POST(request: NextRequest) {
try {
@@ -14,58 +14,99 @@ export async function POST(request: NextRequest) {
const fileId = formData.get('fileId') as string;
if (!chunk || !filename || isNaN(chunkIndex) || isNaN(totalChunks) || !fileId) {
- return NextResponse.json({ success: false, error: '필수 매개변수가 누락되었습니다' }, { status: 400 });
+ return NextResponse.json({
+ success: false,
+ error: '필수 매개변수가 누락되었습니다'
+ }, { status: 400 });
}
-
+
// 임시 디렉토리 생성
const tempDir = path.join(process.cwd(), 'temp', fileId);
await mkdir(tempDir, { recursive: true });
-
+
// 청크 파일 저장
const chunkPath = path.join(tempDir, `chunk-${chunkIndex}`);
const buffer = Buffer.from(await chunk.arrayBuffer());
await writeFile(chunkPath, buffer);
-
+
+ console.log(`📦 청크 저장 완료: ${chunkIndex + 1}/${totalChunks} (${buffer.length} bytes)`);
+
// 마지막 청크인 경우 모든 청크를 합쳐 최종 파일 생성
if (chunkIndex === totalChunks - 1) {
- const uploadDir = path.join(process.cwd(), "public", "basicContract", "template");
- await mkdir(uploadDir, { recursive: true });
+ console.log(`🔄 파일 병합 시작: ${filename}`);
- // 파일명 생성
- const timestamp = Date.now();
- const randomHash = crypto.createHash('md5')
- .update(`${filename}-${timestamp}`)
- .digest('hex')
- .substring(0, 8);
- const hashedFileName = `${timestamp}-${randomHash}${path.extname(filename)}`;
- const finalPath = path.join(uploadDir, hashedFileName);
-
- // 모든 청크 병합
- await writeFile(finalPath, Buffer.alloc(0)); // 빈 파일 생성
- for (let i = 0; i < totalChunks; i++) {
- const chunkData = await require('fs/promises').readFile(path.join(tempDir, `chunk-${i}`));
- await appendFile(finalPath, chunkData);
+ try {
+ // 모든 청크를 순서대로 읽어서 병합
+ const chunks: Buffer[] = [];
+ let totalSize = 0;
+
+ for (let i = 0; i < totalChunks; i++) {
+ const chunkData = await readFile(path.join(tempDir, `chunk-${i}`));
+ chunks.push(chunkData);
+ totalSize += chunkData.length;
+ }
+
+ // 모든 청크를 하나의 Buffer로 병합
+ const mergedBuffer = Buffer.concat(chunks, totalSize);
+
+ console.log(`📄 병합 완료: ${filename} (총 ${totalSize} bytes)`);
+
+ // 공용 함수를 사용하여 파일 저장
+ const saveResult = await saveBuffer({
+ buffer: mergedBuffer,
+ fileName: filename,
+ directory: 'basicContract/template',
+ originalName: filename
+ });
+
+ // 임시 파일 정리 (비동기로 처리)
+ rm(tempDir, { recursive: true, force: true })
+ .then(() => console.log(`🗑️ 임시 파일 정리 완료: ${fileId}`))
+ .catch((e: unknown) => console.error('청크 정리 오류:', e));
+
+ if (saveResult.success) {
+ console.log(`✅ 최종 파일 저장 완료: ${saveResult.fileName}`);
+
+ return NextResponse.json({
+ success: true,
+ fileName: filename,
+ filePath: saveResult.publicPath,
+ hashedFileName: saveResult.fileName,
+ fileSize: totalSize
+ });
+ } else {
+ console.error('파일 저장 실패:', saveResult.error);
+ return NextResponse.json({
+ success: false,
+ error: saveResult.error || '파일 저장에 실패했습니다'
+ }, { status: 500 });
+ }
+
+ } catch (mergeError) {
+ console.error('파일 병합 오류:', mergeError);
+
+ // 오류 발생 시 임시 파일 정리
+ rm(tempDir, { recursive: true, force: true })
+ .catch((e: unknown) => console.error('임시 파일 정리 오류:', e));
+
+ return NextResponse.json({
+ success: false,
+ error: '파일 병합 중 오류가 발생했습니다'
+ }, { status: 500 });
}
-
- // 임시 파일 정리 (비동기로 처리)
- require('fs/promises').rm(tempDir, { recursive: true, force: true })
- .catch((e: unknown) => console.error('청크 정리 오류:', e));
-
- return NextResponse.json({
- success: true,
- fileName: filename,
- filePath: `/basicContract/template/${hashedFileName}`
- });
}
-
- return NextResponse.json({
- success: true,
- chunkIndex,
- message: `청크 ${chunkIndex + 1}/${totalChunks} 업로드 완료`
+
+ return NextResponse.json({
+ success: true,
+ chunkIndex,
+ message: `청크 ${chunkIndex + 1}/${totalChunks} 업로드 완료`
});
} catch (error) {
console.error('청크 업로드 오류:', error);
- return NextResponse.json({ success: false, error: '서버 오류' }, { status: 500 });
+ return NextResponse.json({
+ success: false,
+ error: '서버 오류'
+ }, { status: 500 });
}
} \ No newline at end of file
diff --git a/components/form-data/form-data-table.tsx b/components/form-data/form-data-table.tsx
index 57913192..d964b17b 100644
--- a/components/form-data/form-data-table.tsx
+++ b/components/form-data/form-data-table.tsx
@@ -317,10 +317,6 @@ export default function DynamicTable({
// 새로 추가된 Template 가져오기 함수
const handleGetTemplate = async () => {
- if (selectedRowCount !== 1) {
- toast.error("Please select exactly one row to view template");
- return;
- }
if (!projectCode) {
toast.error("Project code is not available");
@@ -329,12 +325,12 @@ export default function DynamicTable({
try {
setIsLoadingTemplate(true);
-
+
const templateResult = await fetchTemplateFromSEDP(projectCode, formCode);
-
+
setTemplateData(templateResult);
setTemplateDialogOpen(true);
-
+
toast.success("Template data loaded successfully");
} catch (error) {
console.error("Error fetching template:", error);
@@ -818,7 +814,7 @@ export default function DynamicTable({
variant="outline"
size="sm"
onClick={handleGetTemplate}
- disabled={isAnyOperationPending || selectedRowCount !== 1}
+ disabled={isAnyOperationPending}
>
{isLoadingTemplate ? (
<Loader className="mr-2 size-4 animate-spin" />
@@ -826,13 +822,9 @@ export default function DynamicTable({
<Eye className="mr-2 size-4" />
)}
View Template
- {selectedRowCount === 1 && (
- <span className="ml-2 text-xs bg-green-100 text-green-700 px-2 py-1 rounded">
- 1
- </span>
- )}
</Button>
+
{/* COMPARE WITH SEDP 버튼 */}
<Button
variant="outline"
@@ -920,19 +912,33 @@ export default function DynamicTable({
isOpen={templateDialogOpen}
onClose={() => setTemplateDialogOpen(false)}
templateData={templateData}
- selectedRow={selectedRowsData[0]}
+ selectedRow={selectedRowsData[0]} // SPR_ITM_LST_SETUP용
+ tableData={tableData} // SPR_LST_SETUP용 - 새로 추가
formCode={formCode}
contractItemId={contractItemId}
editableFieldsMap={editableFieldsMap}
onUpdateSuccess={(updatedValues) => {
- // SpreadSheets에서 업데이트된 값을 테이블에 반영
- const tagNo = updatedValues.TAG_NO;
- if (tagNo) {
- setTableData(prev =>
- prev.map(item =>
- item.TAG_NO === tagNo ? updatedValues : item
- )
- );
+ // 업데이트 로직도 수정해야 함 - 단일 행 또는 복수 행 처리
+ if (Array.isArray(updatedValues)) {
+ // SPR_LST_SETUP의 경우 - 복수 행 업데이트
+ const updatedData = [...tableData];
+ updatedValues.forEach(updatedItem => {
+ const index = updatedData.findIndex(item => item.TAG_NO === updatedItem.TAG_NO);
+ if (index !== -1) {
+ updatedData[index] = updatedItem;
+ }
+ });
+ setTableData(updatedData);
+ } else {
+ // SPR_ITM_LST_SETUP의 경우 - 단일 행 업데이트
+ const tagNo = updatedValues.TAG_NO;
+ if (tagNo) {
+ setTableData(prev =>
+ prev.map(item =>
+ item.TAG_NO === tagNo ? updatedValues : item
+ )
+ );
+ }
}
}}
/>
diff --git a/components/form-data/spreadJS-dialog.tsx b/components/form-data/spreadJS-dialog.tsx
index 8be9d175..c106f926 100644
--- a/components/form-data/spreadJS-dialog.tsx
+++ b/components/form-data/spreadJS-dialog.tsx
@@ -2,45 +2,31 @@
import * as React from "react";
import dynamic from "next/dynamic";
-import {
- Dialog,
- DialogContent,
- DialogHeader,
- DialogTitle,
- DialogFooter,
- DialogDescription,
-} from "@/components/ui/dialog";
+import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription } from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { GenericData } from "./export-excel-form";
import * as GC from "@mescius/spread-sheets";
import { toast } from "sonner";
import { updateFormDataInDB } from "@/lib/forms/services";
import { Loader, Save } from "lucide-react";
-import {
- Select,
- SelectContent,
- SelectItem,
- SelectTrigger,
- SelectValue,
-} from "@/components/ui/select";
-import "@mescius/spread-sheets/styles/gc.spread.sheets.excel2016colorful.css";
-
-// Dynamically load the SpreadSheets component (disable SSR)
+import '@mescius/spread-sheets/styles/gc.spread.sheets.excel2016colorful.css';
+
+// SpreadSheets를 동적으로 import (SSR 비활성화)
const SpreadSheets = dynamic(
- () => import("@mescius/spread-sheets-react").then((mod) => mod.SpreadSheets),
- {
+ () => import("@mescius/spread-sheets-react").then(mod => mod.SpreadSheets),
+ {
ssr: false,
loading: () => (
<div className="flex items-center justify-center h-full">
<Loader className="mr-2 h-4 w-4 animate-spin" />
Loading SpreadSheets...
</div>
- ),
+ )
}
);
-// Apply license key on the client only
-if (typeof window !== "undefined" && process.env.NEXT_PUBLIC_SPREAD_LICENSE) {
+// 라이센스 키 설정을 클라이언트에서만 실행
+if (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_SPREAD_LICENSE) {
GC.Spread.Sheets.LicenseKey = process.env.NEXT_PUBLIC_SPREAD_LICENSE;
}
@@ -85,11 +71,12 @@ interface TemplateViewDialogProps {
isOpen: boolean;
onClose: () => void;
templateData: TemplateItem[] | any;
- selectedRow: GenericData;
+ selectedRow?: GenericData; // SPR_ITM_LST_SETUP용
+ tableData?: GenericData[]; // SPR_LST_SETUP용
formCode: string;
contractItemId: number;
- editableFieldsMap?: Map<string, string[]>; // editable field info per tag
- onUpdateSuccess?: (updatedValues: Record<string, any>) => void;
+ editableFieldsMap?: Map<string, string[]>;
+ onUpdateSuccess?: (updatedValues: Record<string, any> | GenericData[]) => void;
}
export function TemplateViewDialog({
@@ -97,345 +84,496 @@ export function TemplateViewDialog({
onClose,
templateData,
selectedRow,
+ tableData = [],
formCode,
contractItemId,
editableFieldsMap = new Map(),
- onUpdateSuccess,
+ onUpdateSuccess
}: TemplateViewDialogProps) {
- /* ------------------------- local state ------------------------- */
- const [hostStyle] = React.useState({ width: "100%", height: "100%" });
+ const [hostStyle, setHostStyle] = React.useState({
+ width: '100%',
+ height: '100%'
+ });
+
const [isPending, setIsPending] = React.useState(false);
const [hasChanges, setHasChanges] = React.useState(false);
- const [currentSpread, setCurrentSpread] = React.useState<GC.Spread.Sheets.Workbook | null>(
- null
- );
- const [selectedTemplateId, setSelectedTemplateId] = React.useState<string>("");
- const [cellMappings, setCellMappings] = React.useState<
- Array<{ attId: string; cellAddress: string; isEditable: boolean }>
- >([]);
+ const [currentSpread, setCurrentSpread] = React.useState<any>(null);
+ const [cellMappings, setCellMappings] = React.useState<Array<{attId: string, cellAddress: string, isEditable: boolean}>>([]);
const [isClient, setIsClient] = React.useState(false);
+ const [templateType, setTemplateType] = React.useState<'SPR_LST_SETUP' | 'SPR_ITM_LST_SETUP' | null>(null);
- // Render only on client side
+ // 클라이언트 사이드에서만 렌더링되도록 보장
React.useEffect(() => {
setIsClient(true);
}, []);
- /* ------------------------- helpers ------------------------- */
- // Normalize template list and keep only those with CONTENT
- const normalizedTemplates = React.useMemo((): TemplateItem[] => {
- if (!templateData) return [];
-
- const list = Array.isArray(templateData)
- ? (templateData as TemplateItem[])
- : ([templateData] as TemplateItem[]);
-
- return list.filter(
- (t) => t.SPR_LST_SETUP?.CONTENT || t.SPR_ITM_LST_SETUP?.CONTENT
- );
+ // 템플릿 데이터를 배열로 정규화하고 CONTENT가 있는 것 찾기
+ const { normalizedTemplate, detectedTemplateType } = React.useMemo(() => {
+ if (!templateData) return { normalizedTemplate: null, detectedTemplateType: null };
+
+ let templates: TemplateItem[];
+ if (Array.isArray(templateData)) {
+ templates = templateData as TemplateItem[];
+ } else {
+ templates = [templateData as TemplateItem];
+ }
+
+ // CONTENT가 있는 템플릿 찾기
+ for (const template of templates) {
+ if (template.SPR_LST_SETUP?.CONTENT) {
+ return { normalizedTemplate: template, detectedTemplateType: 'SPR_LST_SETUP' as const };
+ }
+ if (template.SPR_ITM_LST_SETUP?.CONTENT) {
+ return { normalizedTemplate: template, detectedTemplateType: 'SPR_ITM_LST_SETUP' as const };
+ }
+ }
+
+ return { normalizedTemplate: null, detectedTemplateType: null };
}, [templateData]);
- // Choose currently selected template
- const selectedTemplate = React.useMemo(() => {
- if (!selectedTemplateId) return normalizedTemplates[0];
- return (
- normalizedTemplates.find((t) => t.TMPL_ID === selectedTemplateId) ||
- normalizedTemplates[0]
- );
- }, [normalizedTemplates, selectedTemplateId]);
-
- // Editable fields for the current TAG_NO
- const editableFields = React.useMemo(() => {
- if (!selectedRow?.TAG_NO) return [];
- return editableFieldsMap.get(selectedRow.TAG_NO) || [];
- }, [selectedRow?.TAG_NO, editableFieldsMap]);
-
- const isFieldEditable = React.useCallback(
- (attId: string) => {
- // TAG_NO and TAG_DESC are always editable
- if (attId === "TAG_NO" || attId === "TAG_DESC") return true;
- if (!selectedRow?.TAG_NO) return false;
- return editableFields.includes(attId);
- },
- [selectedRow?.TAG_NO, editableFields]
- );
-
- /** Convert a cell address like "M1" into {row:0,col:12}. */
- const parseCellAddress = (addr: string): { row: number; col: number } | null => {
- if (!addr) return null;
- const match = addr.match(/^([A-Z]+)(\d+)$/);
+ // 템플릿 타입 설정
+ React.useEffect(() => {
+ setTemplateType(detectedTemplateType);
+ }, [detectedTemplateType]);
+
+ // 필드가 편집 가능한지 판별하는 함수
+ const isFieldEditable = React.useCallback((attId: string) => {
+ // TAG_NO와 TAG_DESC는 기본적으로 편집 가능
+ if (attId === "TAG_NO" || attId === "TAG_DESC") {
+ return true;
+ }
+
+ // SPR_ITM_LST_SETUP인 경우 selectedRow.shi 확인
+ if (templateType === 'SPR_ITM_LST_SETUP' && selectedRow) {
+ return selectedRow.shi !== true;
+ }
+
+ // SPR_LST_SETUP인 경우는 기본적으로 편집 가능 (개별 행의 shi 상태는 저장시 확인)
+ return true;
+ }, [templateType, selectedRow]);
+
+ // 편집 가능한 필드 개수 계산
+ const editableFieldsCount = React.useMemo(() => {
+ return cellMappings.filter(m => m.isEditable).length;
+ }, [cellMappings]);
+
+ // 셀 주소를 행과 열로 변환하는 함수
+ const parseCellAddress = (address: string): {row: number, col: number} | null => {
+ if (!address || address.trim() === "") return null;
+
+ const match = address.match(/^([A-Z]+)(\d+)$/);
if (!match) return null;
+
const [, colStr, rowStr] = match;
+
let col = 0;
for (let i = 0; i < colStr.length; i++) {
col = col * 26 + (colStr.charCodeAt(i) - 65 + 1);
}
col -= 1;
- const row = parseInt(rowStr, 10) - 1;
+
+ const row = parseInt(rowStr) - 1;
+
return { row, col };
};
- // Auto‑select first template
- React.useEffect(() => {
- if (normalizedTemplates.length && !selectedTemplateId) {
- setSelectedTemplateId(normalizedTemplates[0].TMPL_ID);
- }
- }, [normalizedTemplates, selectedTemplateId]);
-
- /* ------------------------- init spread ------------------------- */
- const initSpread = React.useCallback(
- (spread: GC.Spread.Sheets.Workbook | undefined) => {
- if (!spread || !selectedTemplate || !selectedRow) return;
+ const initSpread = React.useCallback((spread: any) => {
+ if (!spread || !normalizedTemplate) return;
+ try {
setCurrentSpread(spread);
setHasChanges(false);
- // Pick content JSON and data‑sheet mapping
- const contentJson =
- selectedTemplate.SPR_LST_SETUP?.CONTENT ??
- selectedTemplate.SPR_ITM_LST_SETUP?.CONTENT;
- const dataSheets =
- selectedTemplate.SPR_LST_SETUP?.DATA_SHEETS ??
- selectedTemplate.SPR_ITM_LST_SETUP?.DATA_SHEETS;
- if (!contentJson) return;
-
- // Prepare shared styles once
- const editableStyle = new GC.Spread.Sheets.Style();
- editableStyle.backColor = "#f0fdf4";
- editableStyle.locked = false;
+ // 템플릿 타입에 따라 CONTENT와 DATA_SHEETS 가져오기
+ let contentJson = null;
+ let dataSheets = null;
+
+ if (templateType === 'SPR_LST_SETUP') {
+ contentJson = normalizedTemplate.SPR_LST_SETUP.CONTENT;
+ dataSheets = normalizedTemplate.SPR_LST_SETUP.DATA_SHEETS;
+ console.log('Using SPR_LST_SETUP.CONTENT for template:', normalizedTemplate.NAME);
+ } else if (templateType === 'SPR_ITM_LST_SETUP') {
+ contentJson = normalizedTemplate.SPR_ITM_LST_SETUP.CONTENT;
+ dataSheets = normalizedTemplate.SPR_ITM_LST_SETUP.DATA_SHEETS;
+ console.log('Using SPR_ITM_LST_SETUP.CONTENT for template:', normalizedTemplate.NAME);
+ }
- const readOnlyStyle = new GC.Spread.Sheets.Style();
- readOnlyStyle.backColor = "#f9fafb";
- readOnlyStyle.foreColor = "#6b7280";
- readOnlyStyle.locked = true;
+ if (!contentJson) {
+ console.warn('No CONTENT found in template:', normalizedTemplate.NAME);
+ return;
+ }
- const jsonObj = typeof contentJson === "string" ? JSON.parse(contentJson) : contentJson;
+ console.log(`Loading template content for: ${normalizedTemplate.NAME} (Type: ${templateType})`);
+
+ const jsonData = typeof contentJson === 'string'
+ ? JSON.parse(contentJson)
+ : contentJson;
- const sheet = spread.getActiveSheet();
+ // 렌더링 일시 중단
+ spread.suspendPaint();
- /* -------- batch load + style -------- */
- sheet.suspendPaint();
- sheet.suspendCalcService(true);
try {
- spread.fromJSON(jsonObj);
- sheet.options.isProtected = false;
-
- const mappings: Array<{ attId: string; cellAddress: string; isEditable: boolean }> = [];
-
- if (dataSheets?.length) {
- dataSheets.forEach((ds) => {
- ds.MAP_CELL_ATT?.forEach(({ ATT_ID, IN }) => {
- if (!IN) return;
- const pos = parseCellAddress(IN);
- if (!pos) return;
- const editable = isFieldEditable(ATT_ID);
- mappings.push({ attId: ATT_ID, cellAddress: IN, isEditable: editable });
- });
+ // fromJSON으로 템플릿 구조 로드
+ spread.fromJSON(jsonData);
+
+ // 활성 시트 가져오기
+ const activeSheet = spread.getActiveSheet();
+
+ // 시트 보호 먼저 해제
+ activeSheet.options.isProtected = false;
+
+ // MAP_CELL_ATT 정보를 사용해서 데이터 매핑
+ if (dataSheets && dataSheets.length > 0) {
+ const mappings: Array<{attId: string, cellAddress: string, isEditable: boolean}> = [];
+
+ dataSheets.forEach(dataSheet => {
+ if (dataSheet.MAP_CELL_ATT) {
+ dataSheet.MAP_CELL_ATT.forEach(mapping => {
+ const { ATT_ID, IN } = mapping;
+
+ if (IN && IN.trim() !== "") {
+ const cellPos = parseCellAddress(IN);
+ if (cellPos) {
+ const isEditable = isFieldEditable(ATT_ID);
+ mappings.push({
+ attId: ATT_ID,
+ cellAddress: IN,
+ isEditable: isEditable
+ });
+
+ // 템플릿 타입에 따라 다른 데이터 처리
+ if (templateType === 'SPR_ITM_LST_SETUP' && selectedRow) {
+ // 단일 행 처리 (기존 로직)
+ const cell = activeSheet.getCell(cellPos.row, cellPos.col);
+ const value = selectedRow[ATT_ID];
+ if (value !== undefined && value !== null) {
+ cell.value(value);
+ }
+
+ // 스타일 적용
+ cell.locked(!isEditable);
+ const existingStyle = activeSheet.getStyle(cellPos.row, cellPos.col);
+ const newStyle = existingStyle ? Object.assign(new GC.Spread.Sheets.Style(), existingStyle) : new GC.Spread.Sheets.Style();
+
+ if (isEditable) {
+ newStyle.backColor = "#f0fdf4";
+ } else {
+ newStyle.backColor = "#f9fafb";
+ newStyle.foreColor = "#6b7280";
+ }
+
+ activeSheet.setStyle(cellPos.row, cellPos.col, newStyle);
+
+ } else if (templateType === 'SPR_LST_SETUP' && tableData.length > 0) {
+ // 복수 행 처리 - 첫 번째 행부터 시작해서 아래로 채움
+ tableData.forEach((rowData, index) => {
+ const targetRow = cellPos.row + index;
+ const cell = activeSheet.getCell(targetRow, cellPos.col);
+ const value = rowData[ATT_ID];
+
+ if (value !== undefined && value !== null) {
+ cell.value(value);
+ }
+
+ // 개별 행의 편집 가능 여부 확인 (shi 필드 기준)
+ const rowEditable = isEditable && (rowData.shi !== true);
+ cell.locked(!rowEditable);
+
+ // 스타일 적용
+ const existingStyle = activeSheet.getStyle(targetRow, cellPos.col);
+ const newStyle = existingStyle ? Object.assign(new GC.Spread.Sheets.Style(), existingStyle) : new GC.Spread.Sheets.Style();
+
+ if (rowEditable) {
+ newStyle.backColor = "#f0fdf4";
+ } else {
+ newStyle.backColor = "#f9fafb";
+ newStyle.foreColor = "#6b7280";
+ }
+
+ activeSheet.setStyle(targetRow, cellPos.col, newStyle);
+ });
+ }
+
+ console.log(`Mapped ${ATT_ID} to cell ${IN} - ${isEditable ? 'Editable' : 'Read-only'}`);
+ }
+ }
+ });
+ }
+ });
+
+ setCellMappings(mappings);
+
+ // 시트 보호 설정
+ activeSheet.options.isProtected = true;
+ activeSheet.options.protectionOptions = {
+ allowSelectLockedCells: true,
+ allowSelectUnlockedCells: true,
+ allowSort: false,
+ allowFilter: false,
+ allowEditObjects: false,
+ allowResizeRows: false,
+ allowResizeColumns: false
+ };
+
+ // 이벤트 리스너 추가
+ activeSheet.bind(GC.Spread.Sheets.Events.CellChanged, (event: any, info: any) => {
+ console.log('Cell changed:', info);
+ setHasChanges(true);
});
- }
- // Apply values + style in chunks for large templates
- const CHUNK = 500;
- let idx = 0;
- const applyChunk = () => {
- const end = Math.min(idx + CHUNK, mappings.length);
- for (; idx < end; idx++) {
- const { attId, cellAddress, isEditable } = mappings[idx];
- const pos = parseCellAddress(cellAddress)!;
- if (selectedRow[attId] !== undefined && selectedRow[attId] !== null) {
- sheet.setValue(pos.row, pos.col, selectedRow[attId]);
- }
- sheet.setStyle(pos.row, pos.col, isEditable ? editableStyle : readOnlyStyle);
- }
- if (idx < mappings.length) {
- requestAnimationFrame(applyChunk);
- } else {
- // enable protection & events after styling done
- sheet.options.isProtected = true;
- sheet.options.protectionOptions = {
- allowSelectLockedCells: true,
- allowSelectUnlockedCells: true,
- } as any;
-
- // Cell/value change events
- sheet.bind(GC.Spread.Sheets.Events.ValueChanged, () => setHasChanges(true));
- sheet.bind(GC.Spread.Sheets.Events.CellChanged, () => setHasChanges(true));
-
- // Prevent editing read‑only fields
- sheet.bind(
- GC.Spread.Sheets.Events.EditStarting,
- (event: any, info: any) => {
- const map = mappings.find((m) => {
- const pos = parseCellAddress(m.cellAddress);
- return pos && pos.row === info.row && pos.col === info.col;
- });
- if (map && !map.isEditable) {
- toast.warning(`${map.attId} field is read‑only`);
+ activeSheet.bind(GC.Spread.Sheets.Events.ValueChanged, (event: any, info: any) => {
+ console.log('Value changed:', info);
+ setHasChanges(true);
+ });
+
+ // 편집 시작 시 읽기 전용 셀 확인
+ activeSheet.bind(GC.Spread.Sheets.Events.EditStarting, (event: any, info: any) => {
+ const mapping = mappings.find(m => {
+ const cellPos = parseCellAddress(m.cellAddress);
+ return cellPos && cellPos.row <= info.row && cellPos.col === info.col;
+ });
+
+ if (mapping) {
+ // SPR_LST_SETUP인 경우 해당 행의 데이터에서 shi 확인
+ if (templateType === 'SPR_LST_SETUP') {
+ const dataRowIndex = info.row - parseCellAddress(mapping.cellAddress)!.row;
+ const rowData = tableData[dataRowIndex];
+ if (rowData && rowData.shi === true) {
+ toast.warning(`Row ${dataRowIndex + 1}: ${mapping.attId} field is read-only (SHI mode)`);
info.cancel = true;
+ return;
}
}
- );
-
- setCellMappings(mappings);
- sheet.resumeCalcService(false);
- sheet.resumePaint();
- }
- };
- applyChunk();
- } catch (err) {
- console.error(err);
- toast.error("Failed to load template");
- sheet.resumeCalcService(false);
- sheet.resumePaint();
+
+ if (!mapping.isEditable) {
+ toast.warning(`${mapping.attId} field is read-only`);
+ info.cancel = true;
+ }
+ }
+ });
+ }
+ } finally {
+ spread.resumePaint();
}
- },
- [selectedTemplate, selectedRow, isFieldEditable]
- );
- /* ------------------------- handlers ------------------------- */
- const handleTemplateChange = (id: string) => {
- setSelectedTemplateId(id);
- setHasChanges(false);
- if (currentSpread) {
- // re‑init after a short tick so component remounts SpreadSheets
- setTimeout(() => initSpread(currentSpread), 50);
+ } catch (error) {
+ console.error('Error initializing spread:', error);
+ toast.error('Failed to load template');
+ if (spread && spread.resumePaint) {
+ spread.resumePaint();
+ }
}
- };
+ }, [normalizedTemplate, templateType, selectedRow, tableData, isFieldEditable]);
+ // 변경사항 저장 함수
const handleSaveChanges = React.useCallback(async () => {
- if (!currentSpread || !hasChanges || !selectedRow) {
+ if (!currentSpread || !hasChanges) {
toast.info("No changes to save");
return;
}
- setIsPending(true);
-
try {
- const sheet = currentSpread.getActiveSheet();
- const payload: Record<string, any> = { ...selectedRow };
+ setIsPending(true);
+
+ const activeSheet = currentSpread.getActiveSheet();
- cellMappings.forEach((m) => {
- if (m.isEditable) {
- const pos = parseCellAddress(m.cellAddress);
- if (pos) payload[m.attId] = sheet.getValue(pos.row, pos.col);
+ if (templateType === 'SPR_ITM_LST_SETUP' && selectedRow) {
+ // 단일 행 저장 (기존 로직)
+ const dataToSave = { ...selectedRow };
+
+ cellMappings.forEach(mapping => {
+ if (mapping.isEditable) {
+ const cellPos = parseCellAddress(mapping.cellAddress);
+ if (cellPos) {
+ const cellValue = activeSheet.getValue(cellPos.row, cellPos.col);
+ dataToSave[mapping.attId] = cellValue;
+ }
+ }
+ });
+
+ dataToSave.TAG_NO = selectedRow.TAG_NO;
+
+ const { success, message } = await updateFormDataInDB(
+ formCode,
+ contractItemId,
+ dataToSave
+ );
+
+ if (!success) {
+ toast.error(message);
+ return;
}
- });
- payload.TAG_NO = selectedRow.TAG_NO; // never change TAG_NO
+ toast.success("Changes saved successfully!");
+ onUpdateSuccess?.(dataToSave);
+
+ } else if (templateType === 'SPR_LST_SETUP' && tableData.length > 0) {
+ // 복수 행 저장
+ const updatedRows: GenericData[] = [];
+ let saveCount = 0;
+
+ for (let i = 0; i < tableData.length; i++) {
+ const originalRow = tableData[i];
+ const dataToSave = { ...originalRow };
+ let hasRowChanges = false;
+
+ // 각 매핑에 대해 해당 행의 값 확인
+ cellMappings.forEach(mapping => {
+ if (mapping.isEditable && originalRow.shi !== true) { // shi가 true인 행은 편집 불가
+ const cellPos = parseCellAddress(mapping.cellAddress);
+ if (cellPos) {
+ const targetRow = cellPos.row + i;
+ const cellValue = activeSheet.getValue(targetRow, cellPos.col);
+
+ // 값이 변경되었는지 확인
+ if (cellValue !== originalRow[mapping.attId]) {
+ dataToSave[mapping.attId] = cellValue;
+ hasRowChanges = true;
+ }
+ }
+ }
+ });
- const { success, message } = await updateFormDataInDB(
- formCode,
- contractItemId,
- payload
- );
+ // 변경사항이 있는 행만 저장
+ if (hasRowChanges) {
+ dataToSave.TAG_NO = originalRow.TAG_NO; // TAG_NO는 절대 변경되지 않도록
- if (!success) {
- toast.error(message);
- return;
+ const { success } = await updateFormDataInDB(
+ formCode,
+ contractItemId,
+ dataToSave
+ );
+
+ if (success) {
+ updatedRows.push(dataToSave);
+ saveCount++;
+ }
+ } else {
+ updatedRows.push(originalRow); // 변경사항이 없으면 원본 유지
+ }
+ }
+
+ if (saveCount > 0) {
+ toast.success(`${saveCount} rows saved successfully!`);
+ onUpdateSuccess?.(updatedRows);
+ } else {
+ toast.info("No changes to save");
+ }
}
- toast.success("Changes saved successfully!");
- onUpdateSuccess?.({ ...selectedRow, ...payload });
setHasChanges(false);
- } catch (err) {
- console.error(err);
+
+ } catch (error) {
+ console.error("Error saving changes:", error);
toast.error("An unexpected error occurred while saving");
} finally {
setIsPending(false);
}
- }, [currentSpread, hasChanges, selectedRow, cellMappings, formCode, contractItemId, onUpdateSuccess]);
+ }, [currentSpread, hasChanges, templateType, selectedRow, tableData, formCode, contractItemId, onUpdateSuccess, cellMappings]);
- /* ------------------------- render ------------------------- */
if (!isOpen) return null;
+ // 데이터 유효성 검사
+ const isDataValid = templateType === 'SPR_ITM_LST_SETUP' ? !!selectedRow : tableData.length > 0;
+ const dataCount = templateType === 'SPR_ITM_LST_SETUP' ? 1 : tableData.length;
+
return (
<Dialog open={isOpen} onOpenChange={onClose}>
- <DialogContent className="w-[80%] max-w-none h-[80vh] flex flex-col" style={{ maxWidth: "80vw" }}>
+ <DialogContent
+ className="w-[80%] max-w-none h-[80vh] flex flex-col"
+ style={{maxWidth:"80vw"}}
+ >
<DialogHeader className="flex-shrink-0">
- <DialogTitle>SEDP Template – {formCode}</DialogTitle>
+ <DialogTitle>SEDP Template - {formCode}</DialogTitle>
<DialogDescription>
- {selectedRow && `Selected TAG_NO: ${selectedRow.TAG_NO || "N/A"}`}
- {hasChanges && <span className="ml-2 text-orange-600 font-medium">• Unsaved changes</span>}
+ {templateType && (
+ <span className="font-medium text-blue-600">
+ Template Type: {templateType === 'SPR_LST_SETUP' ? 'List View' : 'Item View'}
+ </span>
+ )}
+ {templateType === 'SPR_ITM_LST_SETUP' && selectedRow && (
+ <span className="ml-2">• Selected TAG_NO: {selectedRow.TAG_NO || 'N/A'}</span>
+ )}
+ {templateType === 'SPR_LST_SETUP' && (
+ <span className="ml-2">• {dataCount} rows</span>
+ )}
+ {hasChanges && (
+ <span className="ml-2 text-orange-600 font-medium">
+ • Unsaved changes
+ </span>
+ )}
<br />
<div className="flex items-center gap-4 mt-2">
<span className="text-xs text-muted-foreground">
- <span className="inline-block w-3 h-3 bg-green-100 border border-green-400 mr-1" />
+ <span className="inline-block w-3 h-3 bg-green-100 border border-green-400 mr-1"></span>
Editable fields
</span>
<span className="text-xs text-muted-foreground">
- <span className="inline-block w-3 h-3 bg-gray-100 border border-gray-300 mr-1" />
- Read‑only fields
+ <span className="inline-block w-3 h-3 bg-gray-100 border border-gray-300 mr-1"></span>
+ Read-only fields
</span>
- {!!cellMappings.length && (
+ {cellMappings.length > 0 && (
<span className="text-xs text-blue-600">
- {cellMappings.filter((m) => m.isEditable).length} of {cellMappings.length} fields editable
+ {editableFieldsCount} of {cellMappings.length} fields editable
</span>
)}
</div>
</DialogDescription>
</DialogHeader>
-
- {/* Template selector */}
- {normalizedTemplates.length > 1 && (
- <div className="flex-shrink-0 px-4 py-2 border-b">
- <div className="flex items-center gap-2">
- <label className="text-sm font-medium">Template:</label>
- <Select value={selectedTemplateId} onValueChange={handleTemplateChange}>
- <SelectTrigger className="w-64">
- <SelectValue placeholder="Select a template" />
- </SelectTrigger>
- <SelectContent>
- {normalizedTemplates.map((t) => (
- <SelectItem key={t.TMPL_ID} value={t.TMPL_ID}>
- <div className="flex flex-col">
- <span>{t.NAME || `Template ${t.TMPL_ID.slice(0, 8)}`}</span>
- <span className="text-xs text-muted-foreground">{t.TMPL_TYPE}</span>
- </div>
- </SelectItem>
- ))}
- </SelectContent>
- </Select>
- <span className="text-xs text-muted-foreground">({normalizedTemplates.length} templates available)</span>
- </div>
- </div>
- )}
-
- {/* Spreadsheet */}
+
+ {/* SpreadSheets 컴포넌트 영역 */}
<div className="flex-1 overflow-hidden">
- {selectedTemplate && isClient ? (
- <SpreadSheets key={selectedTemplateId} workbookInitialized={initSpread} hostStyle={hostStyle} />
+ {normalizedTemplate && isClient && isDataValid ? (
+ <SpreadSheets
+ key={`${templateType}-${normalizedTemplate.TMPL_ID}`}
+ workbookInitialized={initSpread}
+ hostStyle={hostStyle}
+ />
) : (
<div className="flex items-center justify-center h-full text-muted-foreground">
{!isClient ? (
<>
- <Loader className="mr-2 h-4 w-4 animate-spin" /> Loading...
+ <Loader className="mr-2 h-4 w-4 animate-spin" />
+ Loading...
</>
- ) : (
+ ) : !normalizedTemplate ? (
"No template available"
+ ) : !isDataValid ? (
+ `No ${templateType === 'SPR_ITM_LST_SETUP' ? 'selected row' : 'data'} available`
+ ) : (
+ "Template not ready"
)}
</div>
)}
</div>
- {/* footer */}
<DialogFooter className="flex-shrink-0">
<Button variant="outline" onClick={onClose}>
Close
</Button>
+
{hasChanges && (
- <Button variant="default" onClick={handleSaveChanges} disabled={isPending}>
+ <Button
+ variant="default"
+ onClick={handleSaveChanges}
+ disabled={isPending}
+ >
{isPending ? (
<>
- <Loader className="mr-2 h-4 w-4 animate-spin" /> Saving...
+ <Loader className="mr-2 h-4 w-4 animate-spin" />
+ Saving...
</>
) : (
<>
- <Save className="mr-2 h-4 w-4" /> Save Changes
+ <Save className="mr-2 h-4 w-4" />
+ Save Changes
</>
)}
</Button>
)}
+
</DialogFooter>
</DialogContent>
</Dialog>
);
-}
+} \ No newline at end of file
diff --git a/db/schema/evaluationTarget.ts b/db/schema/evaluationTarget.ts
index 466143be..1bb9ae02 100644
--- a/db/schema/evaluationTarget.ts
+++ b/db/schema/evaluationTarget.ts
@@ -406,22 +406,22 @@ export const periodicEvaluations = pgTable("periodic_evaluations", {
finalScore: decimal("final_score", { precision: 5, scale: 2 }),
finalGrade: varchar("final_grade", {
length: 5,
- enum: ["S", "A", "B", "C", "D"]
+ enum: ["A", "B", "C", "D"]
}),
// 평가 점수 (평가자 평균)
evaluationScore: decimal("evaluation_score", { precision: 5, scale: 2 }),
evaluationGrade: varchar("evaluation_grade", {
length: 5,
- enum: ["S", "A", "B", "C", "D"]
+ enum: ["A", "B", "C", "D"]
}),
// 평가 상태
status: varchar("status", {
length: 30,
- enum: ["PENDING_SUBMISSION", "SUBMITTED", "IN_REVIEW", "REVIEW_COMPLETED", "FINALIZED"]
- }).notNull().default("PENDING_SUBMISSION"),
+ enum: ["PENDING","PENDING_SUBMISSION", "SUBMITTED", "IN_REVIEW", "REVIEW_COMPLETED", "FINALIZED"]
+ }).notNull().default("PENDING"),
// 평가 완료 정보
reviewCompletedAt: timestamp("review_completed_at"),
diff --git a/lib/admin-users/table/ausers-table.tsx b/lib/admin-users/table/ausers-table.tsx
index ed575e75..1e254b5c 100644
--- a/lib/admin-users/table/ausers-table.tsx
+++ b/lib/admin-users/table/ausers-table.tsx
@@ -45,6 +45,8 @@ export function AdmUserTable({ promises }: UsersTableProps) {
React.use(promises)
+ console.log(roles,"roles")
+
const [rowAction, setRowAction] =
React.useState<DataTableRowAction<UserView> | null>(null)
diff --git a/lib/basic-contract/template/add-basic-contract-template-dialog.tsx b/lib/basic-contract/template/add-basic-contract-template-dialog.tsx
index 3a83d50f..c88819e4 100644
--- a/lib/basic-contract/template/add-basic-contract-template-dialog.tsx
+++ b/lib/basic-contract/template/add-basic-contract-template-dialog.tsx
@@ -73,9 +73,12 @@ const templateFormSchema = z.object({
status: z.enum(["ACTIVE", "DISPOSED"]).default("ACTIVE"),
}).refine((data) => {
// 적어도 하나의 적용 범위는 선택되어야 함
- const hasAnyScope = BUSINESS_UNITS.some(unit =>
- data[unit.key as keyof typeof data] as boolean
- );
+ const scopeFields = [
+ 'shipBuildingApplicable', 'windApplicable', 'pcApplicable', 'nbApplicable',
+ 'rcApplicable', 'gyApplicable', 'sysApplicable', 'infraApplicable'
+ ];
+
+ const hasAnyScope = scopeFields.some(field => data[field as keyof typeof data] === true);
return hasAnyScope;
}, {
message: "적어도 하나의 적용 범위를 선택해야 합니다.",
@@ -274,42 +277,85 @@ export function AddTemplateDialog() {
템플릿 추가
</Button>
</DialogTrigger>
- <DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
- <DialogHeader>
+ <DialogContent className="sm:max-w-[600px] h-[90vh] flex flex-col p-0">
+ {/* 고정된 헤더 */}
+ <DialogHeader className="p-6 pb-4 border-b">
<DialogTitle>새 기본계약서 템플릿 추가</DialogTitle>
<DialogDescription>
템플릿 정보를 입력하고 계약서 파일을 업로드하세요.
<span className="text-red-500 mt-1 block text-sm">* 표시된 항목은 필수 입력사항입니다.</span>
</DialogDescription>
</DialogHeader>
- <Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
- {/* 기본 정보 */}
- <Card>
- <CardHeader>
- <CardTitle className="text-lg">기본 정보</CardTitle>
- </CardHeader>
- <CardContent className="space-y-4">
- <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+
+ {/* 스크롤 가능한 컨텐츠 영역 */}
+ <div className="flex-1 overflow-y-auto px-6">
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 py-4">
+ {/* 기본 정보 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg">기본 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
+ <FormField
+ control={form.control}
+ name="templateCode"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 템플릿 코드 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Input
+ placeholder="TEMPLATE_001"
+ {...field}
+ style={{ textTransform: 'uppercase' }}
+ onChange={(e) => field.onChange(e.target.value.toUpperCase())}
+ />
+ </FormControl>
+ <FormDescription>
+ 영문 대문자, 숫자, '_', '-'만 사용 가능
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="revision"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>리비전</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ min="1"
+ {...field}
+ onChange={(e) => field.onChange(parseInt(e.target.value) || 1)}
+ />
+ </FormControl>
+ <FormDescription>
+ 템플릿 버전 (기본값: 1)
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
<FormField
control={form.control}
- name="templateCode"
+ name="templateName"
render={({ field }) => (
<FormItem>
<FormLabel>
- 템플릿 코드 <span className="text-red-500">*</span>
+ 템플릿 이름 <span className="text-red-500">*</span>
</FormLabel>
<FormControl>
- <Input
- placeholder="TEMPLATE_001"
- {...field}
- style={{ textTransform: 'uppercase' }}
- onChange={(e) => field.onChange(e.target.value.toUpperCase())}
- />
+ <Input placeholder="기본 계약서 템플릿" {...field} />
</FormControl>
- <FormDescription>
- 영문 대문자, 숫자, '_', '-'만 사용 가능
- </FormDescription>
<FormMessage />
</FormItem>
)}
@@ -317,191 +363,157 @@ export function AddTemplateDialog() {
<FormField
control={form.control}
- name="revision"
+ name="legalReviewRequired"
render={({ field }) => (
- <FormItem>
- <FormLabel>리비전</FormLabel>
+ <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
+ <div className="space-y-0.5">
+ <FormLabel>법무검토 필요</FormLabel>
+ <FormDescription>
+ 법무팀 검토가 필요한 템플릿인지 설정
+ </FormDescription>
+ </div>
<FormControl>
- <Input
- type="number"
- min="1"
- {...field}
- onChange={(e) => field.onChange(parseInt(e.target.value) || 1)}
+ <Switch
+ checked={field.value}
+ onCheckedChange={field.onChange}
/>
</FormControl>
- <FormDescription>
- 템플릿 버전 (기본값: 1)
- </FormDescription>
- <FormMessage />
</FormItem>
)}
/>
- </div>
-
- <FormField
- control={form.control}
- name="templateName"
- render={({ field }) => (
- <FormItem>
- <FormLabel>
- 템플릿 이름 <span className="text-red-500">*</span>
- </FormLabel>
- <FormControl>
- <Input placeholder="기본 계약서 템플릿" {...field} />
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
+ </CardContent>
+ </Card>
- <FormField
- control={form.control}
- name="legalReviewRequired"
- render={({ field }) => (
- <FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
- <div className="space-y-0.5">
- <FormLabel>법무검토 필요</FormLabel>
- <FormDescription>
- 법무팀 검토가 필요한 템플릿인지 설정
- </FormDescription>
- </div>
- <FormControl>
- <Switch
- checked={field.value}
- onCheckedChange={field.onChange}
- />
- </FormControl>
- </FormItem>
+ {/* 적용 범위 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg">
+ 적용 범위 <span className="text-red-500">*</span>
+ </CardTitle>
+ <CardDescription>
+ 이 템플릿이 적용될 사업부를 선택하세요. ({selectedScopesCount}개 선택됨)
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="select-all"
+ checked={selectedScopesCount === BUSINESS_UNITS.length}
+ onCheckedChange={handleSelectAllScopes}
+ />
+ <label htmlFor="select-all" className="text-sm font-medium">
+ 전체 선택
+ </label>
+ </div>
+
+ <Separator />
+
+ <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
+ {BUSINESS_UNITS.map((unit) => (
+ <FormField
+ key={unit.key}
+ control={form.control}
+ name={unit.key as keyof TemplateFormValues}
+ render={({ field }) => (
+ <FormItem className="flex flex-row items-start space-x-3 space-y-0">
+ <FormControl>
+ <Checkbox
+ checked={field.value as boolean}
+ onCheckedChange={field.onChange}
+ />
+ </FormControl>
+ <div className="space-y-1 leading-none">
+ <FormLabel className="text-sm font-normal">
+ {unit.label}
+ </FormLabel>
+ </div>
+ </FormItem>
+ )}
+ />
+ ))}
+ </div>
+
+ {form.formState.errors.shipBuildingApplicable && (
+ <p className="text-sm text-destructive">
+ {form.formState.errors.shipBuildingApplicable.message}
+ </p>
)}
- />
- </CardContent>
- </Card>
+ </CardContent>
+ </Card>
- {/* 적용 범위 */}
- <Card>
- <CardHeader>
- <CardTitle className="text-lg">적용 범위</CardTitle>
- <CardDescription>
- 이 템플릿이 적용될 사업부를 선택하세요. ({selectedScopesCount}개 선택됨)
- </CardDescription>
- </CardHeader>
- <CardContent className="space-y-4">
- <div className="flex items-center space-x-2">
- <Checkbox
- id="select-all"
- checked={selectedScopesCount === BUSINESS_UNITS.length}
- onCheckedChange={handleSelectAllScopes}
+ {/* 파일 업로드 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg">파일 업로드</CardTitle>
+ </CardHeader>
+ <CardContent>
+ <FormField
+ control={form.control}
+ name="file"
+ render={() => (
+ <FormItem>
+ <FormLabel>
+ 계약서 파일 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Dropzone
+ onDrop={handleFileChange}
+ accept={{
+ 'application/pdf': ['.pdf']
+ }}
+ >
+ <DropzoneZone>
+ <DropzoneUploadIcon className="h-10 w-10 text-muted-foreground" />
+ <DropzoneTitle>
+ {selectedFile ? selectedFile.name : "PDF 파일을 여기에 드래그하세요"}
+ </DropzoneTitle>
+ <DropzoneDescription>
+ {selectedFile
+ ? `파일 크기: ${(selectedFile.size / (1024 * 1024)).toFixed(2)} MB`
+ : "또는 클릭하여 PDF 파일을 선택하세요 (최대 100MB)"}
+ </DropzoneDescription>
+ <DropzoneInput />
+ </DropzoneZone>
+ </Dropzone>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
/>
- <label htmlFor="select-all" className="text-sm font-medium">
- 전체 선택
- </label>
- </div>
-
- <Separator />
-
- <div className="grid grid-cols-2 md:grid-cols-4 gap-4">
- {BUSINESS_UNITS.map((unit) => (
- <FormField
- key={unit.key}
- control={form.control}
- name={unit.key as keyof TemplateFormValues}
- render={({ field }) => (
- <FormItem className="flex flex-row items-start space-x-3 space-y-0">
- <FormControl>
- <Checkbox
- checked={field.value as boolean}
- onCheckedChange={field.onChange}
- />
- </FormControl>
- <div className="space-y-1 leading-none">
- <FormLabel className="text-sm font-normal">
- {unit.label}
- </FormLabel>
- </div>
- </FormItem>
- )}
- />
- ))}
- </div>
-
- {form.formState.errors.shipBuildingApplicable && (
- <p className="text-sm text-destructive">
- {form.formState.errors.shipBuildingApplicable.message}
- </p>
- )}
- </CardContent>
- </Card>
-
- {/* 파일 업로드 */}
- <Card>
- <CardHeader>
- <CardTitle className="text-lg">파일 업로드</CardTitle>
- </CardHeader>
- <CardContent>
- <FormField
- control={form.control}
- name="file"
- render={() => (
- <FormItem>
- <FormLabel>
- 계약서 파일 <span className="text-red-500">*</span>
- </FormLabel>
- <FormControl>
- <Dropzone
- onDrop={handleFileChange}
- accept={{
- 'application/pdf': ['.pdf']
- }}
- >
- <DropzoneZone>
- <DropzoneUploadIcon className="h-10 w-10 text-muted-foreground" />
- <DropzoneTitle>
- {selectedFile ? selectedFile.name : "PDF 파일을 여기에 드래그하세요"}
- </DropzoneTitle>
- <DropzoneDescription>
- {selectedFile
- ? `파일 크기: ${(selectedFile.size / (1024 * 1024)).toFixed(2)} MB`
- : "또는 클릭하여 PDF 파일을 선택하세요 (최대 100MB)"}
- </DropzoneDescription>
- <DropzoneInput />
- </DropzoneZone>
- </Dropzone>
- </FormControl>
- <FormMessage />
- </FormItem>
- )}
- />
-
- {showProgress && (
- <div className="space-y-2 mt-4">
- <div className="flex justify-between text-sm">
- <span>업로드 진행률</span>
- <span>{uploadProgress}%</span>
+
+ {showProgress && (
+ <div className="space-y-2 mt-4">
+ <div className="flex justify-between text-sm">
+ <span>업로드 진행률</span>
+ <span>{uploadProgress}%</span>
+ </div>
+ <Progress value={uploadProgress} />
</div>
- <Progress value={uploadProgress} />
- </div>
- )}
- </CardContent>
- </Card>
+ )}
+ </CardContent>
+ </Card>
+ </form>
+ </Form>
+ </div>
- <DialogFooter>
- <Button
- type="button"
- variant="outline"
- onClick={() => setOpen(false)}
- disabled={isLoading}
- >
- 취소
- </Button>
- <Button
- type="submit"
- disabled={isLoading || !form.formState.isValid}
- >
- {isLoading ? "처리 중..." : "추가"}
- </Button>
- </DialogFooter>
- </form>
- </Form>
+ {/* 고정된 푸터 */}
+ <DialogFooter className="p-6 pt-4 border-t">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => setOpen(false)}
+ disabled={isLoading}
+ >
+ 취소
+ </Button>
+ <Button
+ type="button"
+ onClick={form.handleSubmit(onSubmit)}
+ disabled={isLoading || !form.formState.isValid}
+ >
+ {isLoading ? "처리 중..." : "추가"}
+ </Button>
+ </DialogFooter>
</DialogContent>
</Dialog>
);
diff --git a/lib/evaluation-submit/service.ts b/lib/evaluation-submit/service.ts
index 99c5cb5e..21ceb36f 100644
--- a/lib/evaluation-submit/service.ts
+++ b/lib/evaluation-submit/service.ts
@@ -16,7 +16,7 @@ import {
reviewerEvaluationAttachments,
users
} from "@/db/schema";
-import { and, asc, desc, eq, ilike, or, SQL, count , sql, avg, isNotNull} from "drizzle-orm";
+import { and, inArray, asc, desc, eq, ilike, or, SQL, count , sql, avg, isNotNull} from "drizzle-orm";
import { filterColumns } from "@/lib/filter-columns";
import { DEPARTMENT_CATEGORY_MAPPING, EvaluationFormData, GetSHIEvaluationsSubmitSchema, REVIEWER_TYPES, ReviewerType } from "./validation";
import { AttachmentInfo, EvaluationQuestionItem } from "@/types/evaluation-form";
@@ -421,18 +421,18 @@ export async function getSHIEvaluationSubmissions(input: GetSHIEvaluationsSubmit
);
}
- const existingReviewer = await db.query.evaluationTargetReviewers.findFirst({
+ const existingReviewer = await db.query.evaluationTargetReviewers.findMany({
where: eq(evaluationTargetReviewers.reviewerUserId, userId),
});
-
const finalWhere = and(
advancedWhere,
globalWhere,
- eq(reviewerEvaluationsView.evaluationTargetReviewerId, existingReviewer?.id),
+ inArray(reviewerEvaluationsView.evaluationTargetReviewerId, existingReviewer.map(e => e.id)),
);
+
// 정렬
const orderBy = input.sort.length > 0
? input.sort.map((item) => {
@@ -458,7 +458,6 @@ export async function getSHIEvaluationSubmissions(input: GetSHIEvaluationsSubmit
.select({ count: count() })
.from(reviewerEvaluationsView)
.where(finalWhere);
-
const total = totalResult[0]?.count || 0;
return { data, total };
diff --git a/lib/evaluation-submit/table/evaluation-submissions-table-columns.tsx b/lib/evaluation-submit/table/evaluation-submissions-table-columns.tsx
index 8d097aff..73c4f378 100644
--- a/lib/evaluation-submit/table/evaluation-submissions-table-columns.tsx
+++ b/lib/evaluation-submit/table/evaluation-submissions-table-columns.tsx
@@ -342,14 +342,14 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
<div className="font-medium text-blue-600">
최종: {parseFloat(finalScore.toString()).toFixed(1)}점
</div>
- <Badge variant="outline">{finalGrade}</Badge>
+ {/* <Badge variant="outline">{finalGrade}</Badge> */}
</div>
) : evaluationScore && evaluationGrade ? (
<div className="space-y-1">
<div className="font-medium">
{parseFloat(evaluationScore.toString()).toFixed(1)}점
</div>
- <Badge variant="outline">{evaluationGrade}</Badge>
+ {/* <Badge variant="outline">{evaluationGrade}</Badge> */}
</div>
) : (
<span className="text-muted-foreground">미산정</span>
diff --git a/lib/evaluation-submit/table/submit-table.tsx b/lib/evaluation-submit/table/submit-table.tsx
index 9000c48b..a1d917fd 100644
--- a/lib/evaluation-submit/table/submit-table.tsx
+++ b/lib/evaluation-submit/table/submit-table.tsx
@@ -33,9 +33,6 @@ export function SHIEvaluationSubmissionsTable({ promises }: EvaluationSubmission
}>({ data: [], pageCount: 0 })
const router = useRouter()
- console.log(tableData)
-
-
// 2. 행 액션 상태 관리
const [rowAction, setRowAction] =
React.useState<DataTableRowAction<ReviewerEvaluationView> | null>(null)
diff --git a/lib/evaluation-target-list/service.ts b/lib/evaluation-target-list/service.ts
index 4559374b..251561f9 100644
--- a/lib/evaluation-target-list/service.ts
+++ b/lib/evaluation-target-list/service.ts
@@ -345,7 +345,7 @@ export async function createEvaluationTarget(
// 담당자들 지정
if (input.reviewers && input.reviewers.length > 0) {
const reviewerIds = input.reviewers.map(r => r.reviewerUserId);
-
+
// 🔧 수정: SQL 배열 처리 개선
const reviewerInfos = await tx
.select({
@@ -354,26 +354,26 @@ export async function createEvaluationTarget(
.from(users)
.where(inArray(users.id, reviewerIds)); // sql 대신 inArray 사용
- const reviewerAssignments: typeof evaluationTargetReviewers.$inferInsert[] = [
- ...input.reviewers.map(r => {
- const info = reviewerInfos.find(i => i.id === r.reviewerUserId);
- return {
- evaluationTargetId,
- departmentCode: r.departmentCode,
- departmentNameFrom: info?.departmentName ?? "TEST 부서",
- reviewerUserId: r.reviewerUserId,
- assignedBy: createdBy,
- };
- }),
- // session user 추가
- {
+ const reviewerAssignments: typeof evaluationTargetReviewers.$inferInsert[] = [
+ ...input.reviewers.map(r => {
+ const info = reviewerInfos.find(i => i.id === r.reviewerUserId);
+ return {
evaluationTargetId,
- departmentCode: "admin",
- departmentNameFrom: "정기평가 관리자",
- reviewerUserId: Number(session.user.id),
+ departmentCode: r.departmentCode,
+ departmentNameFrom: info?.departmentName ?? "TEST 부서",
+ reviewerUserId: r.reviewerUserId,
assignedBy: createdBy,
- }
- ];
+ };
+ }),
+ // session user 추가
+ {
+ evaluationTargetId,
+ departmentCode: "admin",
+ departmentNameFrom: "정기평가 관리자",
+ reviewerUserId: Number(session.user.id),
+ assignedBy: createdBy,
+ }
+ ];
await tx.insert(evaluationTargetReviewers).values(reviewerAssignments);
}
@@ -423,14 +423,14 @@ export interface UpdateEvaluationTargetInput {
ldClaimAmount?: number
ldClaimCurrency?: "KRW" | "USD" | "EUR" | "JPY"
consensusStatus?: boolean | null
-
+
// 각 부서별 평가 결과
orderIsApproved?: boolean | null
procurementIsApproved?: boolean | null
qualityIsApproved?: boolean | null
designIsApproved?: boolean | null
csIsApproved?: boolean | null
-
+
// 담당자 이메일 (사용자 ID로 변환됨)
orderReviewerEmail?: string
procurementReviewerEmail?: string
@@ -441,7 +441,7 @@ export interface UpdateEvaluationTargetInput {
export async function updateEvaluationTarget(input: UpdateEvaluationTargetInput) {
console.log(input, "update input")
-
+
try {
const session = await getServerSession(authOptions)
@@ -486,7 +486,7 @@ export async function updateEvaluationTarget(input: UpdateEvaluationTargetInput)
// 기본 정보가 있으면 업데이트
if (Object.keys(updateFields).length > 0) {
updateFields.updatedAt = new Date()
-
+
await tx
.update(evaluationTargets)
.set(updateFields)
@@ -530,7 +530,7 @@ export async function updateEvaluationTarget(input: UpdateEvaluationTargetInput)
evaluationTargetId: input.id,
departmentCode: update.departmentCode,
reviewerUserId: Number(user[0].id),
- assignedBy:Number( session.user.id),
+ assignedBy: Number(session.user.id),
})
}
}
@@ -550,8 +550,8 @@ export async function updateEvaluationTarget(input: UpdateEvaluationTargetInput)
if (review.isApproved !== undefined) {
// 해당 부서의 담당자 조회
const reviewer = await tx
- .select({
- reviewerUserId: evaluationTargetReviewers.reviewerUserId
+ .select({
+ reviewerUserId: evaluationTargetReviewers.reviewerUserId
})
.from(evaluationTargetReviewers)
.where(
@@ -598,10 +598,25 @@ export async function updateEvaluationTarget(input: UpdateEvaluationTargetInput)
.from(evaluationTargetReviews)
.where(eq(evaluationTargetReviews.evaluationTargetId, input.id))
- console.log("Current reviews:", currentReviews)
+
+ const evaluationTargetForConcensus = await tx
+ .select({
+ materialType: evaluationTargets.materialType,
+ })
+ .from(evaluationTargets)
+ .where(eq(evaluationTargets.id, input.id))
+ .limit(1)
+
+ if (evaluationTargetForConcensus.length === 0) {
+ throw new Error("평가 대상을 찾을 수 없습니다.")
+ }
+
+ const { materialType } = evaluationTargetForConcensus[0]
+ const minimumReviewsRequired = materialType === "BULK" ? 3 : 5
+
// 최소 3개 부서에서 평가가 완료된 경우 의견 일치 상태 계산
- if (currentReviews.length >= 3) {
+ if (currentReviews.length >= minimumReviewsRequired) {
const approvals = currentReviews.map(r => r.isApproved)
const allApproved = approvals.every(approval => approval === true)
const allRejected = approvals.every(approval => approval === false)
@@ -617,7 +632,7 @@ export async function updateEvaluationTarget(input: UpdateEvaluationTargetInput)
await tx
.update(evaluationTargets)
- .set({
+ .set({
consensusStatus: hasConsensus,
confirmedAt: hasConsensus ? new Date() : null,
confirmedBy: hasConsensus ? Number(session.user.id) : null,
@@ -710,24 +725,24 @@ export async function getDepartmentInfo() {
export async function confirmEvaluationTargets(
- targetIds: number[],
+ 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 ="연간"
-
+ const currentPeriod = "연간"
+
// 트랜잭션으로 처리
const result = await db.transaction(async (tx) => {
// 확정 가능한 대상들 확인 (PENDING 상태이면서 consensusStatus가 true인 것들)
@@ -741,13 +756,13 @@ export async function confirmEvaluationTargets(
eq(evaluationTargets.consensusStatus, true)
)
)
-
+
if (eligibleTargets.length === 0) {
throw new Error("확정 가능한 평가 대상이 없습니다. (의견 일치 상태인 대기중 항목만 확정 가능)")
}
-
+
const confirmedTargetIds = eligibleTargets.map(target => target.id)
-
+
// 1. 평가 대상 상태를 CONFIRMED로 변경
await tx
.update(evaluationTargets)
@@ -758,10 +773,10 @@ export async function confirmEvaluationTargets(
updatedAt: new Date()
})
.where(inArray(evaluationTargets.id, confirmedTargetIds))
-
+
// 2. 각 확정된 평가 대상에 대해 periodicEvaluations 레코드 생성
const periodicEvaluationsToCreate = []
-
+
for (const target of eligibleTargets) {
// 이미 해당 기간에 평가가 존재하는지 확인
const existingEvaluation = await tx
@@ -774,7 +789,7 @@ export async function confirmEvaluationTargets(
)
)
.limit(1)
-
+
// 없으면 생성 목록에 추가
if (existingEvaluation.length === 0) {
periodicEvaluationsToCreate.push({
@@ -782,14 +797,14 @@ export async function confirmEvaluationTargets(
evaluationPeriod: currentPeriod,
// 평가년도에 따른 제출 마감일 설정 (예: 상반기는 7월 말, 하반기는 1월 말)
submissionDeadline: getSubmissionDeadline(target.evaluationYear, currentPeriod),
- status: "PENDING_SUBMISSION" as const,
+ status: "PENDING" as const,
createdAt: new Date(),
updatedAt: new Date()
})
}
console.log("periodicEvaluationsToCreate", periodicEvaluationsToCreate)
}
-
+
// 3. periodicEvaluations 레코드들 일괄 생성
let createdEvaluationsCount = 0
if (periodicEvaluationsToCreate.length > 0) {
@@ -797,7 +812,7 @@ export async function confirmEvaluationTargets(
.insert(periodicEvaluations)
.values(periodicEvaluationsToCreate)
.returning({ id: periodicEvaluations.id })
-
+
createdEvaluationsCount = createdEvaluations.length
}
console.log("createdEvaluationsCount", createdEvaluationsCount)
@@ -807,13 +822,13 @@ export async function confirmEvaluationTargets(
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
@@ -832,7 +847,7 @@ export async function confirmEvaluationTargets(
// eq(periodicEvaluations.evaluationPeriod, currentPeriod)
// )
// )
-
+
// // 각 평가에 대해 담당자별 reviewerEvaluations 생성
// for (const periodicEval of newPeriodicEvaluations) {
// // 해당 evaluationTarget의 담당자들 조회
@@ -840,7 +855,7 @@ export async function confirmEvaluationTargets(
// .select()
// .from(evaluationTargetReviewers)
// .where(eq(evaluationTargetReviewers.evaluationTargetId, periodicEval.evaluationTargetId))
-
+
// if (reviewers.length > 0) {
// const reviewerEvaluationsToCreate = reviewers.map(reviewer => ({
// periodicEvaluationId: periodicEval.id,
@@ -849,102 +864,102 @@ export async function confirmEvaluationTargets(
// createdAt: new Date(),
// updatedAt: new Date()
// }))
-
+
// await tx
// .insert(reviewerEvaluations)
// .values(reviewerEvaluationsToCreate)
// }
// }
// }
-
+
// 6. 벤더별 evaluationSubmissions 레코드 생성
- const evaluationSubmissionsToCreate = []
-
- // 생성된 periodicEvaluations의 ID를 매핑하기 위한 맵 생성
- const periodicEvaluationIdMap = new Map()
- if (createdEvaluationsCount > 0) {
- const createdEvaluations = await tx
- .select({
- id: periodicEvaluations.id,
- evaluationTargetId: periodicEvaluations.evaluationTargetId
- })
- .from(periodicEvaluations)
- .where(
- and(
- inArray(periodicEvaluations.evaluationTargetId, confirmedTargetIds),
- eq(periodicEvaluations.evaluationPeriod, currentPeriod)
- )
- )
-
- // evaluationTargetId를 키로 하는 맵 생성
- createdEvaluations.forEach(periodicEval => {
- periodicEvaluationIdMap.set(periodicEval.evaluationTargetId, periodicEval.id)
- })
- }
- console.log("periodicEvaluationIdMap", periodicEvaluationIdMap)
-
- 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) {
- const periodicEvaluationId = periodicEvaluationIdMap.get(target.id)
- if (periodicEvaluationId) {
- evaluationSubmissionsToCreate.push({
- companyId: target.vendorId,
- periodicEvaluationId: periodicEvaluationId,
- 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
- }
-
+ // const evaluationSubmissionsToCreate = []
+
+ // // 생성된 periodicEvaluations의 ID를 매핑하기 위한 맵 생성
+ // const periodicEvaluationIdMap = new Map()
+ // if (createdEvaluationsCount > 0) {
+ // const createdEvaluations = await tx
+ // .select({
+ // id: periodicEvaluations.id,
+ // evaluationTargetId: periodicEvaluations.evaluationTargetId
+ // })
+ // .from(periodicEvaluations)
+ // .where(
+ // and(
+ // inArray(periodicEvaluations.evaluationTargetId, confirmedTargetIds),
+ // eq(periodicEvaluations.evaluationPeriod, currentPeriod)
+ // )
+ // )
+
+ // // evaluationTargetId를 키로 하는 맵 생성
+ // createdEvaluations.forEach(periodicEval => {
+ // periodicEvaluationIdMap.set(periodicEval.evaluationTargetId, periodicEval.id)
+ // })
+ // }
+ // console.log("periodicEvaluationIdMap", periodicEvaluationIdMap)
+
+ // 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) {
+ // const periodicEvaluationId = periodicEvaluationIdMap.get(target.id)
+ // if (periodicEvaluationId) {
+ // evaluationSubmissionsToCreate.push({
+ // companyId: target.vendorId,
+ // periodicEvaluationId: periodicEvaluationId,
+ // 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,
+ // createdSubmissionsCount,
totalConfirmed: confirmedTargetIds.length
}
})
-
+
return {
success: true,
message: `${result.totalConfirmed}개 평가 대상이 확정되었습니다. ${result.createdEvaluationsCount}개의 정기평가와 ${result.createdSubmissionsCount}개의 제출 요청이 생성되었습니다.`,
confirmedCount: result.totalConfirmed,
createdEvaluationsCount: result.createdEvaluationsCount,
- createdSubmissionsCount: result.createdSubmissionsCount
+ // createdSubmissionsCount: result.createdSubmissionsCount
}
-
+
} catch (error) {
console.error("Error confirming evaluation targets:", error)
return {
@@ -959,7 +974,7 @@ export async function confirmEvaluationTargets(
function getCurrentEvaluationPeriod(): string {
const now = new Date()
const month = now.getMonth() + 1 // 0-based이므로 +1
-
+
// 1~6월: 상반기, 7~12월: 하반기
return month <= 6 ? "상반기" : "하반기"
}
@@ -967,7 +982,7 @@ function getCurrentEvaluationPeriod(): string {
// 평가년도와 기간에 따른 제출 마감일 설정하는 헬퍼 함수
function getSubmissionDeadline(evaluationYear: number, period: string): Date {
const year = evaluationYear
-
+
if (period === "상반기") {
// 상반기 평가는 다음 해 6월 말까지
return new Date(year, 5, 31) // 7월은 6 (0-based)
@@ -1022,17 +1037,17 @@ export async function excludeEvaluationTargets(targetIds: number[]) {
})
- return {
- success: true,
+ return {
+ success: true,
message: `${targetIds.length}개 평가 대상이 제외되었습니다.`,
excludedCount: targetIds.length
}
} catch (error) {
console.error("Error excluding evaluation targets:", error)
- return {
- success: false,
- error: error instanceof Error ? error.message : "제외 처리 중 오류가 발생했습니다."
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "제외 처리 중 오류가 발생했습니다."
}
}
}
@@ -1095,7 +1110,7 @@ export async function requestEvaluationReview(targetIds: number[], message?: str
reviewers: []
}
}
-
+
if (item.reviewerEmail) {
acc[item.id].reviewers.push({
email: item.reviewerEmail,
@@ -1104,7 +1119,7 @@ export async function requestEvaluationReview(targetIds: number[], message?: str
departmentName: item.departmentName
})
}
-
+
return acc
}, {} as Record<number, any>)
@@ -1118,14 +1133,14 @@ export async function requestEvaluationReview(targetIds: number[], message?: str
target.reviewers.forEach((reviewer: any) => {
if (reviewer.email) {
reviewerEmails.add(reviewer.email)
-
+
if (!reviewerInfo.has(reviewer.email)) {
reviewerInfo.set(reviewer.email, {
name: reviewer.name || reviewer.email,
departments: []
})
}
-
+
const info = reviewerInfo.get(reviewer.email)!
if (reviewer.departmentName && !info.departments.includes(reviewer.departmentName)) {
info.departments.push(reviewer.departmentName)
@@ -1141,7 +1156,7 @@ export async function requestEvaluationReview(targetIds: number[], message?: str
// 각 담당자에게 이메일 발송
const emailPromises = Array.from(reviewerEmails).map(email => {
const reviewer = reviewerInfo.get(email)!
-
+
return sendEmail({
to: email,
subject: `벤더 평가 의견 요청 - ${targets.length}건`,
@@ -1165,17 +1180,17 @@ export async function requestEvaluationReview(targetIds: number[], message?: str
await Promise.all(emailPromises)
- return {
- success: true,
+ return {
+ success: true,
message: `${reviewerEmails.size}명의 담당자에게 의견 요청 이메일이 발송되었습니다.`,
emailCount: reviewerEmails.size
}
} catch (error) {
console.error("Error requesting evaluation review:", error)
- return {
- success: false,
- error: error instanceof Error ? error.message : "의견 요청 중 오류가 발생했습니다."
+ return {
+ success: false,
+ error: error instanceof Error ? error.message : "의견 요청 중 오류가 발생했습니다."
}
}
}
@@ -1220,7 +1235,7 @@ export async function autoGenerateEvaluationTargets(
// vendor 정보
vendorCode: vendors.vendorCode,
vendorName: vendors.vendorName,
- vendorType: vendors.country ==="KR"? "DOMESTIC":"FOREIGN", // DOMESTIC | FOREIGN
+ vendorType: vendors.country === "KR" ? "DOMESTIC" : "FOREIGN", // DOMESTIC | FOREIGN
// project 정보
projectType: projects.type, // ship | plant
})
@@ -1258,7 +1273,7 @@ export async function autoGenerateEvaluationTargets(
contractsWithDetails.forEach(contract => {
const division = contract.projectType === "ship" ? "SHIP" : "PLANT"
const key = `${contract.vendorId}-${division}`
-
+
if (!targetGroups.has(key)) {
targetGroups.set(key, {
vendorId: contract.vendorId,
diff --git a/lib/evaluation/service.ts b/lib/evaluation/service.ts
index 8e394f88..3e85b4a2 100644
--- a/lib/evaluation/service.ts
+++ b/lib/evaluation/service.ts
@@ -136,7 +136,6 @@ export async function getPeriodicEvaluations(input: GetEvaluationTargetsSchema)
const pageCount = Math.ceil(total / input.perPage);
- console.log(periodicEvaluationsData, "periodicEvaluationsData")
return { data: periodicEvaluationsData, pageCount, total };
} catch (err) {
@@ -359,6 +358,20 @@ export async function requestDocumentsFromVendors(data: RequestDocumentsData[])
})
)
+ // periodic_evaluations 테이블의 status를 PENDING_SUBMISSION으로 업데이트
+ const periodicEvaluationIds = [...new Set(data.map(item => item.periodicEvaluationId))]
+
+ await Promise.all(
+ periodicEvaluationIds.map(async (periodicEvaluationId) => {
+ await db
+ .update(periodicEvaluations)
+ .set({
+ status: 'PENDING_SUBMISSION',
+ updatedAt: new Date()
+ })
+ .where(eq(periodicEvaluations.id, periodicEvaluationId))
+ })
+ )
return {
success: true,
@@ -375,7 +388,6 @@ export async function requestDocumentsFromVendors(data: RequestDocumentsData[])
}
}
}
-
// 기존 요청 상태 확인 함수 추가
export async function checkExistingSubmissions(periodicEvaluationIds: number[]) {
try {
@@ -397,6 +409,8 @@ export async function checkExistingSubmissions(periodicEvaluationIds: number[])
}
})
+ console.log(existingSubmissions, "existingSubmissions")
+
return existingSubmissions
} catch (error) {
console.error("Error checking existing submissions:", error)
diff --git a/lib/evaluation/table/evaluation-columns.tsx b/lib/evaluation/table/evaluation-columns.tsx
index 315ec66b..dca19ddb 100644
--- a/lib/evaluation/table/evaluation-columns.tsx
+++ b/lib/evaluation/table/evaluation-columns.tsx
@@ -40,6 +40,7 @@ const getStatusBadgeVariant = (status: string) => {
const getStatusLabel = (status: string) => {
const statusMap = {
+ PENDING: "대상확정",
PENDING_SUBMISSION: "자료접수중",
SUBMITTED: "제출완료",
IN_REVIEW: "평가중",
diff --git a/lib/evaluation/table/evaluation-filter-sheet.tsx b/lib/evaluation/table/evaluation-filter-sheet.tsx
index 7c1e93d8..7f4de6a6 100644
--- a/lib/evaluation/table/evaluation-filter-sheet.tsx
+++ b/lib/evaluation/table/evaluation-filter-sheet.tsx
@@ -67,11 +67,12 @@ const divisionOptions = [
]
const statusOptions = [
- { value: "PENDING_SUBMISSION", label: "제출대기" },
+ { value: "PENDING", label: "대상확정" },
+ { value: "PENDING_SUBMISSION", label: "자료접수중" },
{ value: "SUBMITTED", label: "제출완료" },
- { value: "IN_REVIEW", label: "검토중" },
- { value: "REVIEW_COMPLETED", label: "검토완료" },
- { value: "FINALIZED", label: "최종확정" },
+ { value: "IN_REVIEW", label: "평가중" },
+ { value: "REVIEW_COMPLETED", label: "평가완료" },
+ { value: "FINALIZED", label: "결과확정" },
]
const domesticForeignOptions = [
@@ -91,7 +92,6 @@ const documentsSubmittedOptions = [
]
const gradeOptions = [
- { value: "S", label: "S등급" },
{ value: "A", label: "A등급" },
{ value: "B", label: "B등급" },
{ value: "C", label: "C등급" },
@@ -470,7 +470,7 @@ export function PeriodicEvaluationFilterSheet({
/>
{/* 평가기간 */}
- <FormField
+ {/* <FormField
control={form.control}
name="evaluationPeriod"
render={({ field }) => (
@@ -514,7 +514,7 @@ export function PeriodicEvaluationFilterSheet({
<FormMessage />
</FormItem>
)}
- />
+ /> */}
{/* 구분 */}
<FormField
diff --git a/lib/evaluation/table/evaluation-table.tsx b/lib/evaluation/table/evaluation-table.tsx
index 0a5db3cb..d4510eb5 100644
--- a/lib/evaluation/table/evaluation-table.tsx
+++ b/lib/evaluation/table/evaluation-table.tsx
@@ -224,7 +224,6 @@ export function PeriodicEvaluationsTable({ promises, evaluationYear, className }
const [promiseData] = React.use(promises)
const tableData = promiseData
- console.log(tableData)
const getSearchParam = React.useCallback((key: string, defaultValue?: string): string => {
return searchParams?.get(key) ?? defaultValue ?? "";
diff --git a/lib/evaluation/table/periodic-evaluation-action-dialogs.tsx b/lib/evaluation/table/periodic-evaluation-action-dialogs.tsx
index fc07aea1..e6eec53a 100644
--- a/lib/evaluation/table/periodic-evaluation-action-dialogs.tsx
+++ b/lib/evaluation/table/periodic-evaluation-action-dialogs.tsx
@@ -82,7 +82,7 @@ export function RequestDocumentsDialog({
// 제출대기 상태인 평가들만 필터링
const pendingEvaluations = React.useMemo(() =>
- evaluations.filter(e => e.status === "PENDING_SUBMISSION"),
+ evaluations.filter(e => e.status === "PENDING_SUBMISSION" ||e.status === "PENDING" ),
[evaluations]
)
diff --git a/lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx b/lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx
index d910f916..38622af4 100644
--- a/lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx
+++ b/lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx
@@ -66,7 +66,7 @@ export function PeriodicEvaluationsTableToolbarActions({
.getFilteredSelectedRowModel()
.rows
.map(row => row.original)
- .filter(e => e.status === "PENDING_SUBMISSION");
+ .filter(e => e.status === "PENDING_SUBMISSION"||e.status === "PENDING");
}, [table.getFilteredSelectedRowModel().rows]);
const submittedEvaluations = React.useMemo(() => {
diff --git a/lib/roles/services.ts b/lib/roles/services.ts
index 1a91d4fa..54c7d833 100644
--- a/lib/roles/services.ts
+++ b/lib/roles/services.ts
@@ -3,7 +3,7 @@
import { revalidateTag, unstable_cache, unstable_noStore } from "next/cache";
import db from "@/db/db";
import { permissions, Role, rolePermissions, roles, RoleView, roleView, userRoles } from "@/db/schema/users";
-import { and, or, asc, desc, ilike, eq, inArray } from "drizzle-orm";
+import { and, or, asc, desc, ilike, eq, inArray, sql } from "drizzle-orm";
import { filterColumns } from "@/lib/filter-columns";
import {
selectRolesWithUserCount,
@@ -297,4 +297,106 @@ export async function getMenuPermissions(
.where(ilike(permissions.permissionKey, pattern));
return rows;
+}
+
+
+export async function checkRegularEvaluationRoleExists(): Promise<boolean> {
+ try {
+ const existingRoles = await db
+ .select({ id: roles.id, name: roles.name })
+ .from(roles)
+ .where(sql`${roles.name} ILIKE '%정기평가%'`)
+ .limit(1)
+
+ return existingRoles.length > 0
+ } catch (error) {
+ console.error("정기평가 role 체크 중 에러:", error)
+ throw new Error("정기평가 role 체크에 실패했습니다")
+ }
+}
+
+
+
+/**
+ * 여러 정기평가 role들의 할당 상태를 한번에 체크
+ */
+export async function checkMultipleRegularEvaluationRolesAssigned(roleIds: number[]): Promise<{[roleId: number]: boolean}> {
+ try {
+ // 정기평가 role들만 필터링
+ const regularEvaluationRoles = await db
+ .select({ id: roles.id, name: roles.name })
+ .from(roles)
+ .where(
+ and(
+ inArray(roles.id, roleIds),
+ sql`${roles.name} ILIKE '%정기평가%'`
+ )
+ )
+
+ const regularEvaluationRoleIds = regularEvaluationRoles.map(r => r.id)
+ const result: {[roleId: number]: boolean} = {}
+
+ // 모든 role ID에 대해 초기값 설정
+ roleIds.forEach(roleId => {
+ result[roleId] = false
+ })
+
+ if (regularEvaluationRoleIds.length > 0) {
+ // 할당된 정기평가 role들 체크
+ const assignedRoles = await db
+ .select({ roleId: userRoles.roleId })
+ .from(userRoles)
+ .where(inArray(userRoles.roleId, regularEvaluationRoleIds))
+
+ // 할당된 role들을 true로 설정
+ assignedRoles.forEach(assignment => {
+ result[assignment.roleId] = true
+ })
+ }
+
+ return result
+ } catch (error) {
+ console.error("여러 정기평가 role 할당 상태 체크 중 에러:", error)
+ throw new Error("정기평가 role 할당 상태 체크에 실패했습니다")
+ }
+}
+
+/**
+ * 특정 유저가 이미 다른 정기평가 role을 가지고 있는지 체크
+ */
+export async function checkUserHasRegularEvaluationRole(userId: string): Promise<{hasRole: boolean, roleName?: string}> {
+ try {
+ const userRegularEvaluationRoles = await db
+ .select({
+ roleId: userRoles.roleId,
+ roleName: roles.name
+ })
+ .from(userRoles)
+ .innerJoin(roles, eq(userRoles.roleId, roles.id))
+ .where(
+ and(
+ eq(userRoles.userId, userId),
+ sql`${roles.name} ILIKE '%정기평가%'`
+ )
+ )
+ .limit(1)
+
+ return {
+ hasRole: userRegularEvaluationRoles.length > 0,
+ roleName: userRegularEvaluationRoles[0]?.roleName
+ }
+ } catch (error) {
+ console.error(`유저 ${userId}의 정기평가 role 체크 중 에러:`, error)
+ throw new Error("유저 정기평가 role 체크에 실패했습니다")
+ }
+}
+
+
+export async function removeRolesFromUsers(roleIds: number[], userIds: number[]) {
+ try {
+ // userRoles 테이블에서 해당 역할들을 제거하는 로직
+ // 구현 필요
+ } catch (error) {
+ return { error: "역할 제거 실패" }
+ }
} \ No newline at end of file
diff --git a/lib/roles/table/add-role-dialog.tsx b/lib/roles/table/add-role-dialog.tsx
index 365daf29..162aaa89 100644
--- a/lib/roles/table/add-role-dialog.tsx
+++ b/lib/roles/table/add-role-dialog.tsx
@@ -21,12 +21,13 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
-import { Check, ChevronsUpDown, Loader } from "lucide-react"
+import { Check, ChevronsUpDown, Loader, AlertTriangle } from "lucide-react"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
+import { Alert, AlertDescription } from "@/components/ui/alert"
import { createRoleSchema, type CreateRoleSchema } from "../validations"
-import { createRole } from "../services"
+import { createRole, checkRegularEvaluationRoleExists } from "../services"
import { Textarea } from "@/components/ui/textarea"
import { Company } from "@/db/schema/companies"
import { getAllCompanies } from "@/lib/admin-users/service"
@@ -44,8 +45,6 @@ import {
CommandEmpty,
} from "@/components/ui/command"
-
-
const domainOptions = [
{ value: "partners", label: "협력업체" },
{ value: "evcp", label: "삼성중공업" },
@@ -54,7 +53,9 @@ const domainOptions = [
export function AddRoleDialog() {
const [open, setOpen] = React.useState(false)
const [isAddPending, startAddTransition] = React.useTransition()
- const [companies, setCompanies] = React.useState<Company[]>([]) // 회사 목록
+ const [companies, setCompanies] = React.useState<Company[]>([])
+ const [regularEvaluationExists, setRegularEvaluationExists] = React.useState(false)
+ const [isCheckingRegularEvaluation, setIsCheckingRegularEvaluation] = React.useState(false)
React.useEffect(() => {
getAllCompanies().then((res) => {
@@ -67,12 +68,39 @@ export function AddRoleDialog() {
resolver: zodResolver(createRoleSchema),
defaultValues: {
name: "",
- domain: "evcp", // 기본값
+ domain: "evcp",
description: "",
- // companyId: null, // optional
},
})
+ // name 필드 watch
+ const watchedName = form.watch("name")
+
+ // "정기평가"가 포함된 이름인지 체크
+ const isRegularEvaluationRole = watchedName.includes("정기평가")
+
+ // 정기평가 role 존재 여부 체크 (debounced)
+ React.useEffect(() => {
+ if (!isRegularEvaluationRole) {
+ setRegularEvaluationExists(false)
+ return
+ }
+
+ const timeoutId = setTimeout(async () => {
+ setIsCheckingRegularEvaluation(true)
+ try {
+ const exists = await checkRegularEvaluationRoleExists()
+ setRegularEvaluationExists(exists)
+ } catch (error) {
+ console.error("정기평가 role 체크 실패:", error)
+ } finally {
+ setIsCheckingRegularEvaluation(false)
+ }
+ }, 500) // 500ms debounce
+
+ return () => clearTimeout(timeoutId)
+ }, [isRegularEvaluationRole, watchedName])
+
async function onSubmit(data: CreateRoleSchema) {
startAddTransition(async () => {
const result = await createRole(data)
@@ -82,19 +110,21 @@ export function AddRoleDialog() {
}
form.reset()
setOpen(false)
- toast.success("Role added")
+ setRegularEvaluationExists(false)
+ toast.success("Role이 성공적으로 추가되었습니다")
})
}
function handleDialogOpenChange(nextOpen: boolean) {
if (!nextOpen) {
form.reset()
+ setRegularEvaluationExists(false)
}
setOpen(nextOpen)
}
- // domain이 partners일 경우 companyId 입력 필드 보이게
const selectedDomain = form.watch("domain")
+ const canSubmit = !isRegularEvaluationRole || !regularEvaluationExists
return (
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
@@ -129,6 +159,35 @@ export function AddRoleDialog() {
/>
</FormControl>
<FormMessage />
+
+ {/* 정기평가 관련 경고 메시지 */}
+ {isRegularEvaluationRole && (
+ <div className="mt-2">
+ {isCheckingRegularEvaluation ? (
+ <Alert>
+ <Loader className="h-4 w-4 animate-spin" />
+ <AlertDescription>
+ 정기평가 role 존재 여부를 확인하고 있습니다...
+ </AlertDescription>
+ </Alert>
+ ) : regularEvaluationExists ? (
+ <Alert variant="destructive">
+ <AlertTriangle className="h-4 w-4" />
+ <AlertDescription>
+ <strong>경고:</strong> "정기평가"가 포함된 role이 이미 존재합니다.
+ 정기평가 role은 시스템에서 하나만 허용됩니다.
+ </AlertDescription>
+ </Alert>
+ ) : (
+ <Alert>
+ <Check className="h-4 w-4" />
+ <AlertDescription>
+ 정기평가 role을 생성할 수 있습니다.
+ </AlertDescription>
+ </Alert>
+ )}
+ </div>
+ )}
</FormItem>
)}
/>
@@ -161,7 +220,6 @@ export function AddRoleDialog() {
<FormLabel>Domain</FormLabel>
<FormControl>
<Select
- // domain이 바뀔 때마다 form state에도 반영
onValueChange={field.onChange}
value={field.value}
>
@@ -184,96 +242,85 @@ export function AddRoleDialog() {
{/* 4) companyId => domain이 partners인 경우만 노출 */}
{selectedDomain === "partners" && (
- <FormField
- control={form.control}
- name="companyId"
- render={({ field }) => {
- // 현재 선택된 회사 ID (number) → 문자열
- const valueString = field.value ? String(field.value) : ""
-
+ <FormField
+ control={form.control}
+ name="companyId"
+ render={({ field }) => {
+ const valueString = field.value ? String(field.value) : ""
+ const selectedCompany = companies.find(
+ (c) => String(c.id) === valueString
+ )
+ const selectedCompanyLabel = selectedCompany && `${selectedCompany.name} ${selectedCompany.taxID}`
+ const [popoverOpen, setPopoverOpen] = React.useState(false)
- // 현재 선택된 회사
- const selectedCompany = companies.find(
- (c) => String(c.id) === valueString
- )
-
- const selectedCompanyLabel = selectedCompany && `${selectedCompany.name} ${selectedCompany.taxID}`
-
- const [popoverOpen, setPopoverOpen] = React.useState(false)
-
-
- return (
- <FormItem>
- <FormLabel>Company</FormLabel>
- <FormControl>
- <Popover
- open={popoverOpen}
- onOpenChange={setPopoverOpen}
- modal={true}
- >
- <PopoverTrigger asChild>
- <Button
- variant="outline"
- role="combobox"
- aria-expanded={popoverOpen}
- className="w-full justify-between"
+ return (
+ <FormItem>
+ <FormLabel>Company</FormLabel>
+ <FormControl>
+ <Popover
+ open={popoverOpen}
+ onOpenChange={setPopoverOpen}
+ modal={true}
>
- {selectedCompany
- ? `${selectedCompany.name} ${selectedCompany.taxID}`
- : "Select company..."}
- <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
- </Button>
- </PopoverTrigger>
-
- <PopoverContent className="w-full p-0">
- <Command>
- <CommandInput
- placeholder="Search company..."
- className="h-9"
-
- />
- <CommandList>
- <CommandEmpty>No company found.</CommandEmpty>
- <CommandGroup>
- {companies.map((comp) => {
- // string(comp.id)
- const compIdStr = String(comp.id)
- const label = `${comp.name}${comp.taxID}`
- const label2 = `${comp.name} ${comp.taxID}`
- return (
- <CommandItem
- key={comp.id}
- value={label2}
- onSelect={() => {
- // 회사 ID를 number로
- field.onChange(Number(comp.id))
- setPopoverOpen(false)
+ <PopoverTrigger asChild>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={popoverOpen}
+ className="w-full justify-between"
+ >
+ {selectedCompany
+ ? `${selectedCompany.name} ${selectedCompany.taxID}`
+ : "Select company..."}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </PopoverTrigger>
- }}
- >
- {label2}
- <Check
- className={cn(
- "ml-auto h-4 w-4",
- selectedCompanyLabel === label2
- ? "opacity-100"
- : "opacity-0"
- )}
- />
- </CommandItem>
- )
- })}
- </CommandGroup>
- </CommandList>
- </Command>
- </PopoverContent>
- </Popover>
- </FormControl>
- <FormMessage />
- </FormItem>
- )
- }}
- />
+ <PopoverContent className="w-full p-0">
+ <Command>
+ <CommandInput
+ placeholder="Search company..."
+ className="h-9"
+ />
+ <CommandList>
+ <CommandEmpty>No company found.</CommandEmpty>
+ <CommandGroup>
+ {companies.map((comp) => {
+ const compIdStr = String(comp.id)
+ const label = `${comp.name}${comp.taxID}`
+ const label2 = `${comp.name} ${comp.taxID}`
+ return (
+ <CommandItem
+ key={comp.id}
+ value={label2}
+ onSelect={() => {
+ field.onChange(Number(comp.id))
+ setPopoverOpen(false)
+ }}
+ >
+ {label2}
+ <Check
+ className={cn(
+ "ml-auto h-4 w-4",
+ selectedCompanyLabel === label2
+ ? "opacity-100"
+ : "opacity-0"
+ )}
+ />
+ </CommandItem>
+ )
+ })}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )
+ }}
+ />
)}
</div>
@@ -289,7 +336,12 @@ export function AddRoleDialog() {
</Button>
<Button
type="submit"
- disabled={form.formState.isSubmitting || isAddPending}
+ disabled={
+ form.formState.isSubmitting ||
+ isAddPending ||
+ !canSubmit ||
+ isCheckingRegularEvaluation
+ }
>
{isAddPending && (
<Loader
diff --git a/lib/roles/table/assign-roles-sheet.tsx b/lib/roles/table/assign-roles-sheet.tsx
index 11c6a1ff..d750081c 100644
--- a/lib/roles/table/assign-roles-sheet.tsx
+++ b/lib/roles/table/assign-roles-sheet.tsx
@@ -1,10 +1,7 @@
"use client"
import * as React from "react"
-import { zodResolver } from "@hookform/resolvers/zod"
-import { useForm } from "react-hook-form"
import { toast } from "sonner"
-
import {
Sheet,
SheetClose,
@@ -15,71 +12,178 @@ import {
SheetTitle,
} from "@/components/ui/sheet"
import { Button } from "@/components/ui/button"
-import { Loader } from "lucide-react"
+import { Loader, Plus, Minus, Users } from "lucide-react"
+import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
+import { Badge } from "@/components/ui/badge"
import { AssginedUserTable } from "../userTable/assignedUsers-table"
-import { assignUsersToRole } from "@/lib/users/service"
+import { assignUsersToRole, removeUsersFromRole } from "@/lib/users/service"
import { RoleView } from "@/db/schema/users"
-export interface UpdateRoleSheetProps
+export interface ManageRoleSheetProps
extends React.ComponentPropsWithRef<typeof Sheet> {
role: RoleView | null
-
- // ★ 새로 추가: 테이블에 필요한 데이터 로딩 promise
- assignedTablePromises: Promise<[
- { data: any[]; pageCount: number }
-
- ]>
+ // 현재 구조에 맞춰 allUsersPromises 사용
+ allUsersPromises: Promise<[{ data: any[]; pageCount: number }]>
}
-export function AssignRolesSheet({ role, assignedTablePromises, ...props }: UpdateRoleSheetProps) {
-
+export function ManageRoleSheet({
+ role,
+ allUsersPromises,
+ ...props
+}: ManageRoleSheetProps) {
const [isUpdatePending, startUpdateTransition] = React.useTransition()
const [selectedUserIds, setSelectedUserIds] = React.useState<number[]>([])
+ const [activeTab, setActiveTab] = React.useState("assign")
- // 2) 자식에서 호출될 콜백
function handleSelectedChange(ids: number[]) {
setSelectedUserIds(ids)
}
async function handleAssign() {
+ if (!role || selectedUserIds.length === 0) {
+ toast.error("선택된 사용자가 없습니다.")
+ return
+ }
+
+ startUpdateTransition(async () => {
+ const { data, error } = await assignUsersToRole(role.id, selectedUserIds)
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ setSelectedUserIds([]) // 선택 초기화
+ toast.success(data?.message || `${selectedUserIds.length}명의 사용자가 "${role.name}" 롤에 할당되었습니다.`)
+
+ // 작업 완료 후 시트를 닫지 않고 유지 (계속 작업할 수 있도록)
+ })
+ }
+
+ async function handleRemove() {
+ if (!role || selectedUserIds.length === 0) {
+ toast.error("선택된 사용자가 없습니다.")
+ return
+ }
+
startUpdateTransition(async () => {
- if (!role) return
- const { error } = await assignUsersToRole(role.id, selectedUserIds)
+ const { data, error } = await removeUsersFromRole(role.id, selectedUserIds)
if (error) {
toast.error(error)
return
}
- props.onOpenChange?.(false)
- toast.success(`Assigned ${selectedUserIds.length} users!`)
+
+ setSelectedUserIds([]) // 선택 초기화
+ toast.success(data?.message || `${selectedUserIds.length}명의 사용자가 "${role.name}" 롤에서 제거되었습니다.`)
})
}
+ // 탭 변경시 선택 초기화
+ React.useEffect(() => {
+ setSelectedUserIds([])
+ }, [activeTab])
+
+ // 롤 변경시 선택 초기화
+ React.useEffect(() => {
+ setSelectedUserIds([])
+ setActiveTab("assign") // 기본적으로 assign 탭으로 리셋
+ }, [role?.id])
+
+ // 시트가 닫힐 때 상태 초기화
+ React.useEffect(() => {
+ if (!props.open) {
+ setSelectedUserIds([])
+ setActiveTab("assign")
+ }
+ }, [props.open])
+
return (
<Sheet {...props}>
- <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md" style={{width: 1000, maxWidth: 1000}}>
<SheetHeader className="text-left">
- <SheetTitle>"{role?.name}"에 유저를 할당하세요</SheetTitle>
- <SheetDescription>
- 현재 {role?.name}에는 {role?.user_count}명이 할당되어있습니다. 이 롤은 다음과 같습니다.<br/> {role?.description}
+ <SheetTitle className="flex items-center gap-2">
+ <Users className="h-5 w-5" />
+ Manage "{role?.name}" Role
+ </SheetTitle>
+ <SheetDescription className="space-y-2">
+ <div className="flex items-center gap-2">
+ <span>Currently assigned:</span>
+ <Badge variant="secondary">{role?.user_count || 0} users</Badge>
+ </div>
+ <div className="text-sm">
+ {role?.description}
+ </div>
</SheetDescription>
</SheetHeader>
- <AssginedUserTable promises={assignedTablePromises} onSelectedChange={handleSelectedChange} />
+ <Tabs value={activeTab} onValueChange={setActiveTab} className="flex-1">
+ <TabsList className="grid w-full grid-cols-2">
+ <TabsTrigger value="assign" className="flex items-center gap-2">
+ <Plus className="h-4 w-4" />
+ Assign Users
+ </TabsTrigger>
+ <TabsTrigger value="remove" className="flex items-center gap-2">
+ <Minus className="h-4 w-4" />
+ Remove Users
+ </TabsTrigger>
+ </TabsList>
+
+ <TabsContent value="assign" className="flex-1 mt-4">
+ <div className="mb-3 text-sm text-muted-foreground">
+ Select users to assign to this role:
+
+ </div>
+ <AssginedUserTable
+ promises={allUsersPromises}
+ onSelectedChange={handleSelectedChange}
+ mode="assign"
+ currentRoleName={role?.name}
+ />
+ </TabsContent>
+
+ <TabsContent value="remove" className="flex-1 mt-4">
+ <div className="mb-3 text-sm text-muted-foreground">
+ Select users to remove from this role:
+ </div>
+ <AssginedUserTable
+ promises={allUsersPromises}
+ onSelectedChange={handleSelectedChange}
+ mode="remove"
+ currentRoleName={role?.name}
+ />
+ </TabsContent>
+ </Tabs>
<SheetFooter className="gap-2 pt-2 sm:space-x-0">
- <SheetClose asChild>
+ <SheetClose asChild>
<Button type="button" variant="outline">
Cancel
</Button>
</SheetClose>
- {/* <Button disabled={isUpdatePending} onClick={onSubmitAssignUsers}> */}
- <Button disabled={isUpdatePending} onClick={handleAssign}>
- {isUpdatePending && (
- <Loader className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
- )}
- Assign
- </Button>
+ {activeTab === "assign" ? (
+ <Button
+ disabled={isUpdatePending || selectedUserIds.length === 0}
+ onClick={handleAssign}
+ >
+ {isUpdatePending && (
+ <Loader className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
+ )}
+ <Plus className="mr-2 h-4 w-4" />
+ Assign ({selectedUserIds.length})
+ </Button>
+ ) : (
+ <Button
+ disabled={isUpdatePending || selectedUserIds.length === 0}
+ onClick={handleRemove}
+ variant="destructive"
+ >
+ {isUpdatePending && (
+ <Loader className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />
+ )}
+ <Minus className="mr-2 h-4 w-4" />
+ Remove ({selectedUserIds.length})
+ </Button>
+ )}
</SheetFooter>
</SheetContent>
</Sheet>
diff --git a/lib/roles/table/roles-table.tsx b/lib/roles/table/roles-table.tsx
index cd7c2a3b..3386d439 100644
--- a/lib/roles/table/roles-table.tsx
+++ b/lib/roles/table/roles-table.tsx
@@ -13,17 +13,15 @@ import { DataTable } from "@/components/data-table/data-table"
import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar"
import { DataTableToolbar } from "@/components/data-table/data-table-toolbar"
-
import { getRolesWithCount } from "@/lib/roles/services"
import { getColumns } from "./roles-table-columns"
import { RoleTableToolbarActions } from "./role-table-toolbar-actions"
import { UpdateRolesSheet } from "./update-roles-sheet"
-import { AssignRolesSheet } from "./assign-roles-sheet"
+import { ManageRoleSheet } from "./assign-roles-sheet" // 업데이트된 컴포넌트
import { getUsersAll } from "@/lib/users/service"
import { DeleteRolesDialog } from "./delete-roles-dialog"
import { RoleView } from "@/db/schema/users"
-
interface RolesTableProps {
promises: Promise<
[
@@ -31,16 +29,15 @@ interface RolesTableProps {
]
>
promises2: Promise<
- [
- Awaited<ReturnType<typeof getUsersAll>>,
- ]
->
+ [
+ Awaited<ReturnType<typeof getUsersAll>>,
+ ]
+ >
}
-export function RolesTable({ promises ,promises2 }: RolesTableProps) {
+export function RolesTable({ promises, promises2 }: RolesTableProps) {
- const [{ data, pageCount }] =
- React.use(promises)
+ const [{ data, pageCount }] = React.use(promises)
const [rowAction, setRowAction] =
React.useState<DataTableRowAction<RoleView> | null>(null)
@@ -50,7 +47,6 @@ export function RolesTable({ promises ,promises2 }: RolesTableProps) {
[setRowAction]
)
-
/**
* This component can render either a faceted filter or a search filter based on the `options` prop.
*
@@ -68,7 +64,6 @@ export function RolesTable({ promises ,promises2 }: RolesTableProps) {
label: "Role Name",
placeholder: "Filter role name...",
},
-
]
/**
@@ -87,19 +82,16 @@ export function RolesTable({ promises ,promises2 }: RolesTableProps) {
label: "Role Name",
type: "text",
},
-
{
id: "domain",
label: "룰 도메인",
type: "text",
},
-
{
id: "company_name",
label: "회사명",
type: "text",
},
-
{
id: "created_at",
label: "Created at",
@@ -107,7 +99,6 @@ export function RolesTable({ promises ,promises2 }: RolesTableProps) {
},
]
-
const { table } = useDataTable({
data,
columns,
@@ -126,10 +117,7 @@ export function RolesTable({ promises ,promises2 }: RolesTableProps) {
return (
<>
- <DataTable
- table={table}
-
- >
+ <DataTable table={table}>
<DataTableAdvancedToolbar
table={table}
filterFields={advancedFilterFields}
@@ -137,21 +125,21 @@ export function RolesTable({ promises ,promises2 }: RolesTableProps) {
>
<RoleTableToolbarActions table={table} />
</DataTableAdvancedToolbar>
+ </DataTable>
- </DataTable>
-
- <UpdateRolesSheet
- open={rowAction?.type === "update"}
- onOpenChange={() => setRowAction(null)}
- role={rowAction?.row.original ?? null}
- />
+ <UpdateRolesSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ role={rowAction?.row.original ?? null}
+ />
- <AssignRolesSheet
- open={rowAction?.type === "user"}
- onOpenChange={() => setRowAction(null)}
- role={rowAction?.row.original ?? null}
- assignedTablePromises={promises2}
- />
+ {/* 업데이트된 ManageRoleSheet - 모드 토글 지원 */}
+ <ManageRoleSheet
+ open={rowAction?.type === "user"}
+ allUsersPromises={promises2}
+ onOpenChange={() => setRowAction(null)}
+ role={rowAction?.row.original ?? null}
+ />
<DeleteRolesDialog
open={rowAction?.type === "delete"}
@@ -160,10 +148,6 @@ export function RolesTable({ promises ,promises2 }: RolesTableProps) {
showTrigger={false}
onSuccess={() => rowAction?.row.toggleSelected(false)}
/>
-
-
-
-
</>
)
-}
+} \ No newline at end of file
diff --git a/lib/roles/table/update-roles-sheet.tsx b/lib/roles/table/update-roles-sheet.tsx
index cbe20352..11eb1fc8 100644
--- a/lib/roles/table/update-roles-sheet.tsx
+++ b/lib/roles/table/update-roles-sheet.tsx
@@ -127,7 +127,7 @@ export function UpdateRolesSheet({ role, ...props }: UpdateRoleSheetProps) {
return (
<Sheet {...props}>
- <SheetContent className="flex flex-col gap-6 sm:max-w-md">
+ <SheetContent className="flex flex-col gap-6 sm:max-w-md" style={{width:1000, maxWidth:1000}}>
<SheetHeader className="text-left">
<SheetTitle>Update user</SheetTitle>
<SheetDescription>
diff --git a/lib/roles/userTable/assignedUsers-table.tsx b/lib/roles/userTable/assignedUsers-table.tsx
index 5ac52f13..565ddda2 100644
--- a/lib/roles/userTable/assignedUsers-table.tsx
+++ b/lib/roles/userTable/assignedUsers-table.tsx
@@ -1,7 +1,7 @@
"use client"
import * as React from "react"
-import { userRoles , type UserView} from "@/db/schema/users"
+import { userRoles, type UserView } from "@/db/schema/users"
import type {
DataTableAdvancedFilterField,
DataTableFilterField,
@@ -19,23 +19,98 @@ import type {
} from "@/lib//users/service"
import { getColumns } from "./assginedUsers-table-columns"
-
+type TableMode = "assign" | "remove"
interface UsersTableProps {
promises: Promise<
[
Awaited<ReturnType<typeof getUsersAll>>
-
]
>
- onSelectedChange:any
+ onSelectedChange: any
+ mode?: TableMode // 새로 추가: assign | remove
+ currentRoleName?: string // 새로 추가: 현재 선택된 롤 ID (필터링용)
+ showAllUsers?: boolean // 디버깅용: 모든 사용자 표시
}
-export function AssginedUserTable({ promises ,onSelectedChange}: UsersTableProps) {
+export function AssginedUserTable({
+ promises,
+ onSelectedChange,
+ mode = "assign",
+ currentRoleName,
+ showAllUsers = false
+}: UsersTableProps) {
+
+ const [{ data: rawData, pageCount }] = React.use(promises)
+
+ // 모드에 따라 데이터 필터링
+ const filteredData = React.useMemo(() => {
+ console.log('🔍 Filtering Debug Info:', {
+ mode,
+ currentRoleName,
+ rawDataLength: rawData?.length,
+ sampleUser: rawData?.[0],
+ showAllUsers
+ })
+
+ // 디버깅용: 모든 사용자 표시
+ if (showAllUsers) {
+ console.log('🔧 Debug mode: showing all users')
+ return rawData
+ }
- const [{ data, pageCount }] =
- React.use(promises)
+ if (!currentRoleName || !rawData) {
+ console.log('❌ No currentRoleId or rawData, returning rawData')
+ return rawData
+ }
+ if (mode === "assign") {
+ // assign 모드: 현재 롤에 할당되지 않은 사용자들만 표시
+ const filtered = rawData.filter(user => {
+ if (!user.roles || !Array.isArray(user.roles)) {
+ console.log('✅ User has no roles, including in assign:', user.user_name)
+ return true
+ }
+
+ // 다양한 roles 구조 지원
+ const hasRole = user.roles.some(role => {
+ if (typeof role === 'string') return role === currentRoleName.toString()
+ return false
+ })
+
+ if (!hasRole) {
+ console.log('✅ User does not have role, including in assign:', user.user_name)
+ }
+ return !hasRole
+ })
+
+ console.log(`📊 Assign mode: ${filtered.length} users available`)
+ return filtered
+ } else {
+ // remove 모드: 현재 롤에 할당된 사용자들만 표시
+ const filtered = rawData.filter(user => {
+ if (!user.roles || !Array.isArray(user.roles)) {
+ console.log('❌ User has no roles, excluding from remove:', user.user_name)
+ return false
+ }
+
+ // 다양한 roles 구조 지원
+ const hasRole = user.roles.some(role => {
+ if (typeof role === 'string') return role === currentRoleName.toString()
+
+ return false
+ })
+
+ if (hasRole) {
+ console.log('✅ User has role, including in remove:', user.user_name, 'roles:', user.roles)
+ }
+ return hasRole
+ })
+
+ console.log(`📊 Remove mode: ${filtered.length} users with role`)
+ return filtered
+ }
+ }, [rawData, mode, currentRoleName, showAllUsers])
const [rowAction, setRowAction] =
React.useState<DataTableRowAction<UserView> | null>(null)
@@ -45,8 +120,6 @@ export function AssginedUserTable({ promises ,onSelectedChange}: UsersTableProps
[setRowAction]
)
-
-
/**
* This component can render either a faceted filter or a search filter based on the `options` prop.
*
@@ -64,7 +137,6 @@ export function AssginedUserTable({ promises ,onSelectedChange}: UsersTableProps
label: "Email",
placeholder: "Filter email...",
},
-
]
/**
@@ -88,8 +160,6 @@ export function AssginedUserTable({ promises ,onSelectedChange}: UsersTableProps
label: "Email",
type: "text",
},
-
-
{
id: "created_at",
label: "Created at",
@@ -98,7 +168,7 @@ export function AssginedUserTable({ promises ,onSelectedChange}: UsersTableProps
]
const { table } = useDataTable({
- data,
+ data: filteredData, // 필터링된 데이터 사용
columns,
pageCount,
filterFields,
@@ -122,6 +192,7 @@ export function AssginedUserTable({ promises ,onSelectedChange}: UsersTableProps
}
return true
}
+
const previousUserIdsRef = React.useRef<number[]>([])
React.useEffect(() => {
@@ -138,11 +209,34 @@ export function AssginedUserTable({ promises ,onSelectedChange}: UsersTableProps
}
}, [rowSelection, onSelectedChange])
+ // 모드 변경시 선택 초기화
+ React.useEffect(() => {
+ table.toggleAllPageRowsSelected(false)
+ setRowAction(null)
+ }, [mode, table])
+
return (
<>
+ {/* 빈 데이터 상태 메시지 */}
+ {filteredData && filteredData.length === 0 && (
+ <div className="flex flex-col items-center justify-center py-8 text-center border-2 border-dashed border-gray-200 rounded-lg">
+ <div className="text-gray-500 mb-2">
+ {mode === "assign"
+ ? "🎯 모든 사용자가 이미 이 롤에 할당되어 있습니다"
+ : "👥 이 롤에 할당된 사용자가 없습니다"
+ }
+ </div>
+ <div className="text-sm text-gray-400">
+ {mode === "assign"
+ ? "할당 가능한 사용자가 없습니다"
+ : "제거할 사용자가 없습니다"
+ }
+ </div>
+ </div>
+ )}
+
<DataTable
table={table}
-
>
<DataTableAdvancedToolbar
table={table}
@@ -150,10 +244,7 @@ export function AssginedUserTable({ promises ,onSelectedChange}: UsersTableProps
shallow={false}
>
</DataTableAdvancedToolbar>
-
</DataTable>
-
-
</>
)
-}
+} \ No newline at end of file
diff --git a/lib/techsales-rfq/table/tech-sales-quotation-attachments-sheet.tsx b/lib/techsales-rfq/table/tech-sales-quotation-attachments-sheet.tsx
index 6d6bde5a..08363535 100644
--- a/lib/techsales-rfq/table/tech-sales-quotation-attachments-sheet.tsx
+++ b/lib/techsales-rfq/table/tech-sales-quotation-attachments-sheet.tsx
@@ -14,6 +14,7 @@ import { Badge } from "@/components/ui/badge"
import { Separator } from "@/components/ui/separator"
import { formatDate } from "@/lib/utils"
import prettyBytes from "pretty-bytes"
+import { downloadFile } from "@/lib/file-download"
// 견적서 첨부파일 타입 정의
export interface QuotationAttachment {
@@ -82,6 +83,8 @@ export function TechSalesQuotationAttachmentsSheet({
// 파일 다운로드 처리
const handleDownload = (attachment: QuotationAttachment) => {
+ downloadFile(attachment.filePath, attachment.originalFileName || attachment.fileName)
+ /*
const link = document.createElement('a');
link.href = attachment.filePath;
link.download = attachment.originalFileName || attachment.fileName;
@@ -89,6 +92,7 @@ export function TechSalesQuotationAttachmentsSheet({
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
+ */
};
// 리비전별로 첨부파일 그룹핑
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 0593206a..fccedf0a 100644
--- a/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx
+++ b/lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx
@@ -51,6 +51,7 @@ import prettyBytes from "pretty-bytes"
import { formatDate } from "@/lib/utils"
import { processTechSalesRfqAttachments } from "@/lib/techsales-rfq/service"
import { useSession } from "next-auth/react"
+import { downloadFile } from "@/lib/file-download"
const MAX_FILE_SIZE = 6e8 // 600MB
@@ -406,8 +407,9 @@ export function TechSalesRfqAttachmentsSheet({
{/* Download button */}
{field.filePath && (
<a
- href={`/api/tech-sales-rfq-download?path=${encodeURIComponent(field.filePath)}`}
- download={field.originalFileName || field.fileName}
+ // href={`/api/tech-sales-rfq-download?path=${encodeURIComponent(field.filePath)}`}
+ // download={field.originalFileName || field.fileName}
+ onClick={() => downloadFile(field.filePath, field.originalFileName || field.fileName)}
className="inline-block"
>
<Button variant="ghost" size="icon" type="button" className="h-8 w-8">
diff --git a/lib/users/service.ts b/lib/users/service.ts
index e32d450e..7a635113 100644
--- a/lib/users/service.ts
+++ b/lib/users/service.ts
@@ -560,12 +560,112 @@ export async function getUsersAllbyVendor(input: GetUsersSchema, domain: string)
}
export async function assignUsersToRole(roleId: number, userIds: number[]) {
- unstable_noStore(); // 캐싱 방지(Next.js 서버 액션용)
- try{
+ unstable_noStore() // 캐싱 방지(Next.js 서버 액션용)
+
+ try {
+ if (userIds.length === 0) {
+ return { data: null, error: "선택된 사용자가 없습니다." }
+ }
+
+ await db.transaction(async (tx) => {
+ // 1) 이미 할당된 사용자들 확인
+ const existingAssignments = await tx
+ .select({ userId: userRoles.userId })
+ .from(userRoles)
+ .where(
+ and(
+ eq(userRoles.roleId, roleId),
+ inArray(userRoles.userId, userIds)
+ )
+ )
+
+ const existingUserIds = existingAssignments.map(item => item.userId)
+
+ // 2) 새로 할당할 사용자들만 필터링
+ const newUserIds = userIds.filter(uid => !existingUserIds.includes(uid))
+
+ // 3) 새로운 할당만 추가
+ if (newUserIds.length > 0) {
+ await tx.insert(userRoles).values(
+ newUserIds.map((uid) => ({
+ userId: uid,
+ roleId: roleId
+ }))
+ )
+ }
+ })
+
+ revalidateTag("users")
+ revalidateTag("roles")
+
+ return {
+ data: {
+ assignedCount: userIds.length,
+ message: `${userIds.length}명의 사용자가 성공적으로 할당되었습니다.`
+ },
+ error: null
+ }
+ } catch (err) {
+ return {
+ data: null,
+ error: getErrorMessage(err)
+ }
+ }
+}
+
+/**
+ * 특정 롤에서 사용자들을 제거합니다
+ */
+export async function removeUsersFromRole(roleId: number, userIds: number[]) {
+ unstable_noStore() // 캐싱 방지(Next.js 서버 액션용)
+
+ try {
+ if (userIds.length === 0) {
+ return { data: null, error: "선택된 사용자가 없습니다." }
+ }
+
+ await db.transaction(async (tx) => {
+ // 해당 롤에서 특정 사용자들만 삭제
+ await tx
+ .delete(userRoles)
+ .where(
+ and(
+ eq(userRoles.roleId, roleId),
+ inArray(userRoles.userId, userIds)
+ )
+ )
+ })
+
+ revalidateTag("users")
+ revalidateTag("roles")
+
+ return {
+ data: {
+ removedCount: userIds.length,
+ message: `${userIds.length}명의 사용자가 성공적으로 제거되었습니다.`
+ },
+ error: null
+ }
+ } catch (err) {
+ return {
+ data: null,
+ error: getErrorMessage(err)
+ }
+ }
+}
+
+/**
+ * 롤의 모든 사용자 할당을 재설정합니다 (기존 함수와 동일)
+ * 기존 할당을 모두 삭제하고 새로운 할당으로 교체합니다
+ */
+export async function replaceRoleAssignments(roleId: number, userIds: number[]) {
+ unstable_noStore() // 캐싱 방지(Next.js 서버 액션용)
+
+ try {
await db.transaction(async (tx) => {
// 1) 기존 userRoles 레코드 삭제
await tx.delete(userRoles).where(eq(userRoles.roleId, roleId))
-
+
// 2) 새로 넣기
if (userIds.length > 0) {
await tx.insert(userRoles).values(
@@ -573,15 +673,41 @@ export async function assignUsersToRole(roleId: number, userIds: number[]) {
)
}
})
- revalidateTag("users");
- revalidateTag("roles");
-
- return { data: null, error: null };
- } catch (err){
- return { data: null, error: getErrorMessage(err) };
-
+
+ revalidateTag("users")
+ revalidateTag("roles")
+
+ return { data: null, error: null }
+ } catch (err) {
+ return { data: null, error: getErrorMessage(err) }
}
+}
+/**
+ * 특정 롤에 할당된 사용자 목록을 가져옵니다
+ */
+export async function getUsersAssignedToRole(roleId: number) {
+ unstable_noStore()
+
+ try {
+ const assignedUsers = await db
+ .select({
+ userId: userRoles.userId,
+ // 필요한 다른 사용자 정보들도 join해서 가져올 수 있습니다
+ })
+ .from(userRoles)
+ .where(eq(userRoles.roleId, roleId))
+
+ return {
+ data: assignedUsers.map(u => u.userId),
+ error: null
+ }
+ } catch (err) {
+ return {
+ data: [],
+ error: getErrorMessage(err)
+ }
+ }
}
@@ -767,3 +893,4 @@ export async function getUserRoles(userId: number): Promise<string[]> {
return []
}
}
+
diff --git a/lib/users/table/assign-roles-dialog.tsx b/lib/users/table/assign-roles-dialog.tsx
index 003f6500..7bc7e138 100644
--- a/lib/users/table/assign-roles-dialog.tsx
+++ b/lib/users/table/assign-roles-dialog.tsx
@@ -21,9 +21,11 @@ import {
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
-import { Check, ChevronsUpDown, Loader, UserRoundPlus } from "lucide-react"
+import { Check, ChevronsUpDown, Loader, UserRoundPlus, AlertTriangle, Users, UserMinus } from "lucide-react"
import { cn } from "@/lib/utils"
import { toast } from "sonner"
+import { Alert, AlertDescription } from "@/components/ui/alert"
+import { Badge } from "@/components/ui/badge"
import { Textarea } from "@/components/ui/textarea"
import { Company } from "@/db/schema/companies"
@@ -41,8 +43,8 @@ import {
CommandItem,
CommandEmpty,
} from "@/components/ui/command"
-import { assignRolesToUsers, getAllRoleView } from "@/lib/roles/services"
-import { RoleView } from "@/db/schema/users"
+import { assignRolesToUsers, getAllRoleView, checkMultipleRegularEvaluationRolesAssigned } from "@/lib/roles/services"
+import { Role, RoleView } from "@/db/schema/users"
import { type UserView } from "@/db/schema/users"
import { type Row } from "@tanstack/react-table"
import { createRoleAssignmentSchema, CreateRoleAssignmentSchema, createRoleSchema, CreateRoleSchema } from "@/lib/roles/validations"
@@ -51,26 +53,81 @@ import { MultiSelect } from "@/components/ui/multi-select"
interface AssignRoleDialogProps
extends React.ComponentPropsWithoutRef<typeof Dialog> {
users: Row<UserView>["original"][]
-
+ roles: RoleView[]
}
+// 역할 상태 타입 정의
+type RoleAssignmentStatus = 'all' | 'some' | 'none'
+
+interface RoleAnalysis {
+ roleId: string
+ roleName: string
+ status: RoleAssignmentStatus
+ assignedUserCount: number
+ totalUserCount: number
+}
-export function AssignRoleDialog({ users }: AssignRoleDialogProps) {
+export function AssignRoleDialog({ users, roles }: AssignRoleDialogProps) {
const [open, setOpen] = React.useState(false)
const [isAddPending, startAddTransition] = React.useTransition()
- const [roles, setRoles] = React.useState<RoleView[]>([]) // 회사 목록
const [loading, setLoading] = React.useState(false)
+ const [regularEvaluationAssigned, setRegularEvaluationAssigned] = React.useState<{[roleId: string]: boolean}>({})
+ const [isCheckingRegularEvaluation, setIsCheckingRegularEvaluation] = React.useState(false)
- const partnersRoles = roles.filter(v => v.domain === "partners")
- const evcpRoles = roles.filter(v => v.domain === "evcp")
+ // 메모이제이션된 필터링된 역할들
+ const partnersRoles = React.useMemo(() =>
+ roles.filter(v => v.domain === "partners"), [roles])
+
+ const evcpRoles = React.useMemo(() =>
+ roles.filter(v => v.domain === "evcp"), [roles])
+ // 메모이제이션된 evcp 사용자들
+ const evcpUsers = React.useMemo(() =>
+ users.filter(v => v.user_domain === "evcp"), [users])
- React.useEffect(() => {
- getAllRoleView("evcp").then((res) => {
- setRoles(res)
+ // 선택된 사용자들의 역할 분석
+ const roleAnalysis = React.useMemo((): RoleAnalysis[] => {
+ if (evcpUsers.length === 0) return []
+
+ const analysis = evcpRoles.map(role => {
+ const assignedUsers = evcpUsers.filter(user =>
+ user.roles && user.roles.includes(role.name)
+ )
+
+ const assignedUserCount = assignedUsers.length
+ const totalUserCount = evcpUsers.length
+
+ let status: RoleAssignmentStatus
+ if (assignedUserCount === totalUserCount) {
+ status = 'all'
+ } else if (assignedUserCount > 0) {
+ status = 'some'
+ } else {
+ status = 'none'
+ }
+
+ return {
+ roleId: String(role.id),
+ roleName: role.name,
+ status,
+ assignedUserCount,
+ totalUserCount
+ }
})
- }, [])
+ console.log('Role analysis:', analysis)
+ return analysis
+ }, [evcpUsers, evcpRoles])
+
+ // 초기 선택된 역할들 (모든 사용자에게 할당된 역할들 + 일부에게 할당된 역할들)
+ const initialSelectedRoles = React.useMemo(() => {
+ const selected = roleAnalysis
+ .filter(analysis => analysis.status === 'all' || analysis.status === 'some')
+ .map(analysis => analysis.roleId)
+
+ console.log('Initial selected roles:', selected)
+ return selected
+ }, [roleAnalysis])
const form = useForm<CreateRoleAssignmentSchema>({
resolver: zodResolver(createRoleAssignmentSchema),
@@ -79,89 +136,280 @@ export function AssignRoleDialog({ users }: AssignRoleDialogProps) {
},
})
-
- function handleDialogOpenChange(nextOpen: boolean) {
+ const handleDialogOpenChange = React.useCallback((nextOpen: boolean) => {
if (!nextOpen) {
- form.reset()
+ // 다이얼로그가 닫힐 때 리셋
+ form.reset({
+ evcpRoles: [],
+ })
+ setRegularEvaluationAssigned({})
}
setOpen(nextOpen)
- }
+ }, [form])
- const evcpUsers = users.filter(v => v.user_domain === "evcp");
+ // 선택된 evcpRoles 감시 - 메모이제이션
+ const selectedEvcpRoles = form.watch("evcpRoles")
+ const memoizedSelectedEvcpRoles = React.useMemo(() =>
+ selectedEvcpRoles || [], [selectedEvcpRoles])
+ // 정기평가 role들 찾기 - 의존성 수정
+ const selectedRegularEvaluationRoles = React.useMemo(() => {
+ return memoizedSelectedEvcpRoles.filter(roleId => {
+ const role = evcpRoles.find(r => String(r.id) === roleId)
+ return role && role.name.includes("정기평가")
+ })
+ }, [memoizedSelectedEvcpRoles, evcpRoles])
- async function onSubmit(data: CreateRoleAssignmentSchema) {
- console.log(data.evcpRoles.map((v)=>Number(v)))
- startAddTransition(async () => {
+ // 정기평가 role 할당 상태 체크 (debounced)
+ React.useEffect(() => {
+ if (selectedRegularEvaluationRoles.length === 0) {
+ setRegularEvaluationAssigned({})
+ return
+ }
+
+ const timeoutId = setTimeout(async () => {
+ setIsCheckingRegularEvaluation(true)
+ try {
+ const roleIds = selectedRegularEvaluationRoles.map(roleId => Number(roleId))
+ const assignmentStatus = await checkMultipleRegularEvaluationRolesAssigned(roleIds)
+
+ const stringKeyStatus: {[roleId: string]: boolean} = {}
+ Object.entries(assignmentStatus).forEach(([roleId, isAssigned]) => {
+ stringKeyStatus[roleId] = isAssigned
+ })
+
+ setRegularEvaluationAssigned(stringKeyStatus)
+ } catch (error) {
+ console.error("정기평가 role 할당 상태 체크 실패:", error)
+ toast.error("정기평가 role 상태 확인에 실패했습니다")
+ } finally {
+ setIsCheckingRegularEvaluation(false)
+ }
+ }, 500)
+ return () => clearTimeout(timeoutId)
+ }, [selectedRegularEvaluationRoles])
- // if(partnerUsers.length>0){
- // const result = await assignRolesToUsers( partnerUsers.map(v=>v.user_id) ,data.partnersRoles)
+ // 할당 불가능한 정기평가 role 확인
+ const blockedRegularEvaluationRoles = React.useMemo(() => {
+ return selectedRegularEvaluationRoles.filter(roleId =>
+ regularEvaluationAssigned[roleId] === true
+ )
+ }, [selectedRegularEvaluationRoles, regularEvaluationAssigned])
- // if (result.error) {
- // toast.error(`에러: ${result.error}`)
- // return
- // }
- // }
+ // 제출 가능 여부
+ const canSubmit = React.useMemo(() =>
+ blockedRegularEvaluationRoles.length === 0, [blockedRegularEvaluationRoles])
- if (evcpUsers.length > 0) {
- const result = await assignRolesToUsers( data.evcpRoles.map((v)=>Number(v)), evcpUsers.map(v => v.user_id))
+ // MultiSelect options 메모이제이션 - 상태 정보와 함께 표시
+ const multiSelectOptions = React.useMemo(() => {
+ return evcpRoles.map((role) => {
+ const analysis = roleAnalysis.find(a => a.roleId === String(role.id))
+
+ let statusSuffix = ''
+ if (analysis) {
+ if (analysis.status === 'all') {
+ statusSuffix = ` (모든 사용자 ${analysis.assignedUserCount}/${analysis.totalUserCount})`
+ } else if (analysis.status === 'some') {
+ statusSuffix = ` (일부 사용자 ${analysis.assignedUserCount}/${analysis.totalUserCount})`
+ }
+ }
+
+ return {
+ value: String(role.id),
+ label: role.name + statusSuffix,
+ disabled: role.name.includes("정기평가") && regularEvaluationAssigned[String(role.id)] === true
+ }
+ })
+ }, [evcpRoles, roleAnalysis, regularEvaluationAssigned])
+ const onSubmit = React.useCallback(async (data: CreateRoleAssignmentSchema) => {
+ startAddTransition(async () => {
+ if (evcpUsers.length === 0) return
+
+ try {
+ const selectedRoleIds = data.evcpRoles.map(v => Number(v))
+ const userIds = evcpUsers.map(v => v.user_id)
+
+ // assignRolesToUsers는 이미 기존 관계를 삭제하고 새로 삽입하므로
+ // 최종 선택된 역할들만 전달하면 됩니다
+ const result = await assignRolesToUsers(selectedRoleIds, userIds)
+
if (result.error) {
- toast.error(`에러: ${result.error}`)
+ toast.error(`역할 업데이트 실패: ${result.error}`)
return
}
- }
- form.reset()
- setOpen(false)
- toast.success("Role assgined")
+ form.reset()
+ setOpen(false)
+ setRegularEvaluationAssigned({})
+
+ // 변경사항 계산해서 피드백
+ const initialRoleIds = initialSelectedRoles.map(v => Number(v))
+ const addedRoles = selectedRoleIds.filter(roleId => !initialRoleIds.includes(roleId))
+ const removedRoles = initialRoleIds.filter(roleId => !selectedRoleIds.includes(roleId))
+
+ if (addedRoles.length > 0 && removedRoles.length > 0) {
+ toast.success(`역할이 성공적으로 업데이트되었습니다 (추가: ${addedRoles.length}, 제거: ${removedRoles.length})`)
+ } else if (addedRoles.length > 0) {
+ toast.success(`${addedRoles.length}개 역할이 성공적으로 추가되었습니다`)
+ } else if (removedRoles.length > 0) {
+ toast.success(`${removedRoles.length}개 역할이 성공적으로 제거되었습니다`)
+ } else {
+ toast.info("변경사항이 없습니다")
+ }
+ } catch (error) {
+ console.error("역할 업데이트 실패:", error)
+ toast.error("역할 업데이트에 실패했습니다")
+ }
})
- }
+ }, [evcpUsers, form, initialSelectedRoles])
+
+ // 정기평가 role 관련 경고 메시지 생성
+ const regularEvaluationWarning = React.useMemo(() => {
+ if (selectedRegularEvaluationRoles.length === 0) return null
+
+ if (isCheckingRegularEvaluation) {
+ return (
+ <Alert key="checking">
+ <Loader className="h-4 w-4 animate-spin" />
+ <AlertDescription>
+ 정기평가 role 할당 상태를 확인하고 있습니다...
+ </AlertDescription>
+ </Alert>
+ )
+ }
+
+ if (blockedRegularEvaluationRoles.length > 0) {
+ const blockedRoleNames = blockedRegularEvaluationRoles.map(roleId => {
+ const role = evcpRoles.find(r => String(r.id) === roleId)
+ return role?.name || roleId
+ })
+
+ return (
+ <Alert key="blocked" variant="destructive">
+ <AlertTriangle className="h-4 w-4" />
+ <AlertDescription>
+ <strong>할당 불가:</strong> 다음 정기평가 role이 이미 다른 유저에게 할당되어 있습니다:
+ <br />
+ <strong>{blockedRoleNames.join(", ")}</strong>
+ <br />
+ 정기평가 role은 한 명의 유저에게만 할당할 수 있습니다.
+ </AlertDescription>
+ </Alert>
+ )
+ }
+
+ if (selectedRegularEvaluationRoles.length > 0) {
+ const availableRoleNames = selectedRegularEvaluationRoles.map(roleId => {
+ const role = evcpRoles.find(r => String(r.id) === roleId)
+ return role?.name || roleId
+ })
+
+ return (
+ <Alert key="available">
+ <Check className="h-4 w-4" />
+ <AlertDescription>
+ 정기평가 role을 할당할 수 있습니다: <strong>{availableRoleNames.join(", ")}</strong>
+ </AlertDescription>
+ </Alert>
+ )
+ }
+
+ return null
+ }, [
+ selectedRegularEvaluationRoles,
+ isCheckingRegularEvaluation,
+ blockedRegularEvaluationRoles,
+ evcpRoles
+ ])
+
+ // 현재 역할 상태 요약
+ const roleStatusSummary = React.useMemo(() => {
+ const allRoles = roleAnalysis.filter(r => r.status === 'all').length
+ const someRoles = roleAnalysis.filter(r => r.status === 'some').length
+ const totalRoles = roleAnalysis.length
+
+ return { allRoles, someRoles, totalRoles }
+ }, [roleAnalysis])
return (
<Dialog open={open} onOpenChange={handleDialogOpenChange}>
<DialogTrigger asChild>
<Button variant="default" size="sm">
<UserRoundPlus className="mr-2 size-4" aria-hidden="true" />
- Assign Role ({users.length})
+ 역할 편집 ({users.length}명)
</Button>
</DialogTrigger>
- <DialogContent>
+ <DialogContent className="max-w-2xl">
<DialogHeader>
- <DialogTitle>Assign Roles to {evcpUsers.length} Users</DialogTitle>
- <DialogDescription>
- Role을 Multi-select 하시기 바랍니다.
+ <DialogTitle className="flex items-center gap-2">
+ <Users className="size-5" />
+ {evcpUsers.length}명 사용자의 역할 편집
+ </DialogTitle>
+ <DialogDescription className="space-y-2">
+ <div>선택된 사용자들의 역할을 편집할 수 있습니다. 기존 역할 상태가 표시됩니다.</div>
+ <div className="flex gap-2 text-sm">
+ <Badge variant="secondary">
+ 공통 역할: {roleStatusSummary.allRoles}개
+ </Badge>
+ <Badge variant="outline">
+ 일부 역할: {roleStatusSummary.someRoles}개
+ </Badge>
+ <Badge variant="secondary">
+ 전체 역할: {roleStatusSummary.totalRoles}개
+ </Badge>
+ </div>
</DialogDescription>
</DialogHeader>
-
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
<div className="space-y-4 py-4">
{/* evcp 롤 선택 */}
- {evcpUsers.length > 0 &&
+ {evcpUsers.length > 0 && (
<FormField
control={form.control}
name="evcpRoles"
render={({ field }) => (
<FormItem>
- <FormLabel>eVCP Role</FormLabel>
+ <FormLabel className="flex items-center gap-2">
+ eVCP 역할 선택
+ <span className="text-sm text-muted-foreground">
+ (체크: 할당됨, 해제: 제거됨)
+ </span>
+ </FormLabel>
<FormControl>
<MultiSelect
- options={evcpRoles.map((role) => ({ value: String(role.id), label: role.name }))}
+ key={`multiselect-${open}-${initialSelectedRoles.join(',')}`}
+ options={multiSelectOptions}
onValueChange={(values) => {
- field.onChange(values);
+ console.log('MultiSelect value changed:', values)
+ field.onChange(values)
}}
-
+ defaultValue={initialSelectedRoles}
/>
</FormControl>
<FormMessage />
+
+ {/* 역할 상태 설명 */}
+ <div className="text-sm text-muted-foreground space-y-1">
+ <div>• <strong>모든 사용자</strong>: 선택된 모든 사용자에게 할당된 역할</div>
+ <div>• <strong>일부 사용자</strong>: 일부 사용자에게만 할당된 역할</div>
+ <div>• 역할을 체크하면 모든 사용자에게 할당되고, 해제하면 모든 사용자에서 제거됩니다</div>
+ </div>
+
+ {/* 정기평가 관련 경고 메시지 */}
+ {regularEvaluationWarning && (
+ <div className="mt-2">
+ {regularEvaluationWarning}
+ </div>
+ )}
</FormItem>
)}
/>
- }
+ )}
</div>
<DialogFooter>
@@ -171,11 +419,16 @@ export function AssignRoleDialog({ users }: AssignRoleDialogProps) {
onClick={() => setOpen(false)}
disabled={isAddPending}
>
- Cancel
+ 취소
</Button>
<Button
type="submit"
- disabled={form.formState.isSubmitting || isAddPending}
+ disabled={
+ form.formState.isSubmitting ||
+ isAddPending ||
+ !canSubmit ||
+ isCheckingRegularEvaluation
+ }
>
{isAddPending && (
<Loader
@@ -183,7 +436,7 @@ export function AssignRoleDialog({ users }: AssignRoleDialogProps) {
aria-hidden="true"
/>
)}
- Assgin
+ 역할 업데이트
</Button>
</DialogFooter>
</form>
diff --git a/lib/users/table/users-table-toolbar-actions.tsx b/lib/users/table/users-table-toolbar-actions.tsx
index 106953a6..eef93546 100644
--- a/lib/users/table/users-table-toolbar-actions.tsx
+++ b/lib/users/table/users-table-toolbar-actions.tsx
@@ -10,15 +10,16 @@ import { Button } from "@/components/ui/button"
-import { UserView } from "@/db/schema/users"
+import { Role, RoleView, UserView } from "@/db/schema/users"
import { DeleteUsersDialog } from "@/lib/admin-users/table/delete-ausers-dialog"
import { AssignRoleDialog } from "./assign-roles-dialog"
interface UsersTableToolbarActionsProps {
table: Table<UserView>
+ roles: RoleView[]
}
-export function UsersTableToolbarActions({ table }: UsersTableToolbarActionsProps) {
+export function UsersTableToolbarActions({ table, roles }: UsersTableToolbarActionsProps) {
// 파일 input을 숨기고, 버튼 클릭 시 참조해 클릭하는 방식
const fileInputRef = React.useRef<HTMLInputElement>(null)
@@ -36,6 +37,7 @@ export function UsersTableToolbarActions({ table }: UsersTableToolbarActionsProp
users={table
.getFilteredSelectedRowModel()
.rows.map((row) => row.original)}
+ roles={roles}
/>
) : null}
diff --git a/lib/users/table/users-table.tsx b/lib/users/table/users-table.tsx
index 53cb961e..784c1e5d 100644
--- a/lib/users/table/users-table.tsx
+++ b/lib/users/table/users-table.tsx
@@ -39,6 +39,9 @@ export function UserTable({ promises }: UsersTableProps) {
React.use(promises)
+ console.log(roles,"user")
+
+
const [rowAction, setRowAction] =
React.useState<DataTableRowAction<UserView> | null>(null)
@@ -139,7 +142,7 @@ export function UserTable({ promises }: UsersTableProps) {
filterFields={advancedFilterFields}
shallow={false}
>
- <UsersTableToolbarActions table={table}/>
+ <UsersTableToolbarActions table={table} roles={roles}/>
</DataTableAdvancedToolbar>
</DataTable>
diff --git a/lib/vendor-document-list/ship/enhanced-doc-table-toolbar-actions.tsx b/lib/vendor-document-list/ship/enhanced-doc-table-toolbar-actions.tsx
index c3b7251c..255b1f9d 100644
--- a/lib/vendor-document-list/ship/enhanced-doc-table-toolbar-actions.tsx
+++ b/lib/vendor-document-list/ship/enhanced-doc-table-toolbar-actions.tsx
@@ -19,31 +19,11 @@ interface EnhancedDocTableToolbarActionsProps {
export function EnhancedDocTableToolbarActions({
table,
projectType,
- contractId,
}: EnhancedDocTableToolbarActionsProps) {
const [bulkUploadDialogOpen, setBulkUploadDialogOpen] = React.useState(false)
// 현재 테이블의 모든 데이터 (필터링된 상태)
const allDocuments = table.getFilteredRowModel().rows.map(row => row.original)
-
- // 모든 문서에서 고유한 contractId들 추출
- const contractIds = React.useMemo(() => {
- const ids = new Set(allDocuments.map(doc => doc.contractId))
- return Array.from(ids)
- }, [allDocuments])
-
- // 주요 contractId (가장 많은 문서가 속한 계약)
- const primaryContractId = React.useMemo(() => {
- if (contractId) return contractId
- if (contractIds.length === 0) return undefined
-
- const contractCounts = contractIds.map(id => ({
- id,
- count: allDocuments.filter(doc => doc.contractId === id).length
- }))
-
- return contractCounts.sort((a, b) => b.count - a.count)[0].id
- }, [contractId, contractIds, allDocuments])
const handleSyncComplete = () => {
// 동기화 완료 후 테이블 새로고침
@@ -98,13 +78,11 @@ export function EnhancedDocTableToolbarActions({
</Button>
{/* Send to SHI 버튼 (공통) - 내부 → 외부로 보내기 */}
- {primaryContractId && (
- <SendToSHIButton
- contractId={primaryContractId}
- onSyncComplete={handleSyncComplete}
- projectType={projectType}
- />
- )}
+ <SendToSHIButton
+ documents={allDocuments}
+ onSyncComplete={handleSyncComplete}
+ projectType={projectType}
+ />
</div>
diff --git a/lib/vendor-document-list/ship/send-to-shi-button.tsx b/lib/vendor-document-list/ship/send-to-shi-button.tsx
index 1a27a794..61893da5 100644
--- a/lib/vendor-document-list/ship/send-to-shi-button.tsx
+++ b/lib/vendor-document-list/ship/send-to-shi-button.tsx
@@ -1,4 +1,4 @@
-// components/sync/send-to-shi-button.tsx (최종 버전)
+// components/sync/send-to-shi-button.tsx (다중 계약 버전)
"use client"
import * as React from "react"
@@ -21,33 +21,59 @@ import {
import { Badge } from "@/components/ui/badge"
import { Progress } from "@/components/ui/progress"
import { Separator } from "@/components/ui/separator"
+import { ScrollArea } from "@/components/ui/scroll-area"
import { useSyncStatus, useTriggerSync } from "@/hooks/use-sync-status"
import type { EnhancedDocument } from "@/types/enhanced-documents"
interface SendToSHIButtonProps {
- contractId: number
documents?: EnhancedDocument[]
onSyncComplete?: () => void
projectType: "ship" | "plant"
}
+interface ContractSyncStatus {
+ contractId: number
+ syncStatus: any
+ isLoading: boolean
+ error: any
+}
+
export function SendToSHIButton({
- contractId,
documents = [],
onSyncComplete,
projectType
}: SendToSHIButtonProps) {
const [isDialogOpen, setIsDialogOpen] = React.useState(false)
const [syncProgress, setSyncProgress] = React.useState(0)
+ const [currentSyncingContract, setCurrentSyncingContract] = React.useState<number | null>(null)
- const targetSystem = projectType === 'ship'?"DOLCE":"SWP"
+ const targetSystem = projectType === 'ship' ? "DOLCE" : "SWP"
- const {
- syncStatus,
- isLoading: statusLoading,
- error: statusError,
- refetch: refetchStatus
- } = useSyncStatus(contractId, targetSystem)
+ // documents에서 contractId 목록 추출
+ const documentsContractIds = React.useMemo(() => {
+ const uniqueIds = [...new Set(documents.map(doc => doc.contractId).filter(Boolean))]
+ return uniqueIds.sort()
+ }, [documents])
+
+ // 각 contract별 동기화 상태 조회
+ const contractStatuses = React.useMemo(() => {
+ return documentsContractIds.map(contractId => {
+ const {
+ syncStatus,
+ isLoading,
+ error,
+ refetch
+ } = useSyncStatus(contractId, targetSystem)
+
+ return {
+ contractId,
+ syncStatus,
+ isLoading,
+ error,
+ refetch
+ }
+ })
+ }, [documentsContractIds, targetSystem])
const {
triggerSync,
@@ -55,60 +81,130 @@ export function SendToSHIButton({
error: syncError
} = useTriggerSync()
+ // 전체 통계 계산
+ const totalStats = React.useMemo(() => {
+ let totalPending = 0
+ let totalSynced = 0
+ let totalFailed = 0
+ let hasError = false
+ let isLoading = false
+
+ contractStatuses.forEach(({ syncStatus, error, isLoading: loading }) => {
+ if (error) hasError = true
+ if (loading) isLoading = true
+ if (syncStatus) {
+ totalPending += syncStatus.pendingChanges || 0
+ totalSynced += syncStatus.syncedChanges || 0
+ totalFailed += syncStatus.failedChanges || 0
+ }
+ })
+
+ return {
+ totalPending,
+ totalSynced,
+ totalFailed,
+ hasError,
+ isLoading,
+ canSync: totalPending > 0 && !hasError
+ }
+ }, [contractStatuses])
+
// 에러 상태 표시
React.useEffect(() => {
- if (statusError) {
- console.warn('Failed to load sync status:', statusError)
+ if (totalStats.hasError) {
+ console.warn('Failed to load sync status for some contracts')
}
- }, [statusError])
+ }, [totalStats.hasError])
const handleSync = async () => {
- if (!contractId) return
+ if (documentsContractIds.length === 0) return
setSyncProgress(0)
+ let successfulSyncs = 0
+ let failedSyncs = 0
+ let totalSuccessCount = 0
+ let totalFailureCount = 0
+ const errors: string[] = []
try {
- // 진행률 시뮬레이션
- const progressInterval = setInterval(() => {
- setSyncProgress(prev => Math.min(prev + 10, 90))
- }, 200)
-
- const result = await triggerSync({
- contractId,
- targetSystem
- })
-
- clearInterval(progressInterval)
- setSyncProgress(100)
+ const contractsToSync = contractStatuses.filter(
+ ({ syncStatus, error }) => !error && syncStatus?.syncEnabled && syncStatus?.pendingChanges > 0
+ )
+
+ if (contractsToSync.length === 0) {
+ toast.info('동기화할 변경사항이 없습니다.')
+ setIsDialogOpen(false)
+ return
+ }
+
+ // 각 contract별로 순차 동기화
+ for (let i = 0; i < contractsToSync.length; i++) {
+ const { contractId } = contractsToSync[i]
+ setCurrentSyncingContract(contractId)
+
+ try {
+ const result = await triggerSync({
+ contractId,
+ targetSystem
+ })
+
+ if (result?.success) {
+ successfulSyncs++
+ totalSuccessCount += result.successCount || 0
+ } else {
+ failedSyncs++
+ totalFailureCount += result?.failureCount || 0
+ if (result?.errors?.[0]) {
+ errors.push(`Contract ${contractId}: ${result.errors[0]}`)
+ }
+ }
+ } catch (error) {
+ failedSyncs++
+ const errorMessage = error instanceof Error ? error.message : '알 수 없는 오류'
+ errors.push(`Contract ${contractId}: ${errorMessage}`)
+ }
+
+ // 진행률 업데이트
+ setSyncProgress(((i + 1) / contractsToSync.length) * 100)
+ }
+
+ setCurrentSyncingContract(null)
setTimeout(() => {
setSyncProgress(0)
setIsDialogOpen(false)
- if (result?.success) {
+ if (failedSyncs === 0) {
toast.success(
- `동기화 완료: ${result.successCount || 0}건 성공`,
+ `모든 계약 동기화 완료: ${totalSuccessCount}건 성공`,
+ {
+ description: `${successfulSyncs}개 계약에서 ${totalSuccessCount}개 항목이 SHI 시스템으로 전송되었습니다.`
+ }
+ )
+ } else if (successfulSyncs > 0) {
+ toast.warning(
+ `부분 동기화 완료: ${successfulSyncs}개 성공, ${failedSyncs}개 실패`,
{
- description: result.successCount > 0
- ? `${result.successCount}개 항목이 SHI 시스템으로 전송되었습니다.`
- : '전송할 새로운 변경사항이 없습니다.'
+ description: errors[0] || '일부 계약 동기화에 실패했습니다.'
}
)
} else {
toast.error(
- `동기화 부분 실패: ${result?.successCount || 0}건 성공, ${result?.failureCount || 0}건 실패`,
+ `동기화 실패: ${failedSyncs}개 계약 모두 실패`,
{
- description: result?.errors?.[0] || '일부 항목 전송에 실패했습니다.'
+ description: errors[0] || '모든 계약 동기화에 실패했습니다.'
}
)
}
- refetchStatus() // SWR 캐시 갱신
+ // 모든 contract 상태 갱신
+ contractStatuses.forEach(({ refetch }) => refetch?.())
onSyncComplete?.()
}, 500)
} catch (error) {
setSyncProgress(0)
+ setCurrentSyncingContract(null)
toast.error('동기화 실패', {
description: error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.'
@@ -117,28 +213,28 @@ export function SendToSHIButton({
}
const getSyncStatusBadge = () => {
- if (statusLoading) {
+ if (totalStats.isLoading) {
return <Badge variant="secondary">확인 중...</Badge>
}
- if (statusError) {
+ if (totalStats.hasError) {
return <Badge variant="destructive">오류</Badge>
}
- if (!syncStatus) {
- return <Badge variant="secondary">데이터 없음</Badge>
+ if (documentsContractIds.length === 0) {
+ return <Badge variant="secondary">계약 없음</Badge>
}
- if (syncStatus.pendingChanges > 0) {
+ if (totalStats.totalPending > 0) {
return (
<Badge variant="destructive" className="gap-1">
<AlertTriangle className="w-3 h-3" />
- {syncStatus.pendingChanges}건 대기
+ {totalStats.totalPending}건 대기
</Badge>
)
}
- if (syncStatus.syncedChanges > 0) {
+ if (totalStats.totalSynced > 0) {
return (
<Badge variant="default" className="gap-1 bg-green-500 hover:bg-green-600">
<CheckCircle className="w-3 h-3" />
@@ -150,86 +246,116 @@ export function SendToSHIButton({
return <Badge variant="secondary">변경사항 없음</Badge>
}
- const canSync = !statusError && syncStatus?.syncEnabled && syncStatus?.pendingChanges > 0
+ const refreshAllStatuses = () => {
+ contractStatuses.forEach(({ refetch }) => refetch?.())
+ }
return (
<>
<Popover>
<PopoverTrigger asChild>
- <div className="flex items-center gap-3">
- <Button
- variant="default"
- size="sm"
- className="flex items-center bg-blue-600 hover:bg-blue-700"
- disabled={isSyncing || statusLoading}
- >
- {isSyncing ? (
- <Loader2 className="w-4 h-4 animate-spin" />
- ) : (
- <Send className="w-4 h-4" />
- )}
- <span className="hidden sm:inline">Send to SHI</span>
- {syncStatus?.pendingChanges > 0 && (
- <Badge
- variant="destructive"
- className="h-5 w-5 p-0 text-xs flex items-center justify-center"
- >
- {syncStatus.pendingChanges}
- </Badge>
- )}
- </Button>
- </div>
+ <div className="flex items-center gap-3">
+ <Button
+ variant="default"
+ size="sm"
+ className="flex items-center bg-blue-600 hover:bg-blue-700"
+ disabled={isSyncing || totalStats.isLoading || documentsContractIds.length === 0}
+ >
+ {isSyncing ? (
+ <Loader2 className="w-4 h-4 animate-spin" />
+ ) : (
+ <Send className="w-4 h-4" />
+ )}
+ <span className="hidden sm:inline">Send to SHI</span>
+ {totalStats.totalPending > 0 && (
+ <Badge
+ variant="destructive"
+ className="h-5 w-5 p-0 text-xs flex items-center justify-center"
+ >
+ {totalStats.totalPending}
+ </Badge>
+ )}
+ </Button>
+ </div>
</PopoverTrigger>
- <PopoverContent className="w-80">
+ <PopoverContent className="w-96">
<div className="space-y-4">
<div className="space-y-2">
<h4 className="font-medium">SHI 동기화 상태</h4>
<div className="flex items-center justify-between">
- <span className="text-sm text-muted-foreground">현재 상태</span>
+ <span className="text-sm text-muted-foreground">전체 상태</span>
{getSyncStatusBadge()}
</div>
+ <div className="text-xs text-muted-foreground">
+ {documentsContractIds.length}개 계약 대상
+ </div>
</div>
- {syncStatus && !statusError && (
+ {!totalStats.hasError && documentsContractIds.length > 0 && (
<div className="space-y-3">
<Separator />
- <div className="grid grid-cols-2 gap-4 text-sm">
+ <div className="grid grid-cols-3 gap-4 text-sm">
<div>
<div className="text-muted-foreground">대기 중</div>
- <div className="font-medium">{syncStatus.pendingChanges || 0}건</div>
+ <div className="font-medium">{totalStats.totalPending}건</div>
</div>
<div>
<div className="text-muted-foreground">동기화됨</div>
- <div className="font-medium">{syncStatus.syncedChanges || 0}건</div>
+ <div className="font-medium">{totalStats.totalSynced}건</div>
</div>
- </div>
-
- {syncStatus.failedChanges > 0 && (
- <div className="text-sm">
+ <div>
<div className="text-muted-foreground">실패</div>
- <div className="font-medium text-red-600">{syncStatus.failedChanges}건</div>
+ <div className="font-medium text-red-600">{totalStats.totalFailed}건</div>
</div>
- )}
+ </div>
- {syncStatus.lastSyncAt && (
- <div className="text-sm">
- <div className="text-muted-foreground">마지막 동기화</div>
- <div className="font-medium">
- {new Date(syncStatus.lastSyncAt).toLocaleString()}
- </div>
+ {/* 계약별 상세 상태 */}
+ {contractStatuses.length > 1 && (
+ <div className="space-y-2">
+ <div className="text-sm font-medium">계약별 상태</div>
+ <ScrollArea className="h-32">
+ <div className="space-y-2">
+ {contractStatuses.map(({ contractId, syncStatus, isLoading, error }) => (
+ <div key={contractId} className="flex items-center justify-between text-xs p-2 rounded border">
+ <span>Contract {contractId}</span>
+ {isLoading ? (
+ <Badge variant="secondary" className="text-xs">로딩...</Badge>
+ ) : error ? (
+ <Badge variant="destructive" className="text-xs">오류</Badge>
+ ) : syncStatus?.pendingChanges > 0 ? (
+ <Badge variant="destructive" className="text-xs">
+ {syncStatus.pendingChanges}건 대기
+ </Badge>
+ ) : (
+ <Badge variant="secondary" className="text-xs">동기화됨</Badge>
+ )}
+ </div>
+ ))}
+ </div>
+ </ScrollArea>
</div>
)}
</div>
)}
- {statusError && (
+ {totalStats.hasError && (
<div className="space-y-2">
<Separator />
<div className="text-sm text-red-600">
<div className="font-medium">연결 오류</div>
- <div className="text-xs">동기화 상태를 확인할 수 없습니다.</div>
+ <div className="text-xs">일부 계약의 동기화 상태를 확인할 수 없습니다.</div>
+ </div>
+ </div>
+ )}
+
+ {documentsContractIds.length === 0 && (
+ <div className="space-y-2">
+ <Separator />
+ <div className="text-sm text-muted-foreground">
+ <div className="font-medium">계약 정보 없음</div>
+ <div className="text-xs">동기화할 문서가 없습니다.</div>
</div>
</div>
)}
@@ -239,7 +365,7 @@ export function SendToSHIButton({
<div className="flex gap-2">
<Button
onClick={() => setIsDialogOpen(true)}
- disabled={!canSync || isSyncing}
+ disabled={!totalStats.canSync || isSyncing}
className="flex-1"
size="sm"
>
@@ -259,10 +385,10 @@ export function SendToSHIButton({
<Button
variant="outline"
size="sm"
- onClick={() => refetchStatus()}
- disabled={statusLoading}
+ onClick={refreshAllStatuses}
+ disabled={totalStats.isLoading}
>
- {statusLoading ? (
+ {totalStats.isLoading ? (
<Loader2 className="w-4 h-4 animate-spin" />
) : (
<Settings className="w-4 h-4" />
@@ -279,16 +405,21 @@ export function SendToSHIButton({
<DialogHeader>
<DialogTitle>SHI 시스템으로 동기화</DialogTitle>
<DialogDescription>
- 변경된 문서 데이터를 SHI 시스템으로 전송합니다.
+ {documentsContractIds.length}개 계약의 변경된 문서 데이터를 SHI 시스템으로 전송합니다.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
- {syncStatus && !statusError && (
+ {!totalStats.hasError && documentsContractIds.length > 0 && (
<div className="rounded-lg border p-4 space-y-3">
<div className="flex items-center justify-between text-sm">
<span>전송 대상</span>
- <span className="font-medium">{syncStatus.pendingChanges || 0}건</span>
+ <span className="font-medium">{totalStats.totalPending}건</span>
+ </div>
+
+ <div className="flex items-center justify-between text-sm">
+ <span>대상 계약</span>
+ <span className="font-medium">{documentsContractIds.length}개</span>
</div>
<div className="text-xs text-muted-foreground">
@@ -299,18 +430,31 @@ export function SendToSHIButton({
<div className="space-y-2">
<div className="flex items-center justify-between text-sm">
<span>진행률</span>
- <span>{syncProgress}%</span>
+ <span>{Math.round(syncProgress)}%</span>
</div>
<Progress value={syncProgress} className="h-2" />
+ {currentSyncingContract && (
+ <div className="text-xs text-muted-foreground">
+ 현재 처리 중: Contract {currentSyncingContract}
+ </div>
+ )}
</div>
)}
</div>
)}
- {statusError && (
+ {totalStats.hasError && (
<div className="rounded-lg border border-red-200 p-4">
<div className="text-sm text-red-600">
- 동기화 상태를 확인할 수 없습니다. 네트워크 연결을 확인해주세요.
+ 일부 계약의 동기화 상태를 확인할 수 없습니다. 네트워크 연결을 확인해주세요.
+ </div>
+ </div>
+ )}
+
+ {documentsContractIds.length === 0 && (
+ <div className="rounded-lg border border-yellow-200 p-4">
+ <div className="text-sm text-yellow-700">
+ 동기화할 계약이 없습니다. 문서를 선택해주세요.
</div>
</div>
)}
@@ -325,7 +469,7 @@ export function SendToSHIButton({
</Button>
<Button
onClick={handleSync}
- disabled={isSyncing || !canSync}
+ disabled={isSyncing || !totalStats.canSync}
>
{isSyncing ? (
<>
diff --git a/lib/vendor-evaluation-submit/service.ts b/lib/vendor-evaluation-submit/service.ts
index 7be18fb8..3a31b380 100644
--- a/lib/vendor-evaluation-submit/service.ts
+++ b/lib/vendor-evaluation-submit/service.ts
@@ -17,7 +17,8 @@ import {
EsgEvaluationResponse,
esgEvaluations,
esgAnswerOptions,
- esgEvaluationItems
+ esgEvaluationItems,
+ periodicEvaluations
} from "@/db/schema";
import { and, asc, desc, eq, ilike, or, SQL, count , sql, avg} from "drizzle-orm";
import { filterColumns } from "@/lib/filter-columns";
@@ -84,6 +85,27 @@ export async function getEvaluationSubmissions(input: GetEvaluationsSubmitSchema
// 데이터 조회
const { data, total } = await db.transaction(async (tx) => {
+
+ const totalGeneralItemsResult = await tx
+ .select({ count: count() })
+ .from(generalEvaluations)
+ .where(eq(generalEvaluations.isActive, true));
+
+ const totalGeneralItemsCount = totalGeneralItemsResult[0]?.count || 0;
+
+ const totalEsgItemsResult = await tx
+ .select({ count: count() })
+ .from(esgEvaluationItems)
+ .innerJoin(esgEvaluations, eq(esgEvaluationItems.esgEvaluationId, esgEvaluations.id))
+ .where(
+ and(
+ eq(esgEvaluations.isActive, true),
+ eq(esgEvaluationItems.isActive, true)
+ )
+ );
+
+ const totalEGSItemsCount = totalEsgItemsResult[0]?.count || 0;
+
// 메인 데이터 조회
const data = await tx
.select({
@@ -98,9 +120,9 @@ export async function getEvaluationSubmissions(input: GetEvaluationsSubmitSchema
reviewedBy: evaluationSubmissions.reviewedBy,
reviewComments: evaluationSubmissions.reviewComments,
averageEsgScore: evaluationSubmissions.averageEsgScore,
- totalGeneralItems: evaluationSubmissions.totalGeneralItems,
+
completedGeneralItems: evaluationSubmissions.completedGeneralItems,
- totalEsgItems: evaluationSubmissions.totalEsgItems,
+
completedEsgItems: evaluationSubmissions.completedEsgItems,
isActive: evaluationSubmissions.isActive,
createdAt: evaluationSubmissions.createdAt,
@@ -161,6 +183,8 @@ export async function getEvaluationSubmissions(input: GetEvaluationsSubmitSchema
return {
...submission,
+ totalGeneralItems: totalGeneralItemsCount ,
+ totalEsgItems: totalEGSItemsCount,
_count: {
generalResponses: generalCount,
esgResponses: esgCount,
@@ -420,6 +444,18 @@ export async function updateEvaluationSubmissionStatus(
.where(eq(evaluationSubmissions.id, submissionId))
.returning();
+ // newStatus === 'submitted'일 때 periodicEvaluations 테이블도 업데이트
+ if (newStatus === 'submitted' && updatedSubmission) {
+ await tx
+ .update(periodicEvaluations)
+ .set({
+ documentsSubmitted: true,
+ submissionDate: new Date(),
+ updatedAt: new Date(),
+ })
+ .where(eq(periodicEvaluations.id, updatedSubmission.periodicEvaluationId));
+ }
+
return updatedSubmission;
});
}
diff --git a/lib/vendor-evaluation-submit/table/esg-evaluation-form-sheet.tsx b/lib/vendor-evaluation-submit/table/esg-evaluation-form-sheet.tsx
index 53d25382..d90f60b8 100644
--- a/lib/vendor-evaluation-submit/table/esg-evaluation-form-sheet.tsx
+++ b/lib/vendor-evaluation-submit/table/esg-evaluation-form-sheet.tsx
@@ -1,7 +1,7 @@
"use client"
import * as React from "react"
-import { useForm } from "react-hook-form"
+import { useForm, useWatch } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod"
import { SaveIcon, CheckIcon, XIcon, BarChart3Icon, TrendingUpIcon } from "lucide-react"
@@ -25,7 +25,6 @@ import { Button } from "@/components/ui/button"
import { Textarea } from "@/components/ui/textarea"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
-import { ScrollArea } from "@/components/ui/scroll-area"
import { Separator } from "@/components/ui/separator"
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group"
import { toast } from "sonner"
@@ -81,6 +80,12 @@ export function EsgEvaluationFormSheet({
}
})
+ // 현재 폼 값을 실시간으로 감시
+ const watchedResponses = useWatch({
+ control: form.control,
+ name: 'responses'
+ })
+
// 데이터 로딩
React.useEffect(() => {
if (open && submission?.id) {
@@ -207,33 +212,52 @@ export function EsgEvaluationFormSheet({
total: 0,
percentage: 0,
averageScore: 0,
- maxAverageScore: 0
+ maxAverageScore: 0,
+ totalPossibleScore: 0,
+ actualTotalScore: 0
}
-
+
let total = 0
let completed = 0
- let totalScore = 0
+ let totalScore = 0 // 숫자로 초기화
let maxTotalScore = 0
-
+
formData.evaluations.forEach(evaluation => {
evaluation.items.forEach(item => {
total++
- if (currentScores[item.item.id] > 0) {
- completed++
- totalScore += currentScores[item.item.id]
- }
- // 최대 점수 계산
+ // 최대 점수 계산 (모든 항목에 대해)
const maxOptionScore = Math.max(...item.answerOptions.map(opt => parseFloat(opt.score.toString())))
maxTotalScore += maxOptionScore
+
+ // 응답이 있는 경우에만 완료된 것으로 계산
+ const currentScore = currentScores[item.item.id]
+ if (currentScore !== undefined && currentScore >= 0) {
+ completed++
+ // 숫자로 명시적 변환하여 더하기
+ totalScore += Number(currentScore) || 0
+ console.log(`Adding score: ${Number(currentScore)}, Total so far: ${totalScore}`)
+ }
})
})
-
+
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0
+
+ // 응답한 항목들에 대해서만 평균 계산 (0으로 나누기 방지)
const averageScore = completed > 0 ? totalScore / completed : 0
+
+ // 전체 항목 기준 최대 평균 점수
const maxAverageScore = total > 0 ? maxTotalScore / total : 0
-
- return { completed, total, percentage, averageScore, maxAverageScore }
+
+ return {
+ completed,
+ total,
+ percentage,
+ averageScore,
+ maxAverageScore,
+ totalPossibleScore: maxTotalScore,
+ actualTotalScore: totalScore
+ }
}
const progress = getProgress()
@@ -241,7 +265,7 @@ export function EsgEvaluationFormSheet({
if (isLoading) {
return (
<Sheet open={open} onOpenChange={onOpenChange}>
- <SheetContent className="w-[900px] sm:max-w-[900px]">
+ <SheetContent className="w-[900px] sm:max-w-[900px]" style={{width:900, maxWidth:900}}>
<div className="flex items-center justify-center h-full">
<div className="text-center space-y-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-gray-900 mx-auto"></div>
@@ -256,7 +280,7 @@ export function EsgEvaluationFormSheet({
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="w-[900px] sm:max-w-[900px] flex flex-col" style={{width:900, maxWidth:900}}>
- <SheetHeader>
+ <SheetHeader>
<SheetTitle>ESG 평가 작성</SheetTitle>
<SheetDescription>
{formData?.submission.vendorName}의 ESG 평가를 작성해주세요.
@@ -318,11 +342,10 @@ export function EsgEvaluationFormSheet({
</div>
<Form {...form}>
- <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-6">
- <div className="flex-1 overflow-y-auto min-h-0">
-
- <ScrollArea className="h-full pr-4">
- <div className="space-y-4 pr-4">
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex-1 flex flex-col min-h-0">
+ {/* 스크롤 가능한 폼 영역 */}
+ <div className="flex-1 overflow-y-auto min-h-0 mt-6">
+ <div className="space-y-4 pr-4">
<Accordion type="multiple" defaultValue={formData.evaluations.map((_, i) => `evaluation-${i}`)}>
{formData.evaluations.map((evaluation, evalIndex) => (
<AccordionItem
@@ -348,7 +371,7 @@ export function EsgEvaluationFormSheet({
<BarChart3Icon className="h-4 w-4" />
<span className="text-sm">
{evaluation.items.filter(item =>
- currentScores[item.item.id] > 0
+ currentScores[item.item.id] >= 0
).length}/{evaluation.items.length}
</span>
</div>
@@ -361,6 +384,10 @@ export function EsgEvaluationFormSheet({
r => r.itemId === item.item.id
)
+ // watchedResponses에서 현재 응답 찾기
+ const currentResponse = watchedResponses?.find(r => r.itemId === item.item.id)
+ const selectedOptionId = currentResponse?.selectedOptionId?.toString() || ''
+
return (
<Card key={item.item.id} className="bg-gray-50">
<CardHeader className="pb-3">
@@ -381,7 +408,7 @@ export function EsgEvaluationFormSheet({
<CardContent className="space-y-4">
{/* 답변 옵션들 */}
<RadioGroup
- value={item.response?.esgAnswerOptionId?.toString() || ''}
+ value={selectedOptionId}
onValueChange={(value) => {
const option = item.answerOptions.find(
opt => opt.id === parseInt(value)
@@ -457,13 +484,12 @@ export function EsgEvaluationFormSheet({
))}
</Accordion>
</div>
- </ScrollArea>
</div>
- <Separator />
+ <Separator className="my-4" />
{/* 하단 버튼 영역 */}
- <div className="flex-shrink-0 flex items-center justify-between pt-4">
+ <div className="flex-shrink-0 flex items-center justify-between">
<div className="text-sm text-muted-foreground">
{progress.percentage === 100 ? (
<div className="flex items-center gap-2 text-green-600">
diff --git a/lib/vendor-evaluation-submit/table/general-evaluation-form-sheet.tsx b/lib/vendor-evaluation-submit/table/general-evaluation-form-sheet.tsx
index cc80e29c..bda087bb 100644
--- a/lib/vendor-evaluation-submit/table/general-evaluation-form-sheet.tsx
+++ b/lib/vendor-evaluation-submit/table/general-evaluation-form-sheet.tsx
@@ -35,6 +35,7 @@ import {
saveGeneralEvaluationResponse,
recalculateEvaluationProgress, // 진행률만 계산
GeneralEvaluationFormData,
+ updateAttachmentStatus,
} from "../service"
import { EvaluationSubmissionWithVendor } from "../service"
diff --git a/types/table.d.ts b/types/table.d.ts
index 3ce8bb99..b1f06a12 100644
--- a/types/table.d.ts
+++ b/types/table.d.ts
@@ -54,7 +54,7 @@ export type Filter<TData> = Prettify<
export interface DataTableRowAction<TData> {
row: Row<TData>
- type:"download_report"|"submit" |"general_evaluation"| "general_evaluation"|"esg_evaluation" |"schedule"| "view"| "upload" | "addInfo"| "view-series"|"log"| "tbeResult" | "requestInfo"| "esign-detail"| "responseDetail"|"signature"|"update" | "delete" | "user" | "pemission" | "invite" | "items" | "attachment" |"comments" | "open" | "select" | "files"
+ type:"dispose"|"restore"|"download_report"|"submit" |"general_evaluation"| "general_evaluation"|"esg_evaluation" |"schedule"| "view"| "upload" | "addInfo"| "view-series"|"log"| "tbeResult" | "requestInfo"| "esign-detail"| "responseDetail"|"signature"|"update" | "delete" | "user" | "pemission" | "invite" | "items" | "attachment" |"comments" | "open" | "select" | "files"
}
export interface QueryBuilderOpts {