summaryrefslogtreecommitdiff
path: root/lib
diff options
context:
space:
mode:
Diffstat (limited to 'lib')
-rw-r--r--lib/admin-users/table/ausers-table.tsx2
-rw-r--r--lib/basic-contract/template/add-basic-contract-template-dialog.tsx406
-rw-r--r--lib/evaluation-submit/service.ts9
-rw-r--r--lib/evaluation-submit/table/evaluation-submissions-table-columns.tsx4
-rw-r--r--lib/evaluation-submit/table/submit-table.tsx3
-rw-r--r--lib/evaluation-target-list/service.ts301
-rw-r--r--lib/evaluation/service.ts18
-rw-r--r--lib/evaluation/table/evaluation-columns.tsx1
-rw-r--r--lib/evaluation/table/evaluation-filter-sheet.tsx14
-rw-r--r--lib/evaluation/table/evaluation-table.tsx1
-rw-r--r--lib/evaluation/table/periodic-evaluation-action-dialogs.tsx2
-rw-r--r--lib/evaluation/table/periodic-evaluations-toolbar-actions.tsx2
-rw-r--r--lib/roles/services.ts104
-rw-r--r--lib/roles/table/add-role-dialog.tsx248
-rw-r--r--lib/roles/table/assign-roles-sheet.tsx168
-rw-r--r--lib/roles/table/roles-table.tsx60
-rw-r--r--lib/roles/table/update-roles-sheet.tsx2
-rw-r--r--lib/roles/userTable/assignedUsers-table.tsx127
-rw-r--r--lib/techsales-rfq/table/tech-sales-quotation-attachments-sheet.tsx4
-rw-r--r--lib/techsales-rfq/table/tech-sales-rfq-attachments-sheet.tsx6
-rw-r--r--lib/users/service.ts147
-rw-r--r--lib/users/table/assign-roles-dialog.tsx353
-rw-r--r--lib/users/table/users-table-toolbar-actions.tsx6
-rw-r--r--lib/users/table/users-table.tsx5
-rw-r--r--lib/vendor-document-list/ship/enhanced-doc-table-toolbar-actions.tsx32
-rw-r--r--lib/vendor-document-list/ship/send-to-shi-button.tsx336
-rw-r--r--lib/vendor-evaluation-submit/service.ts42
-rw-r--r--lib/vendor-evaluation-submit/table/esg-evaluation-form-sheet.tsx78
-rw-r--r--lib/vendor-evaluation-submit/table/general-evaluation-form-sheet.tsx1
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"