diff options
Diffstat (limited to 'lib')
23 files changed, 3732 insertions, 526 deletions
diff --git a/lib/admin-users/table/add-ausers-dialog.tsx b/lib/admin-users/table/add-ausers-dialog.tsx index dd29c190..64941965 100644 --- a/lib/admin-users/table/add-ausers-dialog.tsx +++ b/lib/admin-users/table/add-ausers-dialog.tsx @@ -82,7 +82,7 @@ export function AddUserDialog() { companyId: null, language:'en', // roles는 array<string>, 여기서는 단일 선택 시 [role]로 담음 - roles: [], + roles: ["Vendor Admin"], domain:'partners' // domain, etc. 필요하다면 추가 }, diff --git a/lib/b-rfq/repository.ts b/lib/b-rfq/repository.ts new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/lib/b-rfq/repository.ts diff --git a/lib/b-rfq/service.ts b/lib/b-rfq/service.ts new file mode 100644 index 00000000..f64eb46c --- /dev/null +++ b/lib/b-rfq/service.ts @@ -0,0 +1,291 @@ +'use server' + +import { revalidateTag, unstable_cache } from "next/cache" +import { count, desc, asc, and, or, gte, lte, ilike, eq } from "drizzle-orm" +import { filterColumns } from "@/lib/filter-columns" +import db from "@/db/db" +import { RfqDashboardView, bRfqs, projects, users } from "@/db/schema" // 실제 스키마 import 경로에 맞게 수정 +import { rfqDashboardView } from "@/db/schema" // 뷰 import +import type { SQL } from "drizzle-orm" +import { CreateRfqInput, GetRFQDashboardSchema, createRfqServerSchema } from "./validations" + +export async function getRFQDashboard(input: GetRFQDashboardSchema) { + return unstable_cache( + async () => { + try { + const offset = (input.page - 1) * input.perPage; + + const rfqFilterMapping = createRFQFilterMapping(); + const joinedTables = getRFQJoinedTables(); + + // 1) 고급 필터 조건 + let advancedWhere: SQL<unknown> | undefined = undefined; + if (input.filters && input.filters.length > 0) { + advancedWhere = filterColumns({ + table: rfqDashboardView, + filters: input.filters, + joinOperator: input.joinOperator || 'and', + joinedTables, + customColumnMapping: rfqFilterMapping, + }); + } + + // 2) 기본 필터 조건 + let basicWhere: SQL<unknown> | undefined = undefined; + if (input.basicFilters && input.basicFilters.length > 0) { + basicWhere = filterColumns({ + table: rfqDashboardView, + filters: input.basicFilters, + joinOperator: input.basicJoinOperator || 'and', + joinedTables, + customColumnMapping: rfqFilterMapping, + }); + } + + // 3) 글로벌 검색 조건 + let globalWhere: SQL<unknown> | undefined = undefined; + if (input.search) { + const s = `%${input.search}%`; + + const validSearchConditions: SQL<unknown>[] = []; + + const rfqCodeCondition = ilike(rfqDashboardView.rfqCode, s); + if (rfqCodeCondition) validSearchConditions.push(rfqCodeCondition); + + const descriptionCondition = ilike(rfqDashboardView.description, s); + if (descriptionCondition) validSearchConditions.push(descriptionCondition); + + const projectNameCondition = ilike(rfqDashboardView.projectName, s); + if (projectNameCondition) validSearchConditions.push(projectNameCondition); + + const projectCodeCondition = ilike(rfqDashboardView.projectCode, s); + if (projectCodeCondition) validSearchConditions.push(projectCodeCondition); + + const picNameCondition = ilike(rfqDashboardView.picName, s); + if (picNameCondition) validSearchConditions.push(picNameCondition); + + const packageNoCondition = ilike(rfqDashboardView.packageNo, s); + if (packageNoCondition) validSearchConditions.push(packageNoCondition); + + const packageNameCondition = ilike(rfqDashboardView.packageName, s); + if (packageNameCondition) validSearchConditions.push(packageNameCondition); + + if (validSearchConditions.length > 0) { + globalWhere = or(...validSearchConditions); + } + } + + + + // 6) 최종 WHERE 조건 생성 + const whereConditions: SQL<unknown>[] = []; + + if (advancedWhere) whereConditions.push(advancedWhere); + if (basicWhere) whereConditions.push(basicWhere); + if (globalWhere) whereConditions.push(globalWhere); + + const finalWhere = whereConditions.length > 0 ? and(...whereConditions) : undefined; + + // 7) 전체 데이터 수 조회 + const totalResult = await db + .select({ count: count() }) + .from(rfqDashboardView) + .where(finalWhere); + + const total = totalResult[0]?.count || 0; + + if (total === 0) { + return { data: [], pageCount: 0, total: 0 }; + } + + console.log(total) + + // 8) 정렬 및 페이징 처리된 데이터 조회 + const orderByColumns = input.sort.map((sort) => { + const column = sort.id as keyof typeof rfqDashboardView.$inferSelect; + return sort.desc ? desc(rfqDashboardView[column]) : asc(rfqDashboardView[column]); + }); + + if (orderByColumns.length === 0) { + orderByColumns.push(desc(rfqDashboardView.createdAt)); + } + + const rfqData = await db + .select() + .from(rfqDashboardView) + .where(finalWhere) + .orderBy(...orderByColumns) + .limit(input.perPage) + .offset(offset); + + const pageCount = Math.ceil(total / input.perPage); + + return { data: rfqData, pageCount, total }; + } catch (err) { + console.error("Error in getRFQDashboard:", err); + return { data: [], pageCount: 0, total: 0 }; + } + }, + [JSON.stringify(input)], + { + revalidate: 3600, + tags: ["rfq-dashboard"], + } + )(); +} + +// 헬퍼 함수들 +function createRFQFilterMapping() { + return { + // 뷰의 컬럼명과 실제 필터링할 컬럼 매핑 + rfqCode: rfqDashboardView.rfqCode, + description: rfqDashboardView.description, + status: rfqDashboardView.status, + projectName: rfqDashboardView.projectName, + projectCode: rfqDashboardView.projectCode, + picName: rfqDashboardView.picName, + packageNo: rfqDashboardView.packageNo, + packageName: rfqDashboardView.packageName, + dueDate: rfqDashboardView.dueDate, + overallProgress: rfqDashboardView.overallProgress, + createdAt: rfqDashboardView.createdAt, + }; +} + +function getRFQJoinedTables() { + return { + // 조인된 테이블 정보 (뷰이므로 실제로는 사용되지 않을 수 있음) + projects, + users, + }; +} + +// ================================================================ +// 3. RFQ Dashboard 타입 정의 +// ================================================================ + +async function generateNextSerial(picCode: string): Promise<string> { + try { + // 해당 picCode로 시작하는 RFQ 개수 조회 + const existingCount = await db + .select({ count: count() }) + .from(bRfqs) + .where(eq(bRfqs.picCode, picCode)) + + const nextSerial = (existingCount[0]?.count || 0) + 1 + return nextSerial.toString().padStart(5, '0') // 5자리로 패딩 + } catch (error) { + console.error("시리얼 번호 생성 오류:", error) + return "00001" // 기본값 + } + } + export async function createRfqAction(input: CreateRfqInput) { + try { + // 입력 데이터 검증 + const validatedData = createRfqServerSchema.parse(input) + + // RFQ 코드 자동 생성: N + picCode + 시리얼5자리 + const serialNumber = await generateNextSerial(validatedData.picCode) + const rfqCode = `N${validatedData.picCode}${serialNumber}` + + // 데이터베이스에 삽입 + const result = await db.insert(bRfqs).values({ + rfqCode, + projectId: validatedData.projectId, + dueDate: validatedData.dueDate, + status: "DRAFT", + picCode: validatedData.picCode, + picName: validatedData.picName || null, + EngPicName: validatedData.engPicName || null, + packageNo: validatedData.packageNo || null, + packageName: validatedData.packageName || null, + remark: validatedData.remark || null, + projectCompany: validatedData.projectCompany || null, + projectFlag: validatedData.projectFlag || null, + projectSite: validatedData.projectSite || null, + createdBy: validatedData.createdBy, + updatedBy: validatedData.updatedBy, + }).returning({ + id: bRfqs.id, + rfqCode: bRfqs.rfqCode, + }) + + // 관련 페이지 캐시 무효화 + revalidateTag("rfq-dashboard") + + + return { + success: true, + data: result[0], + message: "RFQ가 성공적으로 생성되었습니다", + } + + } catch (error) { + console.error("RFQ 생성 오류:", error) + + + return { + success: false, + error: "RFQ 생성에 실패했습니다", + } + } + } + + // RFQ 코드 중복 확인 액션 + export async function checkRfqCodeExists(rfqCode: string) { + try { + const existing = await db.select({ id: bRfqs.id }) + .from(bRfqs) + .where(eq(bRfqs.rfqCode, rfqCode)) + .limit(1) + + return existing.length > 0 + } catch (error) { + console.error("RFQ 코드 확인 오류:", error) + return false + } + } + + // picCode별 다음 예상 RFQ 코드 미리보기 + export async function previewNextRfqCode(picCode: string) { + try { + const serialNumber = await generateNextSerial(picCode) + return `N${picCode}${serialNumber}` + } catch (error) { + console.error("RFQ 코드 미리보기 오류:", error) + return `N${picCode}00001` + } + } + +const getBRfqById = async (id: number): Promise<RfqDashboardView | null> => { + // 1) RFQ 단건 조회 + const rfqsRes = await db + .select() + .from(rfqDashboardView) + .where(eq(rfqDashboardView.rfqId, id)) + .limit(1); + + if (rfqsRes.length === 0) return null; + const rfqRow = rfqsRes[0]; + + // 3) RfqWithItems 형태로 반환 + const result: RfqDashboardView = { + ...rfqRow, + + }; + + return result; + }; + + + export const findBRfqById = async (id: number): Promise<RfqDashboardView | null> => { + try { + + const rfq = await getBRfqById(id); + + return rfq; + } catch (error) { + throw new Error('Failed to fetch user'); + } + }; +
\ No newline at end of file diff --git a/lib/b-rfq/summary-table/add-new-rfq-dialog.tsx b/lib/b-rfq/summary-table/add-new-rfq-dialog.tsx new file mode 100644 index 00000000..2333d9cf --- /dev/null +++ b/lib/b-rfq/summary-table/add-new-rfq-dialog.tsx @@ -0,0 +1,523 @@ +"use client" + +import * as React from "react" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { z } from "zod" +import { format } from "date-fns" +import { CalendarIcon, Plus, Loader2, Eye } from "lucide-react" +import { useRouter } from "next/navigation" +import { useSession } from "next-auth/react" + +import { Button } from "@/components/ui/button" +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, + FormDescription, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { Textarea } from "@/components/ui/textarea" +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover" +import { Calendar } from "@/components/ui/calendar" +import { Badge } from "@/components/ui/badge" +import { cn } from "@/lib/utils" +import { toast } from "sonner" +import { ProjectSelector } from "@/components/ProjectSelector" +import { createRfqAction, previewNextRfqCode } from "../service" + +export type Project = { + id: number; + projectCode: string; + projectName: string; +} + +// 클라이언트 폼 스키마 (projectId 필수로 변경) +const createRfqSchema = z.object({ + projectId: z.number().min(1, "프로젝트를 선택해주세요"), // 필수로 변경 + dueDate: z.date({ + required_error: "마감일을 선택해주세요", + }), + picCode: z.string().min(1, "구매 담당자 코드를 입력해주세요"), + picName: z.string().optional(), + engPicName: z.string().optional(), + packageNo: z.string().min(1, "패키지 번호를 입력해주세요"), + packageName: z.string().min(1, "패키지명을 입력해주세요"), + remark: z.string().optional(), + projectCompany: z.string().optional(), + projectFlag: z.string().optional(), + projectSite: z.string().optional(), +}) + +type CreateRfqFormValues = z.infer<typeof createRfqSchema> + +interface CreateRfqDialogProps { + onSuccess?: () => void; +} + +export function CreateRfqDialog({ onSuccess }: CreateRfqDialogProps) { + const [open, setOpen] = React.useState(false) + const [isLoading, setIsLoading] = React.useState(false) + const [previewCode, setPreviewCode] = React.useState<string>("") + const [isLoadingPreview, setIsLoadingPreview] = React.useState(false) + const router = useRouter() + const { data: session } = useSession() + + const userId = React.useMemo(() => { + return session?.user?.id ? Number(session.user.id) : null; + }, [session]); + + const form = useForm<CreateRfqFormValues>({ + resolver: zodResolver(createRfqSchema), + defaultValues: { + projectId: undefined, + dueDate: undefined, + picCode: "", + picName: "", + engPicName: "", + packageNo: "", + packageName: "", + remark: "", + projectCompany: "", + projectFlag: "", + projectSite: "", + }, + }) + + // picCode 변경 시 미리보기 업데이트 + const watchedPicCode = form.watch("picCode") + + React.useEffect(() => { + if (watchedPicCode && watchedPicCode.length > 0) { + setIsLoadingPreview(true) + const timer = setTimeout(async () => { + try { + const preview = await previewNextRfqCode(watchedPicCode) + setPreviewCode(preview) + } catch (error) { + console.error("미리보기 오류:", error) + setPreviewCode("") + } finally { + setIsLoadingPreview(false) + } + }, 500) // 500ms 디바운스 + + return () => clearTimeout(timer) + } else { + setPreviewCode("") + } + }, [watchedPicCode]) + + // 다이얼로그 열림/닫힘 처리 및 폼 리셋 + const handleOpenChange = (newOpen: boolean) => { + setOpen(newOpen) + + // 다이얼로그가 닫힐 때 폼과 상태 초기화 + if (!newOpen) { + form.reset() + setPreviewCode("") + setIsLoadingPreview(false) + } + } + + const handleCancel = () => { + form.reset() + setOpen(false) + } + + + const onSubmit = async (data: CreateRfqFormValues) => { + if (!userId) { + toast.error("로그인이 필요합니다") + return + } + + setIsLoading(true) + + try { + // 서버 액션 호출 - Date 객체를 직접 전달 + const result = await createRfqAction({ + projectId: data.projectId, // 이제 항상 값이 있음 + dueDate: data.dueDate, // Date 객체 직접 전달 + picCode: data.picCode, + picName: data.picName || "", + engPicName: data.engPicName || "", + packageNo: data.packageNo, + packageName: data.packageName, + remark: data.remark || "", + projectCompany: data.projectCompany || "", + projectFlag: data.projectFlag || "", + projectSite: data.projectSite || "", + createdBy: userId, + updatedBy: userId, + }) + + if (result.success) { + toast.success(result.message, { + description: `RFQ 코드: ${result.data?.rfqCode}`, + }) + + // 다이얼로그 닫기 (handleOpenChange에서 리셋 처리됨) + setOpen(false) + + // 성공 콜백 실행 + if (onSuccess) { + onSuccess() + } + + } else { + toast.error(result.error || "RFQ 생성에 실패했습니다") + } + + } catch (error) { + console.error('RFQ 생성 오류:', error) + toast.error("RFQ 생성에 실패했습니다", { + description: "알 수 없는 오류가 발생했습니다", + }) + } finally { + setIsLoading(false) + } + } + + const handleProjectSelect = (project: Project | null) => { + if (project === null) { + form.setValue("projectId", undefined as any); // 타입 에러 방지 + return; + } + form.setValue("projectId", project.id); + }; + + return ( + <Dialog open={open} onOpenChange={handleOpenChange}> + <DialogTrigger asChild> + <Button size="sm" variant="outline"> + <Plus className="mr-2 h-4 w-4" /> + 새 RFQ + </Button> + </DialogTrigger> + <DialogContent className="max-w-3xl h-[90vh] flex flex-col"> + {/* 고정된 헤더 */} + <DialogHeader className="flex-shrink-0"> + <DialogTitle>새 RFQ 생성</DialogTitle> + <DialogDescription> + 새로운 RFQ를 생성합니다. 필수 정보를 입력해주세요. + </DialogDescription> + </DialogHeader> + + {/* 스크롤 가능한 컨텐츠 영역 */} + <div className="flex-1 overflow-y-auto px-1"> + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4 py-2"> + + {/* 프로젝트 선택 (필수) */} + <FormField + control={form.control} + name="projectId" + render={({ field }) => ( + <FormItem> + <FormLabel> + 프로젝트 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <ProjectSelector + selectedProjectId={field.value} + onProjectSelect={handleProjectSelect} + placeholder="프로젝트 선택..." + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 마감일 (필수) */} + <FormField + control={form.control} + name="dueDate" + render={({ field }) => ( + <FormItem className="flex flex-col"> + <FormLabel> + 마감일 <span className="text-red-500">*</span> + </FormLabel> + <Popover> + <PopoverTrigger asChild> + <FormControl> + <Button + variant="outline" + className={cn( + "w-full pl-3 text-left font-normal", + !field.value && "text-muted-foreground" + )} + > + {field.value ? ( + format(field.value, "yyyy-MM-dd") + ) : ( + <span>마감일을 선택하세요</span> + )} + <CalendarIcon className="ml-auto h-4 w-4 opacity-50" /> + </Button> + </FormControl> + </PopoverTrigger> + <PopoverContent className="w-auto p-0" align="start"> + <Calendar + mode="single" + selected={field.value} + onSelect={field.onChange} + disabled={(date) => + date < new Date() || date < new Date("1900-01-01") + } + initialFocus + /> + </PopoverContent> + </Popover> + <FormMessage /> + </FormItem> + )} + /> + + {/* 구매 담당자 코드 (필수) + 미리보기 */} + <FormField + control={form.control} + name="picCode" + render={({ field }) => ( + <FormItem> + <FormLabel> + 구매 담당자 코드 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <div className="space-y-2"> + <Input + placeholder="예: P001, P002, MGR01 등" + {...field} + /> + {/* RFQ 코드 미리보기 */} + {previewCode && ( + <div className="flex items-center gap-2 p-2 bg-muted rounded-md"> + <Eye className="h-4 w-4 text-muted-foreground" /> + <span className="text-sm text-muted-foreground"> + 생성될 RFQ 코드: + </span> + <Badge variant="outline" className="font-mono"> + {isLoadingPreview ? "생성 중..." : previewCode} + </Badge> + </div> + )} + </div> + </FormControl> + <FormDescription> + RFQ 코드는 N + 담당자코드 + 시리얼번호(5자리) 형식으로 자동 생성됩니다 + </FormDescription> + <FormMessage /> + </FormItem> + )} + /> + + {/* 담당자 정보 (두 개 나란히) */} + <div className="space-y-3"> + <h4 className="text-sm font-medium">담당자 정보</h4> + <div className="grid grid-cols-2 gap-4"> + {/* 구매 담당자 */} + <FormField + control={form.control} + name="picName" + render={({ field }) => ( + <FormItem> + <FormLabel>구매 담당자명</FormLabel> + <FormControl> + <Input + placeholder="구매 담당자명" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 설계 담당자 */} + <FormField + control={form.control} + name="engPicName" + render={({ field }) => ( + <FormItem> + <FormLabel>설계 담당자명</FormLabel> + <FormControl> + <Input + placeholder="설계 담당자명" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + </div> + + {/* 패키지 정보 (두 개 나란히) - 필수 */} + <div className="space-y-3"> + <h4 className="text-sm font-medium">패키지 정보</h4> + <div className="grid grid-cols-2 gap-4"> + {/* 패키지 번호 (필수) */} + <FormField + control={form.control} + name="packageNo" + render={({ field }) => ( + <FormItem> + <FormLabel> + 패키지 번호 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input + placeholder="패키지 번호" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 패키지명 (필수) */} + <FormField + control={form.control} + name="packageName" + render={({ field }) => ( + <FormItem> + <FormLabel> + 패키지명 <span className="text-red-500">*</span> + </FormLabel> + <FormControl> + <Input + placeholder="패키지명" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + </div> + + {/* 프로젝트 상세 정보 */} + <div className="space-y-3"> + <h4 className="text-sm font-medium">프로젝트 상세 정보</h4> + <div className="grid grid-cols-1 gap-3"> + <FormField + control={form.control} + name="projectCompany" + render={({ field }) => ( + <FormItem> + <FormLabel>프로젝트 회사</FormLabel> + <FormControl> + <Input + placeholder="프로젝트 회사명" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <div className="grid grid-cols-2 gap-3"> + <FormField + control={form.control} + name="projectFlag" + render={({ field }) => ( + <FormItem> + <FormLabel>프로젝트 플래그</FormLabel> + <FormControl> + <Input + placeholder="프로젝트 플래그" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="projectSite" + render={({ field }) => ( + <FormItem> + <FormLabel>프로젝트 사이트</FormLabel> + <FormControl> + <Input + placeholder="프로젝트 사이트" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </div> + </div> + </div> + + {/* 비고 */} + <FormField + control={form.control} + name="remark" + render={({ field }) => ( + <FormItem> + <FormLabel>비고</FormLabel> + <FormControl> + <Textarea + placeholder="추가 비고사항을 입력하세요" + className="resize-none" + rows={3} + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + </form> + </Form> + </div> + + {/* 고정된 푸터 */} + <DialogFooter className="flex-shrink-0"> + <Button + type="button" + variant="outline" + onClick={handleCancel} + disabled={isLoading} + > + 취소 + </Button> + <Button + type="submit" + onClick={form.handleSubmit(onSubmit)} + disabled={isLoading} + > + {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />} + {isLoading ? "생성 중..." : "RFQ 생성"} + </Button> + </DialogFooter> + </DialogContent> + </Dialog> + ) +}
\ No newline at end of file diff --git a/lib/b-rfq/summary-table/summary-rfq-columns.tsx b/lib/b-rfq/summary-table/summary-rfq-columns.tsx new file mode 100644 index 00000000..f620858a --- /dev/null +++ b/lib/b-rfq/summary-table/summary-rfq-columns.tsx @@ -0,0 +1,499 @@ +"use client" + +import * as React from "react" +import { type DataTableRowAction } from "@/types/table" +import { type ColumnDef } from "@tanstack/react-table" +import { Ellipsis, Eye, Calendar, AlertTriangle, CheckCircle2, Clock, FileText } from "lucide-react" + +import { formatDate, cn } from "@/lib/utils" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Checkbox } from "@/components/ui/checkbox" +import { Progress } from "@/components/ui/progress" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { useRouter } from "next/navigation" +import { RfqDashboardView } from "@/db/schema" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" + +type NextRouter = ReturnType<typeof useRouter>; + +interface GetRFQColumnsProps { + setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<RfqDashboardView> | null>>; + router: NextRouter; +} + +// 상태에 따른 Badge 변형 결정 함수 +function getStatusBadge(status: string) { + switch (status) { + case "DRAFT": + return { variant: "outline" as const, label: "초안" }; + case "Doc. Received": + return { variant: "secondary" as const, label: "문서접수" }; + case "PIC Assigned": + return { variant: "secondary" as const, label: "담당자배정" }; + case "Doc. Confirmed": + return { variant: "default" as const, label: "문서확인" }; + case "Init. RFQ Sent": + return { variant: "default" as const, label: "초기RFQ발송" }; + case "Init. RFQ Answered": + return { variant: "default" as const, label: "초기RFQ회신" }; + case "TBE started": + return { variant: "secondary" as const, label: "TBE시작" }; + case "TBE finished": + return { variant: "secondary" as const, label: "TBE완료" }; + case "Final RFQ Sent": + return { variant: "default" as const, label: "최종RFQ발송" }; + case "Quotation Received": + return { variant: "default" as const, label: "견적접수" }; + case "Vendor Selected": + return { variant: "success" as const, label: "업체선정" }; + default: + return { variant: "outline" as const, label: status }; + } +} + +function getProgressBadge(progress: number) { + if (progress >= 100) { + return { variant: "success" as const, label: "완료" }; + } else if (progress >= 70) { + return { variant: "default" as const, label: "진행중" }; + } else if (progress >= 30) { + return { variant: "secondary" as const, label: "초기진행" }; + } else { + return { variant: "outline" as const, label: "시작" }; + } +} + +function getUrgencyLevel(daysToDeadline: number): "high" | "medium" | "low" { + if (daysToDeadline <= 3) return "high"; + if (daysToDeadline <= 7) return "medium"; + return "low"; +} + +export function getRFQColumns({ setRowAction, router }: GetRFQColumnsProps): ColumnDef<RfqDashboardView>[] { + + // Select 컬럼 + const selectColumn: ColumnDef<RfqDashboardView> = { + id: "select", + header: ({ table }) => ( + <Checkbox + checked={ + table.getIsAllPageRowsSelected() || + (table.getIsSomePageRowsSelected() && "indeterminate") + } + onCheckedChange={(value) => table.toggleAllPageRowsSelected(!!value)} + aria-label="Select all" + className="translate-y-0.5" + /> + ), + cell: ({ row }) => ( + <Checkbox + checked={row.getIsSelected()} + onCheckedChange={(value) => row.toggleSelected(!!value)} + aria-label="Select row" + className="translate-y-0.5" + /> + ), + size: 40, + enableSorting: false, + enableHiding: false, + }; + + // RFQ 코드 컬럼 + const rfqCodeColumn: ColumnDef<RfqDashboardView> = { + accessorKey: "rfqCode", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="RFQ 코드" /> + ), + cell: ({ row }) => ( + <div className="flex flex-col"> + <span className="font-medium">{row.getValue("rfqCode")}</span> + {row.original.description && ( + <span className="text-xs text-muted-foreground truncate max-w-[200px]"> + {row.original.description} + </span> + )} + </div> + ), + }; + + // 프로젝트 정보 컬럼 + const projectColumn: ColumnDef<RfqDashboardView> = { + accessorKey: "projectName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="프로젝트" /> + ), + cell: ({ row }) => { + const projectName = row.original.projectName; + const projectCode = row.original.projectCode; + + if (!projectName) { + return <span className="text-muted-foreground">-</span>; + } + + return ( + <div className="flex flex-col"> + <span className="font-medium">{projectName}</span> + <div className="flex items-center gap-2 text-xs text-muted-foreground"> + {projectCode && <span>{projectCode}</span>} + </div> + </div> + ); + }, + }; + + // 패키지 정보 컬럼 + const packageColumn: ColumnDef<RfqDashboardView> = { + accessorKey: "packageNo", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="패키지" /> + ), + cell: ({ row }) => { + const packageNo = row.original.packageNo; + const packageName = row.original.packageName; + + if (!packageNo) { + return <span className="text-muted-foreground">-</span>; + } + + return ( + <div className="flex flex-col"> + <span className="font-medium">{packageNo}</span> + {packageName && ( + <span className="text-xs text-muted-foreground truncate max-w-[150px]"> + {packageName} + </span> + )} + </div> + ); + }, + }; + + const updatedColumn: ColumnDef<RfqDashboardView> = { + accessorKey: "updatedBy", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="Updated By" /> + ), + cell: ({ row }) => { + const updatedByName = row.original.updatedByName; + const updatedByEmail = row.original.updatedByEmail; + + if (!updatedByName) { + return <span className="text-muted-foreground">-</span>; + } + + return ( + <div className="flex flex-col"> + <span className="font-medium">{updatedByName}</span> + {updatedByEmail && ( + <span className="text-xs text-muted-foreground truncate max-w-[150px]"> + {updatedByEmail} + </span> + )} + </div> + ); + }, + }; + + + // 상태 컬럼 + const statusColumn: ColumnDef<RfqDashboardView> = { + accessorKey: "status", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="상태" /> + ), + cell: ({ row }) => { + const statusBadge = getStatusBadge(row.original.status); + return <Badge variant={statusBadge.variant}>{statusBadge.label}</Badge>; + }, + filterFn: (row, id, value) => { + return value.includes(row.getValue(id)); + }, + }; + + // 진행률 컬럼 + const progressColumn: ColumnDef<RfqDashboardView> = { + accessorKey: "overallProgress", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="진행률" /> + ), + cell: ({ row }) => { + const progress = row.original.overallProgress; + const progressBadge = getProgressBadge(progress); + + return ( + <div className="flex flex-col gap-1 min-w-[120px]"> + <div className="flex items-center justify-between"> + <span className="text-sm font-medium">{progress}%</span> + <Badge variant={progressBadge.variant} className="text-xs"> + {progressBadge.label} + </Badge> + </div> + <Progress value={progress} className="h-2" /> + </div> + ); + }, + }; + + // 마감일 컬럼 + const dueDateColumn: ColumnDef<RfqDashboardView> = { + accessorKey: "dueDate", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="마감일" /> + ), + cell: ({ row }) => { + const dueDate = row.original.dueDate; + const daysToDeadline = row.original.daysToDeadline; + const urgencyLevel = getUrgencyLevel(daysToDeadline); + + if (!dueDate) { + return <span className="text-muted-foreground">-</span>; + } + + return ( + <div className="flex flex-col"> + <div className="flex items-center gap-2"> + <Calendar className="h-4 w-4 text-muted-foreground" /> + <span>{formatDate(dueDate, 'KR')}</span> + </div> + <div className="flex items-center gap-1 text-xs"> + {urgencyLevel === "high" && ( + <AlertTriangle className="h-3 w-3 text-red-500" /> + )} + {urgencyLevel === "medium" && ( + <Clock className="h-3 w-3 text-yellow-500" /> + )} + {urgencyLevel === "low" && ( + <CheckCircle2 className="h-3 w-3 text-green-500" /> + )} + <span className={cn( + urgencyLevel === "high" && "text-red-500", + urgencyLevel === "medium" && "text-yellow-600", + urgencyLevel === "low" && "text-green-600" + )}> + {daysToDeadline > 0 ? `${daysToDeadline}일 남음` : + daysToDeadline === 0 ? "오늘 마감" : + `${Math.abs(daysToDeadline)}일 지남`} + </span> + </div> + </div> + ); + }, + }; + + // 담당자 컬럼 + const picColumn: ColumnDef<RfqDashboardView> = { + accessorKey: "picName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="구매 담당자" /> + ), + cell: ({ row }) => { + const picName = row.original.picName; + return picName ? ( + <span>{picName}</span> + ) : ( + <span className="text-muted-foreground">미배정</span> + ); + }, + }; + + const engPicColumn: ColumnDef<RfqDashboardView> = { + accessorKey: "engPicName", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="설계 담당자" /> + ), + cell: ({ row }) => { + const picName = row.original.engPicName; + return picName ? ( + <span>{picName}</span> + ) : ( + <span className="text-muted-foreground">미배정</span> + ); + }, + }; + + + const pjtCompanyColumn: ColumnDef<RfqDashboardView> = { + accessorKey: "projectCompany", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="프로젝트 Company" /> + ), + cell: ({ row }) => { + const projectCompany = row.original.projectCompany; + return projectCompany ? ( + <span>{projectCompany}</span> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + }; + + const pjtFlagColumn: ColumnDef<RfqDashboardView> = { + accessorKey: "projectFlag", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="프로젝트 Flag" /> + ), + cell: ({ row }) => { + const projectFlag = row.original.projectFlag; + return projectFlag ? ( + <span>{projectFlag}</span> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + }; + + + const pjtSiteColumn: ColumnDef<RfqDashboardView> = { + accessorKey: "projectSite", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="프로젝트 Site" /> + ), + cell: ({ row }) => { + const projectSite = row.original.projectSite; + return projectSite ? ( + <span>{projectSite}</span> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + }; + const remarkColumn: ColumnDef<RfqDashboardView> = { + accessorKey: "remark", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="비고" /> + ), + cell: ({ row }) => { + const remark = row.original.remark; + return remark ? ( + <span>{remark}</span> + ) : ( + <span className="text-muted-foreground">-</span> + ); + }, + }; + + // 첨부파일 수 컬럼 + const attachmentColumn: ColumnDef<RfqDashboardView> = { + accessorKey: "totalAttachments", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="첨부파일" /> + ), + cell: ({ row }) => { + const count = row.original.totalAttachments; + return ( + <div className="flex items-center gap-2"> + <FileText className="h-4 w-4 text-muted-foreground" /> + <span>{count}</span> + </div> + ); + }, + }; + + // 벤더 현황 컬럼 + const vendorStatusColumn: ColumnDef<RfqDashboardView> = { + accessorKey: "initialVendorCount", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="벤더 현황" /> + ), + cell: ({ row }) => { + const initial = row.original.initialVendorCount; + const final = row.original.finalVendorCount; + const initialRate = row.original.initialResponseRate; + const finalRate = row.original.finalResponseRate; + + return ( + <div className="flex flex-col gap-1 text-xs"> + <div className="flex items-center justify-between"> + <span className="text-muted-foreground">초기:</span> + <span>{initial}개사 ({initialRate}%)</span> + </div> + <div className="flex items-center justify-between"> + <span className="text-muted-foreground">최종:</span> + <span>{final}개사 ({finalRate}%)</span> + </div> + </div> + ); + }, + }; + + // 생성일 컬럼 + const createdAtColumn: ColumnDef<RfqDashboardView> = { + accessorKey: "createdAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="생성일" /> + ), + cell: ({ row }) => { + const dateVal = row.original.createdAt as Date; + return formatDate(dateVal, 'KR'); + }, + }; + + const updatedAtColumn: ColumnDef<RfqDashboardView> = { + accessorKey: "updatedAt", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="수정일" /> + ), + cell: ({ row }) => { + const dateVal = row.original.updatedAt as Date; + return formatDate(dateVal, 'KR'); + }, + }; + + // Actions 컬럼 + const actionsColumn: ColumnDef<RfqDashboardView> = { + id: "detail", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="상세내용" /> + ), + // enableHiding: false, + cell: function Cell({ row }) { + const rfq = row.original; + const detailUrl = `/b-rfq/${rfq.rfqId}/initial`; + + return ( + + <Button + aria-label="Open menu" + variant="ghost" + className="flex size-8 p-0 data-[state=open]:bg-muted" + onClick={() => router.push(detailUrl)} + > + <Ellipsis className="size-4" aria-hidden="true" /> + </Button> + ); + }, + size: 40, + }; + + return [ + selectColumn, + rfqCodeColumn, + projectColumn, + packageColumn, + statusColumn, + picColumn, + progressColumn, + dueDateColumn, + actionsColumn, + + engPicColumn, + + pjtCompanyColumn, + pjtFlagColumn, + pjtSiteColumn, + + attachmentColumn, + vendorStatusColumn, + createdAtColumn, + + updatedAtColumn, + updatedColumn, + remarkColumn + ]; +}
\ No newline at end of file diff --git a/lib/b-rfq/summary-table/summary-rfq-filter-sheet.tsx b/lib/b-rfq/summary-table/summary-rfq-filter-sheet.tsx new file mode 100644 index 00000000..ff3bc132 --- /dev/null +++ b/lib/b-rfq/summary-table/summary-rfq-filter-sheet.tsx @@ -0,0 +1,617 @@ +"use client" + +import { useEffect, useTransition, useState, useRef } from "react" +import { useRouter, useParams } from "next/navigation" +import { z } from "zod" +import { useForm } from "react-hook-form" +import { zodResolver } from "@hookform/resolvers/zod" +import { Search, X } from "lucide-react" +import { customAlphabet } from "nanoid" +import { parseAsStringEnum, useQueryState } from "nuqs" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { Badge } from "@/components/ui/badge" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { cn } from "@/lib/utils" +import { getFiltersStateParser } from "@/lib/parsers" + +// nanoid 생성기 +const generateId = customAlphabet("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 6) + +// RFQ 필터 스키마 정의 +const rfqFilterSchema = z.object({ + rfqCode: z.string().optional(), + projectCode: z.string().optional(), + picName: z.string().optional(), + packageNo: z.string().optional(), + packageName: z.string().optional(), + status: z.string().optional(), +}) + +// RFQ 상태 옵션 정의 +const rfqStatusOptions = [ + { value: "DRAFT", label: "초안" }, + { value: "Doc. Received", label: "문서접수" }, + { value: "PIC Assigned", label: "담당자배정" }, + { value: "Doc. Confirmed", label: "문서확인" }, + { value: "Init. RFQ Sent", label: "초기RFQ발송" }, + { value: "Init. RFQ Answered", label: "초기RFQ회신" }, + { value: "TBE started", label: "TBE시작" }, + { value: "TBE finished", label: "TBE완료" }, + { value: "Final RFQ Sent", label: "최종RFQ발송" }, + { value: "Quotation Received", label: "견적접수" }, + { value: "Vendor Selected", label: "업체선정" }, +] + +type RFQFilterFormValues = z.infer<typeof rfqFilterSchema> + +interface RFQFilterSheetProps { + isOpen: boolean; + onClose: () => void; + onSearch?: () => void; + isLoading?: boolean; +} + +export function RFQFilterSheet({ + isOpen, + onClose, + onSearch, + isLoading = false +}: RFQFilterSheetProps) { + const router = useRouter() + const params = useParams(); + const lng = params ? (params.lng as string) : 'ko'; + + const [isPending, startTransition] = useTransition() + + // 초기화 상태 추가 - 폼 초기화 중에는 상태 변경을 방지 + const [isInitializing, setIsInitializing] = useState(false) + // 마지막으로 적용된 필터를 추적하기 위한 ref + const lastAppliedFilters = useRef<string>("") + + // nuqs로 URL 상태 관리 + const [filters, setFilters] = useQueryState( + "basicFilters", + getFiltersStateParser().withDefault([]) + ) + + // joinOperator 설정 + const [joinOperator, setJoinOperator] = useQueryState( + "basicJoinOperator", + parseAsStringEnum(["and", "or"]).withDefault("and") + ) + + // 현재 URL의 페이지 파라미터도 가져옴 + const [page, setPage] = useQueryState("page", { defaultValue: "1" }) + + // 폼 상태 초기화 + const form = useForm<RFQFilterFormValues>({ + resolver: zodResolver(rfqFilterSchema), + defaultValues: { + rfqCode: "", + projectCode: "", + picName: "", + packageNo: "", + packageName: "", + status: "", + }, + }) + + // URL 필터에서 초기 폼 상태 설정 + useEffect(() => { + // 현재 필터를 문자열로 직렬화 + const currentFiltersString = JSON.stringify(filters); + + // 패널이 열렸고, 필터가 있고, 마지막에 적용된 필터와 다를 때만 업데이트 + if (isOpen && filters && filters.length > 0 && currentFiltersString !== lastAppliedFilters.current) { + setIsInitializing(true); + + const formValues = { ...form.getValues() }; + let formUpdated = false; + + filters.forEach(filter => { + if (filter.id in formValues) { + // @ts-ignore - 동적 필드 접근 + formValues[filter.id] = filter.value; + formUpdated = true; + } + }); + + // 폼 값이 변경된 경우에만 reset으로 한 번에 업데이트 + if (formUpdated) { + form.reset(formValues); + lastAppliedFilters.current = currentFiltersString; + } + + setIsInitializing(false); + } + }, [filters, isOpen]) + + // 현재 적용된 필터 카운트 + const getActiveFilterCount = () => { + return filters?.length || 0 + } + + // 폼 제출 핸들러 + async function onSubmit(data: RFQFilterFormValues) { + // 초기화 중이면 제출 방지 + if (isInitializing) return; + + startTransition(async () => { + try { + // 필터 배열 생성 + const newFilters = [] + + if (data.rfqCode?.trim()) { + newFilters.push({ + id: "rfqCode", + value: data.rfqCode.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + + if (data.projectCode?.trim()) { + newFilters.push({ + id: "projectCode", + value: data.projectCode.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + + if (data.picName?.trim()) { + newFilters.push({ + id: "picName", + value: data.picName.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + + if (data.packageNo?.trim()) { + newFilters.push({ + id: "packageNo", + value: data.packageNo.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + + if (data.packageName?.trim()) { + newFilters.push({ + id: "packageName", + value: data.packageName.trim(), + type: "text", + operator: "iLike", + rowId: generateId() + }) + } + + if (data.status?.trim()) { + newFilters.push({ + id: "status", + value: data.status.trim(), + type: "select", + operator: "eq", + rowId: generateId() + }) + } + + // 수동으로 URL 업데이트 (nuqs 대신) + const currentUrl = new URL(window.location.href); + const params = new URLSearchParams(currentUrl.search); + + // 기존 필터 관련 파라미터 제거 + params.delete('basicFilters'); + params.delete('rfqBasicFilters'); + params.delete('basicJoinOperator'); + params.delete('rfqBasicJoinOperator'); + params.delete('page'); + + // 새로운 필터 추가 + if (newFilters.length > 0) { + params.set('basicFilters', JSON.stringify(newFilters)); + params.set('basicJoinOperator', joinOperator); + } + + // 페이지를 1로 설정 + params.set('page', '1'); + + const newUrl = `${currentUrl.pathname}?${params.toString()}`; + console.log("New RFQ Filter URL:", newUrl); + + // 페이지 완전 새로고침으로 서버 렌더링 강제 + window.location.href = newUrl; + + // 마지막 적용된 필터 업데이트 + lastAppliedFilters.current = JSON.stringify(newFilters); + + // 필터 업데이트 후 조회 핸들러 호출 (제공된 경우) + if (onSearch) { + console.log("Calling RFQ onSearch..."); + onSearch(); + } + + console.log("=== RFQ Filter Submit Complete ==="); + } catch (error) { + console.error("RFQ 필터 적용 오류:", error); + } + }) + } + + // 필터 초기화 핸들러 + async function handleReset() { + try { + setIsInitializing(true); + + form.reset({ + rfqCode: "", + projectCode: "", + picName: "", + packageNo: "", + packageName: "", + status: "", + }); + + console.log("=== RFQ Filter Reset Debug ==="); + console.log("Current URL before reset:", window.location.href); + + // 수동으로 URL 초기화 + const currentUrl = new URL(window.location.href); + const params = new URLSearchParams(currentUrl.search); + + // 필터 관련 파라미터 제거 + params.delete('basicFilters'); + params.delete('rfqBasicFilters'); + params.delete('basicJoinOperator'); + params.delete('rfqBasicJoinOperator'); + params.set('page', '1'); + + const newUrl = `${currentUrl.pathname}?${params.toString()}`; + console.log("Reset URL:", newUrl); + + // 페이지 완전 새로고침 + window.location.href = newUrl; + + // 마지막 적용된 필터 초기화 + lastAppliedFilters.current = ""; + + console.log("RFQ 필터 초기화 완료"); + setIsInitializing(false); + } catch (error) { + console.error("RFQ 필터 초기화 오류:", error); + setIsInitializing(false); + } + } + + // Don't render if not open (for side panel use) + if (!isOpen) { + return null; + } + + return ( + <div className="flex flex-col h-full max-h-full bg-[#F5F7FB] px-6 sm:px-8" style={{backgroundColor:"#F5F7FB", paddingLeft:"2rem", paddingRight:"2rem"}}> + {/* Filter Panel Header */} + <div className="flex items-center justify-between px-6 min-h-[60px] shrink-0"> + <h3 className="text-lg font-semibold whitespace-nowrap">RFQ 검색 필터</h3> + <div className="flex items-center gap-2"> + {getActiveFilterCount() > 0 && ( + <Badge variant="secondary" className="px-2 py-1"> + {getActiveFilterCount()}개 필터 적용됨 + </Badge> + )} + </div> + </div> + + {/* Join Operator Selection */} + <div className="px-6 shrink-0"> + <label className="text-sm font-medium">조건 결합 방식</label> + <Select + value={joinOperator} + onValueChange={(value: "and" | "or") => setJoinOperator(value)} + disabled={isInitializing} + > + <SelectTrigger className="h-8 w-[180px] mt-2 bg-white"> + <SelectValue placeholder="조건 결합 방식" /> + </SelectTrigger> + <SelectContent> + <SelectItem value="and">모든 조건 충족 (AND)</SelectItem> + <SelectItem value="or">하나라도 충족 (OR)</SelectItem> + </SelectContent> + </Select> + </div> + + <Form {...form}> + <form onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col h-full min-h-0"> + {/* Scrollable content area */} + <div className="flex-1 min-h-0 overflow-y-auto px-6 pb-4"> + <div className="space-y-4 pt-2"> + + {/* RFQ 코드 */} + <FormField + control={form.control} + name="rfqCode" + render={({ field }) => ( + <FormItem> + <FormLabel>RFQ 코드</FormLabel> + <FormControl> + <div className="relative"> + <Input + placeholder="RFQ 코드 입력" + {...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("rfqCode", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 프로젝트 코드 */} + <FormField + control={form.control} + name="projectCode" + 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("projectCode", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 담당자명 */} + <FormField + control={form.control} + name="picName" + 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("picName", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 패키지 번호 */} + <FormField + control={form.control} + name="packageNo" + 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("packageNo", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* 패키지명 */} + <FormField + control={form.control} + name="packageName" + 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("packageName", ""); + }} + disabled={isInitializing} + > + <X className="size-3.5" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + {/* RFQ 상태 */} + <FormField + control={form.control} + name="status" + render={({ field }) => ( + <FormItem> + <FormLabel>RFQ 상태</FormLabel> + <Select + value={field.value} + onValueChange={field.onChange} + disabled={isInitializing} + > + <FormControl> + <SelectTrigger className={cn(field.value && "pr-8", "bg-white")}> + <div className="flex justify-between w-full"> + <SelectValue placeholder="RFQ 상태 선택" /> + {field.value && ( + <Button + type="button" + variant="ghost" + size="icon" + className="h-4 w-4 -mr-2" + onClick={(e) => { + e.stopPropagation(); + form.setValue("status", ""); + }} + disabled={isInitializing} + > + <X className="size-3" /> + <span className="sr-only">Clear</span> + </Button> + )} + </div> + </SelectTrigger> + </FormControl> + <SelectContent> + {rfqStatusOptions.map(option => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + </FormItem> + )} + /> + </div> + </div> + + {/* Fixed buttons at bottom */} + <div className="p-4 shrink-0"> + <div className="flex gap-2 justify-end"> + <Button + type="button" + variant="outline" + onClick={handleReset} + disabled={isPending || getActiveFilterCount() === 0 || isInitializing} + className="px-4" + > + 초기화 + </Button> + <Button + type="submit" + variant="samsung" + disabled={isPending || isLoading || isInitializing} + className="px-4" + > + <Search className="size-4 mr-2" /> + {isPending || isLoading ? "조회 중..." : "조회"} + </Button> + </div> + </div> + </form> + </Form> + </div> + ) +}
\ No newline at end of file diff --git a/lib/b-rfq/summary-table/summary-rfq-table-toolbar-actions.tsx b/lib/b-rfq/summary-table/summary-rfq-table-toolbar-actions.tsx new file mode 100644 index 00000000..8ba95ce6 --- /dev/null +++ b/lib/b-rfq/summary-table/summary-rfq-table-toolbar-actions.tsx @@ -0,0 +1,68 @@ +"use client" + +import * as React from "react" +import { type Table } from "@tanstack/react-table" +import { Download, FileText, Mail, Search } from "lucide-react" +import { useRouter } from "next/navigation" + +import { Button } from "@/components/ui/button" +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu" +import { RfqDashboardView } from "@/db/schema" +import { CreateRfqDialog } from "./add-new-rfq-dialog" + +interface RFQTableToolbarActionsProps { + table: Table<RfqDashboardView> +} + +export function RFQTableToolbarActions({ table }: RFQTableToolbarActionsProps) { + const router = useRouter() + + // 선택된 행 정보 + const selectedRows = table.getFilteredSelectedRowModel().rows + const selectedCount = selectedRows.length + const isSingleSelected = selectedCount === 1 + + // RFQ 문서 확인 핸들러 + const handleDocumentCheck = () => { + if (isSingleSelected) { + const selectedRfq = selectedRows[0].original + const rfqId = selectedRfq.rfqId + + // RFQ 첨부문서 확인 페이지로 이동 + router.push(`/b-rfq/${rfqId}`) + } + } + + // 테이블 새로고침 핸들러 + const handleRefresh = () => { + // 페이지 새로고침 또는 데이터 다시 fetch + router.refresh() + } + + return ( + <div className="flex items-center gap-2"> + {/* 새 RFQ 생성 다이얼로그 */} + <CreateRfqDialog onSuccess={handleRefresh} /> + + {/* RFQ 문서 확인 버튼 - 단일 선택시만 활성화 */} + <Button + size="sm" + variant="outline" + onClick={handleDocumentCheck} + disabled={!isSingleSelected} + className="flex items-center" + > + <Search className="mr-2 h-4 w-4" /> + RFQ 문서 확인 + </Button> + + + </div> + ) +} diff --git a/lib/b-rfq/summary-table/summary-rfq-table.tsx b/lib/b-rfq/summary-table/summary-rfq-table.tsx new file mode 100644 index 00000000..83d50685 --- /dev/null +++ b/lib/b-rfq/summary-table/summary-rfq-table.tsx @@ -0,0 +1,285 @@ +"use client" + +import * as React from "react" +import { useRouter, useSearchParams } from "next/navigation" +import { Button } from "@/components/ui/button" +import { PanelLeftClose, PanelLeftOpen } from "lucide-react" +import type { + DataTableAdvancedFilterField, + DataTableFilterField, + DataTableRowAction, +} from "@/types/table" + +import { useDataTable } from "@/hooks/use-data-table" +import { DataTable } from "@/components/data-table/data-table" +import { DataTableAdvancedToolbar } from "@/components/data-table/data-table-advanced-toolbar" +import { getRFQDashboard } from "../service" +import { cn } from "@/lib/utils" +import { useTablePresets } from "@/components/data-table/use-table-presets" +import { TablePresetManager } from "@/components/data-table/data-table-preset" +import { useMemo } from "react" +import { getRFQColumns } from "./summary-rfq-columns" +import { RfqDashboardView } from "@/db/schema" +import { RFQTableToolbarActions } from "./summary-rfq-table-toolbar-actions" +import { RFQFilterSheet } from "./summary-rfq-filter-sheet" + +interface RFQDashboardTableProps { + promises: Promise<[Awaited<ReturnType<typeof getRFQDashboard>>]> + className?: string +} + +export function RFQDashboardTable({ promises, className }: RFQDashboardTableProps) { + const [rowAction, setRowAction] = React.useState<DataTableRowAction<RfqDashboardView> | null>(null) + const [isFilterPanelOpen, setIsFilterPanelOpen] = React.useState(false) + + const router = useRouter() + const searchParams = useSearchParams() + + const containerRef = React.useRef<HTMLDivElement>(null) + const [containerTop, setContainerTop] = React.useState(0) + + const updateContainerBounds = React.useCallback(() => { + if (containerRef.current) { + const rect = containerRef.current.getBoundingClientRect() + setContainerTop(rect.top) + } + }, []) + + React.useEffect(() => { + updateContainerBounds() + + const handleResize = () => { + updateContainerBounds() + } + + window.addEventListener('resize', handleResize) + window.addEventListener('scroll', updateContainerBounds) + + return () => { + window.removeEventListener('resize', handleResize) + window.removeEventListener('scroll', updateContainerBounds) + } + }, [updateContainerBounds]) + + const [promiseData] = React.use(promises) + const tableData = promiseData + + console.log("RFQ Dashboard Table Data:", { + dataLength: tableData.data?.length, + pageCount: tableData.pageCount, + total: tableData.total, + sampleData: tableData.data?.[0] + }) + + const initialSettings = React.useMemo(() => ({ + page: parseInt(searchParams.get('page') || '1'), + perPage: parseInt(searchParams.get('perPage') || '10'), + sort: searchParams.get('sort') ? JSON.parse(searchParams.get('sort')!) : [{ id: "createdAt", desc: true }], + filters: searchParams.get('filters') ? JSON.parse(searchParams.get('filters')!) : [], + joinOperator: (searchParams.get('joinOperator') as "and" | "or") || "and", + basicFilters: searchParams.get('basicFilters') || searchParams.get('rfqBasicFilters') ? + JSON.parse(searchParams.get('basicFilters') || searchParams.get('rfqBasicFilters')!) : [], + basicJoinOperator: (searchParams.get('basicJoinOperator') as "and" | "or") || "and", + search: searchParams.get('search') || '', + columnVisibility: {}, + columnOrder: [], + pinnedColumns: { left: [], right: ["actions"] }, + groupBy: [], + expandedRows: [] + }), [searchParams]) + + const { + presets, + activePresetId, + hasUnsavedChanges, + isLoading: presetsLoading, + createPreset, + applyPreset, + updatePreset, + deletePreset, + setDefaultPreset, + renamePreset, + updateClientState, + getCurrentSettings, + } = useTablePresets<RfqDashboardView>('rfq-dashboard-table', initialSettings) + + const columns = React.useMemo( + () => getRFQColumns({ setRowAction, router }), + [setRowAction, router] + ) + + const filterFields: DataTableFilterField<RfqDashboardView>[] = [ + { id: "rfqCode", label: "RFQ 코드" }, + { id: "projectName", label: "프로젝트" }, + { id: "status", label: "상태" }, + ] + + const advancedFilterFields: DataTableAdvancedFilterField<RfqDashboardView>[] = [ + { id: "rfqCode", label: "RFQ 코드", type: "text" }, + { id: "description", label: "설명", type: "text" }, + { id: "projectName", label: "프로젝트명", type: "text" }, + { id: "projectCode", label: "프로젝트 코드", type: "text" }, + { id: "packageNo", label: "패키지 번호", type: "text" }, + { id: "packageName", label: "패키지명", type: "text" }, + { id: "picName", label: "담당자", type: "text" }, + { id: "status", label: "상태", type: "select", options: [ + { label: "초안", value: "DRAFT" }, + { label: "문서접수", value: "Doc. Received" }, + { label: "담당자배정", value: "PIC Assigned" }, + { label: "문서확인", value: "Doc. Confirmed" }, + { label: "초기RFQ발송", value: "Init. RFQ Sent" }, + { label: "초기RFQ회신", value: "Init. RFQ Answered" }, + { label: "TBE시작", value: "TBE started" }, + { label: "TBE완료", value: "TBE finished" }, + { label: "최종RFQ발송", value: "Final RFQ Sent" }, + { label: "견적접수", value: "Quotation Received" }, + { label: "업체선정", value: "Vendor Selected" }, + ]}, + { id: "overallProgress", label: "진행률", type: "number" }, + { id: "dueDate", label: "마감일", type: "date" }, + { id: "createdAt", label: "생성일", type: "date" }, + ] + + const currentSettings = useMemo(() => { + return getCurrentSettings() + }, [getCurrentSettings]) + + const initialState = useMemo(() => { + return { + sorting: initialSettings.sort.filter(sortItem => { + const columnExists = columns.some(col => col.accessorKey === sortItem.id) + return columnExists + }) as any, + columnVisibility: currentSettings.columnVisibility, + columnPinning: currentSettings.pinnedColumns, + } + }, [currentSettings, initialSettings.sort, columns]) + + const { table } = useDataTable({ + data: tableData.data, + columns, + pageCount: tableData.pageCount, + rowCount: tableData.total || tableData.data.length, + filterFields, + enablePinning: true, + enableAdvancedFilter: true, + initialState, + getRowId: (originalRow) => String(originalRow.rfqId), + shallow: false, + clearOnDefault: true, + }) + + const handleSearch = () => { + setIsFilterPanelOpen(false) + } + + const getActiveBasicFilterCount = () => { + try { + const basicFilters = searchParams.get('basicFilters') || searchParams.get('rfqBasicFilters') + return basicFilters ? JSON.parse(basicFilters).length : 0 + } catch (e) { + return 0 + } + } + + const FILTER_PANEL_WIDTH = 400; + + return ( + <> + {/* Filter Panel */} + <div + className={cn( + "fixed left-0 bg-background border-r z-50 flex flex-col transition-all duration-300 ease-in-out overflow-hidden", + isFilterPanelOpen ? "border-r shadow-lg" : "border-r-0" + )} + style={{ + width: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px', + top: `${containerTop}px`, + height: `calc(100vh - ${containerTop}px)` + }} + > + <div className="h-full"> + <RFQFilterSheet + isOpen={isFilterPanelOpen} + onClose={() => setIsFilterPanelOpen(false)} + onSearch={handleSearch} + isLoading={false} + /> + </div> + </div> + + {/* Main Content Container */} + <div + ref={containerRef} + className={cn("relative w-full overflow-hidden", className)} + > + <div className="flex w-full h-full"> + <div + className="flex flex-col min-w-0 overflow-hidden transition-all duration-300 ease-in-out" + style={{ + width: isFilterPanelOpen ? `calc(100% - ${FILTER_PANEL_WIDTH}px)` : '100%', + marginLeft: isFilterPanelOpen ? `${FILTER_PANEL_WIDTH}px` : '0px' + }} + > + {/* 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" + type='button' + onClick={() => setIsFilterPanelOpen(!isFilterPanelOpen)} + className="flex items-center shadow-sm" + > + {isFilterPanelOpen ? <PanelLeftClose className="size-4"/> : <PanelLeftOpen className="size-4"/>} + {getActiveBasicFilterCount() > 0 && ( + <span className="ml-2 bg-primary text-primary-foreground rounded-full px-2 py-0.5 text-xs"> + {getActiveBasicFilterCount()} + </span> + )} + </Button> + </div> + + <div className="text-sm text-muted-foreground"> + {tableData && ( + <span>총 {tableData.total || tableData.data.length}건</span> + )} + </div> + </div> + + {/* Table Content Area */} + <div className="flex-1 overflow-hidden" style={{ height: 'calc(100vh - 380px)' }}> + <div className="h-full w-full"> + <DataTable table={table} className="h-full"> + <DataTableAdvancedToolbar + table={table} + filterFields={advancedFilterFields} + shallow={false} + > + <div className="flex items-center gap-2"> + <TablePresetManager<RfqDashboardView> + presets={presets} + activePresetId={activePresetId} + currentSettings={currentSettings} + hasUnsavedChanges={hasUnsavedChanges} + isLoading={presetsLoading} + onCreatePreset={createPreset} + onUpdatePreset={updatePreset} + onDeletePreset={deletePreset} + onApplyPreset={applyPreset} + onSetDefaultPreset={setDefaultPreset} + onRenamePreset={renamePreset} + /> + + <RFQTableToolbarActions table={table} /> + </div> + </DataTableAdvancedToolbar> + </DataTable> + </div> + </div> + </div> + </div> + </div> + </> + ) +}
\ No newline at end of file diff --git a/lib/b-rfq/validations.ts b/lib/b-rfq/validations.ts new file mode 100644 index 00000000..df8dc6e6 --- /dev/null +++ b/lib/b-rfq/validations.ts @@ -0,0 +1,100 @@ +import { createSearchParamsCache, + parseAsArrayOf, + parseAsInteger, + parseAsString, + parseAsStringEnum,parseAsBoolean + } from "nuqs/server" + import * as z from "zod" + +import { getFiltersStateParser, getSortingStateParser } from "@/lib/parsers" + +export const searchParamsRFQDashboardCache = createSearchParamsCache({ + // 공통 플래그 + flags: parseAsArrayOf(z.enum(["advancedTable", "floatingBar"])).withDefault([]), + + // 페이징 + page: parseAsInteger.withDefault(1), + perPage: parseAsInteger.withDefault(10), + + // 정렬 - rfqDashboardView 기반 + sort: getSortingStateParser<{ + rfqId: number; + rfqCode: string; + description: string; + status: string; + dueDate: Date; + projectCode: string; + projectName: string; + packageNo: string; + packageName: string; + picName: string; + totalAttachments: number; + initialVendorCount: number; + finalVendorCount: number; + initialResponseRate: number; + finalResponseRate: number; + overallProgress: number; + daysToDeadline: number; + createdAt: Date; + }>().withDefault([ + { id: "createdAt", desc: true }, + ]), + + // 고급 필터 + filters: getFiltersStateParser().withDefault([]), + joinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 기본 필터 + rfqBasicFilters: getFiltersStateParser().withDefault([]), + rfqBasicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + basicFilters: getFiltersStateParser().withDefault([]), + basicJoinOperator: parseAsStringEnum(["and", "or"]).withDefault("and"), + + // 검색 키워드 + search: parseAsString.withDefault(""), + + // RFQ 특화 필터 + rfqCode: parseAsString.withDefault(""), + projectName: parseAsString.withDefault(""), + projectCode: parseAsString.withDefault(""), + picName: parseAsString.withDefault(""), + packageNo: parseAsString.withDefault(""), + status: parseAsStringEnum([ + "DRAFT", + "Doc. Received", + "PIC Assigned", + "Doc. Confirmed", + "Init. RFQ Sent", + "Init. RFQ Answered", + "TBE started", + "TBE finished", + "Final RFQ Sent", + "Quotation Received", + "Vendor Selected" + ]), + dueDateFrom: parseAsString.withDefault(""), + dueDateTo: parseAsString.withDefault(""), + progressMin: parseAsInteger.withDefault(0), + progressMax: parseAsInteger.withDefault(100), + }); + + export type GetRFQDashboardSchema = Awaited<ReturnType<typeof searchParamsRFQDashboardCache.parse>> + + + export const createRfqServerSchema = z.object({ + projectId: z.number().min(1, "프로젝트를 선택해주세요"), // 필수로 변경 + dueDate: z.date(), // Date 객체로 직접 받기 + picCode: z.string().min(1, "구매 담당자 코드를 입력해주세요"), + picName: z.string().optional(), + engPicName: z.string().optional(), + packageNo: z.string().min(1, "패키지 번호를 입력해주세요"), + packageName: z.string().min(1, "패키지명을 입력해주세요"), + remark: z.string().optional(), + projectCompany: z.string().optional(), + projectFlag: z.string().optional(), + projectSite: z.string().optional(), + createdBy: z.number(), + updatedBy: z.number(), + }) + + export type CreateRfqInput = z.infer<typeof createRfqServerSchema>
\ No newline at end of file diff --git a/lib/users/send-otp.ts b/lib/users/send-otp.ts index ecaf19a5..96d975a1 100644 --- a/lib/users/send-otp.ts +++ b/lib/users/send-otp.ts @@ -29,47 +29,47 @@ export async function sendOtpAction(email: string, lng: string) { /////테스트 임시 // OTP 및 만료 시간 생성 - // const otp = Math.floor(100000 + Math.random() * 900000).toString(); - // const expires = new Date(Date.now() + 10 * 60 * 1000); // 10분 후 만료 - // const token = jwt.sign( - // { - // email, - // otp, - // exp: Math.floor(expires.getTime() / 1000), - // }, - // process.env.JWT_SECRET! - // ); + const otp = Math.floor(100000 + Math.random() * 900000).toString(); + const expires = new Date(Date.now() + 10 * 60 * 1000); // 10분 후 만료 + const token = jwt.sign( + { + email, + otp, + exp: Math.floor(expires.getTime() / 1000), + }, + process.env.JWT_SECRET! + ); // // DB에 OTP 추가 - // await addNewOtp(email, otp, new Date(), token, expires); + await addNewOtp(email, otp, new Date(), token, expires); // // 이메일에서 사용할 URL 구성 - // const verificationUrl = `http://${host}/ko/login?token=${token}`; + const verificationUrl = `http://${host}/ko/login?token=${token}`; - // // IP 정보로부터 지역 조회 (ip-api 사용) - // const ip = headersList.get('x-forwarded-for')?.split(',')[0]?.trim() || ''; - // let location = ''; - // try { - // const response = await fetch(`http://ip-api.com/json/${ip}?fields=country,city`); - // const data = await response.json(); - // location = data.city && data.country ? `${data.city}, ${data.country}` : ''; - // } catch (error) { - // // 위치 조회 실패 시 무시 - // } + // IP 정보로부터 지역 조회 (ip-api 사용) + const ip = headersList.get('x-forwarded-for')?.split(',')[0]?.trim() || ''; + let location = ''; + try { + const response = await fetch(`http://ip-api.com/json/${ip}?fields=country,city`); + const data = await response.json(); + location = data.city && data.country ? `${data.city}, ${data.country}` : ''; + } catch (error) { + // 위치 조회 실패 시 무시 + } // // OTP 이메일 발송 - // await sendEmail({ - // to: email, - // subject: `${otp} - SHI eVCP Sign-in Verification`, - // template: 'otp', - // context: { - // name: user.name, - // otp, - // verificationUrl, - // location, - // language: lng, - // }, - // }); + await sendEmail({ + to: email, + subject: `${otp} - SHI eVCP Sign-in Verification`, + template: 'otp', + context: { + name: user.name, + otp, + verificationUrl, + location, + language: lng, + }, + }); // 클라이언트로 반환할 수 있는 값 return { diff --git a/lib/vendor-document-list/dolce-upload-service.ts b/lib/vendor-document-list/dolce-upload-service.ts index 0396e819..627e0eba 100644 --- a/lib/vendor-document-list/dolce-upload-service.ts +++ b/lib/vendor-document-list/dolce-upload-service.ts @@ -110,6 +110,7 @@ class DOLCEUploadService { } // 4. 각 리비전별로 처리 + // 4. 각 리비전별로 처리 for (const revision of revisionsToUpload) { try { console.log(`Processing revision ${revision.revision} for document ${revision.documentNo}`) @@ -117,8 +118,9 @@ class DOLCEUploadService { // 4-1. 파일이 있는 경우 먼저 업로드 let uploadId: string | undefined if (revision.attachments && revision.attachments.length > 0) { - const fileUploadResults = await this.uploadFiles(revision.attachments) - + // ✅ userId를 uploadFiles 메서드에 전달 + const fileUploadResults = await this.uploadFiles(revision.attachments, userId) + if (fileUploadResults.length > 0) { uploadId = fileUploadResults[0].uploadId // 첫 번째 파일의 UploadId 사용 uploadedFiles += fileUploadResults.length @@ -128,8 +130,8 @@ class DOLCEUploadService { // 4-2. 문서 정보 업로드 const dolceDoc = this.transformToDoLCEDocument( - revision, - contractInfo, + revision, + contractInfo, uploadId, contractInfo.vendorCode, firstRevisionMap @@ -170,7 +172,6 @@ class DOLCEUploadService { console.error(errorMessage, error) } } - return { success: errors.length === 0, uploadedDocuments, @@ -223,7 +224,7 @@ class DOLCEUploadService { map.set(item.issueStageId, item.firstRevision) } }) - + return map } @@ -242,17 +243,18 @@ class DOLCEUploadService { uploaderName: revisions.uploaderName, submittedDate: revisions.submittedDate, comment: revisions.comment, - + usage: revisions.comment, + // ✅ DOLCE 연동 필드들 (새로 추가) externalUploadId: revisions.externalUploadId, externalRegisterId: revisions.id, externalSentAt: revisions.submittedDate, - + // issueStages 테이블 정보 issueStageId: issueStages.id, stageName: issueStages.stageName, documentId: issueStages.documentId, - + // documents 테이블 정보 (DOLCE 업로드에 필요한 모든 필드) documentNo: documents.docNumber, documentName: documents.title, @@ -260,7 +262,7 @@ class DOLCEUploadService { drawingMoveGbn: documents.drawingMoveGbn, discipline: documents.discipline, registerGroupId: documents.registerGroupId, - + // DOLCE B4 전용 필드들 cGbn: documents.cGbn, dGbn: documents.dGbn, @@ -268,13 +270,13 @@ class DOLCEUploadService { deptGbn: documents.deptGbn, jGbn: documents.jGbn, sGbn: documents.sGbn, - + // DOLCE 추가 정보 manager: documents.manager, managerENM: documents.managerENM, managerNo: documents.managerNo, shiDrawingNo: documents.shiDrawingNo, - + // 외부 시스템 연동 정보 externalDocumentId: documents.externalDocumentId, externalSystemType: documents.externalSystemType, @@ -312,7 +314,13 @@ class DOLCEUploadService { /** * 파일 업로드 (PWPUploadService.ashx) */ - private async uploadFiles(attachments: any[]): Promise<Array<{uploadId: string, fileId: string, filePath: string}>> { + /** + * 파일 업로드 (PWPUploadService.ashx) - DB 업데이트 포함 + */ + private async uploadFiles( + attachments: any[], + userId: string + ): Promise<Array<{ uploadId: string, fileId: string, filePath: string }>> { const uploadResults = [] for (const attachment of attachments) { @@ -339,15 +347,29 @@ class DOLCEUploadService { throw new Error(`File upload failed: HTTP ${response.status} - ${errorText}`) } - const filePath = await response.text() // DOLCE에서 반환하는 파일 경로 + const dolceFilePath = await response.text() // DOLCE에서 반환하는 파일 경로 + + // ✅ 업로드 성공 후 documentAttachments 테이블 업데이트 + await db + .update(documentAttachments) + .set({ + uploadId: uploadId, + fileId: fileId, + uploadedBy: userId, + dolceFilePath: dolceFilePath, + uploadedAt: new Date(), + updatedAt: new Date() + }) + .where(eq(documentAttachments.id, attachment.id)) uploadResults.push({ uploadId, fileId, - filePath + filePath: dolceFilePath }) - console.log(`✅ File uploaded successfully: ${attachment.fileName} -> ${filePath}`) + console.log(`✅ File uploaded successfully: ${attachment.fileName} -> ${dolceFilePath}`) + console.log(`✅ DB updated for attachment ID: ${attachment.id}`) } catch (error) { console.error(`❌ File upload failed for ${attachment.fileName}:`, error) @@ -361,7 +383,7 @@ class DOLCEUploadService { /** * 문서 정보 업로드 (DetailDwgReceiptMgmtEdit) */ - private async uploadDocument(dwgList: DOLCEDocument[], userId: string): Promise<{success: boolean, error?: string, data?: any}> { + private async uploadDocument(dwgList: DOLCEDocument[], userId: string): Promise<{ success: boolean, error?: string, data?: any }> { try { const endpoint = `${this.BASE_URL}/Services/VDCSWebService.svc/DetailDwgReceiptMgmtEdit` @@ -403,7 +425,7 @@ class DOLCEUploadService { /** * 파일 매핑 정보 업로드 (MatchBatchFileDwgEdit) */ - private async uploadFileMapping(mappingList: DOLCEFileMapping[], userId: string): Promise<{success: boolean, error?: string, data?: any}> { + private async uploadFileMapping(mappingList: DOLCEFileMapping[], userId: string): Promise<{ success: boolean, error?: string, data?: any }> { try { const endpoint = `${this.BASE_URL}/Services/VDCSWebService.svc/MatchBatchFileDwgEdit` @@ -445,7 +467,7 @@ class DOLCEUploadService { /** * 리비전 데이터를 DOLCE 문서 형태로 변환 (업데이트된 스키마 사용) */ - private transformToDoLCEDocument( + private transformToDoLCEDocument( revision: any, contractInfo: any, uploadId?: string, @@ -454,7 +476,7 @@ class DOLCEUploadService { ): DOLCEDocument { // Mode 결정: 해당 issueStageId의 첫 번째 revision인지 확인 let mode: "ADD" | "MOD" = "MOD" // 기본값은 MOD - + if (firstRevisionMap && firstRevisionMap.has(revision.issueStageId)) { const firstRevision = firstRevisionMap.get(revision.issueStageId) if (revision.revision === firstRevision) { @@ -464,12 +486,36 @@ class DOLCEUploadService { // RegisterKind 결정: stageName에 따라 설정 let registerKind = "APPC" // 기본값 + if (revision.stageName) { const stageNameLower = revision.stageName.toLowerCase() - if (stageNameLower.includes("pre")) { - registerKind = "RECP" - } else if (stageNameLower.includes("working")) { - registerKind = "RECW" + + if (revision.drawingKind === "B4") { + // B4: 기존 로직 + if (stageNameLower.includes("pre")) { + registerKind = "RECP" + } else if (stageNameLower.includes("working")) { + registerKind = "RECW" + } + } else if (revision.drawingKind === "B5") { + // B5: FMEA 관련 + if (stageNameLower.includes("pre")) { + registerKind = "FMEA-R1" + } else if (stageNameLower.includes("working")) { + registerKind = "FMEA-R2" + } + } else if (revision.drawingKind === "B3") { + // B3: WORK/APPC + if (stageNameLower.includes("work") && revision.usage.includes('Partial')) { + registerKind = "WORK-P" + } else if (stageNameLower.includes("work") && revision.usage.includes('Full')) { + registerKind = "WORK" + } else if (stageNameLower.includes("approval") && revision.usage.includes('Partial')) { + registerKind = "APPC-P" + } + else if (stageNameLower.includes("approval") && revision.usage.includes('Full')) { + registerKind = "APPC" + } } } @@ -479,7 +525,7 @@ class DOLCEUploadService { if (!isNaN(numericValue)) { return numericValue } - + // 문자인 경우 (a=1, b=2, c=3, ...) if (typeof revisionValue === 'string' && revisionValue.length === 1) { const charCode = revisionValue.toLowerCase().charCodeAt(0) @@ -487,7 +533,7 @@ class DOLCEUploadService { return charCode - 96 // a=1, b=2, c=3, ... } } - + // 기본값 return 1 } @@ -558,7 +604,7 @@ class DOLCEUploadService { private async getFileBuffer(filePath: string): Promise<ArrayBuffer> { try { console.log(`Reading file from path: ${filePath}`) - + if (filePath.startsWith('http')) { // URL인 경우 직접 다운로드 const response = await fetch(filePath) @@ -570,9 +616,9 @@ class DOLCEUploadService { // 로컬 파일 경로인 경우 const fs = await import('fs') const path = await import('path') - + let actualFilePath: string - + if (filePath.startsWith('/documents/')) { // DB에 저장된 경로 형태: "/documents/[uuid].ext" // 실제 파일 시스템 경로로 변환: "public/documents/[uuid].ext" @@ -584,16 +630,16 @@ class DOLCEUploadService { // 상대 경로인 경우 그대로 사용 actualFilePath = filePath } - + // 파일 존재 여부 확인 if (!fs.existsSync(actualFilePath)) { throw new Error(`File not found: ${actualFilePath}`) } - + // 파일 읽기 const fileBuffer = fs.readFileSync(actualFilePath) console.log(`✅ File read successfully: ${actualFilePath} (${fileBuffer.length} bytes)`) - + // Buffer를 ArrayBuffer로 변환 (타입 안전성 보장) return new ArrayBuffer(fileBuffer.length).slice(0).constructor(fileBuffer) } @@ -615,7 +661,7 @@ class DOLCEUploadService { // 업로드 성공 시 관련 날짜 설정 if (status === 'SUBMITTED') { updateData.submittedDate = new Date().toISOString().slice(0, 10) - // updateData.externalSentAt = new Date().toISOString().slice(0, 10) + // updateData.externalSentAt = new Date().toISOString().slice(0, 10) } else if (status === 'APPROVED') { updateData.approvedDate = new Date().toISOString().slice(0, 10) } diff --git a/lib/vendor-document-list/import-service.ts b/lib/vendor-document-list/import-service.ts index 4a152299..d2a14980 100644 --- a/lib/vendor-document-list/import-service.ts +++ b/lib/vendor-document-list/import-service.ts @@ -37,10 +37,23 @@ interface DOLCEDocument { DrawingNo: string GTTInput_PlanDate?: string GTTInput_ResultDate?: string + AppDwg_PlanDate?: string + AppDwg_ResultDate?: string + WorDwg_PlanDate?: string + WorDwg_ResultDate?: string + + GTTPreDwg_PlanDate?: string GTTPreDwg_ResultDate?: string GTTWorkingDwg_PlanDate?: string GTTWorkingDwg_ResultDate?: string + + FMEAFirst_PlanDate?: string + FMEAFirst_ResultDate?: string + FMEASecond_PlanDate?: string + FMEASecond_ResultDate?: string + + JGbn?: string Manager: string ManagerENM: string @@ -72,12 +85,12 @@ class ImportService { // 2. 각 drawingKind별로 데이터 조회 const allDocuments: DOLCEDocument[] = [] const drawingKinds = ['B3', 'B4', 'B5'] - + for (const drawingKind of drawingKinds) { try { const documents = await this.fetchFromDOLCE( - contractInfo.projectCode, - contractInfo.vendorCode, + contractInfo.projectCode, + contractInfo.vendorCode, drawingKind ) allDocuments.push(...documents) @@ -87,7 +100,7 @@ class ImportService { // 개별 drawingKind 실패는 전체 실패로 처리하지 않음 } } - + if (allDocuments.length === 0) { return { success: true, @@ -107,13 +120,22 @@ class ImportService { for (const dolceDoc of allDocuments) { try { const result = await this.syncSingleDocument(contractId, dolceDoc, sourceSystem) - + if (result === 'NEW') { newCount++ // B4 문서의 경우 이슈 스테이지 자동 생성 if (dolceDoc.DrawingKind === 'B4') { await this.createIssueStagesForB4Document(dolceDoc.DrawingNo, contractId, dolceDoc) } + + if (dolceDoc.DrawingKind === 'B3') { + await this.createIssueStagesForB3Document(dolceDoc.DrawingNo, contractId, dolceDoc) + } + + + if (dolceDoc.DrawingKind === 'B5') { + await this.createIssueStagesForB5Document(dolceDoc.DrawingNo, contractId, dolceDoc) + } } else if (result === 'UPDATED') { updatedCount++ } else { @@ -151,7 +173,7 @@ class ImportService { vendorCode: string; } | null> { const [result] = await db - .select({ + .select({ projectCode: projects.code, vendorCode: vendors.vendorCode }) @@ -162,7 +184,7 @@ class ImportService { .limit(1) - return result?.projectCode && result?.vendorCode + return result?.projectCode && result?.vendorCode ? { projectCode: result.projectCode, vendorCode: result.vendorCode } : null } @@ -172,15 +194,15 @@ class ImportService { */ private async fetchFromDOLCE( projectCode: string, - vendorCode: string, + vendorCode: string, drawingKind: string ): Promise<DOLCEDocument[]> { const endpoint = process.env.DOLCE_DOC_LIST_API_URL || 'http://60.100.99.217:1111/Services/VDCSWebService.svc/DwgReceiptMgmt' - + const requestBody = { project: projectCode, drawingKind: drawingKind, // B3, B4, B5 - drawingMoveGbn: "", + drawingMoveGbn: "", drawingNo: "", drawingName: "", drawingVendor: vendorCode @@ -204,14 +226,32 @@ class ImportService { } const data = await response.json() - - // 응답 구조에 따라 조정 필요 (실제 API 응답 구조 확인 후) - if (Array.isArray(data)) { - return data as DOLCEDocument[] - } else if (data.documents && Array.isArray(data.documents)) { - return data.documents as DOLCEDocument[] - } else if (data.data && Array.isArray(data.data)) { - return data.data as DOLCEDocument[] + + // DOLCE API 응답 구조에 맞게 처리 + if (data.DwgReceiptMgmtResult) { + const result = data.DwgReceiptMgmtResult + + // drawingKind에 따라 적절한 배열에서 데이터 추출 + let documents: DOLCEDocument[] = [] + + switch (drawingKind) { + case 'B3': + documents = result.VendorDwgList || [] + break + case 'B4': + documents = result.GTTDwgList || [] + break + case 'B5': + documents = result.FMEADwgList || [] + break + default: + console.warn(`Unknown drawingKind: ${drawingKind}`) + documents = [] + } + + console.log(`Found ${documents.length} documents for ${drawingKind} in ${drawingKind === 'B3' ? 'VendorDwgList' : drawingKind === 'B4' ? 'GTTDwgList' : 'FMEADwgList'}`) + return documents as DOLCEDocument[] + } else { console.warn(`Unexpected DOLCE response structure:`, data) return [] @@ -222,7 +262,6 @@ class ImportService { throw error } } - /** * 단일 문서 동기화 */ @@ -247,17 +286,17 @@ class ImportService { docNumber: dolceDoc.DrawingNo, title: dolceDoc.DrawingName, status: 'ACTIVE', - + // DOLCE 전용 필드들 drawingKind: dolceDoc.DrawingKind, drawingMoveGbn: dolceDoc.DrawingMoveGbn, discipline: dolceDoc.Discipline, - + // 외부 시스템 정보 externalDocumentId: dolceDoc.DrawingNo, // DOLCE에서는 DrawingNo가 ID 역할 externalSystemType: sourceSystem, externalSyncedAt: new Date(), - + // B4 전용 필드들 cGbn: dolceDoc.CGbn, dGbn: dolceDoc.DGbn, @@ -265,7 +304,7 @@ class ImportService { deptGbn: dolceDoc.DeptGbn, jGbn: dolceDoc.JGbn, sGbn: dolceDoc.SGbn, - + // 추가 정보 shiDrawingNo: dolceDoc.SHIDrawingNo, manager: dolceDoc.Manager, @@ -273,19 +312,19 @@ class ImportService { managerNo: dolceDoc.ManagerNo, registerGroup: dolceDoc.RegisterGroup, registerGroupId: dolceDoc.RegisterGroupId, - + // 생성자 정보 createUserNo: dolceDoc.CreateUserNo, createUserId: dolceDoc.CreateUserId, createUserENM: dolceDoc.CreateUserENM, - + updatedAt: new Date() } if (existingDoc.length > 0) { // 업데이트 필요 여부 확인 const existing = existingDoc[0] - const hasChanges = + const hasChanges = existing.title !== documentData.title || existing.drawingMoveGbn !== documentData.drawingMoveGbn || existing.manager !== documentData.manager @@ -316,6 +355,36 @@ class ImportService { } } + private convertDolceDateToDate(dolceDate: string | undefined | null): Date | null { + if (!dolceDate || dolceDate.trim() === '') { + return null + } + + // "20250204" 형태의 문자열을 "2025-02-04" 형태로 변환 + if (dolceDate.length === 8 && /^\d{8}$/.test(dolceDate)) { + const year = dolceDate.substring(0, 4) + const month = dolceDate.substring(4, 6) + const day = dolceDate.substring(6, 8) + + try { + const date = new Date(`${year}-${month}-${day}`) + // 유효한 날짜인지 확인 + if (isNaN(date.getTime())) { + console.warn(`Invalid date format: ${dolceDate}`) + return null + } + return date + } catch (error) { + console.warn(`Failed to parse date: ${dolceDate}`, error) + return null + } + } + + console.warn(`Unexpected date format: ${dolceDate}`) + return null +} + + /** * B4 문서용 이슈 스테이지 자동 생성 */ @@ -353,23 +422,25 @@ class ImportService { if (!existingStageNames.includes('For Pre')) { await db.insert(issueStages).values({ documentId: documentId, - stageName: 'For Pre', - planDate: dolceDoc.GTTPreDwg_PlanDate ? dolceDoc.GTTPreDwg_PlanDate : null, - actualDate: dolceDoc.GTTPreDwg_ResultDate ? dolceDoc.GTTPreDwg_ResultDate : null, - stageStatus: dolceDoc.GTTPreDwg_ResultDate ? 'COMPLETED' : 'PLANNED', + stageName: 'GTT → SHI (For Pre.DWG)', + planDate: this.convertDolceDateToDate(dolceDoc.GTTPreDwg_PlanDate), + actualDate: this.convertDolceDateToDate(dolceDoc.GTTPreDwg_ResultDate), + stageStatus: 'PLANNED', stageOrder: 1, + priority: 'MEDIUM', // 기본값 + reminderDays: 3, // 기본값 description: 'GTT 예비 도면 단계' }) } // For Working 스테이지 생성 (GTTWorkingDwg) - if (!existingStageNames.includes('For Working')) { + if (!existingStageNames.includes('For Work')) { await db.insert(issueStages).values({ documentId: documentId, - stageName: 'For Working', - planDate: dolceDoc.GTTWorkingDwg_PlanDate ? dolceDoc.GTTWorkingDwg_PlanDate : null, - actualDate: dolceDoc.GTTWorkingDwg_ResultDate ? dolceDoc.GTTWorkingDwg_ResultDate : null, - stageStatus: dolceDoc.GTTWorkingDwg_ResultDate ? 'COMPLETED' : 'PLANNED', + stageName: 'GTT → SHI (For Work.DWG)', + planDate: this.convertDolceDateToDate(dolceDoc.GTTWorkingDwg_PlanDate), + actualDate: this.convertDolceDateToDate(dolceDoc.GTTWorkingDwg_ResultDate), + stageStatus: 'PLANNED', stageOrder: 2, description: 'GTT 작업 도면 단계' }) @@ -383,6 +454,141 @@ class ImportService { } } + private async createIssueStagesForB3Document( + drawingNo: string, + contractId: number, + dolceDoc: DOLCEDocument + ): Promise<void> { + try { + // 문서 ID 조회 + const [document] = await db + .select({ id: documents.id }) + .from(documents) + .where(and( + eq(documents.contractId, contractId), + eq(documents.docNumber, drawingNo) + )) + .limit(1) + + if (!document) { + throw new Error(`Document not found: ${drawingNo}`) + } + + const documentId = document.id + + // 기존 이슈 스테이지 확인 + const existingStages = await db + .select() + .from(issueStages) + .where(eq(issueStages.documentId, documentId)) + + const existingStageNames = existingStages.map(stage => stage.stageName) + + // For Pre 스테이지 생성 (GTTPreDwg) + if (!existingStageNames.includes('Approval')) { + await db.insert(issueStages).values({ + documentId: documentId, + stageName: 'Vendor → SHI (For Approval)', + + planDate: this.convertDolceDateToDate(dolceDoc.AppDwg_PlanDate), + actualDate: this.convertDolceDateToDate(dolceDoc.AppDwg_ResultDate), + + stageStatus: 'PLANNED', + stageOrder: 1, + description: 'Vendor 승인 도면 단계' + }) + } + + // For Working 스테이지 생성 (GTTWorkingDwg) + if (!existingStageNames.includes('Working')) { + await db.insert(issueStages).values({ + documentId: documentId, + stageName: 'Vendor → SHI (For Working)', + + planDate: this.convertDolceDateToDate(dolceDoc.WorDwg_PlanDate), + actualDate: this.convertDolceDateToDate(dolceDoc.WorDwg_ResultDate), + + stageStatus: 'PLANNED', + stageOrder: 2, + description: 'Vendor 작업 도면 단계' + }) + } + + console.log(`Created issue stages for B4 document: ${drawingNo}`) + + } catch (error) { + console.error(`Failed to create issue stages for ${drawingNo}:`, error) + // 이슈 스테이지 생성 실패는 전체 문서 생성을 막지 않음 + } + } + + private async createIssueStagesForB5Document( + drawingNo: string, + contractId: number, + dolceDoc: DOLCEDocument + ): Promise<void> { + try { + // 문서 ID 조회 + const [document] = await db + .select({ id: documents.id }) + .from(documents) + .where(and( + eq(documents.contractId, contractId), + eq(documents.docNumber, drawingNo) + )) + .limit(1) + + if (!document) { + throw new Error(`Document not found: ${drawingNo}`) + } + + const documentId = document.id + + // 기존 이슈 스테이지 확인 + const existingStages = await db + .select() + .from(issueStages) + .where(eq(issueStages.documentId, documentId)) + + const existingStageNames = existingStages.map(stage => stage.stageName) + + // For Pre 스테이지 생성 (GTTPreDwg) + if (!existingStageNames.includes('Approval')) { + await db.insert(issueStages).values({ + documentId: documentId, + stageName: 'Vendor → SHI (For Approval)', + + planDate: this.convertDolceDateToDate(dolceDoc.FMEAFirst_PlanDate), + actualDate: this.convertDolceDateToDate(dolceDoc.FMEAFirst_ResultDate), + + stageStatus: 'PLANNED', + stageOrder: 1, + description: 'FMEA 예비 도면 단계' + }) + } + + // For Working 스테이지 생성 (GTTWorkingDwg) + if (!existingStageNames.includes('Working')) { + await db.insert(issueStages).values({ + documentId: documentId, + stageName: 'Vendor → SHI (For Working)', + planDate: dolceDoc.FMEASecond_PlanDate ? dolceDoc.FMEASecond_PlanDate : null, + actualDate: dolceDoc.FMEASecond_ResultDate ? dolceDoc.FMEASecond_ResultDate : null, + stageStatus: 'PLANNED', + stageOrder: 2, + description: 'FMEA 작업 도면 단계' + }) + } + + console.log(`Created issue stages for B4 document: ${drawingNo}`) + + } catch (error) { + console.error(`Failed to create issue stages for ${drawingNo}:`, error) + // 이슈 스테이지 생성 실패는 전체 문서 생성을 막지 않음 + } + } + + /** * 가져오기 상태 조회 */ @@ -393,8 +599,8 @@ class ImportService { try { // 마지막 가져오기 시간 조회 const [lastImport] = await db - .select({ - lastSynced: sql<string>`MAX(${documents.externalSyncedAt})` + .select({ + lastSynced: sql<string>`MAX(${documents.externalSyncedAt})` }) .from(documents) .where(and( @@ -405,7 +611,7 @@ class ImportService { // 프로젝트 코드와 벤더 코드 조회 const contractInfo = await this.getContractInfoById(contractId) - console.log(contractInfo,"contractInfo") + console.log(contractInfo, "contractInfo") if (!contractInfo?.projectCode || !contractInfo?.vendorCode) { throw new Error(`Project code or vendor code not found for contract ${contractId}`) @@ -418,12 +624,12 @@ class ImportService { try { // 각 drawingKind별로 확인 const drawingKinds = ['B3', 'B4', 'B5'] - + for (const drawingKind of drawingKinds) { try { const externalDocs = await this.fetchFromDOLCE( - contractInfo.projectCode, - contractInfo.vendorCode, + contractInfo.projectCode, + contractInfo.vendorCode, drawingKind ) availableDocuments += externalDocs.length diff --git a/lib/vendor-document-list/table/enhanced-doc-table-columns.tsx b/lib/vendor-document-list/table/enhanced-doc-table-columns.tsx index c8487d82..191ce3e2 100644 --- a/lib/vendor-document-list/table/enhanced-doc-table-columns.tsx +++ b/lib/vendor-document-list/table/enhanced-doc-table-columns.tsx @@ -1,4 +1,4 @@ -// updated-enhanced-doc-table-columns.tsx +// enhanced-doc-table-columns-with-b4.tsx "use client" import * as React from "react" @@ -32,13 +32,15 @@ import { Edit, Trash2, Building, - Code + Code, + Settings } from "lucide-react" import { cn } from "@/lib/utils" interface GetColumnsProps { setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<EnhancedDocumentsView> | null>> projectType: string | null + drawingKindFilter?: string // ✅ 추가 } // 유틸리티 함수들 @@ -140,11 +142,12 @@ const DueDateInfo = ({ export function getUpdatedEnhancedColumns({ setRowAction, - projectType + projectType, + drawingKindFilter = "all" // ✅ 추가 }: GetColumnsProps): ColumnDef<EnhancedDocumentsView>[] { const isPlantProject = projectType === "plant" + const showB4Columns = drawingKindFilter === "B4" // ✅ B4 컬럼 표시 여부 - // 기본 컬럼들 const baseColumns: ColumnDef<EnhancedDocumentsView>[] = [ // 체크박스 선택 @@ -174,7 +177,7 @@ export function getUpdatedEnhancedColumns({ enableHiding: false, }, - // 문서번호 + 우선순위 + // 문서번호 + Drawing Kind { accessorKey: "docNumber", header: ({ column }) => ( @@ -185,6 +188,11 @@ export function getUpdatedEnhancedColumns({ return ( <div className="flex flex-col gap-1 items-start"> <span className="font-mono text-sm font-medium">{doc.docNumber}</span> + {doc.drawingKind && ( + <Badge variant="outline" className="text-xs"> + {doc.drawingKind} + </Badge> + )} </div> ) }, @@ -196,7 +204,7 @@ export function getUpdatedEnhancedColumns({ }, ] - // ✅ Ship 프로젝트용 추가 컬럼들 + // ✅ Plant 프로젝트용 추가 컬럼들 const plantColumns: ColumnDef<EnhancedDocumentsView>[] = isPlantProject ? [ // 벤더 문서번호 { @@ -233,7 +241,6 @@ export function getUpdatedEnhancedColumns({ const doc = row.original return ( <div className="flex items-center gap-2"> - {/* <Code className="w-4 h-4 text-gray-500" /> */} <span className="font-mono text-sm font-medium text-gray-700"> {doc.projectCode || '-'} </span> @@ -258,7 +265,6 @@ export function getUpdatedEnhancedColumns({ return ( <div className="flex flex-col gap-1 items-start"> <div className="flex items-center gap-2"> - {/* <Building className="w-4 h-4 text-gray-500" /> */} <span className="text-sm font-medium text-gray-900"> {doc.vendorName || '-'} </span> @@ -279,6 +285,116 @@ export function getUpdatedEnhancedColumns({ }, ] : [] + // ✅ B4 전용 컬럼들 (B4 필터 선택 시에만 표시) + const b4Columns: ColumnDef<EnhancedDocumentsView>[] = showB4Columns ? [ + { + accessorKey: "cGbn", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="cGbn" /> + ), + cell: ({ row }) => { + const doc = row.original + return ( + <div className="flex items-center gap-2"> + <Badge variant="secondary" className="text-xs"> + {doc.cGbn || '-'} + </Badge> + </div> + ) + }, + size: 100, + enableResizing: true, + meta: { + excelHeader: "cGbn" + }, + }, + { + accessorKey: "dGbn", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="dGbn" /> + ), + cell: ({ row }) => { + const doc = row.original + return ( + <div className="flex items-center gap-2"> + <Badge variant="secondary" className="text-xs"> + {doc.dGbn || '-'} + </Badge> + </div> + ) + }, + size: 100, + enableResizing: true, + meta: { + excelHeader: "dGbn" + }, + }, + { + accessorKey: "degreeGbn", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="degreeGbn" /> + ), + cell: ({ row }) => { + const doc = row.original + return ( + <div className="flex items-center gap-2"> + <Badge variant="secondary" className="text-xs"> + {doc.degreeGbn || '-'} + </Badge> + </div> + ) + }, + size: 100, + enableResizing: true, + meta: { + excelHeader: "degreeGbn" + }, + }, + { + accessorKey: "deptGbn", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="deptGbn" /> + ), + cell: ({ row }) => { + const doc = row.original + return ( + <div className="flex items-center gap-2"> + <Badge variant="secondary" className="text-xs"> + {doc.deptGbn || '-'} + </Badge> + </div> + ) + }, + size: 100, + enableResizing: true, + meta: { + excelHeader: "deptGbn" + }, + }, + { + accessorKey: "sGbn", + header: ({ column }) => ( + <DataTableColumnHeaderSimple column={column} title="sGbn" /> + ), + cell: ({ row }) => { + const doc = row.original + return ( + <div className="flex items-center gap-2"> + <Badge variant="secondary" className="text-xs"> + {doc.sGbn || '-'} + </Badge> + </div> + ) + }, + size: 100, + enableResizing: true, + meta: { + excelHeader: "sGbn" + }, + }, + + ] : [] + // 나머지 공통 컬럼들 const commonColumns: ColumnDef<EnhancedDocumentsView>[] = [ // 문서명 + 담당자 @@ -310,7 +426,7 @@ export function getUpdatedEnhancedColumns({ </div> ) }, - size: isPlantProject ? 200 : 250, // Ship 프로젝트일 때는 너비 조정 + size: showB4Columns ? 180 : (isPlantProject ? 200 : 250), // ✅ B4 컬럼이 있을 때 너비 조정 enableResizing: true, meta: { excelHeader: "문서명" @@ -378,7 +494,7 @@ export function getUpdatedEnhancedColumns({ size: 140, enableResizing: true, meta: { - excelHeader: "계획일" + excelHeader: "일정" }, }, @@ -476,7 +592,6 @@ export function getUpdatedEnhancedColumns({ const canApprove = doc.currentStageStatus === 'SUBMITTED' const isPlantProject = projectType === "plant" - // 메뉴 아이템들을 그룹별로 정의 const viewActions = [ { key: "view", @@ -519,7 +634,6 @@ export function getUpdatedEnhancedColumns({ } ] - // 각 그룹에서 표시될 아이템이 있는지 확인 const hasEditActions = editActions.some(action => action.show) const hasFileActions = fileActions.some(action => action.show) const hasDangerActions = dangerActions.some(action => action.show) @@ -536,7 +650,6 @@ export function getUpdatedEnhancedColumns({ </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-48"> - {/* 기본 액션 그룹 */} {viewActions.map(action => action.show && ( <DropdownMenuItem key={action.key} @@ -551,7 +664,6 @@ export function getUpdatedEnhancedColumns({ </DropdownMenuItem> ))} - {/* 편집 액션 그룹 */} {hasEditActions && ( <> <DropdownMenuSeparator /> @@ -571,7 +683,6 @@ export function getUpdatedEnhancedColumns({ </> )} - {/* 파일 액션 그룹 */} {hasFileActions && ( <> <DropdownMenuSeparator /> @@ -591,7 +702,6 @@ export function getUpdatedEnhancedColumns({ </> )} - {/* 위험한 액션 그룹 */} {hasDangerActions && ( <> <DropdownMenuSeparator /> @@ -621,84 +731,8 @@ export function getUpdatedEnhancedColumns({ // ✅ 모든 컬럼을 순서대로 결합 return [ ...baseColumns, // 체크박스, 문서번호 - ...plantColumns, // Ship 전용 컬럼들 (조건부) + ...plantColumns, // Plant 전용 컬럼들 (조건부) + ...b4Columns, // B4 전용 컬럼들 (조건부) ...commonColumns // 나머지 공통 컬럼들 ] -} - -// 확장된 행 컨텐츠 컴포넌트 (업데이트된 버전) -export const UpdatedExpandedRowContent = ({ - document -}: { - document: EnhancedDocumentsView -}) => { - if (!document.allStages || document.allStages.length === 0) { - return ( - <div className="p-4 text-sm text-gray-500 italic"> - 스테이지 정보가 없습니다. - </div> - ) - } - - return ( - <div className="p-4 w-1/2"> - <h4 className="font-medium mb-3 flex items-center gap-2"> - <FileText className="w-4 h-4" /> - 전체 스테이지 현황 - </h4> - - <div className="grid gap-3"> - {document.allStages.map((stage, index) => ( - <div key={stage.id} className="flex items-center justify-between p-3 bg-white rounded-lg border"> - <div className="flex items-center gap-3"> - <div className="flex items-center gap-2"> - <div className="w-6 h-6 rounded-full bg-gray-100 flex items-center justify-center text-xs font-medium"> - {stage.stageOrder || index + 1} - </div> - <div className={cn( - "w-3 h-3 rounded-full", - stage.stageStatus === 'COMPLETED' ? 'bg-green-500' : - stage.stageStatus === 'IN_PROGRESS' ? 'bg-blue-500' : - stage.stageStatus === 'SUBMITTED' ? 'bg-purple-500' : - 'bg-gray-300' - )} /> - </div> - - <div> - <div className="font-medium text-sm">{stage.stageName}</div> - {stage.assigneeName && ( - <div className="text-xs text-gray-500 flex items-center gap-1 mt-1"> - <User className="w-3 h-3" /> - {stage.assigneeName} - </div> - )} - </div> - </div> - - <div className="flex items-center gap-4 text-sm"> - <div> - <span className="text-gray-500">계획: </span> - <span>{formatDate(stage.planDate)}</span> - </div> - {stage.actualDate && ( - <div> - <span className="text-gray-500">완료: </span> - <span>{formatDate(stage.actualDate)}</span> - </div> - )} - - <div className="flex items-center gap-2"> - <Badge variant={getPriorityColor(stage.priority)} className="text-xs"> - {getPriorityText(stage.priority)} - </Badge> - <Badge variant={getStatusColor(stage.stageStatus)} className="text-xs"> - {getStatusText(stage.stageStatus)} - </Badge> - </div> - </div> - </div> - ))} - </div> - </div> - ) }
\ No newline at end of file diff --git a/lib/vendor-document-list/table/enhanced-documents-table.tsx b/lib/vendor-document-list/table/enhanced-documents-table.tsx index 3bd6668d..cb49f796 100644 --- a/lib/vendor-document-list/table/enhanced-documents-table.tsx +++ b/lib/vendor-document-list/table/enhanced-documents-table.tsx @@ -1,6 +1,7 @@ +// enhanced-documents-table-with-drawing-filter.tsx "use client" -import * as React from "react" +import React from "react" import type { DataTableAdvancedFilterField, DataTableFilterField, @@ -10,7 +11,6 @@ import type { import { useDataTable } from "@/hooks/use-data-table" import { StageRevisionExpandedContent } from "./stage-revision-expanded-content" import { RevisionUploadDialog } from "./revision-upload-dialog" -// ✅ UpdateDocumentSheet import 추가 import { EnhancedDocTableToolbarActions } from "./enhanced-doc-table-toolbar-actions" import { getEnhancedDocuments } from "../enhanced-document-service" import type { EnhancedDocument } from "@/types/enhanced-documents" @@ -23,69 +23,102 @@ import { TrendingUp, Target, Users, + Settings, + Filter } from "lucide-react" import { getUpdatedEnhancedColumns } from "./enhanced-doc-table-columns" import { ExpandableDataTable } from "@/components/data-table/expandable-data-table" import { toast } from "sonner" import { UpdateDocumentSheet } from "./update-doc-sheet" +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select" +import { Label } from "@/components/ui/label" interface FinalIntegratedDocumentsTableProps { promises: Promise<[Awaited<ReturnType<typeof getEnhancedDocuments>>]> selectedPackageId: number projectType: "ship" | "plant" - // ✅ contractId 추가 (AddDocumentListDialog에서 필요) contractId: number + initialDrawingKind?: string } +// ✅ Drawing Kind 옵션 정의 +const DRAWING_KIND_OPTIONS = [ + { value: "all", label: "전체 문서" }, + { value: "B3", label: "B3: Vendor" }, + { value: "B4", label: "B4: GTT" }, + { value: "B5", label: "B5: FMEA" }, +] as const + export function EnhancedDocumentsTable({ promises, selectedPackageId, projectType, - contractId, // ✅ contractId 추가 + contractId, + initialDrawingKind = "all" }: FinalIntegratedDocumentsTableProps) { - // 데이터 로딩 const [{ data, pageCount, total }] = React.use(promises) - - // 상태 관리 + // ✅ Drawing Kind 필터 상태 추가 + const [drawingKindFilter, setDrawingKindFilter] = React.useState<string>(initialDrawingKind) + + // 기존 상태들 const [rowAction, setRowAction] = React.useState<DataTableRowAction<EnhancedDocument> | null>(null) const [expandedRows, setExpandedRows] = React.useState<Set<string>>(new Set()) const [quickFilter, setQuickFilter] = React.useState<'all' | 'overdue' | 'due_soon' | 'in_progress' | 'high_priority'>('all') - - // ✅ 스테이지 확장 상태 관리 (문서별로 관리) const [expandedStages, setExpandedStages] = React.useState<Record<string, Record<number, boolean>>>({}) - - // ✅ 다이얼로그 상태들 - editDialogOpen -> editSheetOpen으로 변경 const [uploadDialogOpen, setUploadDialogOpen] = React.useState(false) - const [editSheetOpen, setEditSheetOpen] = React.useState(false) // Sheet로 변경 + const [editSheetOpen, setEditSheetOpen] = React.useState(false) const [selectedDocument, setSelectedDocument] = React.useState<EnhancedDocument | null>(null) const [selectedStage, setSelectedStage] = React.useState<string>("") const [selectedRevision, setSelectedRevision] = React.useState<string>("") const [uploadMode, setUploadMode] = React.useState<'new' | 'append'>('new') + // ✅ Drawing Kind별 데이터 필터링 + const filteredByDrawingKind = React.useMemo(() => { + if (drawingKindFilter === "all") return data + return data.filter(doc => doc.drawingKind === drawingKindFilter) + }, [data, drawingKindFilter]) + + // ✅ Drawing Kind별 통계 계산 + const drawingKindStats = React.useMemo(() => { + const stats = DRAWING_KIND_OPTIONS.reduce((acc, option) => { + if (option.value === "all") { + acc[option.value] = data.length + } else { + acc[option.value] = data.filter(doc => doc.drawingKind === option.value).length + } + return acc + }, {} as Record<string, number>) + + return stats + }, [data]) + // 다음 리비전 계산 함수 const getNextRevision = React.useCallback((currentRevision: string): string => { if (!currentRevision) return "A" - // 알파벳 리비전 (A, B, C...) if (/^[A-Z]$/.test(currentRevision)) { const charCode = currentRevision.charCodeAt(0) - if (charCode < 90) { // Z가 아닌 경우 + if (charCode < 90) { return String.fromCharCode(charCode + 1) } - return "AA" // Z 다음은 AA + return "AA" } - // 숫자 리비전 (1, 2, 3...) if (/^\d+$/.test(currentRevision)) { return String(parseInt(currentRevision) + 1) } - // 기타 복잡한 리비전 형태는 그대로 반환 return currentRevision }, []) - // 컬럼 정의 + // ✅ 컬럼 정의 - drawingKindFilter 추가 const columns = React.useMemo( () => getUpdatedEnhancedColumns({ setRowAction: (action) => { @@ -93,17 +126,15 @@ export function EnhancedDocumentsTable({ if (action) { setSelectedDocument(action.row.original) - // 액션 타입에 따른 다이얼로그 열기 switch (action.type) { case "update": - setEditSheetOpen(true) // ✅ Sheet 열기로 변경 + setEditSheetOpen(true) break case "upload": setSelectedStage(action.row.original.currentStageName || "") setUploadDialogOpen(true) break case "view": - // 상세보기는 확장된 행으로 대체 const rowId = action.row.id const newExpanded = new Set(expandedRows) if (newExpanded.has(rowId)) { @@ -116,24 +147,25 @@ export function EnhancedDocumentsTable({ } } }, - projectType + projectType, + drawingKindFilter // ✅ 추가 }), - [expandedRows, projectType] + [expandedRows, projectType, drawingKindFilter] ) - // 통계 계산 + // 기존 통계 계산 const stats = React.useMemo(() => { - const totalDocs = data.length - const overdue = data.filter(doc => doc.isOverdue).length - const dueSoon = data.filter(doc => + const totalDocs = filteredByDrawingKind.length + const overdue = filteredByDrawingKind.filter(doc => doc.isOverdue).length + const dueSoon = filteredByDrawingKind.filter(doc => doc.daysUntilDue !== null && doc.daysUntilDue >= 0 && doc.daysUntilDue <= 3 ).length - const inProgress = data.filter(doc => doc.currentStageStatus === 'IN_PROGRESS').length - const highPriority = data.filter(doc => doc.currentStagePriority === 'HIGH').length + const inProgress = filteredByDrawingKind.filter(doc => doc.currentStageStatus === 'IN_PROGRESS').length + const highPriority = filteredByDrawingKind.filter(doc => doc.currentStagePriority === 'HIGH').length const avgProgress = totalDocs > 0 - ? Math.round(data.reduce((sum, doc) => sum + (doc.progressPercentage || 0), 0) / totalDocs) + ? Math.round(filteredByDrawingKind.reduce((sum, doc) => sum + (doc.progressPercentage || 0), 0) / totalDocs) : 0 return { @@ -144,66 +176,60 @@ export function EnhancedDocumentsTable({ highPriority, avgProgress } - }, [data]) + }, [filteredByDrawingKind]) // 빠른 필터링 const filteredData = React.useMemo(() => { switch (quickFilter) { case 'overdue': - return data.filter(doc => doc.isOverdue) + return filteredByDrawingKind.filter(doc => doc.isOverdue) case 'due_soon': - return data.filter(doc => + return filteredByDrawingKind.filter(doc => doc.daysUntilDue !== null && doc.daysUntilDue >= 0 && doc.daysUntilDue <= 3 ) case 'in_progress': - return data.filter(doc => doc.currentStageStatus === 'IN_PROGRESS') + return filteredByDrawingKind.filter(doc => doc.currentStageStatus === 'IN_PROGRESS') case 'high_priority': - return data.filter(doc => doc.currentStagePriority === 'HIGH') + return filteredByDrawingKind.filter(doc => doc.currentStagePriority === 'HIGH') default: - return data + return filteredByDrawingKind } - }, [data, quickFilter]) + }, [filteredByDrawingKind, quickFilter]) - // ✅ 핸들러 함수 수정: 모드 매개변수 추가 + // 나머지 핸들러 함수들 const handleUploadRevision = React.useCallback((document: EnhancedDocument, stageName?: string, currentRevision?: string, mode: 'new' | 'append' = 'new') => { setSelectedDocument(document) setSelectedStage(stageName || document.currentStageName || "") - setUploadMode(mode) // ✅ 모드 설정 + setUploadMode(mode) if (mode === 'new') { - // 새 리비전 생성: currentRevision이 있으면 다음 리비전을 자동 계산 if (currentRevision) { const nextRevision = getNextRevision(currentRevision) setSelectedRevision(nextRevision) } else { - // 스테이지의 최신 리비전을 찾아서 다음 리비전 계산 const latestRevision = findLatestRevisionInStage(document, stageName || document.currentStageName || "") if (latestRevision) { setSelectedRevision(getNextRevision(latestRevision)) } else { - setSelectedRevision("A") // 첫 번째 리비전 + setSelectedRevision("A") } } } else { - // 기존 리비전에 파일 추가: 같은 리비전 번호 사용 setSelectedRevision(currentRevision || "") } setUploadDialogOpen(true) }, [getNextRevision]) - // ✅ 스테이지에서 최신 리비전을 찾는 헬퍼 함수 const findLatestRevisionInStage = React.useCallback((document: EnhancedDocument, stageName: string) => { const stage = document.allStages?.find(s => s.stageName === stageName) if (!stage || !stage.revisions || stage.revisions.length === 0) { return null } - // 리비전들을 정렬해서 최신 것 찾기 (간단한 알파벳/숫자 정렬) const sortedRevisions = [...stage.revisions].sort((a, b) => { - // 알파벳과 숫자를 구분해서 정렬 const aIsAlpha = /^[A-Z]+$/.test(a.revision) const bIsAlpha = /^[A-Z]+$/.test(b.revision) @@ -212,20 +238,17 @@ export function EnhancedDocumentsTable({ } else if (!aIsAlpha && !bIsAlpha) { return parseInt(a.revision) - parseInt(b.revision) } else { - return aIsAlpha ? -1 : 1 // 알파벳이 숫자보다 먼저 + return aIsAlpha ? -1 : 1 } }) return sortedRevisions[sortedRevisions.length - 1]?.revision || null }, []) - // ✅ 새 문서 추가 핸들러 - EnhancedDocTableToolbarActions에서 AddDocumentListDialog를 직접 렌더링하므로 별도 상태 관리 불필요 const handleNewDocument = () => { // AddDocumentListDialog는 자체적으로 Dialog trigger를 가지므로 별도 처리 불필요 - // EnhancedDocTableToolbarActions에서 처리됨 } - // ✅ 스테이지 토글 핸들러 추가 const handleStageToggle = React.useCallback((documentId: string, stageId: number) => { setExpandedStages(prev => ({ ...prev, @@ -239,17 +262,14 @@ export function EnhancedDocumentsTable({ const handleBulkAction = async (action: string, selectedRows: any[]) => { try { if (action === 'bulk_approve') { - // 일괄 승인 로직 const stageIds = selectedRows .map(row => row.original.currentStageId) .filter(Boolean) if (stageIds.length > 0) { - // await bulkUpdateStageStatus(stageIds, 'APPROVED') toast.success(`${stageIds.length}개 항목이 승인되었습니다.`) } } else if (action === 'bulk_upload') { - // 일괄 업로드 로직 toast.info("일괄 업로드 기능은 준비 중입니다.") } } catch (error) { @@ -257,27 +277,25 @@ export function EnhancedDocumentsTable({ } } - // ✅ 다이얼로그 닫기 함수 수정 const closeAllDialogs = () => { setUploadDialogOpen(false) - setEditSheetOpen(false) // editDialogOpen -> editSheetOpen + setEditSheetOpen(false) setSelectedDocument(null) setSelectedStage("") setSelectedRevision("") - setUploadMode('new') // ✅ 모드 초기화 + setUploadMode('new') setRowAction(null) } - // ✅ EnhancedDocument를 UpdateDocumentSheet의 document 형식으로 변환하는 함수 const convertToUpdateFormat = React.useCallback((doc: EnhancedDocument | null) => { if (!doc) return null return { id: doc.documentId, - contractId: contractId, // contractId 사용 + contractId: contractId, docNumber: doc.docNumber, title: doc.title, - status: doc.status || "pending", // 기본값 설정 + status: doc.status || "pending", description: doc.description || null, remarks: doc.remarks || null, } @@ -309,6 +327,16 @@ export function EnhancedDocumentsTable({ type: "text", }, { + id: "drawingKind", + label: "문서종류", + type: "select", + options: [ + { label: "B3", value: "B3" }, + { label: "B4", value: "B4" }, + { label: "B5", value: "B5" }, + ], + }, + { id: "currentStageStatus", label: "스테이지 상태", type: "select", @@ -351,7 +379,6 @@ export function EnhancedDocumentsTable({ }, ] - // 데이터 테이블 훅 const { table } = useDataTable({ data: filteredData, columns, @@ -371,11 +398,15 @@ export function EnhancedDocumentsTable({ return ( <div className="space-y-6"> + + {/* 통계 대시보드 */} <div className="grid grid-cols-2 md:grid-cols-4 gap-4"> <Card className="cursor-pointer hover:shadow-md transition-shadow" onClick={() => setQuickFilter('all')}> <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2"> - <CardTitle className="text-sm font-medium">전체 문서</CardTitle> + <CardTitle className="text-sm font-medium"> + {drawingKindFilter === "all" ? "전체 문서" : `${DRAWING_KIND_OPTIONS.find(o => o.value === drawingKindFilter)?.label} 문서`} + </CardTitle> <TrendingUp className="h-4 w-4 text-muted-foreground" /> </CardHeader> <CardContent> @@ -420,80 +451,107 @@ export function EnhancedDocumentsTable({ </Card> </div> - {/* 빠른 필터 */} - <div className="flex gap-2 overflow-x-auto pb-2"> - <Badge - variant={quickFilter === 'all' ? 'default' : 'outline'} - className="cursor-pointer hover:bg-primary hover:text-primary-foreground whitespace-nowrap" - onClick={() => setQuickFilter('all')} - > - 전체 ({stats.total}) - </Badge> - <Badge - variant={quickFilter === 'overdue' ? 'destructive' : 'outline'} - className="cursor-pointer hover:bg-destructive hover:text-destructive-foreground whitespace-nowrap" - onClick={() => setQuickFilter('overdue')} - > - <AlertTriangle className="w-3 h-3 mr-1" /> - 지연 ({stats.overdue}) - </Badge> - <Badge - variant={quickFilter === 'due_soon' ? 'default' : 'outline'} - className="cursor-pointer hover:bg-orange-500 hover:text-white whitespace-nowrap" - onClick={() => setQuickFilter('due_soon')} - > - <Clock className="w-3 h-3 mr-1" /> - 마감임박 ({stats.dueSoon}) - </Badge> - <Badge - variant={quickFilter === 'in_progress' ? 'default' : 'outline'} - className="cursor-pointer hover:bg-blue-500 hover:text-white whitespace-nowrap" - onClick={() => setQuickFilter('in_progress')} - > - <Users className="w-3 h-3 mr-1" /> - 진행중 ({stats.inProgress}) - </Badge> - <Badge - variant={quickFilter === 'high_priority' ? 'destructive' : 'outline'} - className="cursor-pointer hover:bg-destructive hover:text-destructive-foreground whitespace-nowrap" - onClick={() => setQuickFilter('high_priority')} - > - <Target className="w-3 h-3 mr-1" /> - 높은우선순위 ({stats.highPriority}) - </Badge> + {/* 빠른 필터 + 문서 종류 필터 */} + <div className="flex flex-col sm:flex-row gap-4 justify-between items-start sm:items-center"> + {/* 왼쪽: 빠른 필터 */} + <div className="flex gap-2 overflow-x-auto pb-2"> + <Badge + variant={quickFilter === 'all' ? 'default' : 'outline'} + className="cursor-pointer hover:bg-primary hover:text-primary-foreground whitespace-nowrap" + onClick={() => setQuickFilter('all')} + > + 전체 ({stats.total}) + </Badge> + <Badge + variant={quickFilter === 'overdue' ? 'destructive' : 'outline'} + className="cursor-pointer hover:bg-destructive hover:text-destructive-foreground whitespace-nowrap" + onClick={() => setQuickFilter('overdue')} + > + <AlertTriangle className="w-3 h-3 mr-1" /> + 지연 ({stats.overdue}) + </Badge> + <Badge + variant={quickFilter === 'due_soon' ? 'default' : 'outline'} + className="cursor-pointer hover:bg-orange-500 hover:text-white whitespace-nowrap" + onClick={() => setQuickFilter('due_soon')} + > + <Clock className="w-3 h-3 mr-1" /> + 마감임박 ({stats.dueSoon}) + </Badge> + <Badge + variant={quickFilter === 'in_progress' ? 'default' : 'outline'} + className="cursor-pointer hover:bg-blue-500 hover:text-white whitespace-nowrap" + onClick={() => setQuickFilter('in_progress')} + > + <Users className="w-3 h-3 mr-1" /> + 진행중 ({stats.inProgress}) + </Badge> + <Badge + variant={quickFilter === 'high_priority' ? 'destructive' : 'outline'} + className="cursor-pointer hover:bg-destructive hover:text-destructive-foreground whitespace-nowrap" + onClick={() => setQuickFilter('high_priority')} + > + <Target className="w-3 h-3 mr-1" /> + 높은우선순위 ({stats.highPriority}) + </Badge> + </div> + + {/* 오른쪽: 문서 종류 필터 */} + <div className="flex items-center gap-2 flex-shrink-0"> + <Select value={drawingKindFilter} onValueChange={setDrawingKindFilter}> + <SelectTrigger className="w-[140px]"> + <SelectValue placeholder="문서 종류" /> + </SelectTrigger> + <SelectContent> + {DRAWING_KIND_OPTIONS.map(option => ( + <SelectItem key={option.value} value={option.value}> + <div className="flex items-center justify-between w-full"> + <span>{option.label}</span> + <Badge variant="outline" className="ml-2 text-xs"> + {drawingKindStats[option.value] || 0} + </Badge> + </div> + </SelectItem> + ))} + </SelectContent> + </Select> + + {/* B4 필드 표시 안내 (아이콘만) */} + {drawingKindFilter === "B4" && ( + <div className="flex items-center gap-1 text-blue-600 bg-blue-50 px-2 py-1 rounded text-xs"> + <Settings className="h-3 w-3" /> + <span className="hidden sm:inline">상세정보 확장가능</span> + </div> + )} + </div> </div> - {/* 메인 테이블 - 가로스크롤 문제 해결을 위한 구조 개선 */} + {/* 메인 테이블 */} <div className="space-y-4"> <div className="rounded-md border bg-white overflow-hidden"> - <ExpandableDataTable - table={table} - expandable={true} - expandedRows={expandedRows} - setExpandedRows={setExpandedRows} - renderExpandedContent={(document) => ( - <div className=""> - <StageRevisionExpandedContent - document={document} - onUploadRevision={handleUploadRevision} - projectType={projectType} - expandedStages={expandedStages[String(document.documentId)] || {}} - onStageToggle={(stageId) => handleStageToggle(String(document.documentId), stageId)} - /> - </div> - )} - expandedRowClassName="!p-0" - // clickableColumns={[ - // 'docNumber', - // 'title', - // 'currentStageStatus', - // 'progressPercentage', - // ]} - excludeFromClick={[ - 'actions', - 'select' - ]} - > + <ExpandableDataTable + table={table} + expandable={true} + expandedRows={expandedRows} + setExpandedRows={setExpandedRows} + renderExpandedContent={(document) => ( + <div className=""> + <StageRevisionExpandedContent + document={document} + onUploadRevision={handleUploadRevision} + projectType={projectType} + expandedStages={expandedStages[String(document.documentId)] || {}} + onStageToggle={(stageId) => handleStageToggle(String(document.documentId), stageId)} + // showB4Fields={document.drawingKind === "B4"} + /> + </div> + )} + expandedRowClassName="!p-0" + excludeFromClick={[ + 'actions', + 'select' + ]} + > <DataTableAdvancedToolbar table={table} filterFields={advancedFilterFields} @@ -503,7 +561,7 @@ export function EnhancedDocumentsTable({ table={table} projectType={projectType} selectedPackageId={selectedPackageId} - contractId={contractId} // ✅ contractId 추가 + contractId={contractId} onNewDocument={handleNewDocument} onBulkAction={handleBulkAction} /> @@ -512,9 +570,7 @@ export function EnhancedDocumentsTable({ </div> </div> - {/* ✅ 분리된 다이얼로그들 - UpdateDocumentSheet와 AddDocumentListDialog로 교체 */} - - {/* 리비전 업로드 다이얼로그 - mode props 추가 */} + {/* 다이얼로그들 */} <RevisionUploadDialog open={uploadDialogOpen} onOpenChange={(open) => { @@ -528,7 +584,6 @@ export function EnhancedDocumentsTable({ mode={uploadMode} /> - {/* ✅ 문서 편집 Sheet로 교체 */} <UpdateDocumentSheet open={editSheetOpen} onOpenChange={(open) => { diff --git a/lib/vendor-document-list/table/revision-upload-dialog.tsx b/lib/vendor-document-list/table/revision-upload-dialog.tsx index 546fa7a3..16fc9fbb 100644 --- a/lib/vendor-document-list/table/revision-upload-dialog.tsx +++ b/lib/vendor-document-list/table/revision-upload-dialog.tsx @@ -66,7 +66,29 @@ const revisionUploadSchema = z.object({ uploaderName: z.string().optional(), comment: z.string().optional(), attachments: z.array(z.instanceof(File)).min(1, "최소 1개 파일이 필요합니다"), -}) + // ✅ B3 문서용 usage 필드 추가 + usage: z.string().optional(), +}).refine((data) => { + // B3 문서이고 특정 stage인 경우 usage 필수 + // 이 검증은 컴포넌트 내에서 조건부로 처리 + return true; +}, { + message: "Usage는 필수입니다", + path: ["usage"], +}); + +const getUsageOptions = (stageName: string): string[] => { + const stageNameLower = stageName.toLowerCase(); + + if (stageNameLower.includes('approval')) { + return ['Approval (Partial)', 'Approval (Full)']; + } else if (stageNameLower.includes('working')) { + return ['Working (Partial)', 'Working (Full)']; + } + + return []; +}; + type RevisionUploadSchema = z.infer<typeof revisionUploadSchema> @@ -93,7 +115,7 @@ export function RevisionUploadDialog({ presetStage, presetRevision, mode = 'new', - onUploadComplete, // ✅ 추가된 prop + onUploadComplete, }: RevisionUploadDialogProps) { const targetSystem = React.useMemo( @@ -106,7 +128,6 @@ export function RevisionUploadDialog({ const [uploadProgress, setUploadProgress] = React.useState(0) const router = useRouter() - // ✅ next-auth session 가져오기 const { data: session } = useSession() // 사용 가능한 스테이지 옵션 @@ -125,17 +146,33 @@ export function RevisionUploadDialog({ uploaderName: session?.user?.name || "", comment: "", attachments: [], + usage: "", // ✅ usage 기본값 추가 }, }) - // ✅ session이 로드되면 uploaderName 업데이트 + // ✅ 현재 선택된 stage 값을 watch + const currentStage = form.watch('stage') + + // ✅ B3 문서 여부 확인 + const isB3Document = document?.drawingKind === 'B3' + + // ✅ 현재 stage에 따른 usage 옵션 + const usageOptions = React.useMemo(() => { + if (!isB3Document || !currentStage) return [] + return getUsageOptions(currentStage) + }, [isB3Document, currentStage]) + + // ✅ usage 필드가 필요한지 확인 + const isUsageRequired = isB3Document && usageOptions.length > 0 + + // session이 로드되면 uploaderName 업데이트 React.useEffect(() => { if (session?.user?.name) { form.setValue('uploaderName', session.user.name) } }, [session?.user?.name, form]) - // ✅ presetStage와 presetRevision이 변경될 때 폼 값 업데이트 + // presetStage와 presetRevision이 변경될 때 폼 값 업데이트 React.useEffect(() => { if (presetStage) { form.setValue('stage', presetStage) @@ -145,6 +182,22 @@ export function RevisionUploadDialog({ } }, [presetStage, presetRevision, form]) + // ✅ stage가 변경될 때 usage 값 리셋 + React.useEffect(() => { + if (isB3Document) { + const newUsageOptions = getUsageOptions(currentStage) + if (newUsageOptions.length === 0) { + form.setValue('usage', '') + } else { + // 기존 값이 새로운 옵션에 없으면 리셋 + const currentUsage = form.getValues('usage') + if (currentUsage && !newUsageOptions.includes(currentUsage)) { + form.setValue('usage', '') + } + } + } + }, [currentStage, isB3Document, form]) + // 파일 드롭 처리 const handleDropAccepted = (acceptedFiles: File[]) => { const newFiles = [...selectedFiles, ...acceptedFiles] @@ -159,26 +212,22 @@ export function RevisionUploadDialog({ form.setValue('attachments', updatedFiles, { shouldValidate: true }) } - // ✅ 캐시 갱신 함수 + // 캐시 갱신 함수 const refreshCaches = async () => { try { - // 1. 서버 컴포넌트 캐시 갱신 (Enhanced Documents 등) router.refresh() - // 2. SWR 캐시 갱신 (Sync Status) if (document?.contractId) { await mutate(`/api/sync/status/${document.contractId}/${targetSystem}`) console.log('✅ Sync status cache refreshed') } - // 3. 다른 관련 SWR 캐시들도 갱신 (필요시) await mutate(key => typeof key === 'string' && key.includes('sync') && key.includes(String(document?.contractId)) ) - // 4. 상위 컴포넌트 콜백 호출 onUploadComplete?.() console.log('✅ All caches refreshed after upload') @@ -187,10 +236,19 @@ export function RevisionUploadDialog({ } } - // 업로드 처리 + // ✅ 업로드 처리 - usage 필드 검증 및 전송 async function onSubmit(data: RevisionUploadSchema) { if (!document) return + // ✅ B3 문서에서 usage가 필요한 경우 검증 + if (isUsageRequired && !data.usage) { + form.setError('usage', { + type: 'required', + message: 'Usage 선택은 필수입니다' + }) + return + } + setIsUploading(true) setUploadProgress(0) @@ -210,6 +268,11 @@ export function RevisionUploadDialog({ formData.append("comment", data.comment) } + // ✅ B3 문서인 경우 usage 추가 + if (isB3Document && data.usage) { + formData.append("usage", data.usage) + } + // 파일들 추가 data.attachments.forEach((file) => { formData.append("attachments", file) @@ -220,7 +283,6 @@ export function RevisionUploadDialog({ setUploadProgress(Math.min(progress, 95)) } - // 파일 크기에 따른 진행률 시뮬레이션 const totalSize = data.attachments.reduce((sum, file) => sum + file.size, 0) let uploadedSize = 0 @@ -230,7 +292,6 @@ export function RevisionUploadDialog({ updateProgress(progress) }, 300) - // ✅ 실제 API 호출 const response = await fetch('/api/revision-upload', { method: 'POST', body: formData, @@ -253,7 +314,6 @@ export function RevisionUploadDialog({ console.log('✅ 업로드 성공:', result) - // ✅ 캐시 갱신 및 다이얼로그 닫기 setTimeout(async () => { await refreshCaches() handleDialogClose() @@ -275,6 +335,7 @@ export function RevisionUploadDialog({ uploaderName: session?.user?.name || "", comment: "", attachments: [], + usage: "", // ✅ usage 리셋 추가 }) setSelectedFiles([]) setIsUploading(false) @@ -295,14 +356,19 @@ export function RevisionUploadDialog({ mode === 'new' ? "문서에 새 리비전을 업로드합니다." : "기존 리비전에 파일을 추가합니다."} </DialogDescription> - <div className="flex items-center gap-2 pt-2"> + <div className="flex items-center gap-2 pt-2 flex-wrap"> <Badge variant={projectType === "ship" ? "default" : "secondary"}> {projectType === "ship" ? "조선 프로젝트" : "플랜트 프로젝트"} </Badge> - {/* ✅ 타겟 시스템 표시 추가 */} <Badge variant="outline" className="text-xs"> → {targetSystem} </Badge> + {/* ✅ B3 문서 표시 */} + {isB3Document && ( + <Badge variant="outline" className="text-xs bg-orange-50 text-orange-700 border-orange-200"> + B3 문서 + </Badge> + )} {session?.user?.name && ( <Badge variant="outline" className="text-xs"> 업로더: {session.user.name} @@ -379,6 +445,40 @@ export function RevisionUploadDialog({ /> </div> + {/* ✅ B3 문서용 Usage 필드 - 조건부 표시 */} + {isB3Document && usageOptions.length > 0 && ( + <FormField + control={form.control} + name="usage" + render={({ field }) => ( + <FormItem> + <FormLabel className="flex items-center gap-2"> + 용도 + {isUsageRequired && <span className="text-red-500">*</span>} + </FormLabel> + <Select onValueChange={field.onChange} value={field.value}> + <FormControl> + <SelectTrigger> + <SelectValue placeholder="용도를 선택하세요" /> + </SelectTrigger> + </FormControl> + <SelectContent> + {usageOptions.map((usage) => ( + <SelectItem key={usage} value={usage}> + {usage} + </SelectItem> + ))} + </SelectContent> + </Select> + <FormMessage /> + <p className="text-xs text-gray-500"> + {currentStage} 스테이지에 필요한 용도를 선택하세요. + </p> + </FormItem> + )} + /> + )} + <FormField control={form.control} name="uploaderName" diff --git a/lib/vendor-document-list/table/stage-revision-expanded-content.tsx b/lib/vendor-document-list/table/stage-revision-expanded-content.tsx index a4de03b7..6b9cffb9 100644 --- a/lib/vendor-document-list/table/stage-revision-expanded-content.tsx +++ b/lib/vendor-document-list/table/stage-revision-expanded-content.tsx @@ -494,6 +494,9 @@ export const StageRevisionExpandedContent = ({ <TableHead className="w-16 py-1 px-2 text-xs"></TableHead> <TableHead className="w-16 py-1 px-2 text-xs">리비전</TableHead> <TableHead className="w-20 py-1 px-2 text-xs">상태</TableHead> + {documentData.drawingKind === 'B3' && ( + <TableHead className="w-24 py-1 px-2 text-xs">용도</TableHead> + )} <TableHead className="w-24 py-1 px-2 text-xs">업로더</TableHead> <TableHead className="w-32 py-1 px-2 text-xs">등록일</TableHead> <TableHead className="w-32 py-1 px-2 text-xs">제출일</TableHead> @@ -529,6 +532,19 @@ export const StageRevisionExpandedContent = ({ </Badge> </TableCell> + {/* ✅ B3 문서일 때만 Usage 셀 표시 */} + {documentData.drawingKind === 'B3' && ( + <TableCell className="py-1 px-2"> + {revision.usage ? ( + <span className="text-xs bg-blue-50 text-blue-700 px-1.5 py-0.5 rounded border border-blue-200"> + {revision.usage} + </span> + ) : ( + <span className="text-gray-400 text-xs">-</span> + )} + </TableCell> + )} + {/* 업로더 */} <TableCell className="py-1 px-2"> <div className="flex items-center gap-1"> diff --git a/lib/welding/repository.ts b/lib/welding/repository.ts index 10e64f58..1e96867b 100644 --- a/lib/welding/repository.ts +++ b/lib/welding/repository.ts @@ -1,6 +1,6 @@ // src/lib/tasks/repository.ts import db from "@/db/db"; -import { ocrRows } from "@/db/schema"; +import { ocrRows, users } from "@/db/schema"; import { eq, inArray, @@ -21,24 +21,47 @@ import { PgTransaction } from "drizzle-orm/pg-core"; * - 트랜잭션(tx)을 받아서 사용하도록 구현 */ export async function selectOcrRows( - tx: PgTransaction<any, any, any>, - params: { - where?: any; // drizzle-orm의 조건식 (and, eq...) 등 - orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[]; - offset?: number; - limit?: number; - } - ) { - const { where, orderBy, offset = 0, limit = 10 } = params; - - return tx - .select() - .from(ocrRows) - .where(where) - .orderBy(...(orderBy ?? [])) - .offset(offset) - .limit(limit); + tx: PgTransaction<any, any, any>, + params: { + where?: any; // drizzle-orm의 조건식 (and, eq...) 등 + orderBy?: (ReturnType<typeof asc> | ReturnType<typeof desc>)[]; + offset?: number; + limit?: number; } +) { + const { where, orderBy, offset = 0, limit = 10 } = params; + + return tx + .select({ + // ocrRows의 모든 필드 + id: ocrRows.id, + tableId: ocrRows.tableId, + sessionId: ocrRows.sessionId, + rowIndex: ocrRows.rowIndex, + reportNo: ocrRows.reportNo, + no: ocrRows.no, + identificationNo: ocrRows.identificationNo, + tagNo: ocrRows.tagNo, + jointNo: ocrRows.jointNo, + jointType: ocrRows.jointType, + weldingDate: ocrRows.weldingDate, + confidence: ocrRows.confidence, + sourceTable: ocrRows.sourceTable, + sourceRow: ocrRows.sourceRow, + userId: ocrRows.userId, + createdAt: ocrRows.createdAt, + + // users 테이블의 필드 + userName: users.name, + userEmail: users.email, + }) + .from(ocrRows) + .leftJoin(users, eq(ocrRows.userId, users.id)) + .where(where) + .orderBy(...(orderBy ?? [])) + .offset(offset) + .limit(limit); +} /** 총 개수 count */ export async function countOcrRows( tx: PgTransaction<any, any, any>, diff --git a/lib/welding/service.ts b/lib/welding/service.ts index 3dce07f8..b3a69c36 100644 --- a/lib/welding/service.ts +++ b/lib/welding/service.ts @@ -1,87 +1,156 @@ -"use server"; // Next.js 서버 액션에서 직접 import하려면 (선택) +"use server"; import { revalidateTag, unstable_noStore } from "next/cache"; -import db from "@/db/db"; +import db from "@/db/db"; import { unstable_cache } from "@/lib/unstable-cache"; import { filterColumns } from "@/lib/filter-columns"; import { tagClasses } from "@/db/schema/vendorData"; -import { asc, desc, ilike, inArray, and, gte, lte, not, or } from "drizzle-orm"; -import { GetOcrRowSchema } from "./validation"; +import { asc, desc, ilike, inArray, and, gte, lte, not, or, eq } from "drizzle-orm"; +import { GetOcrRowSchema, UpdateOcrRowSchema } from "./validation"; import { ocrRows } from "@/db/schema"; import { countOcrRows, selectOcrRows } from "./repository"; +import { getServerSession } from "next-auth/next" +import { authOptions } from "@/app/api/auth/[...nextauth]/route" + + + +async function checkAdminPermission(email: string): Promise<boolean> { + const adminEmails = [ + 'worldbest212@naver.com', + 'jspwhale@naver.com', + 'supervisor@company.com' + ]; + + return adminEmails.includes(email.toLowerCase()); +} export async function getOcrRows(input: GetOcrRowSchema) { - // return unstable_cache( - // async () => { - try { - const offset = (input.page - 1) * input.perPage; - - // const advancedTable = input.flags.includes("advancedTable"); - const advancedTable = true; - - // advancedTable 모드면 filterColumns()로 where 절 구성 - const advancedWhere = filterColumns({ - table: ocrRows, - filters: input.filters, - joinOperator: input.joinOperator, - }); - - - let globalWhere - if (input.search) { - const s = `%${input.search}%` - globalWhere = or(ilike(ocrRows.reportNo, s), - ilike(ocrRows.identificationNo, s), - ilike(ocrRows.tagNo, s), - ilike(ocrRows.jointNo, s) - ) - // 필요시 여러 칼럼 OR조건 (status, priority, etc) - } - - const conditions = []; - if (advancedWhere) conditions.push(advancedWhere); - if (globalWhere) conditions.push(globalWhere); - - let finalWhere; - if (conditions.length > 0) { - finalWhere = conditions.length > 1 ? and(...conditions) : conditions[0]; - } - - // 아니면 ilike, inArray, gte 등으로 where 절 구성 - const where = finalWhere - - - const orderBy = - input.sort.length > 0 - ? input.sort.map((item) => - item.desc ? desc(ocrRows[item.id]) : asc(ocrRows[item.id]) - ) - : [asc(ocrRows.createdAt)]; - // 트랜잭션 내부에서 Repository 호출 - const { data, total } = await db.transaction(async (tx) => { - const data = await selectOcrRows(tx, { - where, - orderBy, - offset, - limit: input.perPage, - }); - - const total = await countOcrRows(tx, where); - return { data, total }; - }); - - const pageCount = Math.ceil(total / input.perPage); - - return { data, pageCount }; - } catch (err) { - // 에러 발생 시 디폴트 - return { data: [], pageCount: 0 }; - } - // }, - // [JSON.stringify(input)], // 캐싱 키 - // { - // revalidate: 3600, - // tags: ["equip-class"], // revalidateTag("items") 호출 시 무효화 - // } - // )(); - }
\ No newline at end of file + try { + const session = await getServerSession(authOptions); + const requesterId = session?.user?.id ? Number(session.user.id) : null; + const requesterEmail = session?.user?.email || 'worldbest212@naver.com'; + + + // 로그인하지 않은 경우 빈 결과 반환 + if (!requesterId) { + return { data: [], pageCount: 0 }; + } + + const offset = (input.page - 1) * input.perPage; + const advancedTable = true; + + // advancedTable 모드면 filterColumns()로 where 절 구성 + const advancedWhere = filterColumns({ + table: ocrRows, + filters: input.filters, + joinOperator: input.joinOperator, + }); + + let globalWhere; + if (input.search) { + const s = `%${input.search}%`; + globalWhere = or( + ilike(ocrRows.reportNo, s), + ilike(ocrRows.identificationNo, s), + ilike(ocrRows.tagNo, s), + ilike(ocrRows.jointNo, s) + ); + } + + const isAdmin = await checkAdminPermission(requesterEmail); + + + // 기본 userId 필터 (항상 적용) + const userIdWhere = eq(ocrRows.userId, requesterId); + + // 모든 조건을 배열에 추가 + const conditions = []; + if (!isAdmin) { + conditions.push(eq(ocrRows.userId, requesterId)); // 일반 사용자만 필터링 + } + if (advancedWhere) conditions.push(advancedWhere); + if (globalWhere) conditions.push(globalWhere); + + // 모든 조건을 AND로 결합 + const finalWhere = conditions.length > 1 ? and(...conditions) : conditions[0]; + + const orderBy = + input.sort.length > 0 + ? input.sort.map((item) => + item.desc ? desc(ocrRows[item.id]) : asc(ocrRows[item.id]) + ) + : [asc(ocrRows.createdAt)]; + + // 트랜잭션 내부에서 Repository 호출 + const { data, total } = await db.transaction(async (tx) => { + const data = await selectOcrRows(tx, { + where: finalWhere, + orderBy, + offset, + limit: input.perPage, + }); + + const total = await countOcrRows(tx, finalWhere); + return { data, total }; + }); + + const pageCount = Math.ceil(total / input.perPage); + + console.log(pageCount); + + return { data, pageCount }; + } catch (err) { + console.log(err); + // 에러 발생 시 디폴트 + return { data: [], pageCount: 0 }; + } +} + +interface ModifyOcrRowInput extends UpdateOcrRowSchema { + id: string; +} + +export async function modifyOcrRow(input: ModifyOcrRowInput) { + try { + const session = await getServerSession(authOptions); + const requesterId = session?.user?.id ? Number(session.user.id) : null; + + // 로그인하지 않은 경우 권한 없음 + if (!requesterId) { + return { + data: null, + error: "로그인이 필요합니다." + }; + } + + const { id, ...updateData } = input; + + // 빈 문자열을 null로 변환 + const cleanedData = Object.fromEntries( + Object.entries(updateData).map(([key, value]) => [ + key, + value === "" ? null : value + ]) + ); + + // 자신의 데이터만 수정할 수 있도록 userId 조건 추가 + const result = await db + .update(ocrRows) + .set({ + ...cleanedData, + // updatedAt: new Date(), // 필요한 경우 추가 + }) + .where(and( + eq(ocrRows.id, id), + eq(ocrRows.userId, requesterId) // 자신의 데이터만 수정 가능 + )); + + return { data: null, error: null }; + } catch (error) { + console.error("OCR 행 업데이트 오류:", error); + return { + data: null, + error: "OCR 행을 업데이트하는 중 오류가 발생했습니다." + }; + } +}
\ No newline at end of file diff --git a/lib/welding/table/ocr-table-columns.tsx b/lib/welding/table/ocr-table-columns.tsx index 85830405..d1aefe06 100644 --- a/lib/welding/table/ocr-table-columns.tsx +++ b/lib/welding/table/ocr-table-columns.tsx @@ -14,11 +14,11 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" -import { DataTableColumnHeader } from "@/components/data-table/data-table-column-header" import { toast } from "sonner" import { formatDate } from "@/lib/utils" import { OcrRow } from "@/db/schema" import { type DataTableRowAction } from "@/types/table" +import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-column-simple-header" interface GetColumnsProps { setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<OcrRow> | null>> @@ -56,26 +56,16 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<OcrRow> { accessorKey: "reportNo", header: ({ column }) => ( - <DataTableColumnHeader column={column} title="Report No" /> + <DataTableColumnHeaderSimple column={column} title="Report No" /> ), cell: ({ getValue }) => { const reportNo = getValue() as string return ( <div className="flex items-center gap-2"> - <Badge variant="outline" className="font-mono text-xs"> + {/* <Badge variant="outline" className="font-mono text-xs"> */} {reportNo || "N/A"} - </Badge> - <Button - variant="ghost" - size="icon" - className="size-6" - onClick={() => { - navigator.clipboard.writeText(reportNo || "") - toast.success("Report No copied to clipboard") - }} - > - <Copy className="size-3" /> - </Button> + {/* </Badge> */} + </div> ) }, @@ -87,7 +77,7 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<OcrRow> { accessorKey: "no", header: ({ column }) => ( - <DataTableColumnHeader column={column} title="No" /> + <DataTableColumnHeaderSimple column={column} title="No" /> ), cell: ({ getValue }) => { const no = getValue() as string @@ -104,7 +94,7 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<OcrRow> { accessorKey: "identificationNo", header: ({ column }) => ( - <DataTableColumnHeader column={column} title="Identification No" /> + <DataTableColumnHeaderSimple column={column} title="Identification No" /> ), cell: ({ getValue }) => { const identificationNo = getValue() as string @@ -121,7 +111,7 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<OcrRow> { accessorKey: "tagNo", header: ({ column }) => ( - <DataTableColumnHeader column={column} title="Tag No" /> + <DataTableColumnHeaderSimple column={column} title="Tag No" /> ), cell: ({ getValue }) => { const tagNo = getValue() as string @@ -138,7 +128,7 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<OcrRow> { accessorKey: "jointNo", header: ({ column }) => ( - <DataTableColumnHeader column={column} title="Joint No" /> + <DataTableColumnHeaderSimple column={column} title="Joint No" /> ), cell: ({ getValue }) => { const jointNo = getValue() as string @@ -155,7 +145,7 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<OcrRow> { accessorKey: "jointType", header: ({ column }) => ( - <DataTableColumnHeader column={column} title="Joint Type" /> + <DataTableColumnHeaderSimple column={column} title="Joint Type" /> ), cell: ({ getValue }) => { const jointType = getValue() as string @@ -172,7 +162,7 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<OcrRow> { accessorKey: "weldingDate", header: ({ column }) => ( - <DataTableColumnHeader column={column} title="Welding Date" /> + <DataTableColumnHeaderSimple column={column} title="Welding Date" /> ), cell: ({ getValue }) => { const weldingDate = getValue() as string @@ -189,7 +179,7 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<OcrRow> { accessorKey: "confidence", header: ({ column }) => ( - <DataTableColumnHeader column={column} title="Confidence" /> + <DataTableColumnHeaderSimple column={column} title="Confidence" /> ), cell: ({ getValue }) => { const confidence = parseFloat(getValue() as string) || 0 @@ -209,46 +199,78 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<OcrRow> }, // Source Table 컬럼 + // { + // accessorKey: "sourceTable", + // header: ({ column }) => ( + // <DataTableColumnHeaderSimple column={column} title="Table" /> + // ), + // cell: ({ getValue }) => { + // const sourceTable = getValue() as number + // return ( + // <div className="text-center"> + // <Badge variant="outline" className="text-xs"> + // T{sourceTable} + // </Badge> + // </div> + // ) + // }, + // enableSorting: true, + // }, + + // // Source Row 컬럼 + // { + // accessorKey: "sourceRow", + // header: ({ column }) => ( + // <DataTableColumnHeaderSimple column={column} title="Row" /> + // ), + // cell: ({ getValue }) => { + // const sourceRow = getValue() as number + // return ( + // <div className="text-center text-sm text-muted-foreground"> + // {sourceRow} + // </div> + // ) + // }, + // enableSorting: true, + // }, + + { - accessorKey: "sourceTable", + accessorKey: "userName", header: ({ column }) => ( - <DataTableColumnHeader column={column} title="Table" /> + <DataTableColumnHeaderSimple column={column} title="User Name" /> ), cell: ({ getValue }) => { - const sourceTable = getValue() as number + const userName = getValue() as string return ( - <div className="text-center"> - <Badge variant="outline" className="text-xs"> - T{sourceTable} - </Badge> + <div className="text-sm"> + {userName || "-"} </div> ) }, enableSorting: true, }, - // Source Row 컬럼 { - accessorKey: "sourceRow", + accessorKey: "userEmail", header: ({ column }) => ( - <DataTableColumnHeader column={column} title="Row" /> + <DataTableColumnHeaderSimple column={column} title="User Email" /> ), cell: ({ getValue }) => { - const sourceRow = getValue() as number + const userEmail = getValue() as string return ( - <div className="text-center text-sm text-muted-foreground"> - {sourceRow} + <div className="text-sm"> + {userEmail || "-"} </div> ) }, enableSorting: true, }, - // Created At 컬럼 { accessorKey: "createdAt", header: ({ column }) => ( - <DataTableColumnHeader column={column} title="생성일" /> + <DataTableColumnHeaderSimple column={column} title="생성일" /> ), cell: ({ cell }) => { const date = cell.getValue() as Date @@ -276,24 +298,17 @@ export function getColumns({ setRowAction }: GetColumnsProps): ColumnDef<OcrRow> </Button> </DropdownMenuTrigger> <DropdownMenuContent align="end" className="w-40"> - <DropdownMenuItem + <DropdownMenuItem onClick={() => { - const rowData = row.original - navigator.clipboard.writeText(JSON.stringify(rowData, null, 2)) - toast.success("Row data copied to clipboard") + setRowAction({ type: "update", row }) }} + // className="text-destructive focus:text-destructive" > - <Copy className="mr-2 size-4" aria-hidden="true" /> - Copy Row Data + Update </DropdownMenuItem> + <DropdownMenuSeparator /> - <DropdownMenuItem - onClick={() => { - setRowAction({ type: "view", row }) - }} - > - View Details - </DropdownMenuItem> + <DropdownMenuItem onClick={() => { setRowAction({ type: "delete", row }) diff --git a/lib/welding/table/ocr-table-toolbar-actions.tsx b/lib/welding/table/ocr-table-toolbar-actions.tsx index 001b21cb..6c6d0637 100644 --- a/lib/welding/table/ocr-table-toolbar-actions.tsx +++ b/lib/welding/table/ocr-table-toolbar-actions.tsx @@ -39,6 +39,24 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) { const [selectedFile, setSelectedFile] = React.useState<File | null>(null) const fileInputRef = React.useRef<HTMLInputElement>(null) + // 다이얼로그 닫기 핸들러 - 업로드 중에는 닫기 방지 + const handleDialogOpenChange = (open: boolean) => { + // 다이얼로그를 닫으려고 할 때 + if (!open) { + // 업로드가 진행 중이면 닫기를 방지 + if (isUploading && uploadProgress?.stage !== "complete") { + toast.warning("Cannot close while processing. Please wait for completion.", { + description: "OCR processing is in progress..." + }) + return // 다이얼로그를 닫지 않음 + } + + // 업로드가 진행 중이 아니거나 완료되었으면 초기화 후 닫기 + resetUpload() + } + + setIsUploadDialogOpen(open) + } const handleFileSelect = (event: React.ChangeEvent<HTMLInputElement>) => { const file = event.target.files?.[0] @@ -142,11 +160,7 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) { // 성공 후 다이얼로그 닫기 및 상태 초기화 setTimeout(() => { setIsUploadDialogOpen(false) - setSelectedFile(null) - setUploadProgress(null) - if (fileInputRef.current) { - fileInputRef.current.value = '' - } + resetUpload() // 테이블 새로고침 window.location.reload() @@ -177,21 +191,60 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) { } } + // Cancel 버튼 핸들러 + const handleCancelClick = () => { + if (isUploading && uploadProgress?.stage !== "complete") { + // 업로드 진행 중이면 취소 불가능 메시지 + toast.warning("Cannot cancel while processing. Please wait for completion.", { + description: "OCR processing cannot be interrupted safely." + }) + } else { + // 업로드 중이 아니거나 완료되었으면 다이얼로그 닫기 + setIsUploadDialogOpen(false) + resetUpload() + } + } + return ( <div className="flex items-center gap-2"> {/* OCR 업로드 다이얼로그 */} - <Dialog open={isUploadDialogOpen} onOpenChange={setIsUploadDialogOpen}> + <Dialog open={isUploadDialogOpen} onOpenChange={handleDialogOpenChange}> <DialogTrigger asChild> <Button variant="samsung" size="sm" className="gap-2"> <Upload className="size-4" aria-hidden="true" /> <span className="hidden sm:inline">Upload OCR</span> </Button> </DialogTrigger> - <DialogContent className="sm:max-w-md"> + <DialogContent + className="sm:max-w-md" + // 업로드 중에는 ESC 키로도 닫기 방지 + onEscapeKeyDown={(e) => { + if (isUploading && uploadProgress?.stage !== "complete") { + e.preventDefault() + toast.warning("Cannot close while processing. Please wait for completion.") + } + }} + // 업로드 중에는 외부 클릭으로도 닫기 방지 + onInteractOutside={(e) => { + if (isUploading && uploadProgress?.stage !== "complete") { + e.preventDefault() + toast.warning("Cannot close while processing. Please wait for completion.") + } + }} + > <DialogHeader> - <DialogTitle>Upload Document for OCR</DialogTitle> + <DialogTitle className="flex items-center gap-2"> + Upload Document for OCR + {/* 업로드 중일 때 로딩 인디케이터 표시 */} + {isUploading && uploadProgress?.stage !== "complete" && ( + <Loader2 className="size-4 animate-spin text-muted-foreground" /> + )} + </DialogTitle> <DialogDescription> - Upload a PDF or image file to extract table data using OCR technology. + {isUploading && uploadProgress?.stage !== "complete" + ? "Processing in progress. Please do not close this dialog." + : "Upload a PDF or image file to extract table data using OCR technology." + } </DialogDescription> </DialogHeader> @@ -239,6 +292,16 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) { <p className="text-xs text-muted-foreground"> {uploadProgress.message} </p> + + {/* 진행 중일 때 안내 메시지 */} + {isUploading && uploadProgress.stage !== "complete" && ( + <div className="flex items-center gap-2 p-2 bg-blue-50 dark:bg-blue-950/20 rounded-md"> + <Loader2 className="size-3 animate-spin text-blue-600" /> + <p className="text-xs text-blue-700 dark:text-blue-300"> + Please wait... This dialog will close automatically when complete. + </p> + </div> + )} </div> )} @@ -247,18 +310,10 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) { <Button variant="outline" size="sm" - onClick={() => { - if (isUploading) { - // 업로드 중이면 취소 불가능하다는 메시지 - toast.warning("Cannot cancel while processing. Please wait...") - } else { - setIsUploadDialogOpen(false) - resetUpload() - } - }} - disabled={isUploading && uploadProgress?.stage !== "complete"} + onClick={handleCancelClick} + disabled={false} // 항상 클릭 가능하지만 핸들러에서 처리 > - {isUploading ? "Close" : "Cancel"} + {isUploading && uploadProgress?.stage !== "complete" ? "Close" : "Cancel"} </Button> <Button size="sm" @@ -277,6 +332,7 @@ export function OcrTableToolbarActions({ table }: OcrTableToolbarActionsProps) { </div> </DialogContent> </Dialog> + {/* Export 버튼 */} <Button variant="outline" diff --git a/lib/welding/table/ocr-table.tsx b/lib/welding/table/ocr-table.tsx index 91af1c67..e14c53d1 100644 --- a/lib/welding/table/ocr-table.tsx +++ b/lib/welding/table/ocr-table.tsx @@ -15,6 +15,7 @@ import { OcrTableToolbarActions } from "./ocr-table-toolbar-actions" import { getColumns } from "./ocr-table-columns" import { OcrRow } from "@/db/schema" import { getOcrRows } from "../service" +import { UpdateOcrRowSheet } from "./update-ocr-row-sheet" interface ItemsTableProps { promises: Promise< @@ -74,8 +75,8 @@ export function OcrTable({ promises }: ItemsTableProps) { type: "text", // group: "Basic Info", }, - - + + { id: "identificationNo", label: "Identification No", @@ -88,19 +89,19 @@ export function OcrTable({ promises }: ItemsTableProps) { type: "text", // group: "Metadata", }, - { + { id: "jointNo", label: "Joint No", type: "text", // group: "Metadata", }, - { + { id: "weldingDate", label: "Welding Date", type: "date", // group: "Metadata", }, - { + { id: "createdAt", label: "생성일", type: "date", @@ -138,6 +139,11 @@ export function OcrTable({ promises }: ItemsTableProps) { <OcrTableToolbarActions table={table} /> </DataTableAdvancedToolbar> </DataTable> + <UpdateOcrRowSheet + open={rowAction?.type === "update"} + onOpenChange={() => setRowAction(null)} + ocrRow={rowAction?.type === "update" ? rowAction.row.original : null} + /> </> ) } diff --git a/lib/welding/table/update-ocr-row-sheet.tsx b/lib/welding/table/update-ocr-row-sheet.tsx new file mode 100644 index 00000000..cbb4f030 --- /dev/null +++ b/lib/welding/table/update-ocr-row-sheet.tsx @@ -0,0 +1,187 @@ +"use client" + +import * as React from "react" +import { OcrRow } from "@/db/schema" +import { zodResolver } from "@hookform/resolvers/zod" +import { Loader } from "lucide-react" +import { useForm } from "react-hook-form" +import { toast } from "sonner" + +import { Button } from "@/components/ui/button" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { Input } from "@/components/ui/input" +import { + Sheet, + SheetClose, + SheetContent, + SheetDescription, + SheetFooter, + SheetHeader, + SheetTitle, +} from "@/components/ui/sheet" + +import { modifyOcrRow } from "../service" +import { updateOcrRowSchema, type UpdateOcrRowSchema } from "../validation" + +interface UpdateOcrRowSheetProps + extends React.ComponentPropsWithRef<typeof Sheet> { + ocrRow: OcrRow | null +} + +export function UpdateOcrRowSheet({ ocrRow, ...props }: UpdateOcrRowSheetProps) { + const [isUpdatePending, startUpdateTransition] = React.useTransition() + + const form = useForm<UpdateOcrRowSchema>({ + resolver: zodResolver(updateOcrRowSchema), + defaultValues: { + identificationNo: ocrRow?.identificationNo ?? "", + tagNo: ocrRow?.tagNo ?? "", + weldingDate: ocrRow?.weldingDate ?? "", + jointType: ocrRow?.jointType ?? "", + }, + }) + + React.useEffect(() => { + if (ocrRow) { + form.reset({ + identificationNo: ocrRow.identificationNo ?? "", + tagNo: ocrRow.tagNo ?? "", + weldingDate: ocrRow.weldingDate ?? "", + jointType: ocrRow.jointType ?? "", + }) + } + }, [ocrRow, form]) + + function onSubmit(input: UpdateOcrRowSchema) { + startUpdateTransition(async () => { + if (!ocrRow) return + + const { error } = await modifyOcrRow({ + id: ocrRow.id, + ...input, + }) + + if (error) { + toast.error(error) + return + } + + form.reset() + props.onOpenChange?.(false) + toast.success("OCR 행이 업데이트되었습니다") + }) + } + + + + return ( + <Sheet {...props}> + <SheetContent className="flex flex-col gap-6 sm:max-w-md"> + <SheetHeader className="text-left"> + <SheetTitle>OCR 행 업데이트</SheetTitle> + <SheetDescription> + OCR 행의 세부 정보를 수정하고 변경 사항을 저장하세요 + </SheetDescription> + </SheetHeader> + <Form {...form}> + <form + onSubmit={form.handleSubmit(onSubmit)} + className="flex flex-col gap-4" + > + <FormField + control={form.control} + name="identificationNo" + render={({ field }) => ( + <FormItem> + <FormLabel>Identification No</FormLabel> + <FormControl> + <Input + placeholder="식별 번호를 입력하세요" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="tagNo" + render={({ field }) => ( + <FormItem> + <FormLabel>Tag No</FormLabel> + <FormControl> + <Input + placeholder="태그 번호를 입력하세요" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="weldingDate" + render={({ field }) => ( + <FormItem> + <FormLabel>Welding Date</FormLabel> + <FormControl> + <Input + placeholder="용접 날짜를 입력하세요 (예: 2024-12-01)" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <FormField + control={form.control} + name="jointType" + render={({ field }) => ( + <FormItem> + <FormLabel>Joint Type</FormLabel> + <FormControl> + <Input + placeholder="조인트 타입을 입력하세요" + {...field} + /> + </FormControl> + <FormMessage /> + </FormItem> + )} + /> + + <SheetFooter className="gap-2 pt-2 sm:space-x-0"> + <SheetClose asChild> + <Button type="button" variant="outline"> + 취소 + </Button> + </SheetClose> + <Button disabled={isUpdatePending}> + {isUpdatePending && ( + <Loader + className="mr-2 size-4 animate-spin" + aria-hidden="true" + /> + )} + 저장 + </Button> + </SheetFooter> + </form> + </Form> + </SheetContent> + </Sheet> + ) +}
\ No newline at end of file diff --git a/lib/welding/validation.ts b/lib/welding/validation.ts index fe5b2cbb..969aafdc 100644 --- a/lib/welding/validation.ts +++ b/lib/welding/validation.ts @@ -34,3 +34,13 @@ export const searchParamsCache = createSearchParamsCache({ // 타입 내보내기 export type GetOcrRowSchema = Awaited<ReturnType<typeof searchParamsCache.parse>>; + + +export const updateOcrRowSchema = z.object({ + identificationNo: z.string().optional(), + tagNo: z.string().optional(), + weldingDate: z.string().optional(), + jointType: z.string().optional(), +}) + +export type UpdateOcrRowSchema = z.infer<typeof updateOcrRowSchema>
\ No newline at end of file |
