summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-10-23 03:30:01 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-10-23 03:30:01 +0000
commitc8cccaf1198ae48754ac036b579732018f5b448a (patch)
tree9c64024818c2be1c7b6699b4e141729432719d86
parent835010104c25c370c1def1f2de52f518058f8b46 (diff)
(최겸) 기술영업 조선 rfq 수정(벤더, 담당자 임시삭제기능 추가)
-rw-r--r--lib/tech-vendors/contacts-table/add-contact-dialog.tsx9
-rw-r--r--lib/tech-vendors/contacts-table/delete-contact-dialog.tsx81
-rw-r--r--lib/tech-vendors/contacts-table/update-contact-sheet.tsx13
-rw-r--r--lib/tech-vendors/possible-items/add-item-dialog.tsx4
-rw-r--r--lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx2
-rw-r--r--lib/tech-vendors/service.ts131
-rw-r--r--lib/tech-vendors/table/add-vendor-dialog.tsx13
-rw-r--r--lib/tech-vendors/table/delete-tech-vendors-dialog.tsx80
-rw-r--r--lib/tech-vendors/table/import-button.tsx19
-rw-r--r--lib/tech-vendors/table/tech-vendors-table-columns.tsx12
-rw-r--r--lib/tech-vendors/table/tech-vendors-table.tsx7
-rw-r--r--lib/tech-vendors/table/update-vendor-sheet.tsx13
-rw-r--r--lib/tech-vendors/utils.ts84
-rw-r--r--lib/techsales-rfq/service.ts28
-rw-r--r--lib/techsales-rfq/table/create-rfq-ship-dialog.tsx9
-rw-r--r--lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx213
-rw-r--r--lib/techsales-rfq/table/detail-table/quotation-contacts-view-dialog.tsx94
-rw-r--r--lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx26
-rw-r--r--lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx1
-rw-r--r--lib/techsales-rfq/table/project-detail-dialog.tsx87
-rw-r--r--lib/techsales-rfq/table/update-rfq-sheet.tsx156
-rw-r--r--lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx1
22 files changed, 857 insertions, 226 deletions
diff --git a/lib/tech-vendors/contacts-table/add-contact-dialog.tsx b/lib/tech-vendors/contacts-table/add-contact-dialog.tsx
index 90ba4e04..447c44d7 100644
--- a/lib/tech-vendors/contacts-table/add-contact-dialog.tsx
+++ b/lib/tech-vendors/contacts-table/add-contact-dialog.tsx
@@ -21,6 +21,7 @@ import {
type CreateTechVendorContactSchema,
} from "@/lib/tech-vendors/validations"
import { createTechVendorContact } from "@/lib/tech-vendors/service"
+import { normalizeEmail } from "@/lib/tech-vendors/utils"
interface AddContactDialogProps {
vendorId: number
@@ -46,8 +47,14 @@ export function AddContactDialog({ vendorId }: AddContactDialogProps) {
})
async function onSubmit(data: CreateTechVendorContactSchema) {
+ // 이메일을 소문자로 변환
+ const normalizedData = {
+ ...data,
+ contactEmail: normalizeEmail(data.contactEmail),
+ }
+
// 혹은 여기서 data.vendorId = vendorId; 해줘도 됨
- const result = await createTechVendorContact(data)
+ const result = await createTechVendorContact(normalizedData)
if (result.error) {
alert(`에러: ${result.error}`)
return
diff --git a/lib/tech-vendors/contacts-table/delete-contact-dialog.tsx b/lib/tech-vendors/contacts-table/delete-contact-dialog.tsx
new file mode 100644
index 00000000..6c8b14d7
--- /dev/null
+++ b/lib/tech-vendors/contacts-table/delete-contact-dialog.tsx
@@ -0,0 +1,81 @@
+"use client"
+
+import * as React from "react"
+import { toast } from "sonner"
+import { Trash2 } from "lucide-react"
+
+import { Button } from "@/components/ui/button"
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alert-dialog"
+
+import { deleteTechVendorContact } from "../service"
+import type { TechVendorContact } from "@/db/schema/techVendors"
+
+interface DeleteContactDialogProps {
+ contact: TechVendorContact
+ vendorId: number
+ onSuccess?: () => void
+}
+
+export function DeleteContactDialog({ contact, vendorId, onSuccess }: DeleteContactDialogProps) {
+ const [isDeleting, setIsDeleting] = React.useState(false)
+
+ const handleDelete = async () => {
+ setIsDeleting(true)
+ try {
+ const result = await deleteTechVendorContact(contact.id, vendorId)
+
+ if (result.success) {
+ toast.success("담당자가 성공적으로 삭제되었습니다.")
+ onSuccess?.()
+ } else {
+ toast.error(result.error || "담당자 삭제 중 오류가 발생했습니다.")
+ }
+ } catch (error) {
+ console.error("담당자 삭제 오류:", error)
+ toast.error("담당자 삭제 중 오류가 발생했습니다.")
+ } finally {
+ setIsDeleting(false)
+ }
+ }
+
+ return (
+ <AlertDialog>
+ <AlertDialogTrigger asChild>
+ <Button variant="ghost" size="sm" className="text-red-600 hover:text-red-700 hover:bg-red-50">
+ <Trash2 className="size-4 mr-2" />
+ 삭제
+ </Button>
+ </AlertDialogTrigger>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>담당자 삭제</AlertDialogTitle>
+ <AlertDialogDescription>
+ <strong>{contact.contactName}</strong> 담당자를 정말 삭제하시겠습니까?
+ <br />
+ 이 작업은 되돌릴 수 없으며, 관련된 모든 데이터(아이템 매핑 등)가 함께 삭제됩니다.
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel>취소</AlertDialogCancel>
+ <AlertDialogAction
+ onClick={handleDelete}
+ disabled={isDeleting}
+ className="bg-red-600 hover:bg-red-700"
+ >
+ {isDeleting ? "삭제 중..." : "삭제"}
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ )
+}
diff --git a/lib/tech-vendors/contacts-table/update-contact-sheet.tsx b/lib/tech-vendors/contacts-table/update-contact-sheet.tsx
index 4713790c..877e8b4f 100644
--- a/lib/tech-vendors/contacts-table/update-contact-sheet.tsx
+++ b/lib/tech-vendors/contacts-table/update-contact-sheet.tsx
@@ -28,6 +28,7 @@ import { Loader2 } from "lucide-react"
import type { TechVendorContact } from "@/db/schema/techVendors"
import { updateTechVendorContactSchema, type UpdateTechVendorContactSchema } from "../validations"
import { updateTechVendorContact } from "../service"
+import { normalizeEmail } from "../utils"
interface UpdateContactSheetProps
extends React.ComponentPropsWithoutRef<typeof Sheet> {
@@ -67,13 +68,19 @@ export function UpdateContactSheet({ contact, vendorId, ...props }: UpdateContac
async function onSubmit(data: UpdateTechVendorContactSchema) {
if (!contact) return
-
+
startTransition(async () => {
try {
- const { error } = await updateTechVendorContact({
+ // 이메일을 소문자로 변환
+ const normalizedData = {
+ ...data,
+ contactEmail: normalizeEmail(data.contactEmail) || undefined,
+ }
+
+ const { error } = await updateTechVendorContact({
id: contact.id,
vendorId: vendorId,
- ...data
+ ...normalizedData,
})
if (error) throw new Error(error)
diff --git a/lib/tech-vendors/possible-items/add-item-dialog.tsx b/lib/tech-vendors/possible-items/add-item-dialog.tsx
index 0e6edd19..ceb34276 100644
--- a/lib/tech-vendors/possible-items/add-item-dialog.tsx
+++ b/lib/tech-vendors/possible-items/add-item-dialog.tsx
@@ -166,9 +166,9 @@ export function AddItemDialog({ open, onOpenChange, vendorId }: AddItemDialogPro
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-4xl max-h-[90vh] flex flex-col">
<DialogHeader>
- <DialogTitle>아이템 추가</DialogTitle>
+ <DialogTitle>아이템 연결</DialogTitle>
<DialogDescription>
- 추가할 아이템을 선택하세요. 복수 선택이 가능합니다.
+ 연결할 아이템을 선택하세요. 복수 선택이 가능합니다.
</DialogDescription>
</DialogHeader>
diff --git a/lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx b/lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx
index bed65727..49a673ff 100644
--- a/lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx
+++ b/lib/tech-vendors/possible-items/possible-items-toolbar-actions.tsx
@@ -362,7 +362,7 @@ export function PossibleItemsTableToolbarActions({
onClick={onAdd}
>
<Plus className="mr-2 h-4 w-4" />
- 아이템 추가
+ 아이템 연결
</Button>
{selectedRows.length > 0 && (
diff --git a/lib/tech-vendors/service.ts b/lib/tech-vendors/service.ts
index 72f8632d..940e59ce 100644
--- a/lib/tech-vendors/service.ts
+++ b/lib/tech-vendors/service.ts
@@ -40,6 +40,7 @@ import { sql } from "drizzle-orm";
import { decryptWithServerAction } from "@/components/drm/drmUtils";
import { deleteFile, saveDRMFile } from "../file-stroage";
import { techSalesContactPossibleItems } from "@/db/schema";
+import { normalizeEmailFields } from "./utils";
/* -----------------------------------------------------
1) 조회 관련
@@ -563,24 +564,6 @@ export async function updateTechVendorContact(input: UpdateTechVendorContactSche
}
}
-export async function deleteTechVendorContact(contactId: number, vendorId: number) {
- unstable_noStore();
- try {
- const [deletedContact] = await db
- .delete(techVendorContacts)
- .where(eq(techVendorContacts.id, contactId))
- .returning();
-
- // 캐시 무효화
- revalidateTag(`tech-vendor-contacts-${contactId}`);
- revalidateTag(`tech-vendor-contacts-${vendorId}`);
-
- return { data: deletedContact, error: null };
- } catch (err) {
- return { data: null, error: getErrorMessage(err) };
- }
-}
-
/* -----------------------------------------------------
5) 아이템 관리
----------------------------------------------------- */
@@ -4116,4 +4099,116 @@ export async function generateContactPossibleItemsErrorExcel(errors: Array<{
return new Blob([buffer], {
type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
})
+}
+
+/**
+ * 기술영업 벤더 담당자 삭제 (cascade 방식)
+ * 1. tech_sales_contact_possible_items (해당 담당자의 아이템 매핑)
+ * 2. tech_vendor_contacts (해당 담당자)
+ */
+export async function deleteTechVendorContact(contactId: number, vendorId: number) {
+ unstable_noStore()
+
+ try {
+ const result = await db.transaction(async (tx) => {
+ // 1. 해당 담당자의 아이템 매핑 삭제 (tech_sales_contact_possible_items)
+ await tx
+ .delete(techSalesContactPossibleItems)
+ .where(eq(techSalesContactPossibleItems.contactId, contactId))
+
+ // 2. 담당자 삭제 (tech_vendor_contacts)
+ const deletedContact = await tx
+ .delete(techVendorContacts)
+ .where(eq(techVendorContacts.id, contactId))
+ .returning()
+
+ return deletedContact
+ })
+
+ // 캐시 무효화
+ revalidateTag(`tech-vendor-contacts-${vendorId}`)
+ revalidateTag("tech-vendors")
+
+ return {
+ success: true,
+ data: result,
+ error: null
+ }
+ } catch (err) {
+ console.error("담당자 삭제 오류:", err)
+ return {
+ success: false,
+ data: null,
+ error: getErrorMessage(err)
+ }
+ }
+}
+
+/**
+ * 기술영업 벤더 삭제 (cascade 방식)
+ * 1. tech_sales_contact_possible_items (담당자별 아이템)
+ * 2. tech_vendor_possible_items (벤더 아이템)
+ * 3. tech_vendor_contacts (벤더 담당자)
+ * 4. tech_vendor_attachments (벤더 첨부파일)
+ * 5. tech_vendors (벤더)
+ */
+// 임시 삭제 함수
+export async function deleteTechVendor(vendorId: number) {
+ unstable_noStore()
+
+ try {
+ const result = await db.transaction(async (tx) => {
+ // 1. 해당 벤더의 담당자별 아이템 삭제 (tech_sales_contact_possible_items)
+ await tx
+ .delete(techSalesContactPossibleItems)
+ .where(
+ inArray(
+ techSalesContactPossibleItems.contactId,
+ tx
+ .select({ contactId: techVendorContacts.id })
+ .from(techVendorContacts)
+ .where(eq(techVendorContacts.vendorId, vendorId))
+ )
+ )
+
+ // 2. 해당 벤더의 아이템 삭제 (tech_vendor_possible_items)
+ await tx
+ .delete(techVendorPossibleItems)
+ .where(eq(techVendorPossibleItems.vendorId, vendorId))
+
+ // 3. 해당 벤더의 담당자 삭제 (tech_vendor_contacts)
+ await tx
+ .delete(techVendorContacts)
+ .where(eq(techVendorContacts.vendorId, vendorId))
+
+ // 4. 해당 벤더의 첨부파일 삭제 (tech_vendor_attachments)
+ await tx
+ .delete(techVendorAttachments)
+ .where(eq(techVendorAttachments.vendorId, vendorId))
+
+ // 5. 벤더 삭제 (tech_vendors)
+ const deletedVendor = await tx
+ .delete(techVendors)
+ .where(eq(techVendors.id, vendorId))
+ .returning()
+
+ return deletedVendor
+ })
+
+ // 캐시 무효화
+ revalidateTag("tech-vendors")
+
+ return {
+ success: true,
+ data: result,
+ error: null
+ }
+ } catch (err) {
+ console.error("벤더 삭제 오류:", err)
+ return {
+ success: false,
+ data: null,
+ error: getErrorMessage(err)
+ }
+ }
} \ No newline at end of file
diff --git a/lib/tech-vendors/table/add-vendor-dialog.tsx b/lib/tech-vendors/table/add-vendor-dialog.tsx
index e89f5d6b..f696b30a 100644
--- a/lib/tech-vendors/table/add-vendor-dialog.tsx
+++ b/lib/tech-vendors/table/add-vendor-dialog.tsx
@@ -30,6 +30,7 @@ import { Textarea } from "@/components/ui/textarea"
import { Plus, Loader2 } from "lucide-react"
import { addTechVendor } from "../service"
+import { normalizeEmailFields } from "../utils"
// 폼 스키마 정의
const addVendorSchema = z.object({
@@ -92,6 +93,13 @@ export function AddVendorDialog({ onSuccess }: AddVendorDialogProps) {
const onSubmit = async (data: AddVendorFormData) => {
setIsLoading(true)
try {
+ // 이메일 필드들을 소문자로 변환
+ const normalizedEmails = normalizeEmailFields({
+ email: data.email,
+ agentEmail: data.agentEmail,
+ representativeEmail: data.representativeEmail,
+ })
+
const result = await addTechVendor({
...data,
vendorCode: data.vendorCode || null,
@@ -100,13 +108,14 @@ export function AddVendorDialog({ onSuccess }: AddVendorDialogProps) {
countryFab: data.countryFab || null,
agentName: data.agentName || null,
agentPhone: data.agentPhone || null,
- agentEmail: data.agentEmail || null,
+ agentEmail: normalizedEmails.agentEmail,
address: data.address || null,
phone: data.phone || null,
website: data.website || null,
+ email: normalizedEmails.email,
techVendorType: data.techVendorType.join(','),
representativeName: data.representativeName || null,
- representativeEmail: data.representativeEmail || null,
+ representativeEmail: normalizedEmails.representativeEmail,
representativePhone: data.representativePhone || null,
representativeBirth: data.representativeBirth || null,
taxId: data.taxId || "",
diff --git a/lib/tech-vendors/table/delete-tech-vendors-dialog.tsx b/lib/tech-vendors/table/delete-tech-vendors-dialog.tsx
new file mode 100644
index 00000000..4fd3f32a
--- /dev/null
+++ b/lib/tech-vendors/table/delete-tech-vendors-dialog.tsx
@@ -0,0 +1,80 @@
+"use client"
+
+import * as React from "react"
+import { toast } from "sonner"
+import { Trash2 } from "lucide-react"
+
+import { Button } from "@/components/ui/button"
+import {
+ AlertDialog,
+ AlertDialogAction,
+ AlertDialogCancel,
+ AlertDialogContent,
+ AlertDialogDescription,
+ AlertDialogFooter,
+ AlertDialogHeader,
+ AlertDialogTitle,
+ AlertDialogTrigger,
+} from "@/components/ui/alert-dialog"
+
+import { deleteTechVendor } from "../service"
+import type { TechVendor } from "@/db/schema/techVendors"
+
+interface DeleteTechVendorDialogProps {
+ vendor: TechVendor
+ onSuccess?: () => void
+}
+// 임시 삭제 버튼
+export function DeleteTechVendorDialog({ vendor, onSuccess }: DeleteTechVendorDialogProps) {
+ const [isDeleting, setIsDeleting] = React.useState(false)
+
+ const handleDelete = async () => {
+ setIsDeleting(true)
+ try {
+ const result = await deleteTechVendor(vendor.id)
+
+ if (result.success) {
+ toast.success("벤더가 성공적으로 삭제되었습니다.")
+ onSuccess?.()
+ } else {
+ toast.error(result.error || "벤더 삭제 중 오류가 발생했습니다.")
+ }
+ } catch (error) {
+ console.error("벤더 삭제 오류:", error)
+ toast.error("벤더 삭제 중 오류가 발생했습니다.")
+ } finally {
+ setIsDeleting(false)
+ }
+ }
+
+ return (
+ <AlertDialog>
+ <AlertDialogTrigger asChild>
+ <Button variant="ghost" size="sm" className="text-red-600 hover:text-red-700 hover:bg-red-50">
+ <Trash2 className="size-4 mr-2" />
+ 삭제
+ </Button>
+ </AlertDialogTrigger>
+ <AlertDialogContent>
+ <AlertDialogHeader>
+ <AlertDialogTitle>벤더 삭제</AlertDialogTitle>
+ <AlertDialogDescription>
+ <strong>{vendor.vendorName}</strong> 벤더를 정말 삭제하시겠습니까?
+ <br />
+ 이 작업은 되돌릴 수 없으며, 관련된 모든 데이터(담당자, 아이템 등)가 함께 삭제됩니다.
+ </AlertDialogDescription>
+ </AlertDialogHeader>
+ <AlertDialogFooter>
+ <AlertDialogCancel>취소</AlertDialogCancel>
+ <AlertDialogAction
+ onClick={handleDelete}
+ disabled={isDeleting}
+ className="bg-red-600 hover:bg-red-700"
+ >
+ {isDeleting ? "삭제 중..." : "삭제"}
+ </AlertDialogAction>
+ </AlertDialogFooter>
+ </AlertDialogContent>
+ </AlertDialog>
+ )
+}
diff --git a/lib/tech-vendors/table/import-button.tsx b/lib/tech-vendors/table/import-button.tsx
index 85b16bc7..e0f95195 100644
--- a/lib/tech-vendors/table/import-button.tsx
+++ b/lib/tech-vendors/table/import-button.tsx
@@ -17,6 +17,7 @@ import {
import { Progress } from "@/components/ui/progress"
import { importTechVendorsFromExcel } from "../service"
import { decryptWithServerAction } from "@/components/drm/drmUtils"
+import { normalizeEmail } from "../utils"
interface ImportTechVendorButtonProps {
onSuccess?: () => void;
@@ -192,13 +193,17 @@ export function ImportTechVendorButton({ onSuccess }: ImportTechVendorButtonProp
// 벤더 데이터 처리
const vendors = dataRows.map(row => {
- const vendorEmail = row["이메일"] || row["email"] || "";
+ // 이메일들을 소문자로 변환
+ const vendorEmail = normalizeEmail(row["이메일"] || row["email"]) || null;
+ const contactEmail = normalizeEmail(row["담당자이메일"] || row["contactEmail"]);
+ const agentEmail = normalizeEmail(row["에이전트이메일"] || row["agentEmail"]);
+ const representativeEmail = normalizeEmail(row["대표자이메일"] || row["representativeEmail"]);
+
const contactName = row["담당자명"] || row["contactName"] || "";
- const contactEmail = row["담당자이메일"] || row["contactEmail"] || "";
-
+
// 담당자 정보 처리: 담당자가 없으면 벤더 이메일을 기본 담당자로 사용
const contacts = [];
-
+
if (contactName && contactEmail) {
// 명시적인 담당자가 있는 경우
contacts.push({
@@ -221,7 +226,7 @@ export function ImportTechVendorButton({ onSuccess }: ImportTechVendorButtonProp
isPrimary: true
});
}
-
+
return {
vendorName: row["업체명"] || row["vendorName"] || "",
vendorCode: row["업체코드"] || row["vendorCode"] || null,
@@ -232,13 +237,13 @@ export function ImportTechVendorButton({ onSuccess }: ImportTechVendorButtonProp
countryFab: row["제조국"] || row["countryFab"] || null,
agentName: row["에이전트명"] || row["agentName"] || null,
agentPhone: row["에이전트연락처"] || row["agentPhone"] || null,
- agentEmail: row["에이전트이메일"] || row["agentEmail"] || null,
+ agentEmail: agentEmail,
address: row["주소"] || row["address"] || null,
phone: row["전화번호"] || row["phone"] || null,
website: row["웹사이트"] || row["website"] || null,
techVendorType: row["벤더타입"] || row["techVendorType"] || "",
representativeName: row["대표자명"] || row["representativeName"] || null,
- representativeEmail: row["대표자이메일"] || row["representativeEmail"] || null,
+ representativeEmail: representativeEmail,
representativePhone: row["대표자연락처"] || row["representativePhone"] || null,
representativeBirth: row["대표자생년월일"] || row["representativeBirth"] || null,
items: row["아이템"] || row["items"] || "",
diff --git a/lib/tech-vendors/table/tech-vendors-table-columns.tsx b/lib/tech-vendors/table/tech-vendors-table-columns.tsx
index da17a975..c1bf6229 100644
--- a/lib/tech-vendors/table/tech-vendors-table-columns.tsx
+++ b/lib/tech-vendors/table/tech-vendors-table-columns.tsx
@@ -30,6 +30,7 @@ import { DataTableColumnHeaderSimple } from "@/components/data-table/data-table-
import { techVendorColumnsConfig } from "@/config/techVendorColumnsConfig"
import { Separator } from "@/components/ui/separator"
import { getVendorStatusIcon } from "../utils"
+import { DeleteTechVendorDialog } from "./delete-tech-vendors-dialog"
// 타입 정의 추가
type StatusType = (typeof techVendors.status.enumValues)[number];
@@ -47,6 +48,7 @@ type NextRouter = ReturnType<typeof useRouter>;
interface GetColumnsProps {
setRowAction: React.Dispatch<React.SetStateAction<DataTableRowAction<TechVendor> | null>>;
router: NextRouter;
+ onVendorDeleted?: () => void;
}
@@ -55,7 +57,7 @@ interface GetColumnsProps {
/**
* tanstack table 컬럼 정의 (중첩 헤더 버전)
*/
-export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef<TechVendor>[] {
+export function getColumns({ setRowAction, router, onVendorDeleted }: GetColumnsProps): ColumnDef<TechVendor>[] {
// ----------------------------------------------------------------
// 1) select 컬럼 (체크박스)
// ----------------------------------------------------------------
@@ -169,6 +171,14 @@ export function getColumns({ setRowAction, router }: GetColumnsProps): ColumnDef
</DropdownMenuSubContent>
</DropdownMenuSub>
+ <Separator />
+ {/* 벤더 임시 삭제 버튼 */}
+ <div className="px-2 py-1">
+ <DeleteTechVendorDialog
+ vendor={row.original}
+ onSuccess={onVendorDeleted}
+ />
+ </div>
</DropdownMenuContent>
</DropdownMenu>
diff --git a/lib/tech-vendors/table/tech-vendors-table.tsx b/lib/tech-vendors/table/tech-vendors-table.tsx
index 553ff109..e432a453 100644
--- a/lib/tech-vendors/table/tech-vendors-table.tsx
+++ b/lib/tech-vendors/table/tech-vendors-table.tsx
@@ -58,8 +58,13 @@ export function TechVendorsTable({
const router = useRouter()
// getColumns() 호출 시, router를 주입
+ // 임시 삭제 버튼 추가
const columns = React.useMemo(
- () => getColumns({ setRowAction, router }),
+ () => getColumns({
+ setRowAction,
+ router,
+ onVendorDeleted: () => router.refresh()
+ }),
[setRowAction, router]
)
diff --git a/lib/tech-vendors/table/update-vendor-sheet.tsx b/lib/tech-vendors/table/update-vendor-sheet.tsx
index 8498df51..3cbb2ba3 100644
--- a/lib/tech-vendors/table/update-vendor-sheet.tsx
+++ b/lib/tech-vendors/table/update-vendor-sheet.tsx
@@ -45,6 +45,7 @@ import { useSession } from "next-auth/react"
import { TechVendor, techVendors } from "@/db/schema/techVendors"
import { updateTechVendorSchema, type UpdateTechVendorSchema } from "../validations"
import { modifyTechVendor } from "../service"
+import { normalizeEmailFields } from "../utils"
interface UpdateVendorSheetProps
extends React.ComponentPropsWithRef<typeof Sheet> {
@@ -186,12 +187,22 @@ export function UpdateVendorSheet({ vendor, ...props }: UpdateVendorSheetProps)
? `상태 변경: ${getStatusConfig(oldStatus).label} → ${getStatusConfig(newStatus).label}`
: "" // Empty string instead of undefined
+ // 이메일 필드들을 소문자로 변환
+ const normalizedEmails = normalizeEmailFields({
+ email: data.email,
+ agentEmail: data.agentEmail,
+ representativeEmail: data.representativeEmail,
+ })
+
// 업체 정보 업데이트 - userId와 상태 변경 코멘트 추가
- const { error } = await modifyTechVendor({
+ const { error } = await modifyTechVendor({
id: String(vendor.id),
userId: Number(session.user.id), // Add user ID from session
comment: statusComment, // Add comment for status changes
...data, // 모든 데이터 전달 - 서비스 함수에서 필요한 필드만 처리
+ email: normalizedEmails.email || undefined,
+ agentEmail: normalizedEmails.agentEmail || undefined,
+ representativeEmail: normalizedEmails.representativeEmail || undefined,
techVendorType: Array.isArray(data.techVendorType) ? data.techVendorType.join(',') : undefined,
})
diff --git a/lib/tech-vendors/utils.ts b/lib/tech-vendors/utils.ts
index ac91cd8d..ea8cdbd3 100644
--- a/lib/tech-vendors/utils.ts
+++ b/lib/tech-vendors/utils.ts
@@ -1,28 +1,56 @@
-import { LucideIcon, CheckCircle2, CircleAlert, Clock, ShieldAlert, Mail, BarChart2 } from "lucide-react";
-import type { TechVendor } from "@/db/schema/techVendors";
-
-type StatusType = TechVendor["status"];
-
-/**
- * 기술벤더 상태에 대한 아이콘을 반환합니다.
- */
-export function getVendorStatusIcon(status: StatusType): LucideIcon {
- switch (status) {
- case "PENDING_INVITE":
- return Clock;
- case "INVITED":
- return Mail;
- case "QUOTE_COMPARISON":
- return BarChart2;
- case "ACTIVE":
- return CheckCircle2;
- case "INACTIVE":
- return CircleAlert;
- case "BLACKLISTED":
- return ShieldAlert;
- default:
- return CircleAlert;
- }
-}
-
-
+import { LucideIcon, CheckCircle2, CircleAlert, Clock, ShieldAlert, Mail, BarChart2 } from "lucide-react";
+import type { TechVendor } from "@/db/schema/techVendors";
+
+type StatusType = TechVendor["status"];
+
+/**
+ * 기술벤더 상태에 대한 아이콘을 반환합니다.
+ */
+export function getVendorStatusIcon(status: StatusType): LucideIcon {
+ switch (status) {
+ case "PENDING_INVITE":
+ return Clock;
+ case "INVITED":
+ return Mail;
+ case "QUOTE_COMPARISON":
+ return BarChart2;
+ case "ACTIVE":
+ return CheckCircle2;
+ case "INACTIVE":
+ return CircleAlert;
+ case "BLACKLISTED":
+ return ShieldAlert;
+ default:
+ return CircleAlert;
+ }
+}
+
+/**
+ * 이메일을 소문자로 변환합니다.
+ * null, undefined, 또는 빈 문자열인 경우 null을 반환합니다.
+ */
+export function normalizeEmail(email: string | null | undefined): string | null {
+ if (!email || typeof email !== 'string' || email.trim() === '') {
+ return null;
+ }
+ return email.toLowerCase().trim();
+}
+
+/**
+ * 여러 이메일 필드들을 소문자로 변환합니다.
+ */
+export function normalizeEmailFields(data: {
+ email?: string | null;
+ agentEmail?: string | null;
+ representativeEmail?: string | null;
+ contactEmail?: string | null;
+}) {
+ return {
+ email: normalizeEmail(data.email),
+ agentEmail: normalizeEmail(data.agentEmail),
+ representativeEmail: normalizeEmail(data.representativeEmail),
+ contactEmail: normalizeEmail(data.contactEmail),
+ };
+}
+
+
diff --git a/lib/techsales-rfq/service.ts b/lib/techsales-rfq/service.ts
index 058ef48b..9a198ee5 100644
--- a/lib/techsales-rfq/service.ts
+++ b/lib/techsales-rfq/service.ts
@@ -2,8 +2,8 @@
import { unstable_noStore, revalidateTag, revalidatePath } from "next/cache";
import db from "@/db/db";
-import {
- techSalesRfqs,
+import {
+ techSalesRfqs,
techSalesVendorQuotations,
techSalesVendorQuotationRevisions,
techSalesAttachments,
@@ -13,7 +13,8 @@ import {
users,
techSalesRfqComments,
techSalesRfqItems,
- biddingProjects
+ biddingProjects,
+ projectSeries
} from "@/db/schema";
import { and, desc, eq, ilike, or, sql, inArray, count, asc, lt, ne } from "drizzle-orm";
import { unstable_cache } from "@/lib/unstable-cache";
@@ -4209,6 +4210,7 @@ export async function getQuotationContacts(quotationId: number) {
contactId: techSalesVendorQuotationContacts.contactId,
contactName: techVendorContacts.contactName,
contactPosition: techVendorContacts.contactPosition,
+ contactTitle: techVendorContacts.contactTitle,
contactEmail: techVendorContacts.contactEmail,
contactPhone: techVendorContacts.contactPhone,
contactCountry: techVendorContacts.contactCountry,
@@ -4320,15 +4322,23 @@ export async function getTechSalesRfqById(id: number) {
pjtType: biddingProjects.pjtType,
ptypeNm: biddingProjects.ptypeNm,
projMsrm: biddingProjects.projMsrm,
+ pspid: biddingProjects.pspid,
})
.from(biddingProjects)
.where(eq(biddingProjects.id, rfq?.biddingProjectId ?? 0));
-
+
+ // 시리즈 정보 가져오기
+ const series = await db
+ .select()
+ .from(projectSeries)
+ .where(eq(projectSeries.pspid, project?.pspid ?? ""))
+ .orderBy(projectSeries.sersNo);
+
if (!rfq) {
return { data: null, error: "RFQ를 찾을 수 없습니다." };
}
-
- return { data: { ...rfq, project }, error: null };
+
+ return { data: { ...rfq, project, series }, error: null };
} catch (err) {
console.error("Error fetching RFQ:", err);
return { data: null, error: getErrorMessage(err) };
@@ -4339,6 +4349,7 @@ export async function getTechSalesRfqById(id: number) {
export async function updateTechSalesRfq(data: {
id: number;
description: string;
+ remark: string;
dueDate: Date;
updatedBy: number;
}) {
@@ -4347,15 +4358,16 @@ export async function updateTechSalesRfq(data: {
const rfq = await tx.query.techSalesRfqs.findFirst({
where: eq(techSalesRfqs.id, data.id),
});
-
+
if (!rfq) {
return { data: null, error: "RFQ를 찾을 수 없습니다." };
}
-
+
const [updatedRfq] = await tx
.update(techSalesRfqs)
.set({
description: data.description, // description 필드로 업데이트
+ remark: data.remark, // remark 필드로 업데이트
dueDate: data.dueDate,
updatedAt: new Date(),
})
diff --git a/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx b/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx
index b851f7e8..035cd97e 100644
--- a/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx
+++ b/lib/techsales-rfq/table/create-rfq-ship-dialog.tsx
@@ -4,6 +4,7 @@ import * as React from "react"
import { toast } from "sonner"
import { ArrowUpDown, CheckSquare, Plus, Search, Square, X, Loader2 } from "lucide-react"
import { Input } from "@/components/ui/input"
+import { Textarea } from "@/components/ui/textarea"
import { Calendar } from "@/components/ui/calendar"
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"
import { CalendarIcon } from "lucide-react"
@@ -228,9 +229,10 @@ export function CreateShipRfqDialog({ onCreated }: CreateShipRfqDialogProps) {
return filtered
}, [allItems, itemSearchQuery, selectedWorkType, selectedShipType])
- // 사용 가능한 선종 목록 가져오기 (OPTION 제외)
+ // 사용 가능한 선종 목록 가져오기 (OPTION 제외, others는 맨 밑으로)
const availableShipTypes = React.useMemo(() => {
- return shipTypes.filter(shipType => shipType !== "OPTION")
+ const filtered = shipTypes.filter(shipType => shipType !== "OPTION")
+ return [...filtered.filter(type => type !== "OTHERS"), ...filtered.filter(type => type === "OTHERS")]
}, [shipTypes])
// 프로젝트 선택 처리
@@ -408,8 +410,9 @@ export function CreateShipRfqDialog({ onCreated }: CreateShipRfqDialogProps) {
<FormItem>
<FormLabel>RFQ Context</FormLabel>
<FormControl>
- <Input
+ <Textarea
placeholder="RFQ Context를 입력하세요 (선택사항)"
+ className="min-h-[100px]"
{...field}
/>
</FormControl>
diff --git a/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx b/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx
index ea982407..438ee840 100644
--- a/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx
+++ b/lib/techsales-rfq/table/detail-table/add-vendor-dialog.tsx
@@ -362,99 +362,128 @@ export function AddVendorDialog({
)
// 벤더 구분자 설정 UI
- const renderVendorFlagsStep = () => (
- <div className="space-y-4">
- <div className="text-sm text-muted-foreground">
- 선택된 벤더들의 구분자를 설정해주세요. 각 벤더별로 여러 구분자를 선택할 수 있습니다.
- </div>
-
- {selectedVendorData.length > 0 ? (
- <div className="space-y-4">
- {selectedVendorData.map((vendor) => (
- <Card key={vendor.id}>
- <CardHeader className="pb-3">
- <CardTitle className="text-base">{vendor.vendorName}</CardTitle>
- <CardDescription>
- {vendor.vendorCode || 'N/A'} {vendor.country && `• ${vendor.country}`}
- </CardDescription>
- </CardHeader>
- <CardContent className="space-y-3">
- <div className="grid grid-cols-2 gap-3">
- <div className="flex items-center space-x-2">
- <Checkbox
- id={`customer-preferred-${vendor.id}`}
- checked={vendorFlags[vendor.id.toString()]?.isCustomerPreferred || false}
- onCheckedChange={(checked) =>
- handleVendorFlagChange(vendor.id, 'isCustomerPreferred', checked as boolean)
- }
- />
- <label
- htmlFor={`customer-preferred-${vendor.id}`}
- className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
- >
- 고객(선주) 선호 벤더
- </label>
- </div>
-
- <div className="flex items-center space-x-2">
- <Checkbox
- id={`new-discovery-${vendor.id}`}
- checked={vendorFlags[vendor.id.toString()]?.isNewDiscovery || false}
- onCheckedChange={(checked) =>
- handleVendorFlagChange(vendor.id, 'isNewDiscovery', checked as boolean)
- }
- />
- <label
- htmlFor={`new-discovery-${vendor.id}`}
- className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
- >
- 신규 발굴 벤더
- </label>
- </div>
-
- <div className="flex items-center space-x-2">
- <Checkbox
- id={`project-approved-${vendor.id}`}
- checked={vendorFlags[vendor.id.toString()]?.isProjectApproved || false}
- onCheckedChange={(checked) =>
- handleVendorFlagChange(vendor.id, 'isProjectApproved', checked as boolean)
- }
- />
- <label
- htmlFor={`project-approved-${vendor.id}`}
- className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
- >
- Project Approved Vendor
- </label>
- </div>
-
- <div className="flex items-center space-x-2">
- <Checkbox
- id={`shi-proposal-${vendor.id}`}
- checked={vendorFlags[vendor.id.toString()]?.isShiProposal || false}
- onCheckedChange={(checked) =>
- handleVendorFlagChange(vendor.id, 'isShiProposal', checked as boolean)
- }
- />
- <label
- htmlFor={`shi-proposal-${vendor.id}`}
- className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
- >
- SHI Proposal Vendor
- </label>
- </div>
- </div>
- </CardContent>
- </Card>
- ))}
- </div>
- ) : (
- <div className="text-center py-8 text-muted-foreground border rounded-md">
- 선택된 벤더가 없습니다
+ const renderVendorFlagsStep = () => {
+ const isShipRfq = selectedRfq?.rfqType === 'SHIP'
+
+ return (
+ <div className="space-y-4">
+ <div className="text-sm text-muted-foreground">
+ 선택된 벤더들의 구분자를 설정해주세요. 각 벤더별로 여러 구분자를 선택할 수 있습니다.
</div>
- )}
- </div>
- )
+
+ {selectedVendorData.length > 0 ? (
+ <div className="space-y-4">
+ {selectedVendorData.map((vendor) => (
+ <Card key={vendor.id}>
+ <CardHeader className="pb-3">
+ <CardTitle className="text-base">{vendor.vendorName}</CardTitle>
+ <CardDescription>
+ {vendor.vendorCode || 'N/A'} {vendor.country && `• ${vendor.country}`}
+ </CardDescription>
+ </CardHeader>
+ <CardContent className="space-y-3">
+ <div className="grid grid-cols-2 gap-3">
+ {/* 조선 RFQ인 경우: 고객 선호벤더, 신규발굴벤더, SHI Proposal Vendor 표시 */}
+ {isShipRfq && (
+ <>
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id={`customer-preferred-${vendor.id}`}
+ checked={vendorFlags[vendor.id.toString()]?.isCustomerPreferred || false}
+ onCheckedChange={(checked) =>
+ handleVendorFlagChange(vendor.id, 'isCustomerPreferred', checked as boolean)
+ }
+ />
+ <label
+ htmlFor={`customer-preferred-${vendor.id}`}
+ className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+ >
+ 고객(선주) 선호 벤더
+ </label>
+ </div>
+
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id={`new-discovery-${vendor.id}`}
+ checked={vendorFlags[vendor.id.toString()]?.isNewDiscovery || false}
+ onCheckedChange={(checked) =>
+ handleVendorFlagChange(vendor.id, 'isNewDiscovery', checked as boolean)
+ }
+ />
+ <label
+ htmlFor={`new-discovery-${vendor.id}`}
+ className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+ >
+ 신규 발굴 벤더
+ </label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id={`shi-proposal-${vendor.id}`}
+ checked={vendorFlags[vendor.id.toString()]?.isShiProposal || false}
+ onCheckedChange={(checked) =>
+ handleVendorFlagChange(vendor.id, 'isShiProposal', checked as boolean)
+ }
+ />
+ <label
+ htmlFor={`shi-proposal-${vendor.id}`}
+ className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+ >
+ SHI Proposal Vendor
+ </label>
+ </div>
+ </>
+ )}
+
+ {/* 조선 RFQ가 아닌 경우: Project Approved Vendor, SHI Proposal Vendor 표시 */}
+ {!isShipRfq && (
+ <>
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id={`project-approved-${vendor.id}`}
+ checked={vendorFlags[vendor.id.toString()]?.isProjectApproved || false}
+ onCheckedChange={(checked) =>
+ handleVendorFlagChange(vendor.id, 'isProjectApproved', checked as boolean)
+ }
+ />
+ <label
+ htmlFor={`project-approved-${vendor.id}`}
+ className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+ >
+ Project Approved Vendor
+ </label>
+ </div>
+
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id={`shi-proposal-${vendor.id}`}
+ checked={vendorFlags[vendor.id.toString()]?.isShiProposal || false}
+ onCheckedChange={(checked) =>
+ handleVendorFlagChange(vendor.id, 'isShiProposal', checked as boolean)
+ }
+ />
+ <label
+ htmlFor={`shi-proposal-${vendor.id}`}
+ className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
+ >
+ SHI Proposal Vendor
+ </label>
+ </div>
+ </>
+ )}
+ </div>
+ </CardContent>
+ </Card>
+ ))}
+ </div>
+ ) : (
+ <div className="text-center py-8 text-muted-foreground border rounded-md">
+ 선택된 벤더가 없습니다
+ </div>
+ )}
+ </div>
+ )
+ }
return (
<Dialog open={open} onOpenChange={onOpenChange}>
diff --git a/lib/techsales-rfq/table/detail-table/quotation-contacts-view-dialog.tsx b/lib/techsales-rfq/table/detail-table/quotation-contacts-view-dialog.tsx
index 61c97b1b..608b5670 100644
--- a/lib/techsales-rfq/table/detail-table/quotation-contacts-view-dialog.tsx
+++ b/lib/techsales-rfq/table/detail-table/quotation-contacts-view-dialog.tsx
@@ -109,60 +109,62 @@ export function QuotationContactsViewDialog({
</div>
) : (
<div className="space-y-3">
- {contacts.map((contact) => (
- <div
- key={contact.id}
- className="flex items-center justify-between p-4 border rounded-lg bg-gray-50"
- >
- <div className="flex items-center gap-3">
- <User className="size-4 text-muted-foreground" />
- <div>
- <div className="flex items-center gap-2">
- <span className="font-medium">{contact.contactName}</span>
- {contact.isPrimary && (
- <Badge variant="secondary" className="text-xs">
- 주담당자
- </Badge>
+ {contacts
+ .filter((contact) => contact.contactTitle) // contactTitle이 있는 담당자만 필터링 (체크표시된 담당자)
+ .map((contact) => (
+ <div
+ key={contact.id}
+ className="flex items-center justify-between p-4 border rounded-lg bg-gray-50"
+ >
+ <div className="flex items-center gap-3">
+ <User className="size-4 text-muted-foreground" />
+ <div>
+ <div className="flex items-center gap-2">
+ <span className="font-medium">{contact.contactName}</span>
+ {contact.isPrimary && (
+ <Badge variant="secondary" className="text-xs">
+ 주담당자
+ </Badge>
+ )}
+ </div>
+ {contact.contactPosition && (
+ <p className="text-sm text-muted-foreground">
+ {contact.contactPosition}
+ </p>
+ )}
+ {contact.contactTitle && (
+ <p className="text-sm text-muted-foreground">
+ {contact.contactTitle}
+ </p>
+ )}
+ {contact.contactCountry && (
+ <p className="text-xs text-muted-foreground">
+ {contact.contactCountry}
+ </p>
)}
</div>
- {contact.contactPosition && (
- <p className="text-sm text-muted-foreground">
- {contact.contactPosition}
- </p>
- )}
- {contact.contactTitle && (
- <p className="text-sm text-muted-foreground">
- {contact.contactTitle}
- </p>
- )}
- {contact.contactCountry && (
- <p className="text-xs text-muted-foreground">
- {contact.contactCountry}
- </p>
- )}
- </div>
- </div>
-
- <div className="flex flex-col items-end gap-1 text-sm">
- <div className="flex items-center gap-1">
- <Mail className="size-4 text-muted-foreground" />
- <span>{contact.contactEmail}</span>
</div>
- {contact.contactPhone && (
+
+ <div className="flex flex-col items-end gap-1 text-sm">
<div className="flex items-center gap-1">
- <Phone className="size-4 text-muted-foreground" />
- <span>{contact.contactPhone}</span>
+ <Mail className="size-4 text-muted-foreground" />
+ <span>{contact.contactEmail}</span>
+ </div>
+ {contact.contactPhone && (
+ <div className="flex items-center gap-1">
+ <Phone className="size-4 text-muted-foreground" />
+ <span>{contact.contactPhone}</span>
+ </div>
+ )}
+ <div className="text-xs text-muted-foreground">
+ 발송일: {new Date(contact.createdAt).toLocaleDateString('ko-KR')}
</div>
- )}
- <div className="text-xs text-muted-foreground">
- 발송일: {new Date(contact.createdAt).toLocaleDateString('ko-KR')}
</div>
</div>
- </div>
- ))}
-
+ ))}
+
<div className="text-center pt-4 text-sm text-muted-foreground border-t">
- 총 {contacts.length}명의 담당자에게 발송됨
+ 총 {contacts.filter((contact) => contact.contactTitle).length}명의 담당자에게 발송됨
</div>
</div>
)}
diff --git a/lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx b/lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx
index 7d972b91..023d3599 100644
--- a/lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx
+++ b/lib/techsales-rfq/table/detail-table/quotation-history-dialog.tsx
@@ -78,6 +78,7 @@ interface QuotationHistoryDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
quotationId: number | null
+ isInternal?: boolean // 내부 사용자인지 여부 (partners가 아니면 내부)
}
const statusConfig = {
@@ -88,15 +89,16 @@ const statusConfig = {
"Rejected": { label: "거절됨", color: "bg-red-100 text-red-800" },
}
-function QuotationCard({
- data,
- version,
- isCurrent = false,
- revisedBy,
+function QuotationCard({
+ data,
+ version,
+ isCurrent = false,
+ revisedBy,
revisedAt,
attachments,
revisionId,
revisionNote,
+ isInternal = false,
}: {
data: QuotationSnapshot | QuotationHistoryData["current"]
version: number
@@ -106,6 +108,7 @@ function QuotationCard({
attachments?: QuotationAttachment[]
revisionId?: number
revisionNote?: string | null
+ isInternal?: boolean
}) {
const statusInfo = statusConfig[data.status as keyof typeof statusConfig] ||
{ label: data.status || "알 수 없음", color: "bg-gray-100 text-gray-800" }
@@ -171,7 +174,7 @@ function QuotationCard({
</div>
)}
- {revisionId && (
+ {revisionId && isInternal && (
<div>
<p className="text-sm font-medium text-muted-foreground mt-2">SHI Comment</p>
<textarea
@@ -241,10 +244,11 @@ function QuotationCard({
)
}
-export function QuotationHistoryDialog({
- open,
- onOpenChange,
- quotationId
+export function QuotationHistoryDialog({
+ open,
+ onOpenChange,
+ quotationId,
+ isInternal = false
}: QuotationHistoryDialogProps) {
const [data, setData] = useState<QuotationHistoryData | null>(null)
const [isLoading, setIsLoading] = useState(false)
@@ -312,6 +316,7 @@ export function QuotationHistoryDialog({
version={data.current.quotationVersion || 1}
isCurrent={true}
attachments={data.current.attachments}
+ isInternal={isInternal}
/>
{/* 이전 버전들 (스냅샷) - SHI Comment 포함 */}
@@ -326,6 +331,7 @@ export function QuotationHistoryDialog({
attachments={revision.attachments}
revisionId={revision.id}
revisionNote={revision.revisionNote}
+ isInternal={isInternal}
/>
))
) : (
diff --git a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
index fe6f84e0..8ce55d56 100644
--- a/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
+++ b/lib/techsales-rfq/table/detail-table/rfq-detail-table.tsx
@@ -781,6 +781,7 @@ export function RfqDetailTables({ selectedRfq, maxHeight }: RfqDetailTablesProps
open={historyDialogOpen}
onOpenChange={setHistoryDialogOpen}
quotationId={selectedQuotationId}
+ isInternal={true}
/>
{/* 견적서 첨부파일 Sheet */}
diff --git a/lib/techsales-rfq/table/project-detail-dialog.tsx b/lib/techsales-rfq/table/project-detail-dialog.tsx
index 00202501..b2a14efa 100644
--- a/lib/techsales-rfq/table/project-detail-dialog.tsx
+++ b/lib/techsales-rfq/table/project-detail-dialog.tsx
@@ -1,6 +1,7 @@
"use client"
import * as React from "react"
+import { toast } from "sonner"
import {
Dialog,
DialogContent,
@@ -10,6 +11,8 @@ import {
} from "@/components/ui/dialog"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
+import { type ProjectSeries } from "@/db/schema/projects"
+import { getProjectSeriesForProject } from "@/lib/bidding-projects/service"
// 기본적인 RFQ 타입 정의 (rfq-table.tsx와 일치)
interface TechSalesRfq {
@@ -41,6 +44,25 @@ interface TechSalesRfq {
quotationCount: number
}
+// K/L 날짜를 4분기로 변환하는 함수
+function convertKLToQuarter(klDate: string | null): string {
+ if (!klDate) return "정보 없음"
+
+ try {
+ // YYYYMMDD 형식의 날짜를 파싱
+ const year = parseInt(klDate.substring(0, 4))
+ const month = parseInt(klDate.substring(4, 6))
+
+ // 4분기 계산 (1-3월: 1Q, 4-6월: 2Q, 7-9월: 3Q, 10-12월: 4Q)
+ const quarter = Math.ceil(month / 3)
+
+ return `${year} ${quarter}Q`
+ } catch (error) {
+ console.error("K/L 날짜 변환 오류:", error)
+ return "날짜 오류"
+ }
+}
+
interface ProjectDetailDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
@@ -52,6 +74,30 @@ export function ProjectDetailDialog({
onOpenChange,
selectedRfq,
}: ProjectDetailDialogProps) {
+ const [projectSeries, setProjectSeries] = React.useState<ProjectSeries[]>([])
+ const [isLoadingSeries, setIsLoadingSeries] = React.useState(false)
+
+ React.useEffect(() => {
+ async function loadSeries() {
+ if (!selectedRfq?.pspid) return
+
+ setIsLoadingSeries(true)
+ try {
+ const result = await getProjectSeriesForProject(selectedRfq.pspid)
+ setProjectSeries(result)
+ } catch (error) {
+ console.error("프로젝트 시리즈 로드 오류:", error)
+ toast.error("프로젝트 시리즈 로드 실패")
+ } finally {
+ setIsLoadingSeries(false)
+ }
+ }
+
+ if (open && selectedRfq) {
+ loadSeries()
+ }
+ }, [selectedRfq, open])
+
if (!selectedRfq) {
return null
}
@@ -104,6 +150,47 @@ export function ProjectDetailDialog({
</div>
</div>
</div>
+
+ {/* 시리즈 정보 */}
+ <div className="space-y-4">
+ <h3 className="text-lg font-semibold">시리즈 정보</h3>
+ <div className="bg-gray-50 rounded-lg p-4">
+ {isLoadingSeries ? (
+ <div className="text-center py-4 text-gray-500">
+ 시리즈 정보 로딩 중...
+ </div>
+ ) : projectSeries.length > 0 ? (
+ <div className="grid grid-cols-1 gap-3">
+ {projectSeries.map((series) => (
+ <div key={series.sersNo} className="bg-white rounded border p-3">
+ <div className="grid grid-cols-1 md:grid-cols-4 gap-4 text-sm">
+ <div>
+ <span className="font-medium text-gray-700">시리즈번호:</span>
+ <div className="text-gray-900">{series.sersNo}</div>
+ </div>
+ <div>
+ <span className="font-medium text-gray-700">K/L (Keel Laying):</span>
+ <div className="text-gray-900">{convertKLToQuarter(series.klDt)}</div>
+ </div>
+ <div>
+ <span className="font-medium text-gray-700">도크코드:</span>
+ <div className="text-gray-900">{series.dockNo || "N/A"}</div>
+ </div>
+ <div>
+ <span className="font-medium text-gray-700">도크명:</span>
+ <div className="text-gray-900">{series.dockNm || "N/A"}</div>
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ ) : (
+ <div className="text-center py-4 text-gray-500">
+ 시리즈 데이터가 없습니다.
+ </div>
+ )}
+ </div>
+ </div>
</div>
{/* 닫기 버튼 */}
diff --git a/lib/techsales-rfq/table/update-rfq-sheet.tsx b/lib/techsales-rfq/table/update-rfq-sheet.tsx
index 7dcc0e0e..f7bcbf9d 100644
--- a/lib/techsales-rfq/table/update-rfq-sheet.tsx
+++ b/lib/techsales-rfq/table/update-rfq-sheet.tsx
@@ -11,16 +11,20 @@ import { Loader2, CalendarIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form";
import { Input } from "@/components/ui/input";
+import { Textarea } from "@/components/ui/textarea";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetDescription, SheetFooter, SheetClose } from "@/components/ui/sheet";
import { Popover, PopoverTrigger, PopoverContent } from "@/components/ui/popover";
import { Calendar } from "@/components/ui/calendar";
import { cn } from "@/lib/utils";
import { updateTechSalesRfq, getTechSalesRfqById } from "@/lib/techsales-rfq/service";
+import { getProjectSeriesForProject } from "@/lib/bidding-projects/service";
+import { type ProjectSeries } from "@/db/schema/projects";
// Zod schema for form validation
const updateRfqSchema = z.object({
rfqId: z.number().min(1, "RFQ ID is required"),
description: z.string(),
+ remark: z.string(),
dueDate: z.string(),
});
@@ -41,8 +45,39 @@ export default function UpdateSheet({ open, onOpenChange, rfqId, onUpdated }: Up
projMsrm: "",
ptypeNm: "",
rfqNo: "",
+ pspid: "",
});
+ const [rfqInfo, setRfqInfo] = React.useState({
+ status: "",
+ createdAt: "",
+ updatedAt: "",
+ createdByName: "",
+ updatedByName: "",
+ sentByName: "",
+ rfqSendDate: "",
+ });
+ const [seriesInfo, setSeriesInfo] = React.useState<ProjectSeries[]>([]);
const [isLoading, setIsLoading] = React.useState(false);
+ const [isLoadingSeries, setIsLoadingSeries] = React.useState(false);
+
+ // K/L 날짜를 4분기로 변환하는 함수
+ const convertKLToQuarter = React.useCallback((klDate: string | null): string => {
+ if (!klDate) return "정보 없음"
+
+ try {
+ // YYYYMMDD 형식의 날짜를 파싱
+ const year = parseInt(klDate.substring(0, 4))
+ const month = parseInt(klDate.substring(4, 6))
+
+ // 4분기 계산 (1-3월: 1Q, 4-6월: 2Q, 7-9월: 3Q, 10-12월: 4Q)
+ const quarter = Math.ceil(month / 3)
+
+ return `${year} ${quarter}Q`
+ } catch (error) {
+ console.error("K/L 날짜 변환 오류:", error)
+ return "날짜 오류"
+ }
+ }, [])
// Initialize form with React Hook Form and Zod
const form = useForm<UpdateRfqSchema>({
@@ -50,6 +85,7 @@ export default function UpdateSheet({ open, onOpenChange, rfqId, onUpdated }: Up
defaultValues: {
rfqId,
description: "",
+ remark: "",
dueDate: "",
},
});
@@ -74,15 +110,43 @@ export default function UpdateSheet({ open, onOpenChange, rfqId, onUpdated }: Up
form.reset({
rfqId,
description: result.data.description || "",
+ remark: result.data.remark || "",
dueDate: result.data.dueDate ? new Date(result.data.dueDate).toISOString().slice(0, 10) : "",
});
+ const pspid = result.data.project[0].pspid || "";
setProjectInfo({
projNm: result.data.project[0].projectName || "",
sector: result.data.project[0].pjtType || "",
projMsrm: result.data.project[0].projMsrm || "",
ptypeNm: result.data.project[0].ptypeNm || "",
rfqNo: result.data.rfqCode || "",
+ pspid: pspid,
+ });
+ setRfqInfo({
+ status: result.data.status || "",
+ createdAt: result.data.createdAt ? format(new Date(result.data.createdAt), "yyyy-MM-dd HH:mm") : "",
+ updatedAt: result.data.updatedAt ? format(new Date(result.data.updatedAt), "yyyy-MM-dd HH:mm") : "",
+ createdByName: (result.data as any).createdByName || (result.data as any).createdBy || "",
+ updatedByName: (result.data as any).updatedByName || (result.data as any).updatedBy || "",
+ sentByName: (result.data as any).sentByName || (result.data as any).sentBy || "",
+ rfqSendDate: result.data.rfqSendDate ? format(new Date(result.data.rfqSendDate), "yyyy-MM-dd HH:mm") : "",
});
+
+ // 시리즈 정보 로드
+ if (pspid) {
+ setIsLoadingSeries(true);
+ try {
+ const seriesResult = await getProjectSeriesForProject(pspid);
+ setSeriesInfo(seriesResult);
+ } catch (error) {
+ console.error("시리즈 정보 로드 오류:", error);
+ setSeriesInfo([]);
+ } finally {
+ setIsLoadingSeries(false);
+ }
+ } else {
+ setSeriesInfo([]);
+ }
}
} catch (error: any) {
toast.error("RFQ 정보를 불러오는 중 오류가 발생했습니다: " + error.message);
@@ -101,6 +165,7 @@ export default function UpdateSheet({ open, onOpenChange, rfqId, onUpdated }: Up
const result = await updateTechSalesRfq({
id: values.rfqId,
description: values.description,
+ remark: values.remark,
dueDate: new Date(values.dueDate),
updatedBy: 1, // Replace with actual user ID
});
@@ -134,9 +199,9 @@ export default function UpdateSheet({ open, onOpenChange, rfqId, onUpdated }: Up
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent className="flex flex-col h-full sm:max-w-xl bg-gray-50">
<SheetHeader className="text-left flex-shrink-0">
- <SheetTitle className="text-2xl font-bold">RFQ 수정</SheetTitle>
+ <SheetTitle className="text-2xl font-bold">RFQ 미리보기</SheetTitle>
<SheetDescription className="">
- RFQ 정보를 수정합니다. 모든 필드를 입력한 후 저장 버튼을 클릭하세요.
+ RFQ 정보를 확인하고 필요한 항목을 수정하세요. RFQ Title, Context, 마감일만 수정 가능합니다.
</SheetDescription>
</SheetHeader>
@@ -147,7 +212,9 @@ export default function UpdateSheet({ open, onOpenChange, rfqId, onUpdated }: Up
</div>
) : (
<div className="space-y-6">
+ {/* 프로젝트 정보 */}
<div className="bg-white shadow-sm rounded-lg p-5 border border-gray-200">
+ <h3 className="text-lg font-semibold mb-3 text-gray-800">프로젝트 정보</h3>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="font-semibold text-gray-700">프로젝트명:</span> {projectInfo.projNm}
@@ -167,6 +234,73 @@ export default function UpdateSheet({ open, onOpenChange, rfqId, onUpdated }: Up
</div>
</div>
+ {/* 시리즈 정보 - 프로젝트 정보 아래에 배치 */}
+ <div className="bg-white shadow-sm rounded-lg p-5 border border-gray-200">
+ <h3 className="text-lg font-semibold mb-3 text-gray-800">시리즈 정보</h3>
+ {isLoadingSeries ? (
+ <div className="text-center py-4 text-gray-500">
+ 시리즈 정보 로딩 중...
+ </div>
+ ) : seriesInfo && seriesInfo.length > 0 ? (
+ <div className="grid grid-cols-1 gap-3">
+ {seriesInfo.map((series) => (
+ <div key={series.sersNo} className="bg-gray-50 rounded border p-3">
+ <div className="grid grid-cols-1 md:grid-cols-4 gap-4 text-sm">
+ <div>
+ <span className="font-medium text-gray-700">시리즈번호:</span>
+ <div className="text-gray-900">{series.sersNo}</div>
+ </div>
+ <div>
+ <span className="font-medium text-gray-700">K/L (Keel Laying):</span>
+ <div className="text-gray-900">{convertKLToQuarter(series.klDt)}</div>
+ </div>
+ <div>
+ <span className="font-medium text-gray-700">도크코드:</span>
+ <div className="text-gray-900">{series.dockNo || "N/A"}</div>
+ </div>
+ <div>
+ <span className="font-medium text-gray-700">도크명:</span>
+ <div className="text-gray-900">{series.dockNm || "N/A"}</div>
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ ) : (
+ <div className="text-center py-4 text-gray-500">
+ 시리즈 데이터가 없습니다.
+ </div>
+ )}
+ </div>
+
+ {/* RFQ 정보 */}
+ {/* <div className="bg-white shadow-sm rounded-lg p-5 border border-gray-200">
+ <h3 className="text-lg font-semibold mb-3 text-gray-800">RFQ 정보</h3>
+ <div className="grid grid-cols-2 gap-4 text-sm">
+ <div>
+ <span className="font-semibold text-gray-700">상태:</span> {rfqInfo.status}
+ </div>
+ <div>
+ <span className="font-semibold text-gray-700">생성자:</span> {rfqInfo.createdByName}
+ </div>
+ <div>
+ <span className="font-semibold text-gray-700">생성일:</span> {rfqInfo.createdAt}
+ </div>
+ <div>
+ <span className="font-semibold text-gray-700">수정자:</span> {rfqInfo.updatedByName}
+ </div>
+ <div>
+ <span className="font-semibold text-gray-700">수정일:</span> {rfqInfo.updatedAt}
+ </div>
+ <div>
+ <span className="font-semibold text-gray-700">발송자:</span> {rfqInfo.sentByName}
+ </div>
+ <div>
+ <span className="font-semibold text-gray-700">발송일:</span> {rfqInfo.rfqSendDate}
+ </div>
+ </div>
+ </div> */}
+
<Form {...form}>
<form id="update-rfq-form" onSubmit={form.handleSubmit(onSubmit)} className="flex flex-col gap-4">
<FormField
@@ -189,6 +323,24 @@ export default function UpdateSheet({ open, onOpenChange, rfqId, onUpdated }: Up
<FormField
control={form.control}
+ name="remark"
+ render={({ field }) => (
+ <FormItem>
+ <FormLabel className="text-sm font-medium text-gray-700">RFQ Context</FormLabel>
+ <FormControl>
+ <Textarea
+ {...field}
+ placeholder="RFQ Context를 입력하세요"
+ className="border-gray-300 rounded-md min-h-[100px]"
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+
+ <FormField
+ control={form.control}
name="dueDate"
render={({ field }) => (
<FormItem className="flex flex-col">
diff --git a/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx b/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx
index 087e2a4d..afb491b8 100644
--- a/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx
+++ b/lib/techsales-rfq/vendor-response/detail/quotation-response-tab.tsx
@@ -517,6 +517,7 @@ export function QuotationResponseTab({ quotation }: QuotationResponseTabProps) {
open={historyDialogOpen}
onOpenChange={setHistoryDialogOpen}
quotationId={quotation.id}
+ isInternal={false}
/>
</ScrollArea>
)