summaryrefslogtreecommitdiff
path: root/lib/rfq-last
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-11-03 10:15:45 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-11-03 10:15:45 +0000
commitf2fafe555b65f9207c2c6e216b7d7b2ff83af866 (patch)
tree4a230e4bde10a612150a299922bc04cb15b0930f /lib/rfq-last
parent1e857a0b1443ad2124caf3d180b7195651fe33e4 (diff)
(최겸) 구매 PQ/실사 수정
Diffstat (limited to 'lib/rfq-last')
-rw-r--r--lib/rfq-last/service.ts229
-rw-r--r--lib/rfq-last/table/rfq-table-toolbar-actions.tsx59
-rw-r--r--lib/rfq-last/table/update-general-rfq-dialog.tsx749
3 files changed, 1030 insertions, 7 deletions
diff --git a/lib/rfq-last/service.ts b/lib/rfq-last/service.ts
index 8475aac0..2c1aa2ca 100644
--- a/lib/rfq-last/service.ts
+++ b/lib/rfq-last/service.ts
@@ -5515,4 +5515,233 @@ export async function getVendorDocumentConfirmStatus(
console.error("문서 확정 상태 조회 중 오류:", error);
return { isConfirmed: false, count: 0 };
}
+}
+
+// 일반견적 수정 입력 인터페이스
+interface UpdateGeneralRfqInput {
+ id: number; // 수정할 RFQ ID
+ rfqType: string;
+ rfqTitle: string;
+ dueDate: Date;
+ picUserId: number;
+ projectId?: number;
+ remark?: string;
+ items: Array<{
+ itemCode: string;
+ itemName: string;
+ materialCode?: string;
+ materialName?: string;
+ quantity: number;
+ uom: string;
+ remark?: string;
+ }>;
+ updatedBy: number;
+}
+
+// 일반견적 수정 서버 액션
+export async function updateGeneralRfqAction(input: UpdateGeneralRfqInput) {
+ try {
+ // 트랜잭션으로 처리
+ const result = await db.transaction(async (tx) => {
+ // 1. 기존 RFQ 조회 (존재 확인 및 상태 확인)
+ const existingRfq = await tx
+ .select()
+ .from(rfqsLast)
+ .where(eq(rfqsLast.id, input.id))
+ .limit(1);
+
+ if (!existingRfq || existingRfq.length === 0) {
+ throw new Error("수정할 일반견적을 찾을 수 없습니다");
+ }
+
+ const rfq = existingRfq[0];
+
+ // 상태 검증 (RFQ 생성 상태만 수정 가능)
+ if (rfq.status !== "RFQ 생성") {
+ throw new Error("RFQ 생성 상태인 일반견적만 수정할 수 있습니다");
+ }
+
+ // 2. 구매 담당자 정보 조회
+ const picUser = await tx
+ .select({
+ name: users.name,
+ email: users.email,
+ userCode: users.userCode
+ })
+ .from(users)
+ .where(eq(users.id, input.picUserId))
+ .limit(1);
+
+ if (!picUser || picUser.length === 0) {
+ throw new Error("구매 담당자를 찾을 수 없습니다");
+ }
+
+ // 3. userCode 확인 (3자리)
+ const userCode = picUser[0].userCode;
+ if (!userCode || userCode.length !== 3) {
+ throw new Error("구매 담당자의 userCode가 올바르지 않습니다 (3자리 필요)");
+ }
+
+ // 4. 대표 아이템 정보 추출 (첫 번째 아이템)
+ const representativeItem = input.items[0];
+
+ // 5. rfqsLast 테이블 업데이트
+ const [updatedRfq] = await tx
+ .update(rfqsLast)
+ .set({
+ rfqType: input.rfqType,
+ rfqTitle: input.rfqTitle,
+ dueDate: input.dueDate,
+ projectId: input.projectId || null,
+ itemCode: representativeItem.itemCode,
+ itemName: representativeItem.itemName,
+ pic: input.picUserId,
+ picCode: userCode,
+ picName: picUser[0].name || '',
+ remark: input.remark || null,
+ updatedBy: input.updatedBy,
+ updatedAt: new Date(),
+ })
+ .where(eq(rfqsLast.id, input.id))
+ .returning();
+
+ // 6. 기존 rfqPrItems 삭제 후 재삽입
+ await tx
+ .delete(rfqPrItems)
+ .where(eq(rfqPrItems.rfqsLastId, input.id));
+
+ // 7. rfqPrItems 테이블에 아이템들 재삽입
+ const prItemsData = input.items.map((item, index) => ({
+ rfqsLastId: input.id,
+ rfqItem: `${index + 1}`.padStart(3, '0'), // 001, 002, ...
+ prItem: null, // 일반견적에서는 PR 아이템 번호를 null로 설정
+ prNo: null, // 일반견적에서는 PR 번호를 null로 설정
+
+ materialCode: item.materialCode || item.itemCode, // SAP 자재코드 (없으면 자재그룹코드 사용)
+ materialCategory: item.itemCode, // 자재그룹코드
+ materialDescription: item.materialName || item.itemName, // SAP 자재명 (없으면 자재그룹명 사용)
+ quantity: item.quantity,
+ uom: item.uom,
+
+ majorYn: index === 0, // 첫 번째 아이템을 주요 아이템으로 설정
+ remark: item.remark || null,
+ }));
+
+ await tx.insert(rfqPrItems).values(prItemsData);
+
+ return updatedRfq;
+ });
+
+ return {
+ success: true,
+ message: "일반견적이 성공적으로 수정되었습니다",
+ data: {
+ id: result.id,
+ rfqCode: result.rfqCode,
+ },
+ };
+
+ } catch (error) {
+ console.error("일반견적 수정 오류:", error);
+
+ if (error instanceof Error) {
+ return {
+ success: false,
+ error: error.message,
+ };
+ }
+
+ return {
+ success: false,
+ error: "일반견적 수정 중 오류가 발생했습니다",
+ };
+ }
+}
+
+// 일반견적 수정용 데이터 조회 함수
+export async function getGeneralRfqForUpdate(rfqId: number) {
+ try {
+ // RFQ 기본 정보 조회
+ const rfqData = await db
+ .select({
+ id: rfqsLast.id,
+ rfqCode: rfqsLast.rfqCode,
+ rfqType: rfqsLast.rfqType,
+ rfqTitle: rfqsLast.rfqTitle,
+ status: rfqsLast.status,
+ dueDate: rfqsLast.dueDate,
+ projectId: rfqsLast.projectId,
+ pic: rfqsLast.pic,
+ picCode: rfqsLast.picCode,
+ picName: rfqsLast.picName,
+ remark: rfqsLast.remark,
+ createdAt: rfqsLast.createdAt,
+ updatedAt: rfqsLast.updatedAt,
+ })
+ .from(rfqsLast)
+ .where(
+ and(
+ eq(rfqsLast.id, rfqId),
+ eq(rfqsLast.status, "RFQ 생성") // RFQ 생성 상태만 조회
+ )
+ )
+ .limit(1);
+
+ if (!rfqData || rfqData.length === 0) {
+ return {
+ success: false,
+ error: "수정할 일반견적을 찾을 수 없거나 수정할 수 없는 상태입니다",
+ };
+ }
+
+ const rfq = rfqData[0];
+
+ // RFQ 아이템들 조회
+ const items = await db
+ .select({
+ rfqItem: rfqPrItems.rfqItem,
+ materialCode: rfqPrItems.materialCode,
+ materialCategory: rfqPrItems.materialCategory,
+ materialDescription: rfqPrItems.materialDescription,
+ quantity: rfqPrItems.quantity,
+ uom: rfqPrItems.uom,
+ remark: rfqPrItems.remark,
+ })
+ .from(rfqPrItems)
+ .where(eq(rfqPrItems.rfqsLastId, rfqId))
+ .orderBy(rfqPrItems.rfqItem);
+
+ // 아이템 데이터를 폼 형식으로 변환
+ const formItems = items.map(item => ({
+ itemCode: item.materialCategory || "", // 자재그룹코드
+ itemName: item.materialDescription || "", // 자재그룹명
+ materialCode: item.materialCode || "", // SAP 자재코드
+ materialName: item.materialDescription || "", // SAP 자재명 (설명으로 사용)
+ quantity: Math.floor(Number(item.quantity)), // 소수점 제거
+ uom: item.uom,
+ remark: item.remark || "",
+ }));
+
+ return {
+ success: true,
+ data: {
+ id: rfq.id,
+ rfqCode: rfq.rfqCode,
+ rfqType: rfq.rfqType,
+ rfqTitle: rfq.rfqTitle,
+ dueDate: rfq.dueDate,
+ picUserId: rfq.pic,
+ projectId: rfq.projectId,
+ remark: rfq.remark,
+ items: formItems,
+ },
+ };
+
+ } catch (error) {
+ console.error("일반견적 조회 오류:", error);
+ return {
+ success: false,
+ error: "일반견적 조회 중 오류가 발생했습니다",
+ };
+ }
} \ No newline at end of file
diff --git a/lib/rfq-last/table/rfq-table-toolbar-actions.tsx b/lib/rfq-last/table/rfq-table-toolbar-actions.tsx
index 00c41402..148336fb 100644
--- a/lib/rfq-last/table/rfq-table-toolbar-actions.tsx
+++ b/lib/rfq-last/table/rfq-table-toolbar-actions.tsx
@@ -3,10 +3,11 @@
import * as React from "react";
import { Table } from "@tanstack/react-table";
import { Button } from "@/components/ui/button";
-import { Users, RefreshCw, FileDown, Plus } from "lucide-react";
+import { Users, RefreshCw, FileDown, Plus, Edit } from "lucide-react";
import { RfqsLastView } from "@/db/schema";
import { RfqAssignPicDialog } from "./rfq-assign-pic-dialog";
import { CreateGeneralRfqDialog } from "./create-general-rfq-dialog"; // 추가
+import { UpdateGeneralRfqDialog } from "./update-general-rfq-dialog"; // 수정용
import { Badge } from "@/components/ui/badge";
import {
Tooltip,
@@ -21,12 +22,14 @@ interface RfqTableToolbarActionsProps<TData> {
onRefresh?: () => void;
}
-export function RfqTableToolbarActions<TData>({
- table,
+export function RfqTableToolbarActions<TData>({
+ table,
rfqCategory = "itb",
- onRefresh
+ onRefresh
}: RfqTableToolbarActionsProps<TData>) {
const [showAssignDialog, setShowAssignDialog] = React.useState(false);
+ const [showUpdateDialog, setShowUpdateDialog] = React.useState(false);
+ const [selectedRfqForUpdate, setSelectedRfqForUpdate] = React.useState<number | null>(null);
console.log(rfqCategory)
@@ -41,6 +44,9 @@ export function RfqTableToolbarActions<TData>({
(row.status === "RFQ 생성" || row.status === "구매담당지정")
);
+ // 수정 가능한 RFQ (general 카테고리에서 RFQ 생성 상태인 항목, 단일 선택만)
+ const updatableRfq = rfqCategory === "general" && rows.length === 1 && rows[0].status === "RFQ 생성" ? rows[0] : null;
+
return {
ids: rows.map(row => row.id),
codes: rows.map(row => row.rfqCode || ""),
@@ -51,9 +57,12 @@ export function RfqTableToolbarActions<TData>({
// 담당자 지정 가능한 ITB (상태가 "RFQ 생성" 또는 "구매담당지정"인 ITB)
assignableItbCount: assignableRows.length,
assignableIds: assignableRows.map(row => row.id),
- assignableCodes: assignableRows.map(row => row.rfqCode || "")
+ assignableCodes: assignableRows.map(row => row.rfqCode || ""),
+ // 수정 가능한 RFQ 정보
+ updatableRfq: updatableRfq,
+ canUpdate: updatableRfq !== null,
};
- }, [selectedRows]);
+ }, [selectedRows, rfqCategory]);
// 담당자 지정 가능 여부 체크 (상태가 "RFQ 생성" 또는 "구매담당지정"인 ITB가 있는지)
const canAssignPic = selectedRfqData.assignableItbCount > 0;
@@ -69,6 +78,20 @@ export function RfqTableToolbarActions<TData>({
onRefresh?.(); // 테이블 데이터 새로고침
};
+ const handleUpdateGeneralRfqSuccess = () => {
+ // 테이블 선택 초기화
+ table.toggleAllPageRowsSelected(false);
+ // 데이터 새로고침
+ onRefresh?.();
+ };
+
+ const handleUpdateClick = () => {
+ if (selectedRfqData.updatableRfq) {
+ setSelectedRfqForUpdate(selectedRfqData.updatableRfq.id);
+ setShowUpdateDialog(true);
+ }
+ };
+
return (
<>
<div className="flex items-center gap-2">
@@ -131,7 +154,21 @@ export function RfqTableToolbarActions<TData>({
</Button>
{rfqCategory === "general" && (
- <CreateGeneralRfqDialog onSuccess={handleCreateGeneralRfqSuccess} />
+ <>
+ <CreateGeneralRfqDialog onSuccess={handleCreateGeneralRfqSuccess} />
+ {/* 일반견적 수정 버튼 - 선택된 항목이 1개이고 RFQ 생성 상태일 때만 활성화 */}
+ {selectedRfqData.canUpdate && (
+ <Button
+ variant="outline"
+ size="sm"
+ onClick={handleUpdateClick}
+ className="flex items-center gap-2"
+ >
+ <Edit className="h-4 w-4" />
+ 일반견적 수정
+ </Button>
+ )}
+ </>
)}
<Button
variant="outline"
@@ -153,6 +190,14 @@ export function RfqTableToolbarActions<TData>({
selectedRfqCodes={selectedRfqData.assignableCodes}
onSuccess={handleAssignSuccess}
/>
+
+ {/* 일반견적 수정 다이얼로그 */}
+ <UpdateGeneralRfqDialog
+ open={showUpdateDialog}
+ onOpenChange={setShowUpdateDialog}
+ rfqId={selectedRfqForUpdate || 0}
+ onSuccess={handleUpdateGeneralRfqSuccess}
+ />
</>
);
} \ No newline at end of file
diff --git a/lib/rfq-last/table/update-general-rfq-dialog.tsx b/lib/rfq-last/table/update-general-rfq-dialog.tsx
new file mode 100644
index 00000000..161a2840
--- /dev/null
+++ b/lib/rfq-last/table/update-general-rfq-dialog.tsx
@@ -0,0 +1,749 @@
+"use client";
+
+import * as React from "react"
+import { useForm, useFieldArray } from "react-hook-form"
+import { zodResolver } from "@hookform/resolvers/zod"
+import { z } from "zod"
+import { format } from "date-fns"
+import { CalendarIcon, Plus, Loader2, Trash2, PlusCircle } from "lucide-react"
+import { useSession } from "next-auth/react"
+
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from "@/components/ui/dialog"
+import {
+ Form,
+ FormControl,
+ FormField,
+ FormItem,
+ FormLabel,
+ FormMessage,
+ FormDescription,
+} from "@/components/ui/form"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+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 { ScrollArea } from "@/components/ui/scroll-area"
+import { Separator } from "@/components/ui/separator"
+import { updateGeneralRfqAction, getGeneralRfqForUpdate } from "../service"
+import { MaterialGroupSelectorDialogSingle } from "@/components/common/material/material-group-selector-dialog-single"
+import { MaterialSearchItem } from "@/lib/material/material-group-service" // 단순 타입 임포트 목적
+import { MaterialSelectorDialogSingle } from "@/components/common/selectors/material/material-selector-dialog-single"
+import { MaterialSearchItem as SAPMaterialSearchItem } from "@/components/common/selectors/material/material-service"
+import { ProjectSelector } from "@/components/ProjectSelector"
+import {
+ PurchaseGroupCodeSingleSelector,
+ PurchaseGroupCodeWithUser
+} from "@/components/common/selectors/purchase-group-code"
+
+// 아이템 스키마 (수정용)
+const updateItemSchema = z.object({
+ itemCode: z.string().optional(),
+ itemName: z.string().min(1, "자재명을 입력해주세요"),
+ materialCode: z.string().optional(),
+ materialName: z.string().optional(),
+ quantity: z.number().min(1, "수량은 1 이상이어야 합니다"),
+ uom: z.string().min(1, "단위를 입력해주세요"),
+ remark: z.string().optional(),
+})
+
+// 일반견적 수정 폼 스키마
+const updateGeneralRfqSchema = z.object({
+ rfqType: z.string().min(1, "견적 종류를 선택해주세요"),
+ rfqTitle: z.string().min(1, "견적명을 입력해주세요"),
+ dueDate: z.date({
+ required_error: "제출마감일을 선택해주세요",
+ }),
+ picUserId: z.number().min(1, "견적담당자를 선택해주세요"),
+ projectId: z.number().optional(),
+ remark: z.string().optional(),
+ items: z.array(updateItemSchema).min(1, "최소 하나의 자재를 추가해주세요"),
+})
+
+type UpdateGeneralRfqFormValues = z.infer<typeof updateGeneralRfqSchema>
+
+interface UpdateGeneralRfqDialogProps {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ rfqId: number;
+ onSuccess?: () => void;
+}
+
+export function UpdateGeneralRfqDialog({
+ open,
+ onOpenChange,
+ rfqId,
+ onSuccess
+}: UpdateGeneralRfqDialogProps) {
+ const [isLoading, setIsLoading] = React.useState(false)
+ const [isLoadingData, setIsLoadingData] = React.useState(false)
+ const [selectedPurchaseGroupCode, setSelectedPurchaseGroupCode] = React.useState<PurchaseGroupCodeWithUser | undefined>(undefined)
+ const [selectorOpen, setSelectorOpen] = React.useState(false)
+ const { data: session } = useSession()
+
+ const userId = React.useMemo(() => {
+ return session?.user?.id ? Number(session.user.id) : null;
+ }, [session]);
+
+ const form = useForm<UpdateGeneralRfqFormValues>({
+ resolver: zodResolver(updateGeneralRfqSchema),
+ defaultValues: {
+ rfqType: "",
+ rfqTitle: "",
+ dueDate: undefined,
+ picUserId: userId || undefined,
+ projectId: undefined,
+ remark: "",
+ items: [
+ {
+ itemCode: "",
+ itemName: "",
+ materialCode: "",
+ materialName: "",
+ quantity: 1,
+ uom: "",
+ remark: "",
+ },
+ ],
+ },
+ })
+
+ const { fields, append, remove } = useFieldArray({
+ control: form.control,
+ name: "items",
+ })
+
+ // 견적 종류 변경
+ const handleRfqTypeChange = (value: string) => {
+ form.setValue("rfqType", value)
+ }
+
+ // 구매그룹코드 선택 핸들러
+ const handlePurchaseGroupCodeSelect = React.useCallback((code: PurchaseGroupCodeWithUser) => {
+ setSelectedPurchaseGroupCode(code)
+
+ // 사용자 정보가 있으면 폼에 설정
+ if (code.user) {
+ form.setValue("picUserId", code.user.id)
+ } else {
+ // 유저 정보가 없는 경우 경고
+ toast.warning(
+ `해당 구매그룹코드(${code.PURCHASE_GROUP_CODE})의 사번 정보의 유저가 없습니다`,
+ {
+ description: `사번: ${code.EMPLOYEE_NUMBER}`,
+ duration: 5000,
+ }
+ )
+ }
+ }, [form])
+
+ // 데이터 로드 함수
+ const loadRfqData = React.useCallback(async () => {
+ if (!rfqId || !open) return
+
+ setIsLoadingData(true)
+ try {
+ const result = await getGeneralRfqForUpdate(rfqId)
+
+ if (result.success && result.data) {
+ const data = result.data
+
+ // 폼 데이터 설정
+ form.reset({
+ rfqType: data.rfqType,
+ rfqTitle: data.rfqTitle,
+ dueDate: new Date(data.dueDate),
+ picUserId: data.picUserId,
+ projectId: data.projectId,
+ remark: data.remark || "",
+ items: data.items.length > 0 ? data.items : [
+ {
+ itemCode: "",
+ itemName: "",
+ materialCode: "",
+ materialName: "",
+ quantity: 1,
+ uom: "",
+ remark: "",
+ },
+ ],
+ })
+
+ // 구매그룹코드 정보도 초기화 (필요시)
+ // TODO: picUserId로부터 구매그룹코드 정보를 조회하여 설정
+
+ } else {
+ toast.error(result.error || "일반견적 데이터를 불러올 수 없습니다")
+ onOpenChange(false)
+ }
+ } catch (error) {
+ console.error("데이터 로드 오류:", error)
+ toast.error("일반견적 데이터를 불러오는 중 오류가 발생했습니다")
+ onOpenChange(false)
+ } finally {
+ setIsLoadingData(false)
+ }
+ }, [rfqId, open, form, onOpenChange])
+
+ // 다이얼로그 열림/닫힘 처리
+ React.useEffect(() => {
+ if (open && rfqId) {
+ loadRfqData()
+ } else if (!open) {
+ // 다이얼로그가 닫힐 때 폼 초기화
+ form.reset({
+ rfqType: "",
+ rfqTitle: "",
+ dueDate: undefined,
+ picUserId: userId || undefined,
+ projectId: undefined,
+ remark: "",
+ items: [
+ {
+ itemCode: "",
+ itemName: "",
+ materialCode: "",
+ materialName: "",
+ quantity: 1,
+ uom: "",
+ remark: "",
+ },
+ ],
+ })
+ setSelectedPurchaseGroupCode(undefined)
+ }
+ }, [open, rfqId, form, userId, loadRfqData])
+
+ const onSubmit = async (data: UpdateGeneralRfqFormValues) => {
+ if (!userId) {
+ toast.error("로그인이 필요합니다")
+ return
+ }
+
+ if (!rfqId) {
+ toast.error("수정할 일반견적 ID가 없습니다")
+ return
+ }
+
+ setIsLoading(true)
+
+ try {
+ // 서버 액션 호출
+ const result = await updateGeneralRfqAction({
+ id: rfqId,
+ rfqType: data.rfqType,
+ rfqTitle: data.rfqTitle,
+ dueDate: data.dueDate,
+ picUserId: data.picUserId,
+ projectId: data.projectId,
+ remark: data.remark || "",
+ items: data.items as Array<{
+ itemCode: string;
+ itemName: string;
+ materialCode?: string;
+ materialName?: string;
+ quantity: number;
+ uom: string;
+ remark?: string;
+ }>,
+ updatedBy: userId,
+ })
+
+ if (result.success) {
+ toast.success(result.message)
+
+ // 다이얼로그 닫기
+ onOpenChange(false)
+
+ // 성공 콜백 실행
+ if (onSuccess) {
+ onSuccess()
+ }
+
+ } else {
+ toast.error(result.error || "일반견적 수정에 실패했습니다")
+ }
+
+ } catch (error) {
+ console.error('일반견적 수정 오류:', error)
+ toast.error("일반견적 수정에 실패했습니다", {
+ description: "알 수 없는 오류가 발생했습니다",
+ })
+ } finally {
+ setIsLoading(false)
+ }
+ }
+
+ // 아이템 추가
+ const handleAddItem = () => {
+ append({
+ itemCode: "",
+ itemName: "",
+ materialCode: "",
+ materialName: "",
+ quantity: 1,
+ uom: "",
+ remark: "",
+ })
+ }
+
+ return (
+ <Dialog open={open} onOpenChange={onOpenChange}>
+ <DialogContent className="max-w-4xl h-[90vh] flex flex-col">
+ {/* 고정된 헤더 */}
+ <DialogHeader className="flex-shrink-0">
+ <DialogTitle>일반견적 수정</DialogTitle>
+ <DialogDescription>
+ 기존 일반견적을 수정합니다. 필수 정보를 입력해주세요.
+ </DialogDescription>
+ </DialogHeader>
+
+ {/* 스크롤 가능한 컨텐츠 영역 */}
+ <ScrollArea className="flex-1 px-1">
+ {isLoadingData ? (
+ <div className="flex items-center justify-center py-8">
+ <Loader2 className="h-6 w-6 animate-spin mr-2" />
+ <span>데이터를 불러오는 중...</span>
+ </div>
+ ) : (
+ <Form {...form}>
+ <form id="updateGeneralRfqForm" onSubmit={form.handleSubmit(onSubmit)} className="space-y-6 py-2">
+
+ {/* 기본 정보 섹션 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-semibold">기본 정보</h3>
+
+ <div className="grid grid-cols-2 gap-4">
+ {/* 견적 종류 */}
+ <div className="space-y-2">
+ <FormField
+ control={form.control}
+ name="rfqType"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>
+ 견적 종류 <span className="text-red-500">*</span>
+ </FormLabel>
+ <Select onValueChange={handleRfqTypeChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder="견적 종류 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="단가계약">단가계약</SelectItem>
+ <SelectItem value="매각계약">매각계약</SelectItem>
+ <SelectItem value="일반계약">일반계약</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 제출마감일 */}
+ <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>
+ )}
+ />
+ </div>
+
+ {/* 견적명 */}
+ <FormField
+ control={form.control}
+ name="rfqTitle"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>
+ 견적명 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Input
+ placeholder="예: 2025년 1분기 사무용품 구매 견적"
+ {...field}
+ />
+ </FormControl>
+ <FormDescription>
+ 견적의 목적이나 내용을 간단명료하게 입력해주세요
+ </FormDescription>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 프로젝트 선택 */}
+ <FormField
+ control={form.control}
+ name="projectId"
+ render={({ field }) => (
+ <FormItem className="flex flex-col">
+ <FormLabel>프로젝트</FormLabel>
+ <FormControl>
+ <ProjectSelector
+ selectedProjectId={field.value}
+ onProjectSelect={(project) => field.onChange(project.id)}
+ placeholder="프로젝트 선택 (선택사항)..."
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 구매 담당자 - 구매그룹코드 선택기 */}
+ <FormField
+ control={form.control}
+ name="picUserId"
+ render={() => (
+ <FormItem className="flex flex-col">
+ <FormLabel>
+ 견적담당자 (구매그룹코드) <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Button
+ type="button"
+ variant="outline"
+ className="w-full justify-start h-auto min-h-[36px]"
+ onClick={() => setSelectorOpen(true)}
+ >
+ {selectedPurchaseGroupCode ? (
+ <div className="flex flex-col items-start gap-1 w-full">
+ <div className="flex items-center gap-2">
+ <Badge variant="secondary" className="font-mono text-xs">
+ {selectedPurchaseGroupCode.PURCHASE_GROUP_CODE}
+ </Badge>
+ <span className="text-sm">{selectedPurchaseGroupCode.DISPLAY_NAME}</span>
+ </div>
+ {selectedPurchaseGroupCode.user && (
+ <div className="text-xs text-muted-foreground">
+ 담당자: {selectedPurchaseGroupCode.user.name} ({selectedPurchaseGroupCode.user.email})
+ </div>
+ )}
+ {!selectedPurchaseGroupCode.user && (
+ <div className="text-xs text-orange-600">
+ ⚠️ 연결된 사용자가 없습니다
+ </div>
+ )}
+ </div>
+ ) : (
+ <span className="text-muted-foreground text-sm">
+ 구매그룹코드를 선택하세요
+ </span>
+ )}
+ </Button>
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 비고 */}
+ <FormField
+ control={form.control}
+ name="remark"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel>비고</FormLabel>
+ <FormControl>
+ <Textarea
+ placeholder="추가 비고사항을 입력하세요"
+ className="resize-none"
+ rows={3}
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ <Separator />
+
+ {/* 아이템 정보 섹션 - 컴팩트한 UI */}
+ <div className="space-y-4">
+ <div className="flex items-center justify-between">
+ <h3 className="text-lg font-semibold">자재 정보</h3>
+ <Button
+ type="button"
+ variant="outline"
+ size="sm"
+ onClick={handleAddItem}
+ >
+ <PlusCircle className="mr-2 h-4 w-4" />
+ 자재 추가
+ </Button>
+ </div>
+
+ <div className="space-y-3">
+ {fields.map((field, index) => (
+ <div key={field.id} className="border rounded-lg p-3 bg-gray-50/50">
+ <div className="flex items-center justify-between mb-3">
+ <span className="text-sm font-medium text-gray-700">
+ 자재 #{index + 1}
+ </span>
+ {fields.length > 1 && (
+ <Button
+ type="button"
+ variant="ghost"
+ size="sm"
+ onClick={() => remove(index)}
+ className="h-6 w-6 p-0 text-destructive hover:text-destructive"
+ >
+ <Trash2 className="h-3 w-3" />
+ </Button>
+ )}
+ </div>
+
+ {/* 자재그룹 선택 - 그리드 외부 */}
+ <div className="mb-3">
+ <FormLabel className="text-xs">
+ 자재그룹(자재그룹명) <span className="text-red-500">*</span>
+ </FormLabel>
+ <div className="mt-1">
+ <MaterialGroupSelectorDialogSingle
+ triggerLabel="자재그룹 선택"
+ selectedMaterial={(() => {
+ const itemCode = form.watch(`items.${index}.itemCode`);
+ const itemName = form.watch(`items.${index}.itemName`);
+ if (itemCode && itemName) {
+ return {
+ materialGroupCode: itemCode,
+ materialGroupDescription: itemName,
+ displayText: `${itemCode} - ${itemName}`
+ } as MaterialSearchItem;
+ }
+ return null;
+ })()}
+ onMaterialSelect={(material) => {
+ form.setValue(`items.${index}.itemCode`, material?.materialGroupCode || '');
+ form.setValue(`items.${index}.itemName`, material?.materialGroupDescription || '');
+ }}
+ placeholder="자재그룹을 검색하세요..."
+ title="자재그룹 선택"
+ description="원하는 자재그룹을 검색하고 선택해주세요."
+ triggerVariant="outline"
+ />
+ </div>
+ </div>
+
+ {/* 자재코드 선택 - 그리드 외부 */}
+ <div className="mb-3">
+ <FormLabel className="text-xs">
+ 자재코드(자재명)
+ </FormLabel>
+ <div className="mt-1">
+ <MaterialSelectorDialogSingle
+ triggerLabel="자재코드 선택"
+ selectedMaterial={(() => {
+ const materialCode = form.watch(`items.${index}.materialCode`);
+ const materialName = form.watch(`items.${index}.materialName`);
+ if (materialCode && materialName) {
+ return {
+ materialCode: materialCode,
+ materialName: materialName,
+ displayText: `${materialCode} - ${materialName}`
+ } as SAPMaterialSearchItem;
+ }
+ return null;
+ })()}
+ onMaterialSelect={(material) => {
+ form.setValue(`items.${index}.materialCode`, material?.materialCode || '');
+ form.setValue(`items.${index}.materialName`, material?.materialName || '');
+ }}
+ placeholder="자재코드를 검색하세요..."
+ title="자재코드 선택"
+ description="원하는 자재코드를 검색하고 선택해주세요."
+ triggerVariant="outline"
+ />
+ </div>
+ </div>
+
+ <div className="grid grid-cols-2 gap-3">
+ {/* 수량 */}
+ <FormField
+ control={form.control}
+ name={`items.${index}.quantity`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="text-xs">
+ 수량 <span className="text-red-500">*</span>
+ </FormLabel>
+ <FormControl>
+ <Input
+ type="number"
+ min="1"
+ placeholder="1"
+ className="h-8 text-sm"
+ {...field}
+ onChange={(e) => field.onChange(Number(e.target.value))}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ {/* 단위 */}
+ <FormField
+ control={form.control}
+ name={`items.${index}.uom`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="text-xs">
+ 단위 <span className="text-red-500">*</span>
+ </FormLabel>
+ <Select onValueChange={field.onChange} value={field.value}>
+ <FormControl>
+ <SelectTrigger className="h-8 text-sm">
+ <SelectValue placeholder="단위 선택" />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ <SelectItem value="EA">EA (Each)</SelectItem>
+ <SelectItem value="KG">KG (Kilogram)</SelectItem>
+ <SelectItem value="M">M (Meter)</SelectItem>
+ <SelectItem value="L">L (Liter)</SelectItem>
+ <SelectItem value="PC">PC (Piece)</SelectItem>
+ <SelectItem value="BOX">BOX (Box)</SelectItem>
+ <SelectItem value="SET">SET (Set)</SelectItem>
+ <SelectItem value="LOT">LOT (Lot)</SelectItem>
+ <SelectItem value="PCS">PCS (Pieces)</SelectItem>
+ <SelectItem value="TON">TON (Ton)</SelectItem>
+ <SelectItem value="G">G (Gram)</SelectItem>
+ <SelectItem value="ML">ML (Milliliter)</SelectItem>
+ <SelectItem value="CM">CM (Centimeter)</SelectItem>
+ <SelectItem value="MM">MM (Millimeter)</SelectItem>
+ </SelectContent>
+ </Select>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+
+ {/* 비고 - 별도 행에 배치 */}
+ <div className="mt-3">
+ <FormField
+ control={form.control}
+ name={`items.${index}.remark`}
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="text-xs">비고</FormLabel>
+ <FormControl>
+ <Input
+ placeholder="자재별 비고사항"
+ className="h-8 text-sm"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+ </form>
+ </Form>
+ )}
+ </ScrollArea>
+
+ {/* 고정된 푸터 */}
+ <DialogFooter className="flex-shrink-0">
+ <Button
+ type="button"
+ variant="outline"
+ onClick={() => onOpenChange(false)}
+ disabled={isLoading || isLoadingData}
+ >
+ 취소
+ </Button>
+ <Button
+ type="submit"
+ form="updateGeneralRfqForm"
+ onClick={form.handleSubmit(onSubmit)}
+ disabled={isLoading || isLoadingData}
+ >
+ {isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
+ {isLoading ? "수정 중..." : "일반견적 수정"}
+ </Button>
+ </DialogFooter>
+
+ {/* 구매그룹코드 선택 다이얼로그 */}
+ <PurchaseGroupCodeSingleSelector
+ open={selectorOpen}
+ onOpenChange={setSelectorOpen}
+ selectedCode={selectedPurchaseGroupCode}
+ onCodeSelect={handlePurchaseGroupCodeSelect}
+ title="견적 담당자 선택"
+ description="일반견적의 담당자를 구매그룹코드로 선택하세요"
+ showConfirmButtons={false}
+ />
+ </DialogContent>
+ </Dialog>
+ )
+}