summaryrefslogtreecommitdiff
path: root/lib/evaluation-target-list/table
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-06-20 11:37:31 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-06-20 11:37:31 +0000
commitaa86729f9a2ab95346a2851e3837de1c367aae17 (patch)
treeb601b18b6724f2fb449c7fa9ea50cbd652a8077d /lib/evaluation-target-list/table
parent95bbe9c583ff841220da1267630e7b2025fc36dc (diff)
(대표님) 20250620 작업사항
Diffstat (limited to 'lib/evaluation-target-list/table')
-rw-r--r--lib/evaluation-target-list/table/evaluation-target-action-dialogs.tsx384
-rw-r--r--lib/evaluation-target-list/table/evaluation-target-table.tsx154
-rw-r--r--lib/evaluation-target-list/table/evaluation-targets-columns.tsx459
-rw-r--r--lib/evaluation-target-list/table/evaluation-targets-filter-sheet.tsx255
-rw-r--r--lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx138
-rw-r--r--lib/evaluation-target-list/table/manual-create-evaluation-target-dialog.tsx151
-rw-r--r--lib/evaluation-target-list/table/update-evaluation-target.tsx760
7 files changed, 1949 insertions, 352 deletions
diff --git a/lib/evaluation-target-list/table/evaluation-target-action-dialogs.tsx b/lib/evaluation-target-list/table/evaluation-target-action-dialogs.tsx
new file mode 100644
index 00000000..47af419d
--- /dev/null
+++ b/lib/evaluation-target-list/table/evaluation-target-action-dialogs.tsx
@@ -0,0 +1,384 @@
+// evaluation-target-action-dialogs.tsx
+"use client"
+
+import * as React from "react"
+import { Loader2, AlertTriangle, Check, X, MessageSquare } from "lucide-react"
+import { toast } from "sonner"
+
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+} from "@/components/ui/alert-dialog"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import { Button } from "@/components/ui/button"
+import { Textarea } from "@/components/ui/textarea"
+import { Label } from "@/components/ui/label"
+import { Badge } from "@/components/ui/badge"
+
+import {
+ confirmEvaluationTargets,
+ excludeEvaluationTargets,
+ requestEvaluationReview
+} from "../service"
+import { EvaluationTargetWithDepartments } from "@/db/schema"
+
+// ----------------------------------------------------------------
+// 확정 컨펌 다이얼로그
+// ----------------------------------------------------------------
+interface ConfirmTargetsDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ targets: EvaluationTargetWithDepartments[]
+ onSuccess?: () => void
+}
+
+export function ConfirmTargetsDialog({
+ open,
+ onOpenChange,
+ targets,
+ onSuccess
+}: ConfirmTargetsDialogProps) {
+ const [isLoading, setIsLoading] = React.useState(false)
+
+ // 확정 가능한 대상들 (consensusStatus가 true인 것들)
+ const confirmableTargets = targets.filter(
+ t => t.status === "PENDING" && t.consensusStatus === true
+ )
+
+ const handleConfirm = async () => {
+ if (confirmableTargets.length === 0) return
+
+ setIsLoading(true)
+ try {
+ const targetIds = confirmableTargets.map(t => t.id)
+ const result = await confirmEvaluationTargets(targetIds)
+
+ if (result.success) {
+ toast.success(result.message)
+ onSuccess?.()
+ onOpenChange(false)
+ } else {
+ toast.error(result.error)
+ }
+ } catch (error) {
+ toast.error("확정 처리 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ return (
+ <AlertDialog open={open} onOpenChange={onOpenChange}>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle className="flex items-center gap-2">
+ <Check className="h-5 w-5 text-green-600" />
+ 평가 대상 확정
+ </AlertDialogTitle>
+ <AlertDialogDescription asChild>
+ <div className="space-y-3">
+ <p>
+ 선택된 {targets.length}개 항목 중{" "}
+ <span className="font-semibold text-green-600">
+ {confirmableTargets.length}개 항목
+ </span>
+ 을 확정하시겠습니까?
+ </p>
+
+ {confirmableTargets.length !== targets.length && (
+ <div className="p-3 bg-yellow-50 rounded-lg border border-yellow-200">
+ <p className="text-sm text-yellow-800">
+ <AlertTriangle className="h-4 w-4 inline mr-1" />
+ 의견 일치 상태인 대기중 항목만 확정 가능합니다.
+ ({targets.length - confirmableTargets.length}개 항목 제외됨)
+ </p>
+ </div>
+ )}
+
+ {confirmableTargets.length > 0 && (
+ <div className="max-h-32 overflow-y-auto">
+ <div className="text-sm space-y-1">
+ {confirmableTargets.slice(0, 5).map(target => (
+ <div key={target.id} className="flex items-center gap-2">
+ <Badge variant="outline" className="text-xs">
+ {target.vendorCode}
+ </Badge>
+ <span className="text-xs">{target.vendorName}</span>
+ </div>
+ ))}
+ {confirmableTargets.length > 5 && (
+ <p className="text-xs text-muted-foreground">
+ ...외 {confirmableTargets.length - 5}개
+ </p>
+ )}
+ </div>
+ </div>
+ )}
+ </div>
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel disabled={isLoading}>취소</AlertDialogCancel>
+ <AlertDialogAction
+ onClick={handleConfirm}
+ disabled={isLoading || confirmableTargets.length === 0}
+ className="bg-green-600 hover:bg-green-700"
+ >
+ {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ 확정 ({confirmableTargets.length})
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ )
+}
+
+// ----------------------------------------------------------------
+// 제외 컨펌 다이얼로그
+// ----------------------------------------------------------------
+interface ExcludeTargetsDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ targets: EvaluationTargetWithDepartments[]
+ onSuccess?: () => void
+}
+
+export function ExcludeTargetsDialog({
+ open,
+ onOpenChange,
+ targets,
+ onSuccess
+}: ExcludeTargetsDialogProps) {
+ const [isLoading, setIsLoading] = React.useState(false)
+
+ // 제외 가능한 대상들 (PENDING 상태인 것들)
+ const excludableTargets = targets.filter(t => t.status === "PENDING")
+
+ const handleExclude = async () => {
+ if (excludableTargets.length === 0) return
+
+ setIsLoading(true)
+ try {
+ const targetIds = excludableTargets.map(t => t.id)
+ const result = await excludeEvaluationTargets(targetIds)
+
+ if (result.success) {
+ toast.success(result.message)
+ onSuccess?.()
+ onOpenChange(false)
+ } else {
+ toast.error(result.error)
+ }
+ } catch (error) {
+ toast.error("제외 처리 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ return (
+ <AlertDialog open={open} onOpenChange={onOpenChange}>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle className="flex items-center gap-2">
+ <X className="h-5 w-5 text-red-600" />
+ 평가 대상 제외
+ </AlertDialogTitle>
+ <AlertDialogDescription asChild>
+ <div className="space-y-3">
+ <p>
+ 선택된 {targets.length}개 항목 중{" "}
+ <span className="font-semibold text-red-600">
+ {excludableTargets.length}개 항목
+ </span>
+ 을 제외하시겠습니까?
+ </p>
+
+ {excludableTargets.length !== targets.length && (
+ <div className="p-3 bg-yellow-50 rounded-lg border border-yellow-200">
+ <p className="text-sm text-yellow-800">
+ <AlertTriangle className="h-4 w-4 inline mr-1" />
+ 대기중 상태인 항목만 제외 가능합니다.
+ ({targets.length - excludableTargets.length}개 항목 제외됨)
+ </p>
+ </div>
+ )}
+
+ {excludableTargets.length > 0 && (
+ <div className="max-h-32 overflow-y-auto">
+ <div className="text-sm space-y-1">
+ {excludableTargets.slice(0, 5).map(target => (
+ <div key={target.id} className="flex items-center gap-2">
+ <Badge variant="outline" className="text-xs">
+ {target.vendorCode}
+ </Badge>
+ <span className="text-xs">{target.vendorName}</span>
+ </div>
+ ))}
+ {excludableTargets.length > 5 && (
+ <p className="text-xs text-muted-foreground">
+ ...외 {excludableTargets.length - 5}개
+ </p>
+ )}
+ </div>
+ </div>
+ )}
+ </div>
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel disabled={isLoading}>취소</AlertDialogCancel>
+ <AlertDialogAction
+ onClick={handleExclude}
+ disabled={isLoading || excludableTargets.length === 0}
+ className="bg-red-600 hover:bg-red-700"
+ >
+ {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ 제외 ({excludableTargets.length})
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ )
+}
+
+// ----------------------------------------------------------------
+// 의견 요청 다이얼로그
+// ----------------------------------------------------------------
+interface RequestReviewDialogProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ targets: EvaluationTargetWithDepartments[]
+ onSuccess?: () => void
+}
+
+export function RequestReviewDialog({
+ open,
+ onOpenChange,
+ targets,
+ onSuccess
+}: RequestReviewDialogProps) {
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [message, setMessage] = React.useState("")
+
+ // 의견 요청 가능한 대상들 (PENDING 상태인 것들)
+ const reviewableTargets = targets.filter(t => t.status === "PENDING")
+
+ // 담당자 이메일들 수집
+ const reviewerEmails = React.useMemo(() => {
+ const emails = new Set<string>()
+ reviewableTargets.forEach(target => {
+ if (target.orderReviewerEmail) emails.add(target.orderReviewerEmail)
+ if (target.procurementReviewerEmail) emails.add(target.procurementReviewerEmail)
+ if (target.qualityReviewerEmail) emails.add(target.qualityReviewerEmail)
+ if (target.designReviewerEmail) emails.add(target.designReviewerEmail)
+ if (target.csReviewerEmail) emails.add(target.csReviewerEmail)
+ })
+ return Array.from(emails)
+ }, [reviewableTargets])
+
+ const handleRequestReview = async () => {
+ if (reviewableTargets.length === 0) return
+
+ setIsLoading(true)
+ try {
+ const targetIds = reviewableTargets.map(t => t.id)
+ const result = await requestEvaluationReview(targetIds, message)
+
+ if (result.success) {
+ toast.success(result.message)
+ onSuccess?.()
+ onOpenChange(false)
+ setMessage("")
+ } else {
+ toast.error(result.error)
+ }
+ } catch (error) {
+ toast.error("의견 요청 중 오류가 발생했습니다.")
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="sm:max-w-md">
+ <DialogHeader>
+ <DialogTitle className="flex items-center gap-2">
+ <MessageSquare className="h-5 w-5 text-blue-600" />
+ 평가 의견 요청
+ </DialogTitle>
+ <DialogDescription>
+ 선택된 평가 대상에 대한 의견을 담당자들에게 요청합니다.
+ </DialogDescription>
+ </DialogHeader>
+
+ <div className="space-y-4">
+ {/* 요약 정보 */}
+ <div className="p-3 bg-blue-50 rounded-lg border border-blue-200">
+ <div className="text-sm space-y-1">
+ <p>
+ <span className="font-medium">요청 대상:</span> {reviewableTargets.length}개 평가 항목
+ </p>
+ <p>
+ <span className="font-medium">받는 사람:</span> {reviewerEmails.length}명의 담당자
+ </p>
+ </div>
+ </div>
+
+ {/* 메시지 입력 */}
+ <div className="space-y-2">
+ <Label htmlFor="review-message">추가 메시지 (선택사항)</Label>
+ <Textarea
+ id="review-message"
+ placeholder="담당자들에게 전달할 추가 메시지를 입력하세요..."
+ value={message}
+ onChange={(e) => setMessage(e.target.value)}
+ rows={3}
+ />
+ </div>
+
+ {reviewableTargets.length !== targets.length && (
+ <div className="p-3 bg-yellow-50 rounded-lg border border-yellow-200">
+ <p className="text-sm text-yellow-800">
+ <AlertTriangle className="h-4 w-4 inline mr-1" />
+ 대기중 상태인 항목만 의견 요청 가능합니다.
+ ({targets.length - reviewableTargets.length}개 항목 제외됨)
+ </p>
+ </div>
+ )}
+ </div>
+
+ <DialogFooter>
+ <Button
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isLoading}
+ >
+ 취소
+ </Button>
+ <Button
+ onClick={handleRequestReview}
+ disabled={isLoading || reviewableTargets.length === 0}
+ >
+ {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ 의견 요청 발송
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+} \ No newline at end of file
diff --git a/lib/evaluation-target-list/table/evaluation-target-table.tsx b/lib/evaluation-target-list/table/evaluation-target-table.tsx
index 15837733..fe0b3188 100644
--- a/lib/evaluation-target-list/table/evaluation-target-table.tsx
+++ b/lib/evaluation-target-list/table/evaluation-target-table.tsx
@@ -25,6 +25,7 @@ import { getEvaluationTargetsColumns } from "./evaluation-targets-columns"
import { EvaluationTargetsTableToolbarActions } from "./evaluation-targets-toolbar-actions"
import { EvaluationTargetFilterSheet } from "./evaluation-targets-filter-sheet"
import { EvaluationTargetWithDepartments } from "@/db/schema"
+import { EditEvaluationTargetSheet } from "./update-evaluation-target"
interface EvaluationTargetsTableProps {
promises: Promise<[Awaited<ReturnType<typeof getEvaluationTargets>>]>
@@ -40,13 +41,13 @@ function EvaluationTargetsStats({ evaluationYear }: { evaluationYear: number })
React.useEffect(() => {
let isMounted = true
-
+
async function fetchStats() {
try {
setIsLoading(true)
setError(null)
const statsData = await getEvaluationTargetsStats(evaluationYear)
-
+
if (isMounted) {
setStats(statsData)
}
@@ -186,45 +187,59 @@ function EvaluationTargetsStats({ evaluationYear }: { evaluationYear: number })
export function EvaluationTargetsTable({ promises, evaluationYear, className }: EvaluationTargetsTableProps) {
const [rowAction, setRowAction] = React.useState<DataTableRowAction<EvaluationTargetWithDepartments> | null>(null)
const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false)
- console.count("E Targets render");
const router = useRouter()
const searchParams = useSearchParams()
const containerRef = React.useRef<HTMLDivElement>(null)
const [containerTop, setContainerTop] = React.useState(0)
+ // ✅ 스크롤 이벤트 throttling으로 성능 최적화
const updateContainerBounds = React.useCallback(() => {
if (containerRef.current) {
const rect = containerRef.current.getBoundingClientRect()
- setContainerTop(rect.top)
+ const newTop = rect.top
+
+ // ✅ 값이 실제로 변경될 때만 상태 업데이트
+ setContainerTop(prevTop => {
+ if (Math.abs(prevTop - newTop) > 1) { // 1px 이상 차이날 때만 업데이트
+ return newTop
+ }
+ return prevTop
+ })
}
}, [])
+ // ✅ throttle 함수 추가
+ const throttledUpdateBounds = React.useCallback(() => {
+ let timeoutId: NodeJS.Timeout
+ return () => {
+ clearTimeout(timeoutId)
+ timeoutId = setTimeout(updateContainerBounds, 16) // ~60fps
+ }
+ }, [updateContainerBounds])
+
React.useEffect(() => {
updateContainerBounds()
-
+
+ const throttledHandler = throttledUpdateBounds()
+
const handleResize = () => {
updateContainerBounds()
}
-
+
window.addEventListener('resize', handleResize)
- window.addEventListener('scroll', updateContainerBounds)
-
+ window.addEventListener('scroll', throttledHandler) // ✅ throttled 함수 사용
+
return () => {
window.removeEventListener('resize', handleResize)
- window.removeEventListener('scroll', updateContainerBounds)
+ window.removeEventListener('scroll', throttledHandler)
}
- }, [updateContainerBounds])
+ }, [updateContainerBounds, throttledUpdateBounds])
const [promiseData] = React.use(promises)
const tableData = promiseData
- console.log("Evaluation Targets Table Data:", {
- dataLength: tableData.data?.length,
- pageCount: tableData.pageCount,
- total: tableData.total,
- sampleData: tableData.data?.[0]
- })
+ console.log(tableData)
const initialSettings = React.useMemo(() => ({
page: parseInt(searchParams.get('page') || '1'),
@@ -232,7 +247,7 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }:
sort: searchParams.get('sort') ? JSON.parse(searchParams.get('sort')!) : [{ id: "createdAt", desc: true }],
filters: searchParams.get('filters') ? JSON.parse(searchParams.get('filters')!) : [],
joinOperator: (searchParams.get('joinOperator') as "and" | "or") || "and",
- basicFilters: searchParams.get('basicFilters') ?
+ basicFilters: searchParams.get('basicFilters') ?
JSON.parse(searchParams.get('basicFilters')!) : [],
basicJoinOperator: (searchParams.get('basicJoinOperator') as "and" | "or") || "and",
search: searchParams.get('search') || '',
@@ -259,8 +274,8 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }:
} = useTablePresets<EvaluationTargetWithDepartments>('evaluation-targets-table', initialSettings)
const columns = React.useMemo(
- () => getEvaluationTargetsColumns(),
- []
+ () => getEvaluationTargetsColumns({ setRowAction }),
+ [setRowAction]
)
const filterFields: DataTableFilterField<EvaluationTargetWithDepartments>[] = [
@@ -271,31 +286,41 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }:
const advancedFilterFields: DataTableAdvancedFilterField<EvaluationTargetWithDepartments>[] = [
{ id: "evaluationYear", label: "평가년도", type: "number" },
- { id: "division", label: "구분", type: "select", options: [
- { label: "해양", value: "OCEAN" },
- { label: "조선", value: "SHIPYARD" },
- ]},
+ {
+ id: "division", label: "구분", type: "select", options: [
+ { label: "해양", value: "OCEAN" },
+ { label: "조선", value: "SHIPYARD" },
+ ]
+ },
{ id: "vendorCode", label: "벤더 코드", type: "text" },
{ id: "vendorName", label: "벤더명", type: "text" },
- { id: "domesticForeign", label: "내외자", type: "select", options: [
- { label: "내자", value: "DOMESTIC" },
- { label: "외자", value: "FOREIGN" },
- ]},
- { id: "materialType", label: "자재구분", type: "select", options: [
- { label: "기자재", value: "EQUIPMENT" },
- { label: "벌크", value: "BULK" },
- { label: "기자재/벌크", value: "EQUIPMENT_BULK" },
- ]},
- { id: "status", label: "상태", type: "select", options: [
- { label: "검토 중", value: "PENDING" },
- { label: "확정", value: "CONFIRMED" },
- { label: "제외", value: "EXCLUDED" },
- ]},
- { id: "consensusStatus", label: "의견 일치", type: "select", options: [
- { label: "의견 일치", value: "true" },
- { label: "의견 불일치", value: "false" },
- { label: "검토 중", value: "null" },
- ]},
+ {
+ id: "domesticForeign", label: "내외자", type: "select", options: [
+ { label: "내자", value: "DOMESTIC" },
+ { label: "외자", value: "FOREIGN" },
+ ]
+ },
+ {
+ id: "materialType", label: "자재구분", type: "select", options: [
+ { label: "기자재", value: "EQUIPMENT" },
+ { label: "벌크", value: "BULK" },
+ { label: "기자재/벌크", value: "EQUIPMENT_BULK" },
+ ]
+ },
+ {
+ id: "status", label: "상태", type: "select", options: [
+ { label: "검토 중", value: "PENDING" },
+ { label: "확정", value: "CONFIRMED" },
+ { label: "제외", value: "EXCLUDED" },
+ ]
+ },
+ {
+ id: "consensusStatus", label: "의견 일치", type: "select", options: [
+ { label: "의견 일치", value: "true" },
+ { label: "의견 불일치", value: "false" },
+ { label: "검토 중", value: "null" },
+ ]
+ },
{ id: "adminComment", label: "관리자 의견", type: "text" },
{ id: "consolidatedComment", label: "종합 의견", type: "text" },
{ id: "confirmedAt", label: "확정일", type: "date" },
@@ -305,17 +330,21 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }:
const currentSettings = useMemo(() => {
return getCurrentSettings()
}, [getCurrentSettings])
-
+
+ function getColKey<T>(c: ColumnDef<T>): string | undefined {
+ if ("accessorKey" in c && c.accessorKey) return c.accessorKey as string
+ if ("id" in c && c.id) return c.id as string
+ return undefined
+ }
+
const initialState = useMemo(() => {
return {
- sorting: initialSettings.sort.filter(sortItem => {
- const columnExists = columns.some(col => col.accessorKey === sortItem.id || col.id === sortItem.id)
- return columnExists
- }) as any,
+ sorting: initialSettings.sort.filter(s =>
+ columns.some(c => getColKey(c) === s.id)),
columnVisibility: currentSettings.columnVisibility,
columnPinning: currentSettings.pinnedColumns,
}
- }, [currentSettings, initialSettings.sort, columns])
+ }, [columns, currentSettings, initialSettings.sort])
const { table } = useDataTable({
data: tableData.data,
@@ -349,12 +378,12 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }:
return (
<>
{/* Filter Panel */}
- <div
+ <div
className={cn(
"fixed left-0 bg-background border-r z-50 flex flex-col transition-all duration-300 ease-in-out overflow-hidden",
isFilterPanelOpen ? "border-r shadow-lg" : "border-r-0"
)}
- style={{
+ style={{
width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px',
top: `${containerTop}px`,
height: `calc(100vh - ${containerTop}px)`
@@ -362,7 +391,7 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }:
>
<div className="h-full">
<EvaluationTargetFilterSheet
- isOpen={isFilterPanelOpen}
+ isOpen={isFilterPanelOpen}
onClose={() => setIsFilterPanelOpen(false)}
onSearch={handleSearch}
isLoading={false}
@@ -371,12 +400,12 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }:
</div>
{/* Main Content Container */}
- <div
+ <div
ref={containerRef}
className={cn("relative w-full overflow-hidden", className)}
>
<div className="flex w-full h-full">
- <div
+ <div
className="flex flex-col min-w-0 overflow-hidden transition-all duration-300 ease-in-out"
style={{
width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : '100%',
@@ -386,14 +415,14 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }:
{/* Header Bar */}
<div className="flex items-center justify-between p-4 bg-background shrink-0">
<div className="flex items-center gap-3">
- <Button
- variant="outline"
- size="sm"
+ <Button
+ variant="outline"
+ size="sm"
type='button'
onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)}
className="flex items-center shadow-sm"
>
- {isFilterPanelOpen ? <PanelLeftClose className="size-4"/> : <PanelLeftOpen className="size-4"/>}
+ {isFilterPanelOpen ? <PanelLeftClose className="size-4" /> : <PanelLeftOpen className="size-4" />}
{getActiveBasicFilterCount() > 0 && (
<span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs">
{getActiveBasicFilterCount()}
@@ -401,7 +430,7 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }:
)}
</Button>
</div>
-
+
<div className="text-sm text-muted-foreground">
{tableData && (
<span>총 {tableData.total || tableData.data.length}건</span>
@@ -437,11 +466,18 @@ export function EvaluationTargetsTable({ promises, evaluationYear, className }:
onSetDefaultPreset={setDefaultPreset}
onRenamePreset={renamePreset}
/>
-
+
<EvaluationTargetsTableToolbarActions table={table} />
</div>
</DataTableAdvancedToolbar>
</DataTable>
+
+ <EditEvaluationTargetSheet
+ open={rowAction?.type === "update"}
+ onOpenChange={() => setRowAction(null)}
+ evaluationTarget={rowAction?.row.original ?? null}
+ />
+
</div>
</div>
</div>
diff --git a/lib/evaluation-target-list/table/evaluation-targets-columns.tsx b/lib/evaluation-target-list/table/evaluation-targets-columns.tsx
index b1e19434..93807ef9 100644
--- a/lib/evaluation-target-list/table/evaluation-targets-columns.tsx
+++ b/lib/evaluation-target-list/table/evaluation-targets-columns.tsx
@@ -7,6 +7,11 @@ import { Button } from "@/components/ui/button";
import { Pencil, Eye, MessageSquare, Check, X } from "lucide-react";
import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header";
import { EvaluationTargetWithDepartments } from "@/db/schema";
+import { EditEvaluationTargetSheet } from "./update-evaluation-target";
+
+interface GetColumnsProps {
+ setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<EvaluationTargetWithDepartments> | null>>;
+}
// 상태별 색상 매핑
const getStatusBadgeVariant = (status: string) => {
@@ -36,8 +41,8 @@ const getConsensusBadge = (consensusStatus: boolean | null) => {
// 구분 배지
const getDivisionBadge = (division: string) => {
return (
- <Badge variant={division === "OCEAN" ? "default" : "secondary"}>
- {division === "OCEAN" ? "해양" : "조선"}
+ <Badge variant={division === "PLANT" ? "default" : "secondary"}>
+ {division === "PLANT" ? "해양" : "조선"}
</Badge>
);
};
@@ -46,7 +51,7 @@ const getDivisionBadge = (division: string) => {
const getMaterialTypeBadge = (materialType: string) => {
const typeMap = {
EQUIPMENT: "기자재",
- BULK: "벌크",
+ BULK: "벌크",
EQUIPMENT_BULK: "기자재/벌크"
};
return <Badge variant="outline">{typeMap[materialType] || materialType}</Badge>;
@@ -61,8 +66,23 @@ const getDomesticForeignBadge = (domesticForeign: string) => {
);
};
-export function getEvaluationTargetsColumns(): ColumnDef<EvaluationTargetWithDepartments>[] {
+// 평가 상태 배지
+const getApprovalBadge = (isApproved: boolean | null) => {
+ if (isApproved === null) {
+ return <Badge variant="outline" className="text-xs">대기중</Badge>;
+ }
+ if (isApproved === true) {
+ return <Badge variant="default" className="bg-green-600 text-xs">승인</Badge>;
+ }
+ return <Badge variant="destructive" className="text-xs">거부</Badge>;
+};
+
+export function getEvaluationTargetsColumns({setRowAction}:GetColumnsProps): ColumnDef<EvaluationTargetWithDepartments>[] {
return [
+ // ═══════════════════════════════════════════════════════════════
+ // 기본 정보
+ // ═══════════════════════════════════════════════════════════════
+
// Checkbox
{
id: "select",
@@ -102,46 +122,6 @@ export function getEvaluationTargetsColumns(): ColumnDef<EvaluationTargetWithDep
cell: ({ row }) => getDivisionBadge(row.getValue("division")),
size: 80,
},
-
- // ░░░ 벤더 코드 ░░░
- {
- accessorKey: "vendorCode",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="벤더 코드" />,
- cell: ({ row }) => (
- <span className="font-mono text-sm">{row.getValue("vendorCode")}</span>
- ),
- size: 120,
- },
-
- // ░░░ 벤더명 ░░░
- {
- accessorKey: "vendorName",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="벤더명" />,
- cell: ({ row }) => (
- <div className="truncate max-w-[200px]" title={row.getValue<string>("vendorName")!}>
- {row.getValue("vendorName") as string}
- </div>
- ),
- size: 200,
- },
-
- // ░░░ 내외자 ░░░
- {
- accessorKey: "domesticForeign",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="내외자" />,
- cell: ({ row }) => getDomesticForeignBadge(row.getValue("domesticForeign")),
- size: 80,
- },
-
- // ░░░ 자재구분 ░░░
- {
- accessorKey: "materialType",
- header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재구분" />,
- cell: ({ row }) => getMaterialTypeBadge(row.getValue("materialType")),
- size: 120,
- },
-
- // ░░░ 상태 ░░░
{
accessorKey: "status",
header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="상태" />,
@@ -161,6 +141,54 @@ export function getEvaluationTargetsColumns(): ColumnDef<EvaluationTargetWithDep
size: 100,
},
+ // ░░░ 벤더 코드 ░░░
+
+ {
+ header: "협력업체 정보",
+ columns: [
+ {
+ accessorKey: "vendorCode",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="벤더 코드" />,
+ cell: ({ row }) => (
+ <span className="font-mono text-sm">{row.getValue("vendorCode")}</span>
+ ),
+ size: 120,
+ },
+
+ // ░░░ 벤더명 ░░░
+ {
+ accessorKey: "vendorName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="벤더명" />,
+ cell: ({ row }) => (
+ <div className="truncate max-w-[200px]" title={row.getValue<string>("vendorName")!}>
+ {row.getValue("vendorName") as string}
+ </div>
+ ),
+ size: 200,
+ },
+
+ // ░░░ 내외자 ░░░
+ {
+ accessorKey: "domesticForeign",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="내외자" />,
+ cell: ({ row }) => getDomesticForeignBadge(row.getValue("domesticForeign")),
+ size: 80,
+ },
+
+ ]
+ },
+
+ // ░░░ 자재구분 ░░░
+ {
+ accessorKey: "materialType",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="자재구분" />,
+ cell: ({ row }) => getMaterialTypeBadge(row.getValue("materialType")),
+ size: 120,
+ },
+
+ // ░░░ 상태 ░░░
+
+
// ░░░ 의견 일치 여부 ░░░
{
accessorKey: "consensusStatus",
@@ -169,56 +197,235 @@ export function getEvaluationTargetsColumns(): ColumnDef<EvaluationTargetWithDep
size: 100,
},
- // ░░░ 담당자 현황 ░░░
+ // ═══════════════════════════════════════════════════════════════
+ // 주문 부서 그룹
+ // ═══════════════════════════════════════════════════════════════
{
- id: "reviewers",
- header: "담당자 현황",
- cell: ({ row }) => {
- const reviewers = row.original.reviewers || [];
- const totalReviewers = reviewers.length;
- const completedReviews = reviewers.filter(r => r.review?.isApproved !== null).length;
- const approvedReviews = reviewers.filter(r => r.review?.isApproved === true).length;
-
- return (
- <div className="flex items-center gap-2">
- <div className="text-xs">
- <span className="text-green-600 font-medium">{approvedReviews}</span>
- <span className="text-muted-foreground">/{completedReviews}</span>
- <span className="text-muted-foreground">/{totalReviewers}</span>
- </div>
- {totalReviewers > 0 && (
- <div className="flex gap-1">
- {reviewers.slice(0, 3).map((reviewer, idx) => (
- <div
- key={idx}
- className={`w-2 h-2 rounded-full ${
- reviewer.review?.isApproved === true
- ? "bg-green-500"
- : reviewer.review?.isApproved === false
- ? "bg-red-500"
- : "bg-gray-300"
- }`}
- title={`${reviewer.departmentCode}: ${
- reviewer.review?.isApproved === true
- ? "승인"
- : reviewer.review?.isApproved === false
- ? "거부"
- : "대기중"
- }`}
- />
- ))}
- {totalReviewers > 3 && (
- <span className="text-xs text-muted-foreground">+{totalReviewers - 3}</span>
- )}
+ header: "발주 평가 담당자",
+ columns: [
+ {
+ accessorKey: "orderDepartmentName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="부서명" />,
+ cell: ({ row }) => {
+ const departmentName = row.getValue<string>("orderDepartmentName");
+ return departmentName ? (
+ <div className="truncate max-w-[120px]" title={departmentName}>
+ {departmentName}
</div>
- )}
- </div>
- );
- },
- size: 120,
- enableSorting: false,
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ );
+ },
+ size: 120,
+ },
+ {
+ accessorKey: "orderReviewerName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자" />,
+ cell: ({ row }) => {
+ const reviewerName = row.getValue<string>("orderReviewerName");
+ return reviewerName ? (
+ <div className="truncate max-w-[100px]" title={reviewerName}>
+ {reviewerName}
+ </div>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ );
+ },
+ size: 100,
+ },
+ {
+ accessorKey: "orderIsApproved",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가" />,
+ cell: ({ row }) => getApprovalBadge(row.getValue("orderIsApproved")),
+ size: 80,
+ },
+ ],
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // 조달 부서 그룹
+ // ═══════════════════════════════════════════════════════════════
+ {
+ header: "조달 평가 담당자",
+ columns: [
+ {
+ accessorKey: "procurementDepartmentName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="부서명" />,
+ cell: ({ row }) => {
+ const departmentName = row.getValue<string>("procurementDepartmentName");
+ return departmentName ? (
+ <div className="truncate max-w-[120px]" title={departmentName}>
+ {departmentName}
+ </div>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ );
+ },
+ size: 120,
+ },
+ {
+ accessorKey: "procurementReviewerName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자" />,
+ cell: ({ row }) => {
+ const reviewerName = row.getValue<string>("procurementReviewerName");
+ return reviewerName ? (
+ <div className="truncate max-w-[100px]" title={reviewerName}>
+ {reviewerName}
+ </div>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ );
+ },
+ size: 100,
+ },
+ {
+ accessorKey: "procurementIsApproved",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가" />,
+ cell: ({ row }) => getApprovalBadge(row.getValue("procurementIsApproved")),
+ size: 80,
+ },
+ ],
},
+ // ═══════════════════════════════════════════════════════════════
+ // 품질 부서 그룹
+ // ═══════════════════════════════════════════════════════════════
+ {
+ header: "품질 평가 담당자",
+ columns: [
+ {
+ accessorKey: "qualityDepartmentName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="부서명" />,
+ cell: ({ row }) => {
+ const departmentName = row.getValue<string>("qualityDepartmentName");
+ return departmentName ? (
+ <div className="truncate max-w-[120px]" title={departmentName}>
+ {departmentName}
+ </div>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ );
+ },
+ size: 120,
+ },
+ {
+ accessorKey: "qualityReviewerName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자" />,
+ cell: ({ row }) => {
+ const reviewerName = row.getValue<string>("qualityReviewerName");
+ return reviewerName ? (
+ <div className="truncate max-w-[100px]" title={reviewerName}>
+ {reviewerName}
+ </div>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ );
+ },
+ size: 100,
+ },
+ {
+ accessorKey: "qualityIsApproved",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가" />,
+ cell: ({ row }) => getApprovalBadge(row.getValue("qualityIsApproved")),
+ size: 80,
+ },
+ ],
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // 설계 부서 그룹
+ // ═══════════════════════════════════════════════════════════════
+ {
+ header: "설계 평가 담당자",
+ columns: [
+ {
+ accessorKey: "designDepartmentName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="부서명" />,
+ cell: ({ row }) => {
+ const departmentName = row.getValue<string>("designDepartmentName");
+ return departmentName ? (
+ <div className="truncate max-w-[120px]" title={departmentName}>
+ {departmentName}
+ </div>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ );
+ },
+ size: 120,
+ },
+ {
+ accessorKey: "designReviewerName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자" />,
+ cell: ({ row }) => {
+ const reviewerName = row.getValue<string>("designReviewerName");
+ return reviewerName ? (
+ <div className="truncate max-w-[100px]" title={reviewerName}>
+ {reviewerName}
+ </div>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ );
+ },
+ size: 100,
+ },
+ {
+ accessorKey: "designIsApproved",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가" />,
+ cell: ({ row }) => getApprovalBadge(row.getValue("designIsApproved")),
+ size: 80,
+ },
+ ],
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // CS 부서 그룹
+ // ═══════════════════════════════════════════════════════════════
+ {
+ header: "CS 평가 담당자",
+ columns: [
+ {
+ accessorKey: "csDepartmentName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="부서명" />,
+ cell: ({ row }) => {
+ const departmentName = row.getValue<string>("csDepartmentName");
+ return departmentName ? (
+ <div className="truncate max-w-[120px]" title={departmentName}>
+ {departmentName}
+ </div>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ );
+ },
+ size: 120,
+ },
+ {
+ accessorKey: "csReviewerName",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="담당자" />,
+ cell: ({ row }) => {
+ const reviewerName = row.getValue<string>("csReviewerName");
+ return reviewerName ? (
+ <div className="truncate max-w-[100px]" title={reviewerName}>
+ {reviewerName}
+ </div>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ );
+ },
+ size: 100,
+ },
+ {
+ accessorKey: "csIsApproved",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="평가" />,
+ cell: ({ row }) => getApprovalBadge(row.getValue("csIsApproved")),
+ size: 80,
+ },
+ ],
+ },
+
+ // ═══════════════════════════════════════════════════════════════
+ // 관리 정보
+ // ═══════════════════════════════════════════════════════════════
+
// ░░░ 관리자 의견 ░░░
{
accessorKey: "adminComment",
@@ -274,69 +481,47 @@ export function getEvaluationTargetsColumns(): ColumnDef<EvaluationTargetWithDep
size: 100,
},
+ // ░░░ 생성일 ░░░
+ {
+ accessorKey: "createdAt",
+ header: ({ column }) => <DataTableColumnHeaderSimple column={column} title="생성일" />,
+ cell: ({ row }) => {
+ const createdAt = row.getValue<Date>("createdAt");
+ return createdAt ? (
+ <span className="text-sm">
+ {new Intl.DateTimeFormat("ko-KR", {
+ year: "numeric",
+ month: "2-digit",
+ day: "2-digit",
+ }).format(new Date(createdAt))}
+ </span>
+ ) : (
+ <span className="text-muted-foreground">-</span>
+ );
+ },
+ size: 100,
+ },
+
// ░░░ Actions ░░░
{
id: "actions",
enableHiding: false,
- size: 120,
- minSize: 120,
+ size: 40,
+ minSize: 40,
cell: ({ row }) => {
- const record = row.original;
- const [openDetail, setOpenDetail] = React.useState(false);
- const [openEdit, setOpenEdit] = React.useState(false);
- const [openRequest, setOpenRequest] = React.useState(false);
-
- return (
+ return (
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="icon"
className="size-8"
- onClick={() => setOpenDetail(true)}
- aria-label="상세보기"
- title="상세보기"
- >
- <Eye className="size-4" />
- </Button>
-
- <Button
- variant="ghost"
- size="icon"
- className="size-8"
- onClick={() => setOpenEdit(true)}
+ onClick={() => setRowAction({ row, type: "update" })}
aria-label="수정"
title="수정"
>
<Pencil className="size-4" />
</Button>
- <Button
- variant="ghost"
- size="icon"
- className="size-8"
- onClick={() => setOpenRequest(true)}
- aria-label="의견요청"
- title="의견요청"
- >
- <MessageSquare className="size-4" />
- </Button>
-
- {/* TODO: 실제 다이얼로그 컴포넌트들로 교체 */}
- {openDetail && (
- <div onClick={() => setOpenDetail(false)}>
- {/* <EvaluationTargetDetailDialog /> */}
- </div>
- )}
- {openEdit && (
- <div onClick={() => setOpenEdit(false)}>
- {/* <EditEvaluationTargetDialog /> */}
- </div>
- )}
- {openRequest && (
- <div onClick={() => setOpenRequest(false)}>
- {/* <RequestReviewDialog /> */}
- </div>
- )}
</div>
);
},
diff --git a/lib/evaluation-target-list/table/evaluation-targets-filter-sheet.tsx b/lib/evaluation-target-list/table/evaluation-targets-filter-sheet.tsx
index c14ae83f..502ee974 100644
--- a/lib/evaluation-target-list/table/evaluation-targets-filter-sheet.tsx
+++ b/lib/evaluation-target-list/table/evaluation-targets-filter-sheet.tsx
@@ -44,12 +44,17 @@ const evaluationTargetFilterSchema = z.object({
vendorCode: z.string().optional(),
vendorName: z.string().optional(),
reviewerUserId: z.string().optional(), // 담당자 ID로 필터링
+ orderReviewerName: z.string().optional(), // 주문 검토자명
+ procurementReviewerName: z.string().optional(), // 조달 검토자명
+ qualityReviewerName: z.string().optional(), // 품질 검토자명
+ designReviewerName: z.string().optional(), // 설계 검토자명
+ csReviewerName: z.string().optional(), // CS 검토자명
})
// 옵션 정의
const divisionOptions = [
- { value: "OCEAN", label: "해양" },
- { value: "SHIPYARD", label: "조선" },
+ { value: "PLANT", label: "해양" },
+ { value: "SHIP", label: "조선" },
]
const statusOptions = [
@@ -128,6 +133,11 @@ export function EvaluationTargetFilterSheet({
vendorCode: "",
vendorName: "",
reviewerUserId: "",
+ orderReviewerName: "",
+ procurementReviewerName: "",
+ qualityReviewerName: "",
+ designReviewerName: "",
+ csReviewerName: "",
},
})
@@ -261,6 +271,57 @@ export function EvaluationTargetFilterSheet({
})
}
+ // 새로 추가된 검토자명 필터들
+ if (data.orderReviewerName?.trim()) {
+ newFilters.push({
+ id: "orderReviewerName",
+ value: data.orderReviewerName.trim(),
+ type: "text",
+ operator: "iLike",
+ rowId: generateId()
+ })
+ }
+
+ if (data.procurementReviewerName?.trim()) {
+ newFilters.push({
+ id: "procurementReviewerName",
+ value: data.procurementReviewerName.trim(),
+ type: "text",
+ operator: "iLike",
+ rowId: generateId()
+ })
+ }
+
+ if (data.qualityReviewerName?.trim()) {
+ newFilters.push({
+ id: "qualityReviewerName",
+ value: data.qualityReviewerName.trim(),
+ type: "text",
+ operator: "iLike",
+ rowId: generateId()
+ })
+ }
+
+ if (data.designReviewerName?.trim()) {
+ newFilters.push({
+ id: "designReviewerName",
+ value: data.designReviewerName.trim(),
+ type: "text",
+ operator: "iLike",
+ rowId: generateId()
+ })
+ }
+
+ if (data.csReviewerName?.trim()) {
+ newFilters.push({
+ id: "csReviewerName",
+ value: data.csReviewerName.trim(),
+ type: "text",
+ operator: "iLike",
+ rowId: generateId()
+ })
+ }
+
// URL 업데이트
const currentUrl = new URL(window.location.href);
const params = new URLSearchParams(currentUrl.search);
@@ -313,6 +374,11 @@ export function EvaluationTargetFilterSheet({
vendorCode: "",
vendorName: "",
reviewerUserId: "",
+ orderReviewerName: "",
+ procurementReviewerName: "",
+ qualityReviewerName: "",
+ designReviewerName: "",
+ csReviewerName: "",
});
// URL 초기화
@@ -723,6 +789,191 @@ export function EvaluationTargetFilterSheet({
)}
/>
+ {/* 주문 검토자명 */}
+ <FormField
+ control={form.control}
+ name="orderReviewerName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>발주 담당자명</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder="발주 담당자명 입력"
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("orderReviewerName", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 조달 검토자명 */}
+ <FormField
+ control={form.control}
+ name="procurementReviewerName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>조달 담당자명</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder="조달 담당자명 입력"
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("procurementReviewerName", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 품질 검토자명 */}
+ <FormField
+ control={form.control}
+ name="qualityReviewerName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>품질 담당자명</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder="품질 담당자명 입력"
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("qualityReviewerName", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 설계 검토자명 */}
+ <FormField
+ control={form.control}
+ name="designReviewerName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>설계 담당자명</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder="설계 담당자명 입력"
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("designReviewerName", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* CS 검토자명 */}
+ <FormField
+ control={form.control}
+ name="csReviewerName"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>CS 담당자명</FormLabel>
+ <FormControl>
+ <div className="relative">
+ <Input
+ placeholder="CS 담당자명 입력"
+ {...field}
+ className={cn(field.value && "pr-8", "bg-white")}
+ disabled={isInitializing}
+ />
+ {field.value && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="icon"
+ className="absolute right-0 top-0 h-full px-2"
+ onClick={(e) => {
+ e.stopPropagation();
+ form.setValue("csReviewerName", "");
+ }}
+ disabled={isInitializing}
+ >
+ <X className="size-3.5" />
+ </Button>
+ )}
+ </div>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
</div>
</div>
diff --git a/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx b/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx
index 3fb47771..9043c588 100644
--- a/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx
+++ b/lib/evaluation-target-list/table/evaluation-targets-toolbar-actions.tsx
@@ -24,7 +24,13 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { ManualCreateEvaluationTargetDialog } from "./manual-create-evaluation-target-dialog"
+import {
+ ConfirmTargetsDialog,
+ ExcludeTargetsDialog,
+ RequestReviewDialog
+} from "./evaluation-target-action-dialogs"
import { EvaluationTargetWithDepartments } from "@/db/schema"
+import { exportTableToExcel } from "@/lib/export"
interface EvaluationTargetsTableToolbarActionsProps {
table: Table<EvaluationTargetWithDepartments>
@@ -37,6 +43,9 @@ export function EvaluationTargetsTableToolbarActions({
}: EvaluationTargetsTableToolbarActionsProps) {
const [isLoading, setIsLoading] = React.useState(false)
const [manualCreateDialogOpen, setManualCreateDialogOpen] = React.useState(false)
+ const [confirmDialogOpen, setConfirmDialogOpen] = React.useState(false)
+ const [excludeDialogOpen, setExcludeDialogOpen] = React.useState(false)
+ const [reviewDialogOpen, setReviewDialogOpen] = React.useState(false)
const router = useRouter()
// 선택된 행들
@@ -91,84 +100,12 @@ export function EvaluationTargetsTableToolbarActions({
}
// ----------------------------------------------------------------
- // 선택된 항목들 확정
+ // 다이얼로그 성공 핸들러
// ----------------------------------------------------------------
- const handleConfirmSelected = async () => {
- if (!hasSelection || !selectedStats.canConfirm) return
-
- setIsLoading(true)
- try {
- // TODO: 확정 API 호출
- const confirmableTargets = selectedTargets.filter(
- t => t.status === "PENDING" && t.consensusStatus === true
- )
-
- toast.success(`${confirmableTargets.length}개 항목이 확정되었습니다.`)
- table.resetRowSelection()
- router.refresh()
- } catch (error) {
- console.error('Error confirming targets:', error)
- toast.error("확정 처리 중 오류가 발생했습니다.")
- } finally {
- setIsLoading(false)
- }
- }
-
- // ----------------------------------------------------------------
- // 선택된 항목들 제외
- // ----------------------------------------------------------------
- const handleExcludeSelected = async () => {
- if (!hasSelection || !selectedStats.canExclude) return
-
- setIsLoading(true)
- try {
- // TODO: 제외 API 호출
- const excludableTargets = selectedTargets.filter(t => t.status === "PENDING")
-
- toast.success(`${excludableTargets.length}개 항목이 제외되었습니다.`)
- table.resetRowSelection()
- router.refresh()
- } catch (error) {
- console.error('Error excluding targets:', error)
- toast.error("제외 처리 중 오류가 발생했습니다.")
- } finally {
- setIsLoading(false)
- }
- }
-
- // ----------------------------------------------------------------
- // 선택된 항목들 의견 요청
- // ----------------------------------------------------------------
- const handleRequestReview = async () => {
- if (!hasSelection || !selectedStats.canRequestReview) return
-
- // TODO: 의견 요청 다이얼로그 열기
- toast.info("의견 요청 다이얼로그를 구현해주세요.")
- }
-
- // ----------------------------------------------------------------
- // Excel 내보내기
- // ----------------------------------------------------------------
- const handleExport = () => {
- try {
- // TODO: Excel 내보내기 구현
- toast.success("Excel 파일이 다운로드되었습니다.")
- } catch (error) {
- console.error('Error exporting to Excel:', error)
- toast.error("Excel 내보내기 중 오류가 발생했습니다.")
- }
- }
-
- // ----------------------------------------------------------------
- // 새로고침
- // ----------------------------------------------------------------
- const handleRefresh = () => {
- if (onRefresh) {
- onRefresh()
- } else {
- router.refresh()
- }
- toast.success("데이터가 새로고침되었습니다.")
+ const handleActionSuccess = () => {
+ table.resetRowSelection()
+ onRefresh?.()
+ router.refresh()
}
return (
@@ -204,22 +141,17 @@ export function EvaluationTargetsTableToolbarActions({
<Button
variant="outline"
size="sm"
- onClick={handleExport}
+ onClick={() =>
+ exportTableToExcel(table, {
+ filename: "vendor-target-list",
+ excludeColumns: ["select", "actions"],
+ })
+ }
className="gap-2"
>
<Download className="size-4" aria-hidden="true" />
<span className="hidden sm:inline">내보내기</span>
</Button>
-
- <Button
- variant="outline"
- size="sm"
- onClick={handleRefresh}
- className="gap-2"
- >
- <RefreshCw className="size-4" aria-hidden="true" />
- <span className="hidden sm:inline">새로고침</span>
- </Button>
</div>
{/* 선택된 항목 액션 버튼들 */}
@@ -231,7 +163,7 @@ export function EvaluationTargetsTableToolbarActions({
variant="default"
size="sm"
className="gap-2 bg-green-600 hover:bg-green-700"
- onClick={handleConfirmSelected}
+ onClick={() => setConfirmDialogOpen(true)}
disabled={isLoading}
>
<Check className="size-4" aria-hidden="true" />
@@ -247,7 +179,7 @@ export function EvaluationTargetsTableToolbarActions({
variant="destructive"
size="sm"
className="gap-2"
- onClick={handleExcludeSelected}
+ onClick={() => setExcludeDialogOpen(true)}
disabled={isLoading}
>
<X className="size-4" aria-hidden="true" />
@@ -263,7 +195,7 @@ export function EvaluationTargetsTableToolbarActions({
variant="outline"
size="sm"
className="gap-2"
- onClick={handleRequestReview}
+ onClick={() => setReviewDialogOpen(true)}
disabled={isLoading}
>
<MessageSquare className="size-4" aria-hidden="true" />
@@ -282,6 +214,30 @@ export function EvaluationTargetsTableToolbarActions({
onOpenChange={setManualCreateDialogOpen}
/>
+ {/* 확정 컨펌 다이얼로그 */}
+ <ConfirmTargetsDialog
+ open={confirmDialogOpen}
+ onOpenChange={setConfirmDialogOpen}
+ targets={selectedTargets}
+ onSuccess={handleActionSuccess}
+ />
+
+ {/* 제외 컨펌 다이얼로그 */}
+ <ExcludeTargetsDialog
+ open={excludeDialogOpen}
+ onOpenChange={setExcludeDialogOpen}
+ targets={selectedTargets}
+ onSuccess={handleActionSuccess}
+ />
+
+ {/* 의견 요청 다이얼로그 */}
+ <RequestReviewDialog
+ open={reviewDialogOpen}
+ onOpenChange={setReviewDialogOpen}
+ targets={selectedTargets}
+ onSuccess={handleActionSuccess}
+ />
+
{/* 선택 정보 표시 */}
{hasSelection && (
<div className="text-xs text-muted-foreground">
diff --git a/lib/evaluation-target-list/table/manual-create-evaluation-target-dialog.tsx b/lib/evaluation-target-list/table/manual-create-evaluation-target-dialog.tsx
index 5704cba1..af369ea6 100644
--- a/lib/evaluation-target-list/table/manual-create-evaluation-target-dialog.tsx
+++ b/lib/evaluation-target-list/table/manual-create-evaluation-target-dialog.tsx
@@ -60,26 +60,7 @@ import {
import { EVALUATION_TARGET_FILTER_OPTIONS, getDefaultEvaluationYear } from "../validation"
import { useSession } from "next-auth/react"
-// 폼 스키마 정의
-const createEvaluationTargetSchema = z.object({
- evaluationYear: z.number().min(2020).max(2030),
- division: z.enum(["OCEAN", "SHIPYARD"]),
- vendorId: z.number().min(1, "벤더를 선택해주세요"),
- materialType: z.enum(["EQUIPMENT", "BULK", "EQUIPMENT_BULK"]),
- adminComment: z.string().optional(),
- // L/D 클레임 정보
- ldClaimCount: z.number().min(0).optional(),
- ldClaimAmount: z.number().min(0).optional(),
- ldClaimCurrency: z.enum(["KRW", "USD", "EUR", "JPY"]).optional(),
- reviewers: z.array(
- z.object({
- departmentCode: z.string(),
- reviewerUserId: z.number().min(1, "담당자를 선택해주세요"),
- })
- ).min(1, "최소 1명의 담당자를 지정해주세요"),
-})
-type CreateEvaluationTargetFormValues = z.infer<typeof createEvaluationTargetSchema>
interface ManualCreateEvaluationTargetDialogProps {
open: boolean
@@ -114,12 +95,49 @@ export function ManualCreateEvaluationTargetDialog({
// 부서 정보 상태
const [departments, setDepartments] = React.useState<Array<{ code: string, name: string, key: string }>>([])
+ // 폼 스키마 정의
+const createEvaluationTargetSchema = z.object({
+ evaluationYear: z.number().min(2020).max(2030),
+ division: z.enum(["PLANT", "SHIP"]),
+ vendorId: z.number().min(0), // 0도 허용, 클라이언트에서 검증
+ materialType: z.enum(["EQUIPMENT", "BULK", "EQUIPMENT_BULK"]),
+ adminComment: z.string().optional(),
+ // L/D 클레임 정보
+ ldClaimCount: z.number().min(0).optional(),
+ ldClaimAmount: z.number().min(0).optional(),
+ ldClaimCurrency: z.enum(["KRW", "USD", "EUR", "JPY"]).optional(),
+ reviewers: z.array(
+ z.object({
+ departmentCode: z.string(),
+ reviewerUserId: z.number(), // min(1) 제거, 나중에 클라이언트에서 필터링
+ })
+ ),
+}).refine((data) => {
+ // 벤더가 선택되어야 함
+ if (data.vendorId === 0) {
+ return false;
+ }
+ // 최소 1명의 담당자가 지정되어야 함 (reviewerUserId > 0)
+ const validReviewers = data.reviewers
+ .filter(r => r.reviewerUserId > 0)
+ .map((r, i) => ({
+ departmentCode: r.departmentCode || departments[i]?.code, // 없으면 보충
+ reviewerUserId: r.reviewerUserId,
+ }));
+ return validReviewers.length > 0;
+}, {
+ message: "벤더를 선택하고 최소 1명의 담당자를 지정해주세요.",
+ path: ["vendorId"]
+})
+type CreateEvaluationTargetFormValues = z.infer<typeof createEvaluationTargetSchema>
+
+
const form = useForm<CreateEvaluationTargetFormValues>({
resolver: zodResolver(createEvaluationTargetSchema),
defaultValues: {
evaluationYear: getDefaultEvaluationYear(),
- division: "OCEAN",
- vendorId: 0,
+ division: "SHIP",
+ vendorId: 0, // 임시로 0, 나중에 검증에서 체크
materialType: "EQUIPMENT",
adminComment: "",
ldClaimCount: 0,
@@ -180,17 +198,12 @@ export function ManualCreateEvaluationTargetDialog({
// 부서 정보가 로드되면 reviewers 기본값 설정
React.useEffect(() => {
if (departments.length > 0 && open) {
- const currentReviewers = form.getValues("reviewers")
-
- // 이미 설정되어 있으면 다시 설정하지 않음
- if (currentReviewers.length === 0) {
- const defaultReviewers = departments.map(dept => ({
- departmentCode: dept.code,
- reviewerUserId: 0,
- }))
- form.setValue('reviewers', defaultReviewers)
- }
- }
+ const defaultReviewers = departments.map(dept => ({
+ departmentCode: dept.code, // ✅ 반드시 포함
+ reviewerUserId: 0,
+ }));
+ form.setValue("reviewers", defaultReviewers, { shouldValidate: false });
+ }
}, [departments, open]) // form 의존성 제거하고 조건 추가
console.log(departments)
@@ -234,7 +247,7 @@ export function ManualCreateEvaluationTargetDialog({
// 폼과 상태 초기화
form.reset({
evaluationYear: getDefaultEvaluationYear(),
- division: "OCEAN",
+ division: "SHIP",
vendorId: 0,
materialType: "EQUIPMENT",
adminComment: "",
@@ -269,7 +282,7 @@ export function ManualCreateEvaluationTargetDialog({
if (!open) {
form.reset({
evaluationYear: getDefaultEvaluationYear(),
- division: "OCEAN",
+ division: "SHIP",
vendorId: 0,
materialType: "EQUIPMENT",
adminComment: "",
@@ -326,24 +339,29 @@ export function ManualCreateEvaluationTargetDialog({
return (
<Dialog open={open} onOpenChange={handleOpenChange}>
- <DialogContent className="max-w-lg flex flex-col h-[90vh]">
+ <DialogContent className="max-w-lg h-[90vh] p-0 flex flex-col">
{/* 고정 헤더 */}
- <DialogHeader className="flex-shrink-0">
- <DialogTitle>평가 대상 수동 생성</DialogTitle>
- <DialogDescription>
- 새로운 평가 대상을 수동으로 생성하고 담당자를 지정합니다.
- </DialogDescription>
- </DialogHeader>
+ <div className="flex-shrink-0 p-6 border-b">
+ <DialogHeader>
+ <DialogTitle>평가 대상 수동 생성</DialogTitle>
+ <DialogDescription>
+ 새로운 평가 대상을 수동으로 생성하고 담당자를 지정합니다.
+ </DialogDescription>
+ </DialogHeader>
+ </div>
{/* Form을 전체 콘텐츠를 감싸도록 수정 */}
<Form {...form}>
<form
- onSubmit={form.handleSubmit(onSubmit)}
- className="flex flex-col flex-1"
+ onSubmit={form.handleSubmit(
+ onSubmit,
+ (errors) => console.log('❌ validation errors:', errors)
+ )}
+ className="flex flex-col flex-1 min-h-0"
id="evaluation-target-form"
>
{/* 스크롤 가능한 콘텐츠 영역 */}
- <div className="flex-1 overflow-y-auto">
+ <div className="flex-1 overflow-y-auto p-6">
<div className="space-y-6">
{/* 기본 정보 */}
<Card>
@@ -693,9 +711,14 @@ export function ManualCreateEvaluationTargetDialog({
<CommandItem
value="선택 안함"
onSelect={() => {
- field.onChange(0)
- setReviewerOpens(prev => ({...prev, [department.code]: false}))
- }}
+ // reviewers[index] 전체를 갱신
+ form.setValue(
+ `reviewers.${index}`,
+ { departmentCode: department.code, reviewerUserId: reviewer.id },
+ { shouldValidate: true }
+ );
+ setReviewerOpens(prev => ({ ...prev, [department.code]: false }));
+ }}
>
<Check
className={cn(
@@ -747,22 +770,24 @@ export function ManualCreateEvaluationTargetDialog({
</div>
{/* 고정 버튼 영역 */}
- <div className="flex-shrink-0 flex justify-end gap-3 pt-4 border-t">
- <Button
- type="button"
- variant="outline"
- onClick={() => handleOpenChange(false)}
- disabled={isSubmitting}
- >
- 취소
- </Button>
- <Button
- type="submit"
- disabled={isSubmitting}
- >
- {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
- 생성
- </Button>
+ <div className="flex-shrink-0 border-t bg-background p-6">
+ <div className="flex justify-end gap-3">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => handleOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ disabled={isSubmitting}
+ >
+ {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ 생성
+ </Button>
+ </div>
</div>
</form>
</Form>
diff --git a/lib/evaluation-target-list/table/update-evaluation-target.tsx b/lib/evaluation-target-list/table/update-evaluation-target.tsx
new file mode 100644
index 00000000..0d56addb
--- /dev/null
+++ b/lib/evaluation-target-list/table/update-evaluation-target.tsx
@@ -0,0 +1,760 @@
+"use client"
+
+import * as React from "react"
+import { useRouter } from "next/navigation"
+import { useForm } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import * as z from "zod"
+import { Check, ChevronsUpDown, Loader2, X } from "lucide-react"
+import { toast } from "sonner"
+import { useSession } from "next-auth/react"
+
+import { Button } from "@/components/ui/button"
+import {
+ Sheet,
+ SheetContent,
+ SheetDescription,
+ SheetHeader,
+ SheetTitle,
+} from "@/components/ui/sheet"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+} from "@/components/ui/form"
+import {
+ Command,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+} from "@/components/ui/command"
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
+import { Badge } from "@/components/ui/badge"
+import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
+import { cn } from "@/lib/utils"
+
+import {
+ updateEvaluationTarget,
+ getAvailableReviewers,
+ getDepartmentInfo,
+ type UpdateEvaluationTargetInput,
+} from "../service"
+import { EvaluationTargetWithDepartments } from "@/db/schema"
+
+// 편집 가능한 필드들에 대한 스키마
+const editEvaluationTargetSchema = z.object({
+ adminComment: z.string().optional(),
+ consolidatedComment: z.string().optional(),
+ ldClaimCount: z.number().min(0).optional(),
+ ldClaimAmount: z.number().min(0).optional(),
+ ldClaimCurrency: z.enum(["KRW", "USD", "EUR", "JPY"]).optional(),
+ consensusStatus: z.boolean().nullable().optional(),
+ orderIsApproved: z.boolean().nullable().optional(),
+ procurementIsApproved: z.boolean().nullable().optional(),
+ qualityIsApproved: z.boolean().nullable().optional(),
+ designIsApproved: z.boolean().nullable().optional(),
+ csIsApproved: z.boolean().nullable().optional(),
+ // 담당자 정보 수정
+ orderReviewerEmail: z.string().optional(),
+ procurementReviewerEmail: z.string().optional(),
+ qualityReviewerEmail: z.string().optional(),
+ designReviewerEmail: z.string().optional(),
+ csReviewerEmail: z.string().optional(),
+})
+
+type EditEvaluationTargetFormValues = z.infer<typeof editEvaluationTargetSchema>
+
+interface EditEvaluationTargetSheetProps {
+ open: boolean
+ onOpenChange: (open: boolean) => void
+ evaluationTarget: EvaluationTargetWithDepartments | null
+}
+
+// 권한 타입 정의
+type PermissionLevel = "none" | "department" | "admin"
+
+interface UserPermissions {
+ level: PermissionLevel
+ editableApprovals: string[] // 편집 가능한 approval 필드들
+}
+
+export function EditEvaluationTargetSheet({
+ open,
+ onOpenChange,
+ evaluationTarget,
+}: EditEvaluationTargetSheetProps) {
+ const router = useRouter()
+ const [isSubmitting, setIsSubmitting] = React.useState(false)
+ const { data: session } = useSession()
+
+ // 담당자 관련 상태
+ const [reviewers, setReviewers] = React.useState<Array<{ id: number, name: string, email: string }>>([])
+ const [isLoadingReviewers, setIsLoadingReviewers] = React.useState(false)
+
+ // 각 부서별 담당자 선택 상태
+ const [reviewerSearches, setReviewerSearches] = React.useState<Record<string, string>>({})
+ const [reviewerOpens, setReviewerOpens] = React.useState<Record<string, boolean>>({})
+
+ // 부서 정보 상태
+ const [departments, setDepartments] = React.useState<Array<{ code: string, name: string, key: string }>>([])
+
+ // 사용자 권한 계산
+ const userPermissions = React.useMemo((): UserPermissions => {
+ if (!session?.user || !evaluationTarget) {
+ return { level: "none", editableApprovals: [] }
+ }
+
+ const userEmail = session.user.email
+ const userRole = session.user.role
+
+ // 평가관리자는 모든 권한
+ if (userRole === "평가관리자") {
+ return {
+ level: "admin",
+ editableApprovals: [
+ "orderIsApproved",
+ "procurementIsApproved",
+ "qualityIsApproved",
+ "designIsApproved",
+ "csIsApproved"
+ ]
+ }
+ }
+
+ // 부서별 담당자 권한 확인
+ const editableApprovals: string[] = []
+
+ if (evaluationTarget.orderReviewerEmail === userEmail) {
+ editableApprovals.push("orderIsApproved")
+ }
+ if (evaluationTarget.procurementReviewerEmail === userEmail) {
+ editableApprovals.push("procurementIsApproved")
+ }
+ if (evaluationTarget.qualityReviewerEmail === userEmail) {
+ editableApprovals.push("qualityIsApproved")
+ }
+ if (evaluationTarget.designReviewerEmail === userEmail) {
+ editableApprovals.push("designIsApproved")
+ }
+ if (evaluationTarget.csReviewerEmail === userEmail) {
+ editableApprovals.push("csIsApproved")
+ }
+
+ return {
+ level: editableApprovals.length > 0 ? "department" : "none",
+ editableApprovals
+ }
+ }, [session, evaluationTarget])
+
+ const form = useForm<EditEvaluationTargetFormValues>({
+ resolver: zodResolver(editEvaluationTargetSchema),
+ defaultValues: {
+ adminComment: "",
+ consolidatedComment: "",
+ ldClaimCount: 0,
+ ldClaimAmount: 0,
+ ldClaimCurrency: "KRW",
+ consensusStatus: null,
+ orderIsApproved: null,
+ procurementIsApproved: null,
+ qualityIsApproved: null,
+ designIsApproved: null,
+ csIsApproved: null,
+ orderReviewerEmail: "",
+ procurementReviewerEmail: "",
+ qualityReviewerEmail: "",
+ designReviewerEmail: "",
+ csReviewerEmail: "",
+ },
+ })
+
+ // 부서 정보 로드
+ const loadDepartments = React.useCallback(async () => {
+ try {
+ const departmentList = await getDepartmentInfo()
+ setDepartments(departmentList)
+ } catch (error) {
+ console.error("Error loading departments:", error)
+ toast.error("부서 정보를 불러오는데 실패했습니다.")
+ }
+ }, [])
+
+ // 담당자 목록 로드
+ const loadReviewers = React.useCallback(async () => {
+ setIsLoadingReviewers(true)
+ try {
+ const reviewerList = await getAvailableReviewers()
+ setReviewers(reviewerList)
+ } catch (error) {
+ console.error("Error loading reviewers:", error)
+ toast.error("담당자 목록을 불러오는데 실패했습니다.")
+ } finally {
+ setIsLoadingReviewers(false)
+ }
+ }, [])
+
+ // 시트가 열릴 때 데이터 로드 및 폼 초기화
+ React.useEffect(() => {
+ if (open && evaluationTarget) {
+ loadDepartments()
+ loadReviewers()
+
+ // 폼에 기존 데이터 설정
+ form.reset({
+ adminComment: evaluationTarget.adminComment || "",
+ consolidatedComment: evaluationTarget.consolidatedComment || "",
+ ldClaimCount: evaluationTarget.ldClaimCount || 0,
+ ldClaimAmount: parseFloat(evaluationTarget.ldClaimAmount || "0"),
+ ldClaimCurrency: evaluationTarget.ldClaimCurrency || "KRW",
+ consensusStatus: evaluationTarget.consensusStatus,
+ orderIsApproved: evaluationTarget.orderIsApproved,
+ procurementIsApproved: evaluationTarget.procurementIsApproved,
+ qualityIsApproved: evaluationTarget.qualityIsApproved,
+ designIsApproved: evaluationTarget.designIsApproved,
+ csIsApproved: evaluationTarget.csIsApproved,
+ orderReviewerEmail: evaluationTarget.orderReviewerEmail || "",
+ procurementReviewerEmail: evaluationTarget.procurementReviewerEmail || "",
+ qualityReviewerEmail: evaluationTarget.qualityReviewerEmail || "",
+ designReviewerEmail: evaluationTarget.designReviewerEmail || "",
+ csReviewerEmail: evaluationTarget.csReviewerEmail || "",
+ })
+ }
+ }, [open, evaluationTarget, form])
+
+ // 폼 제출
+ async function onSubmit(data: EditEvaluationTargetFormValues) {
+ if (!evaluationTarget) return
+
+ setIsSubmitting(true)
+ try {
+ const input: UpdateEvaluationTargetInput = {
+ id: evaluationTarget.id,
+ ...data,
+ }
+
+ console.log("Updating evaluation target:", input)
+
+ const result = await updateEvaluationTarget(input)
+
+ if (result.success) {
+ toast.success(result.message || "평가 대상이 성공적으로 수정되었습니다.")
+ onOpenChange(false)
+ router.refresh()
+ } else {
+ toast.error(result.error || "평가 대상 수정에 실패했습니다.")
+ }
+ } catch (error) {
+ console.error("Error updating evaluation target:", error)
+ toast.error("평가 대상 수정 중 오류가 발생했습니다.")
+ } finally {
+ setIsSubmitting(false)
+ }
+ }
+
+ // 시트 닫기 핸들러
+ const handleOpenChange = (open: boolean) => {
+ onOpenChange(open)
+ if (!open) {
+ form.reset()
+ setReviewerSearches({})
+ setReviewerOpens({})
+ }
+ }
+
+ // 담당자 검색 필터링
+ const getFilteredReviewers = (search: string) => {
+ if (!search) return reviewers
+ return reviewers.filter(reviewer =>
+ reviewer.name.toLowerCase().includes(search.toLowerCase()) ||
+ reviewer.email.toLowerCase().includes(search.toLowerCase())
+ )
+ }
+
+ // 필드 편집 권한 확인
+ const canEditField = (fieldName: string): boolean => {
+ if (userPermissions.level === "admin") return true
+ if (userPermissions.level === "none") return false
+
+ // 부서 담당자는 자신의 approval만 편집 가능
+ return userPermissions.editableApprovals.includes(fieldName)
+ }
+
+ // 관리자 전용 필드 확인
+ const canEditAdminFields = (): boolean => {
+ return userPermissions.level === "admin"
+ }
+
+ if (!evaluationTarget) {
+ return null
+ }
+
+ // 권한이 없는 경우
+ if (userPermissions.level === "none") {
+ return (
+ <Sheet open={open} onOpenChange={handleOpenChange}>
+ <SheetContent className="sm:max-w-lg overflow-y-auto">
+ <SheetHeader>
+ <SheetTitle>평가 대상 수정</SheetTitle>
+ <SheetDescription>
+ 권한이 없어 수정할 수 없습니다.
+ </SheetDescription>
+ </SheetHeader>
+ <div className="mt-6 p-4 bg-muted rounded-lg text-center">
+ <p className="text-sm text-muted-foreground">
+ 이 평가 대상을 수정할 권한이 없습니다.
+ </p>
+ </div>
+ </SheetContent>
+ </Sheet>
+ )
+ }
+
+ return (
+ <Sheet open={open} onOpenChange={handleOpenChange}>
+ <SheetContent className="flex flex-col h-full sm:max-w-xl">
+ <SheetHeader className="flex-shrink-0 text-left pb-6">
+ <SheetTitle>평가 대상 수정</SheetTitle>
+ <SheetDescription>
+ 평가 대상 정보를 수정합니다.
+ {userPermissions.level === "department" && (
+ <div className="mt-2 p-2 bg-blue-50 rounded text-sm">
+ <strong>부서 담당자 권한:</strong> 해당 부서의 평가 항목만 수정 가능합니다.
+ </div>
+ )}
+ {userPermissions.level === "admin" && (
+ <div className="mt-2 p-2 bg-green-50 rounded text-sm">
+ <strong>평가관리자 권한:</strong> 모든 항목을 수정할 수 있습니다.
+ </div>
+ )}
+ </SheetDescription>
+ </SheetHeader>
+
+ <Form {...form}>
+ <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col flex-1 min-h-0">
+ {/* 스크롤 가능한 컨텐츠 영역 */}
+ <div className="flex-1 overflow-y-auto pr-2 -mr-2">
+ <div className="space-y-6">
+ {/* 기본 정보 (읽기 전용) */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg">기본 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div>
+ <span className="font-medium">평가년도:</span> {evaluationTarget.evaluationYear}
+ </div>
+ <div>
+ <span className="font-medium">구분:</span> {evaluationTarget.division === "PLANT" ? "해양" : "조선"}
+ </div>
+ <div>
+ <span className="font-medium">벤더 코드:</span> {evaluationTarget.vendorCode}
+ </div>
+ <div>
+ <span className="font-medium">벤더명:</span> {evaluationTarget.vendorName}
+ </div>
+ <div>
+ <span className="font-medium">자재구분:</span> {evaluationTarget.materialType}
+ </div>
+ <div>
+ <span className="font-medium">상태:</span> {evaluationTarget.status}
+ </div>
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* L/D 클레임 정보 (관리자만) */}
+ {canEditAdminFields() && (
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg">L/D 클레임 정보</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ <div className="grid grid-cols-3 gap-4">
+ <FormField
+ control={form.control}
+ name="ldClaimCount"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>클레임 건수</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ min="0"
+ {...field}
+ onChange={(e) => field.onChange(parseInt(e.target.value) || 0)}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="ldClaimAmount"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>클레임 금액</FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ min="0"
+ step="0.01"
+ {...field}
+ onChange={(e) => field.onChange(parseFloat(e.target.value) || 0)}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
+ name="ldClaimCurrency"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>통화단위</FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="KRW">KRW (원)</SelectItem>
+ <SelectItem value="USD">USD (달러)</SelectItem>
+ <SelectItem value="EUR">EUR (유로)</SelectItem>
+ <SelectItem value="JPY">JPY (엔)</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </CardContent>
+ </Card>
+ )}
+
+ {/* 평가 상태 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg">평가 상태</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+
+ {/* 의견 일치 여부 (관리자만) */}
+ {canEditAdminFields() && (
+ <FormField
+ control={form.control}
+ name="consensusStatus"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>의견 일치 여부</FormLabel>
+ <Select
+ onValueChange={(value) => {
+ if (value === "null") field.onChange(null)
+ else field.onChange(value === "true")
+ }}
+ value={field.value === null ? "null" : field.value.toString()}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="null">검토 중</SelectItem>
+ <SelectItem value="true">의견 일치</SelectItem>
+ <SelectItem value="false">의견 불일치</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ )}
+
+ {/* 각 부서별 평가 */}
+ <div className="grid grid-cols-1 gap-4">
+ {[
+ { key: "orderIsApproved", label: "주문 부서 평가", email: evaluationTarget.orderReviewerEmail },
+ { key: "procurementIsApproved", label: "조달 부서 평가", email: evaluationTarget.procurementReviewerEmail },
+ { key: "qualityIsApproved", label: "품질 부서 평가", email: evaluationTarget.qualityReviewerEmail },
+ { key: "designIsApproved", label: "설계 부서 평가", email: evaluationTarget.designReviewerEmail },
+ { key: "csIsApproved", label: "CS 부서 평가", email: evaluationTarget.csReviewerEmail },
+ ].map(({ key, label, email }) => (
+ <FormField
+ key={key}
+ control={form.control}
+ name={key as keyof EditEvaluationTargetFormValues}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="flex items-center justify-between">
+ <span>{label}</span>
+ {email && (
+ <span className="text-xs text-muted-foreground">
+ 담당자: {email}
+ </span>
+ )}
+ </FormLabel>
+ <Select
+ onValueChange={(value) => {
+ if (value === "null") field.onChange(null)
+ else field.onChange(value === "true")
+ }}
+ value={field.value === null ? "null" : field.value?.toString()}
+ disabled={!canEditField(key)}
+ >
+ <FormControl>
+ <SelectTrigger className={!canEditField(key) ? "opacity-50" : ""}>
+ <SelectValue />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="null">대기중</SelectItem>
+ <SelectItem value="true">평가대상 맞음</SelectItem>
+ <SelectItem value="false">평가대상 아님</SelectItem>
+ </SelectContent>
+ </Select>
+ {!canEditField(key) && (
+ <p className="text-xs text-muted-foreground">
+ 편집 권한이 없습니다.
+ </p>
+ )}
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ ))}
+ </div>
+ </CardContent>
+ </Card>
+
+ {/* 의견 */}
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg">의견</CardTitle>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {/* 관리자 의견 (관리자만) */}
+ {canEditAdminFields() && (
+ <FormField
+ control={form.control}
+ name="adminComment"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>관리자 의견</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="관리자 의견을 입력하세요..."
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ )}
+
+ {/* 종합 의견 (모든 권한자) */}
+ <FormField
+ control={form.control}
+ name="consolidatedComment"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>종합 의견</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="종합 의견을 입력하세요..."
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </CardContent>
+ </Card>
+
+ {/* 담당자 변경 (관리자만) */}
+ {canEditAdminFields() && departments.length > 0 && (
+ <Card>
+ <CardHeader>
+ <CardTitle className="text-lg">담당자 변경</CardTitle>
+ <p className="text-sm text-muted-foreground">
+ 각 부서별 담당자를 변경할 수 있습니다.
+ </p>
+ </CardHeader>
+ <CardContent className="space-y-4">
+ {[
+ { key: "orderReviewerEmail", label: "주문 부서 담당자", current: evaluationTarget.orderReviewerEmail },
+ { key: "procurementReviewerEmail", label: "조달 부서 담당자", current: evaluationTarget.procurementReviewerEmail },
+ { key: "qualityReviewerEmail", label: "품질 부서 담당자", current: evaluationTarget.qualityReviewerEmail },
+ { key: "designReviewerEmail", label: "설계 부서 담당자", current: evaluationTarget.designReviewerEmail },
+ { key: "csReviewerEmail", label: "CS 부서 담당자", current: evaluationTarget.csReviewerEmail },
+ ].map(({ key, label, current }) => {
+ const selectedReviewer = reviewers.find(r => r.email === form.watch(key as keyof EditEvaluationTargetFormValues))
+ const filteredReviewers = getFilteredReviewers(reviewerSearches[key] || "")
+
+ return (
+ <FormField
+ key={key}
+ control={form.control}
+ name={key as keyof EditEvaluationTargetFormValues}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ {label}
+ {current && (
+ <span className="text-xs text-muted-foreground ml-2">
+ (현재: {current})
+ </span>
+ )}
+ </FormLabel>
+ <Popover
+ open={reviewerOpens[key] || false}
+ onOpenChange={(open) => setReviewerOpens(prev => ({...prev, [key]: open}))}
+ >
+ <PopoverTrigger asChild>
+ <FormControl>
+ <Button
+ variant="outline"
+ role="combobox"
+ aria-expanded={reviewerOpens[key]}
+ className="w-full justify-between"
+ >
+ {selectedReviewer ? (
+ <span className="flex items-center gap-2">
+ <span>{selectedReviewer.name}</span>
+ <span className="text-xs text-muted-foreground">
+ ({selectedReviewer.email})
+ </span>
+ </span>
+ ) : field.value ? (
+ field.value
+ ) : (
+ "담당자 선택..."
+ )}
+ <ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
+ </Button>
+ </FormControl>
+ </PopoverTrigger>
+ <PopoverContent className="w-full p-0" align="start">
+ <Command>
+ <CommandInput
+ placeholder="담당자 검색..."
+ value={reviewerSearches[key] || ""}
+ onValueChange={(value) => setReviewerSearches(prev => ({...prev, [key]: value}))}
+ />
+ <CommandList>
+ <CommandEmpty>
+ {isLoadingReviewers ? (
+ <div className="flex items-center gap-2">
+ <Loader2 className="h-4 w-4 animate-spin" />
+ 로딩 중...
+ </div>
+ ) : (
+ "검색 결과가 없습니다."
+ )}
+ </CommandEmpty>
+ <CommandGroup>
+ <CommandItem
+ value="선택 안함"
+ onSelect={() => {
+ field.onChange("")
+ setReviewerOpens(prev => ({ ...prev, [key]: false }))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ !field.value ? "opacity-100" : "opacity-0"
+ )}
+ />
+ 선택 안함
+ </CommandItem>
+ {filteredReviewers.map((reviewer) => (
+ <CommandItem
+ key={reviewer.id}
+ value={`${reviewer.name} ${reviewer.email}`}
+ onSelect={() => {
+ field.onChange(reviewer.email)
+ setReviewerOpens(prev => ({...prev, [key]: false}))
+ }}
+ >
+ <Check
+ className={cn(
+ "mr-2 h-4 w-4",
+ reviewer.email === field.value ? "opacity-100" : "opacity-0"
+ )}
+ />
+ <div className="flex items-center gap-2">
+ <span>{reviewer.name}</span>
+ <span className="text-xs text-muted-foreground">
+ ({reviewer.email})
+ </span>
+ </div>
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ </CommandList>
+ </Command>
+ </PopoverContent>
+ </Popover>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ )
+ })}
+ </CardContent>
+ </Card>
+ )}
+ </div>
+ </div>
+
+ {/* 고정된 버튼 영역 */}
+ <div className="flex-shrink-0 flex justify-end gap-3 pt-6 mt-6 border-t bg-background">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => handleOpenChange(false)}
+ disabled={isSubmitting}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ disabled={isSubmitting}
+ >
+ {isSubmitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ 수정
+ </Button>
+ </div>
+ </form>
+ </Form>
+ </SheetContent>
+ </Sheet>
+ )
+} \ No newline at end of file