diff options
| -rw-r--r-- | lib/vendor-investigation/table/investigation-table-columns.tsx | 12 | ||||
| -rw-r--r-- | lib/vendor-investigation/table/investigation-table.tsx | 6 | ||||
| -rw-r--r-- | lib/vendor-investigation/table/update-investigation-sheet.tsx | 10 | ||||
| -rw-r--r-- | lib/vendor-investigation/validations.ts | 12 | ||||
| -rw-r--r-- | lib/vendors/service.ts | 173 | ||||
| -rw-r--r-- | lib/vendors/table/request-pq-dialog.tsx | 286 | ||||
| -rw-r--r-- | lib/vendors/table/vendors-table-toolbar-actions.tsx | 17 |
7 files changed, 405 insertions, 111 deletions
diff --git a/lib/vendor-investigation/table/investigation-table-columns.tsx b/lib/vendor-investigation/table/investigation-table-columns.tsx index 6146d940..88b6644f 100644 --- a/lib/vendor-investigation/table/investigation-table-columns.tsx +++ b/lib/vendor-investigation/table/investigation-table-columns.tsx @@ -295,10 +295,14 @@ function formatStatus(status: string): string { function formatEnumValue(value: string): string { switch (value) { // Evaluation types - case "SITE_AUDIT": - return "실사의뢰평가" - case "QM_SELF_AUDIT": - return "QM자체평가" + case "PURCHASE_SELF_EVAL": + return "구매자체평가" + case "DOCUMENT_EVAL": + return "서류평가" + case "PRODUCT_INSPECTION": + return "제품검사평가" + case "SITE_VISIT_EVAL": + return "방문실사평가" // Evaluation results case "APPROVED": diff --git a/lib/vendor-investigation/table/investigation-table.tsx b/lib/vendor-investigation/table/investigation-table.tsx index d5dc05ac..660a8507 100644 --- a/lib/vendor-investigation/table/investigation-table.tsx +++ b/lib/vendor-investigation/table/investigation-table.tsx @@ -102,8 +102,10 @@ export function VendorsInvestigationTable({ promises }: VendorsTableProps) { label: "평가 유형", type: "select", options: [ - { label: "실사의뢰평가", value: "SITE_AUDIT" }, - { label: "QM자체평가", value: "QM_SELF_AUDIT" }, + { label: "구매자체평가", value: "PURCHASE_SELF_EVAL" }, + { label: "서류평가", value: "DOCUMENT_EVAL" }, + { label: "제품검사평가", value: "PRODUCT_INSPECTION" }, + { label: "방문실사평가", value: "SITE_VISIT_EVAL" }, ] }, { diff --git a/lib/vendor-investigation/table/update-investigation-sheet.tsx b/lib/vendor-investigation/table/update-investigation-sheet.tsx index 29f0fa92..fbaf000e 100644 --- a/lib/vendor-investigation/table/update-investigation-sheet.tsx +++ b/lib/vendor-investigation/table/update-investigation-sheet.tsx @@ -125,7 +125,7 @@ export function UpdateVendorInvestigationSheet({ investigationStatus: investigation?.investigationStatus ?? "PLANNED", evaluationType: investigation?.evaluationType ?? undefined, investigationAddress: investigation?.investigationAddress ?? "", - investigationMethod: investigation?.investigationMethod ?? "", + investigationMethod: investigation?.investigationMethod ?? undefined, forecastedAt: investigation?.forecastedAt ?? undefined, requestedAt: investigation?.requestedAt ?? undefined, confirmedAt: investigation?.confirmedAt ?? undefined, @@ -145,7 +145,7 @@ export function UpdateVendorInvestigationSheet({ investigationStatus: investigation.investigationStatus || "PLANNED", evaluationType: investigation.evaluationType ?? undefined, investigationAddress: investigation.investigationAddress ?? "", - investigationMethod: investigation.investigationMethod ?? "", + investigationMethod: investigation.investigationMethod ?? undefined, forecastedAt: investigation.forecastedAt ?? undefined, requestedAt: investigation.requestedAt ?? undefined, confirmedAt: investigation.confirmedAt ?? undefined, @@ -573,8 +573,10 @@ export function UpdateVendorInvestigationSheet({ </SelectTrigger> <SelectContent> <SelectGroup> - <SelectItem value="SITE_AUDIT">실사의뢰평가</SelectItem> - <SelectItem value="QM_SELF_AUDIT">QM자체평가</SelectItem> + <SelectItem value="PURCHASE_SELF_EVAL">구매자체평가</SelectItem> + <SelectItem value="DOCUMENT_EVAL">서류평가</SelectItem> + <SelectItem value="PRODUCT_INSPECTION">제품검사평가</SelectItem> + <SelectItem value="SITE_VISIT_EVAL">방문실사평가</SelectItem> </SelectGroup> </SelectContent> </Select> diff --git a/lib/vendor-investigation/validations.ts b/lib/vendor-investigation/validations.ts index d04f100f..e4ec2b52 100644 --- a/lib/vendor-investigation/validations.ts +++ b/lib/vendor-investigation/validations.ts @@ -33,8 +33,8 @@ export const searchParamsInvestigationCache = createSearchParamsCache({ // Fields specific to vendor investigations // ----------------------------------------------------------------- - // investigationStatus: PLANNED, IN_PROGRESS, COMPLETED, CANCELED - investigationStatus: parseAsStringEnum(["PLANNED", "IN_PROGRESS", "COMPLETED", "CANCELED"]), + // investigationStatus: PLANNED, IN_PROGRESS, COMPLETED, CANCELED, RESULT_SENT + investigationStatus: parseAsStringEnum(["PLANNED", "IN_PROGRESS", "COMPLETED", "CANCELED", "RESULT_SENT"]), // In case you also want to filter by vendorName, vendorCode, etc. vendorName: parseAsString.withDefault(""), @@ -64,12 +64,12 @@ export const updateVendorInvestigationSchema = z.object({ investigationId: z.number({ required_error: "Investigation ID is required", }), - investigationStatus: z.enum(["PLANNED", "IN_PROGRESS", "COMPLETED", "CANCELED"], { + investigationStatus: z.enum(["PLANNED", "IN_PROGRESS", "COMPLETED", "CANCELED", "RESULT_SENT"], { required_error: "실사 상태를 선택해주세요.", }), - evaluationType: z.enum(["SITE_AUDIT", "QM_SELF_AUDIT"]).optional(), + evaluationType: z.enum(["PURCHASE_SELF_EVAL", "DOCUMENT_EVAL", "PRODUCT_INSPECTION", "SITE_VISIT_EVAL"]).optional(), investigationAddress: z.string().optional(), - investigationMethod: z.string().max(100, "실사 방법은 100자 이내로 입력해주세요.").optional(), + investigationMethod: z.enum(["PURCHASE_SELF_EVAL", "DOCUMENT_EVAL", "PRODUCT_INSPECTION", "SITE_VISIT_EVAL"]).optional(), // 날짜 필드들을 string에서 Date로 변환하도록 수정 forecastedAt: z.union([ @@ -97,7 +97,7 @@ export const updateVendorInvestigationSchema = z.object({ .min(0, "평가 점수는 0점 이상이어야 합니다.") .max(100, "평가 점수는 100점 이하여야 합니다.") .optional(), - evaluationResult: z.enum(["APPROVED", "SUPPLEMENT", "REJECTED"]).optional(), + evaluationResult: z.enum(["APPROVED", "SUPPLEMENT", "REJECTED", "RESULT_SENT"]).optional(), investigationNotes: z.string().max(1000, "QM 의견은 1000자 이내로 입력해주세요.").optional(), attachments: z.any().optional(), // File 업로드를 위한 필드 }) diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts index 853b3701..e3309786 100644 --- a/lib/vendors/service.ts +++ b/lib/vendors/service.ts @@ -1654,14 +1654,20 @@ export async function generatePQNumber(isProject: boolean = false) { } } -export async function requestPQVendors(input: ApproveVendorsInput & { userId: number }) { +export async function requestPQVendors(input: ApproveVendorsInput & { + userId: number, + agreements?: Record<string, boolean>, + dueDate?: string | null, + type?: "GENERAL" | "PROJECT" | "NON_INSPECTION", + extraNote?: string, + pqItems?: string +}) { unstable_noStore(); - + const session = await getServerSession(authOptions); const requesterId = session?.user?.id ? Number(session.user.id) : null; try { - // 프로젝트 정보 가져오기 (projectId가 있는 경우) let projectInfo = null; if (input.projectId) { const project = await db @@ -1673,95 +1679,72 @@ export async function requestPQVendors(input: ApproveVendorsInput & { userId: nu .from(projects) .where(eq(projects.id, input.projectId)) .limit(1); - + if (project.length > 0) { projectInfo = project[0]; } } - - // 트랜잭션 내에서 협력업체 상태 업데이트 및 이메일 발송 + const result = await db.transaction(async (tx) => { - // 0. 업데이트 전 협력업체 상태 조회 const vendorsBeforeUpdate = await tx - .select({ - id: vendors.id, - status: vendors.status, - }) + .select({ id: vendors.id, status: vendors.status }) .from(vendors) .where(inArray(vendors.id, input.ids)); - - // 1. 협력업체 상태 업데이트 + const [updated] = await tx .update(vendors) - .set({ - status: "IN_PQ", - updatedAt: new Date() - }) + .set({ status: "IN_PQ", updatedAt: new Date() }) .where(inArray(vendors.id, input.ids)) .returning(); - - // 2. 업데이트된 협력업체 정보 조회 + const updatedVendors = await tx - .select({ - id: vendors.id, - vendorName: vendors.vendorName, - email: vendors.email, - }) + .select({ id: vendors.id, vendorName: vendors.vendorName, email: vendors.email }) .from(vendors) .where(inArray(vendors.id, input.ids)); - - // 3. vendorPQSubmissions 테이블에 레코드 추가 (프로젝트 PQ와 일반 PQ 모두) - const pqType = input.projectId ? "PROJECT" : "GENERAL"; + + const pqType = input.type; const currentDate = new Date(); - - // 기존 PQ 요청이 있는지 확인 (중복 방지) + const existingSubmissions = await tx - .select({ - vendorId: vendorPQSubmissions.vendorId, - projectId: vendorPQSubmissions.projectId, - type: vendorPQSubmissions.type - }) + .select({ vendorId: vendorPQSubmissions.vendorId }) .from(vendorPQSubmissions) .where( and( inArray(vendorPQSubmissions.vendorId, input.ids), - eq(vendorPQSubmissions.type, pqType), - input.projectId + pqType ? eq(vendorPQSubmissions.type, pqType) : undefined, + input.projectId ? eq(vendorPQSubmissions.projectId, input.projectId) : isNull(vendorPQSubmissions.projectId) ) ); - - // 중복되지 않는 벤더에 대해서만 새 PQ 요청 생성 - const existingVendorIds = new Set(existingSubmissions.map(s => s.vendorId)); - const newVendorIds = input.ids.filter(id => !existingVendorIds.has(id)); - + + const existingVendorIds = new Set(existingSubmissions.map((s) => s.vendorId)); + const newVendorIds = input.ids.filter((id) => !existingVendorIds.has(id)); + if (newVendorIds.length > 0) { - // 각 벤더별로 유니크한 PQ 번호 생성 및 저장 const vendorPQDataPromises = newVendorIds.map(async (vendorId) => { - // PQ 번호 생성 (프로젝트 PQ인지 여부 전달) const pqNumber = await generatePQNumber(pqType === "PROJECT"); - - return { - vendorId, - pqNumber, // 생성된 PQ 번호 저장 - projectId: input.projectId || null, - type: pqType, - status: "REQUESTED", - requesterId: input.userId || requesterId, // 요청자 ID 저장 - createdAt: currentDate, - updatedAt: currentDate, - }; + + return { + vendorId, + pqNumber, + projectId: input.projectId || null, + type: pqType, + status: "REQUESTED", + requesterId: input.userId || requesterId, + dueDate: input.dueDate ? new Date(input.dueDate) : null, + agreements: input.agreements ?? {}, + pqItems: input.pqItems || null, + createdAt: currentDate, + updatedAt: currentDate, + }; }); - - // 모든 PQ 번호 생성 완료 대기 + const vendorPQData = await Promise.all(vendorPQDataPromises); - - // 트랜잭션 내에서 데이터 삽입 + await tx.insert(vendorPQSubmissions).values(vendorPQData); } - // 4. 로그 기록 await Promise.all( vendorsBeforeUpdate.map(async (vendorBefore) => { await tx.insert(vendorsLogs).values({ @@ -1770,25 +1753,23 @@ export async function requestPQVendors(input: ApproveVendorsInput & { userId: nu action: "status_change", oldStatus: vendorBefore.status, newStatus: "IN_PQ", - comment: input.projectId - ? `Project PQ requested (Project: ${projectInfo?.projectCode || input.projectId})` + comment: input.projectId + ? `Project PQ requested (Project: ${projectInfo?.projectCode || input.projectId})` : "General PQ requested", }); }) ); const headersList = await headers(); - const host = headersList.get('host') || 'localhost:3000'; + const host = headersList.get("host") || "localhost:3000"; - // 5. 각 벤더에게 이메일 발송 await Promise.all( updatedVendors.map(async (vendor) => { - if (!vendor.email) return; // 이메일이 없으면 스킵 - + if (!vendor.email) return; + try { - const userLang = "en"; // 기본값, 필요시 협력업체 언어 설정에서 가져오기 - - // PQ 번호 조회 (이메일에 포함하기 위해) + const userLang = "en"; + const vendorPQ = await tx .select({ pqNumber: vendorPQSubmissions.pqNumber }) .from(vendorPQSubmissions) @@ -1796,59 +1777,76 @@ export async function requestPQVendors(input: ApproveVendorsInput & { userId: nu and( eq(vendorPQSubmissions.vendorId, vendor.id), eq(vendorPQSubmissions.type, pqType), - input.projectId + input.projectId ? eq(vendorPQSubmissions.projectId, input.projectId) : isNull(vendorPQSubmissions.projectId) ) ) .limit(1) - .then(rows => rows[0]); - - // 프로젝트 PQ인지 일반 PQ인지에 따라 제목 변경 + .then((rows) => rows[0]); + const subject = input.projectId - ? `[eVCP] You are invited to submit Project PQ ${vendorPQ?.pqNumber || ''} for ${projectInfo?.projectCode || 'a project'}` - : `[eVCP] You are invited to submit PQ ${vendorPQ?.pqNumber || ''}`; - - // 로그인 URL에 프로젝트 ID 추가 (프로젝트 PQ인 경우) + ? `[eVCP] You are invited to submit Project PQ ${vendorPQ?.pqNumber || ""} for ${projectInfo?.projectCode || "a project"}` + : input.type === "NON_INSPECTION" + ? `[eVCP] You are invited to submit Non-Inspection PQ ${vendorPQ?.pqNumber || ""}` + : `[eVCP] You are invited to submit PQ ${vendorPQ?.pqNumber || ""}`; + const baseLoginUrl = `${host}/partners/pq`; const loginUrl = input.projectId ? `${baseLoginUrl}?projectId=${input.projectId}` : baseLoginUrl; - + + // 체크된 계약 항목 배열 생성 + const contracts = input.agreements + ? Object.entries(input.agreements) + .filter(([_, checked]) => checked) + .map(([name, _]) => name) + : []; + + // PQ 대상 품목 + const pqItems = input.pqItems || " - "; + await sendEmail({ to: vendor.email, subject, - template: input.projectId ? "project-pq" : "pq", // 프로젝트별 템플릿 사용 + template: input.projectId ? "project-pq" : input.type === "NON_INSPECTION" ? "non-inspection-pq" : "pq", context: { vendorName: vendor.vendorName, + vendorContact: "", // 담당자 정보가 없으므로 빈 문자열 + pqNumber: vendorPQ?.pqNumber || "", + senderName: session?.user?.name || "eVCP", + senderEmail: session?.user?.email || "noreply@evcp.com", + dueDate: input.dueDate ? new Date(input.dueDate).toLocaleDateString('ko-KR') : "", + pqItems, + contracts, + extraNote: input.extraNote || "", + currentYear: new Date().getFullYear().toString(), loginUrl, language: userLang, - projectCode: projectInfo?.projectCode || '', - projectName: projectInfo?.projectName || '', + projectCode: projectInfo?.projectCode || "", + projectName: projectInfo?.projectName || "", hasProject: !!input.projectId, - pqNumber: vendorPQ?.pqNumber || '', // PQ 번호 추가 + pqType: input.type || "GENERAL", }, }); } catch (emailError) { console.error(`Failed to send email to vendor ${vendor.id}:`, emailError); - // 이메일 전송 실패는 전체 트랜잭션을 실패시키지 않음 } }) ); - + return updated; }); - - // 캐시 무효화 + revalidateTag("vendors"); revalidateTag("vendor-status-counts"); revalidateTag("vendor-pq-submissions"); - + if (input.projectId) { revalidateTag(`project-${input.projectId}`); revalidateTag(`project-pq-submissions-${input.projectId}`); } - + return { data: result, error: null }; } catch (err) { console.error("Error requesting PQ from vendors:", err); @@ -1856,6 +1854,7 @@ export async function requestPQVendors(input: ApproveVendorsInput & { userId: nu } } + interface SendVendorsInput { ids: number[]; } diff --git a/lib/vendors/table/request-pq-dialog.tsx b/lib/vendors/table/request-pq-dialog.tsx new file mode 100644 index 00000000..6d477d9f --- /dev/null +++ b/lib/vendors/table/request-pq-dialog.tsx @@ -0,0 +1,286 @@ +"use client"
+
+import * as React from "react"
+import { type Row } from "@tanstack/react-table"
+import { Loader, SendHorizonal } from "lucide-react"
+import { toast } from "sonner"
+import { useMediaQuery } from "@/hooks/use-media-query"
+import { Button } from "@/components/ui/button"
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from "@/components/ui/dialog"
+import {
+ Drawer,
+ DrawerClose,
+ DrawerContent,
+ DrawerDescription,
+ DrawerFooter,
+ DrawerHeader,
+ DrawerTitle,
+ DrawerTrigger,
+} from "@/components/ui/drawer"
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@/components/ui/select"
+import { Checkbox } from "@/components/ui/checkbox"
+import { Label } from "@/components/ui/label"
+import { Vendor } from "@/db/schema/vendors"
+import { requestPQVendors } from "../service"
+import { getProjectsWithPQList } from "@/lib/pq/service"
+import type { Project } from "@/lib/pq/service"
+import { useSession } from "next-auth/react"
+import { DatePicker } from "@/components/ui/date-picker"
+
+interface RequestPQDialogProps extends React.ComponentPropsWithoutRef<typeof Dialog> {
+ vendors: Row<Vendor>["original"][]
+ showTrigger?: boolean
+ onSuccess?: () => void
+}
+
+const AGREEMENT_LIST = [
+ "준법서약",
+ "표준하도급계약",
+ "안전보건관리계약",
+ "윤리규범 준수 서약",
+ "동반성장협약",
+ "내국신용장 미개설 합의",
+ "기술자료 제출 기본 동의",
+ "GTC 합의",
+]
+
+export function RequestPQDialog({ vendors, showTrigger = true, onSuccess, ...props }: RequestPQDialogProps) {
+ const [isApprovePending, startApproveTransition] = React.useTransition()
+ const isDesktop = useMediaQuery("(min-width: 640px)")
+ const { data: session } = useSession()
+
+ const [type, setType] = React.useState<"GENERAL" | "PROJECT" | "NON_INSPECTION" | null>(null)
+ const [dueDate, setDueDate] = React.useState<string | null>(null)
+ const [projects, setProjects] = React.useState<Project[]>([])
+ const [selectedProjectId, setSelectedProjectId] = React.useState<number | null>(null)
+ const [agreements, setAgreements] = React.useState<Record<string, boolean>>({})
+ const [extraNote, setExtraNote] = React.useState<string>("")
+ const [pqItems, setPqItems] = React.useState<string>("")
+ const [isLoadingProjects, setIsLoadingProjects] = React.useState(false)
+
+ React.useEffect(() => {
+ if (type === "PROJECT") {
+ setIsLoadingProjects(true)
+ getProjectsWithPQList().then(setProjects).catch(() => toast.error("프로젝트 로딩 실패"))
+ .finally(() => setIsLoadingProjects(false))
+ }
+ }, [type])
+
+ React.useEffect(() => {
+ if (!props.open) {
+ setType(null)
+ setSelectedProjectId(null)
+ setAgreements({})
+ setDueDate(null)
+ setPqItems("")
+ setExtraNote("")
+ }
+ }, [props.open])
+
+ const onApprove = () => {
+ if (!type) return toast.error("PQ 유형을 선택하세요.")
+ if (type === "PROJECT" && !selectedProjectId) return toast.error("프로젝트를 선택하세요.")
+ if (!dueDate) return toast.error("마감일을 선택하세요.")
+ if (!session?.user?.id) return toast.error("인증 실패")
+
+ startApproveTransition(async () => {
+ const { error } = await requestPQVendors({
+ ids: vendors.map((v) => v.id),
+ userId: Number(session.user.id),
+ agreements,
+ dueDate,
+ projectId: type === "PROJECT" ? selectedProjectId : null,
+ type: type || "GENERAL",
+ extraNote,
+ pqItems,
+ })
+
+ if (error) {
+ toast.error(error)
+ return
+ }
+
+ props.onOpenChange?.(false)
+ toast.success("PQ가 성공적으로 요청되었습니다")
+ onSuccess?.()
+ })
+ }
+
+ const dialogContent = (
+ <div className="space-y-4 py-2">
+ {/* 선택된 협력업체 정보 */}
+ <div className="space-y-2">
+ <Label>선택된 협력업체 ({vendors.length}개)</Label>
+ <div className="max-h-40 overflow-y-auto border rounded-md p-3 space-y-2">
+ {vendors.map((vendor) => (
+ <div key={vendor.id} className="flex items-center justify-between text-sm">
+ <div className="flex-1">
+ <div className="font-medium">{vendor.vendorName}</div>
+ <div className="text-muted-foreground">
+ {vendor.vendorCode} • {vendor.email || "이메일 없음"}
+ </div>
+ </div>
+ </div>
+ ))}
+ </div>
+ </div>
+
+ <div className="space-y-2">
+ <Label htmlFor="type">PQ 종류 선택</Label>
+ <Select onValueChange={(val: "GENERAL" | "PROJECT" | "NON_INSPECTION") => setType(val)} value={type ?? undefined}>
+ <SelectTrigger id="type"><SelectValue placeholder="PQ 종류를 선택하세요" /></SelectTrigger>
+ <SelectContent>
+ <SelectItem value="GENERAL">일반 PQ</SelectItem>
+ <SelectItem value="PROJECT">프로젝트 PQ</SelectItem>
+ <SelectItem value="NON_INSPECTION">미실사 PQ</SelectItem>
+ </SelectContent>
+ </Select>
+ </div>
+
+ {type === "PROJECT" && (
+ <div className="space-y-2">
+ <Label htmlFor="project">프로젝트 선택</Label>
+ <Select onValueChange={(val) => setSelectedProjectId(Number(val))}>
+ <SelectTrigger id="project">
+ <SelectValue placeholder="프로젝트 선택" />
+ </SelectTrigger>
+ <SelectContent>
+ {isLoadingProjects ? (
+ <SelectItem value="loading" disabled>로딩 중...</SelectItem>
+ ) : projects.map((p) => (
+ <SelectItem key={p.id} value={p.id.toString()}>{p.projectCode} - {p.projectName}</SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
+ </div>
+ )}
+
+ {/* 마감일 입력 */}
+ <div className="space-y-2">
+ <Label htmlFor="dueDate">PQ 제출 마감일</Label>
+ <DatePicker
+ date={dueDate ? new Date(dueDate) : undefined}
+ onSelect={(date?: Date) => setDueDate(date ? date.toISOString().slice(0, 10) : "")}
+ placeholder="마감일 선택"
+ />
+ </div>
+
+ {/* PQ 대상품목 */}
+ <div className="space-y-2">
+ <Label htmlFor="pqItems">PQ 대상품목</Label>
+ <textarea
+ id="pqItems"
+ value={pqItems}
+ onChange={(e) => setPqItems(e.target.value)}
+ placeholder="PQ 대상품목을 입력하세요 (선택사항)"
+ className="w-full rounded-md border px-3 py-2 text-sm min-h-20 resize-none"
+ />
+ </div>
+
+ {/* 추가 안내사항 */}
+ <div className="space-y-2">
+ <Label htmlFor="extraNote">추가 안내사항</Label>
+ <textarea
+ id="extraNote"
+ value={extraNote}
+ onChange={(e) => setExtraNote(e.target.value)}
+ placeholder="추가 안내사항을 입력하세요 (선택사항)"
+ className="w-full rounded-md border px-3 py-2 text-sm min-h-20 resize-none"
+ />
+ </div>
+
+ <div className="space-y-2">
+ <Label>계약 항목 선택</Label>
+ {AGREEMENT_LIST.map((label) => (
+ <div key={label} className="flex items-center gap-2">
+ <Checkbox
+ id={label}
+ checked={agreements[label] || false}
+ onCheckedChange={(val) =>
+ setAgreements((prev) => ({ ...prev, [label]: Boolean(val) }))
+ }
+ />
+ <Label htmlFor={label}>{label}</Label>
+ </div>
+ ))}
+ </div>
+ </div>
+ )
+
+ if (isDesktop) {
+ return (
+ <Dialog {...props}>
+ {showTrigger && (
+ <DialogTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <SendHorizonal className="size-4" /> PQ 요청 ({vendors.length})
+ </Button>
+ </DialogTrigger>
+ )}
+ <DialogContent className="max-h-[80vh] flex flex-col">
+ <DialogHeader>
+ <DialogTitle>PQ 요청</DialogTitle>
+ <DialogDescription>
+ <span className="font-medium">{vendors.length}</span>
+ {vendors.length === 1 ? "개 협력업체" : "개 협력업체들"}에게 PQ를 요청합니다.
+ </DialogDescription>
+ </DialogHeader>
+ <div className="flex-1 overflow-y-auto">
+ {dialogContent}
+ </div>
+ <DialogFooter>
+ <DialogClose asChild><Button variant="outline">취소</Button></DialogClose>
+ <Button onClick={onApprove} disabled={isApprovePending || !type || (type === "PROJECT" && !selectedProjectId)}>
+ {isApprovePending && <Loader className="mr-2 size-4 animate-spin" />}요청하기
+ </Button>
+ </DialogFooter>
+ </DialogContent>
+ </Dialog>
+ )
+ }
+
+ return (
+ <Drawer {...props}>
+ {showTrigger && (
+ <DrawerTrigger asChild>
+ <Button variant="outline" size="sm" className="gap-2">
+ <SendHorizonal className="size-4" /> PQ 요청 ({vendors.length})
+ </Button>
+ </DrawerTrigger>
+ )}
+ <DrawerContent className="max-h-[80vh] flex flex-col">
+ <DrawerHeader>
+ <DrawerTitle>PQ 요청</DrawerTitle>
+ <DrawerDescription>
+ <span className="font-medium">{vendors.length}</span>
+ {vendors.length === 1 ? "개 협력업체" : "개 협력업체들"}에게 PQ를 요청합니다.
+ </DrawerDescription>
+ </DrawerHeader>
+ <div className="flex-1 overflow-y-auto px-4">
+ {dialogContent}
+ </div>
+ <DrawerFooter>
+ <DrawerClose asChild><Button variant="outline">취소</Button></DrawerClose>
+ <Button onClick={onApprove} disabled={isApprovePending || !type || (type === "PROJECT" && !selectedProjectId)}>
+ {isApprovePending && <Loader className="mr-2 size-4 animate-spin" />}요청하기
+ </Button>
+ </DrawerFooter>
+ </DrawerContent>
+ </Drawer>
+ )
+}
diff --git a/lib/vendors/table/vendors-table-toolbar-actions.tsx b/lib/vendors/table/vendors-table-toolbar-actions.tsx index 12f1dfcd..6d5f7425 100644 --- a/lib/vendors/table/vendors-table-toolbar-actions.tsx +++ b/lib/vendors/table/vendors-table-toolbar-actions.tsx @@ -17,6 +17,7 @@ import { import { VendorWithType } from "@/db/schema/vendors" import { ApproveVendorsDialog } from "./approve-vendor-dialog" import { RequestPQVendorsDialog } from "./request-vendor-pg-dialog" +import { RequestPQDialog } from "./request-pq-dialog" import { RequestProjectPQDialog } from "./request-project-pq-dialog" import { SendVendorsDialog } from "./send-vendor-dialog" import { RequestVendorsInvestigateDialog } from "./request-vendor-investigate-dialog" @@ -75,8 +76,8 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions .filter(vendor => vendor.status === "PQ_APPROVED"); }, [table.getFilteredSelectedRowModel().rows]); - // 프로젝트 PQ를 보낼 수 있는 협력업체 상태 필터링 - const projectPQEligibleVendors = React.useMemo(() => { + // 통합 PQ를 보낼 수 있는 협력업체 상태 필터링 + const pqEligibleVendors = React.useMemo(() => { return table .getFilteredSelectedRowModel() .rows @@ -153,17 +154,17 @@ export function VendorsTableToolbarActions({ table }: VendorsTableToolbarActions )} {/* 일반 PQ 요청: IN_REVIEW 상태인 협력업체가 있을 때만 표시 */} - {inReviewVendors.length > 0 && ( + {/* {inReviewVendors.length > 0 && ( <RequestPQVendorsDialog vendors={inReviewVendors} onSuccess={() => table.toggleAllRowsSelected(false)} /> - )} + )} */} - {/* 프로젝트 PQ 요청: 적격 상태의 협력업체가 있을 때만 표시 */} - {projectPQEligibleVendors.length > 0 && ( - <RequestProjectPQDialog - vendors={projectPQEligibleVendors} + {/* 통합 PQ 요청: 적격 상태의 협력업체가 있을 때만 표시 */} + {pqEligibleVendors.length > 0 && ( + <RequestPQDialog + vendors={pqEligibleVendors} onSuccess={() => table.toggleAllRowsSelected(false)} /> )} |
