diff options
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>
)
|
