diff options
Diffstat (limited to 'lib')
29 files changed, 1715 insertions, 767 deletions
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" |
