diff options
| author | joonhoekim <26rote@gmail.com> | 2025-07-09 12:16:43 +0000 |
|---|---|---|
| committer | joonhoekim <26rote@gmail.com> | 2025-07-09 12:25:40 +0000 |
| commit | ad4855620f4aa80841c4d7b1aa39d19ab2205f0e (patch) | |
| tree | 24dabc8e98390937b75967faa6c6e25a23a0f894 | |
| parent | e328f73e5c23df7af5096d31e80fd74d9070ebc7 (diff) | |
(김준회) 벤더 상세페이지 - 기본정보 구현중 (구매 정의서)
| -rw-r--r-- | app/[lng]/evcp/(evcp)/vendors/[id]/info/basic/actions.ts | 44 | ||||
| -rw-r--r-- | app/[lng]/evcp/(evcp)/vendors/[id]/info/basic/basic-info-client.tsx | 1108 | ||||
| -rw-r--r-- | app/[lng]/evcp/(evcp)/vendors/[id]/info/basic/constants.ts | 1 | ||||
| -rw-r--r-- | app/[lng]/evcp/(evcp)/vendors/[id]/info/basic/page.tsx | 54 | ||||
| -rw-r--r-- | app/[lng]/evcp/(evcp)/vendors/[id]/info/basic/types.ts | 176 | ||||
| -rw-r--r-- | app/[lng]/evcp/(evcp)/vendors/[id]/info/basic/vendor-basic-info.tsx | 743 | ||||
| -rw-r--r-- | app/[lng]/evcp/(evcp)/vendors/[id]/info/basic/vendor-evcp-info.tsx | 396 | ||||
| -rw-r--r-- | app/[lng]/evcp/(evcp)/vendors/[id]/info/layout.tsx | 2 | ||||
| -rw-r--r-- | db/schema/vendors.ts | 20 | ||||
| -rw-r--r-- | lib/soap/mdg/send/vendor-master/action.ts | 122 | ||||
| -rw-r--r-- | lib/vendors/service.ts | 121 |
11 files changed, 1589 insertions, 1198 deletions
diff --git a/app/[lng]/evcp/(evcp)/vendors/[id]/info/basic/actions.ts b/app/[lng]/evcp/(evcp)/vendors/[id]/info/basic/actions.ts new file mode 100644 index 00000000..866103a6 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/vendors/[id]/info/basic/actions.ts @@ -0,0 +1,44 @@ +"use server"; + +import { getVendorBasicInfo } from "@/lib/vendors/service"; +import { VendorFormData } from "./types"; + +/** + * 벤더 기본정보를 가져오는 서버 액션 + */ +export async function getVendorData(vendorId: string) { + try { + const id = parseInt(vendorId); + if (isNaN(id)) { + return null; + } + + const vendorData = await getVendorBasicInfo(id); + return vendorData; + } catch (error) { + console.error("Error in getVendorData:", error); + return null; + } +} + +/** + * 벤더 기본정보를 업데이트하는 서버 액션 (향후 구현) + */ +export async function updateVendorData(vendorId: string, formData: VendorFormData) { + try { + // TODO: 실제 업데이트 로직 구현 + console.log("Updating vendor data:", { vendorId, formData }); + + // 임시로 성공 응답 반환 + return { + success: true, + message: "(개발중입니다) 벤더 정보가 성공적으로 업데이트되었습니다.", + }; + } catch (error) { + console.error("Error in updateVendorData:", error); + return { + success: false, + message: "업데이트 중 오류가 발생했습니다.", + }; + } +}
\ No newline at end of file diff --git a/app/[lng]/evcp/(evcp)/vendors/[id]/info/basic/basic-info-client.tsx b/app/[lng]/evcp/(evcp)/vendors/[id]/info/basic/basic-info-client.tsx new file mode 100644 index 00000000..d537c5fe --- /dev/null +++ b/app/[lng]/evcp/(evcp)/vendors/[id]/info/basic/basic-info-client.tsx @@ -0,0 +1,1108 @@ +"use client"; + +import { useState, useTransition } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Separator } from "@/components/ui/separator"; +import { Checkbox } from "@/components/ui/checkbox"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Edit, Save, X } from "lucide-react"; +import { toast } from "sonner"; +import { VendorData, VendorFormData, VendorAttachment } from "./types"; +import { updateVendorData } from "./actions"; +import { noDataString } from "./constants"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; + +interface BasicInfoClientProps { + initialData: VendorData | null; + vendorId: string; +} + +interface DropdownOption { + value: string; + label: string; +} + +interface InfoItemProps { + title: string; + value: string | boolean; + isEditable?: boolean; + editMode?: boolean; + onChange?: (value: string | boolean) => void; + fieldKey?: string; + type?: "text" | "checkbox" | "dropdown" | "file-button" | "readonly"; + options?: DropdownOption[]; // dropdown용 옵션들 + onFileButtonClick?: () => void; // 파일 버튼 클릭 핸들러 + placeholder?: string; // input placeholder +} + +const InfoItem = ({ + title, + value, + isEditable = false, + editMode = false, + onChange, + fieldKey, + type = "text", + options = [], + onFileButtonClick, + placeholder, +}: InfoItemProps) => { + // 편집 가능 여부 결정 (readonly 타입은 항상 읽기 전용) + const canEdit = isEditable && editMode && type !== "readonly"; + + // 표시할 값 결정 (빈 값일 때 처리) + const displayValue = value || ""; + const showNoData = !value && !canEdit; + + const renderEditableField = () => { + switch (type) { + case "checkbox": + return ( + <div className="flex items-center space-x-2"> + <Checkbox + id={fieldKey} + checked={value as boolean} + onCheckedChange={(checked) => onChange?.(checked)} + /> + <Label + htmlFor={fieldKey} + className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" + > + {value ? "있음" : "없음"} + </Label> + </div> + ); + + case "dropdown": + return ( + <Select + value={displayValue as string} + onValueChange={(val) => onChange?.(val)} + > + <SelectTrigger className="h-8"> + <SelectValue placeholder={placeholder || "선택하세요"} /> + </SelectTrigger> + <SelectContent> + {options.map((option) => ( + <SelectItem key={option.value} value={option.value}> + {option.label} + </SelectItem> + ))} + </SelectContent> + </Select> + ); + + case "file-button": + return ( + <Button + type="button" + variant="outline" + className="h-8 text-xs" + onClick={onFileButtonClick} + > + {displayValue || "파일 관리"} + </Button> + ); + + case "text": + default: + return ( + <Input + id={fieldKey} + value={displayValue as string} + onChange={(e) => onChange?.(e.target.value)} + className="h-8" + placeholder={placeholder} + /> + ); + } + }; + + const renderReadOnlyField = () => { + switch (type) { + case "checkbox": + return ( + <div className="flex items-center space-x-2"> + <Checkbox + id={`readonly-${fieldKey}`} + checked={value as boolean} + disabled={true} + /> + <Label + htmlFor={`readonly-${fieldKey}`} + className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70" + > + {value ? "있음" : "없음"} + </Label> + </div> + ); + + case "file-button": + return ( + <Button + type="button" + variant="ghost" + className="h-auto p-0 text-xs text-left justify-start" + onClick={onFileButtonClick} + disabled={!onFileButtonClick} + > + {displayValue || noDataString} + </Button> + ); + + case "dropdown": + case "text": + case "readonly": + default: + return showNoData ? noDataString : displayValue; + } + }; + + return ( + <div className="grid grid-cols-2 gap-2 py-1 min-w-0"> + <div className="text-sm text-muted-foreground break-words">{title}:</div> + <div className="text-sm font-medium space-y-1 break-words overflow-hidden min-w-0"> + {canEdit ? ( + <div className="space-y-1"> + <Label htmlFor={fieldKey} className="sr-only"> + {title} + </Label> + {renderEditableField()} + </div> + ) : ( + <span>{renderReadOnlyField()}</span> + )} + </div> + </div> + ); +}; + +const OrganizationChart = ({ + data, + editMode = false, + onChange, +}: { + data: any; + editMode?: boolean; + onChange?: (field: string, value: string) => void; +}) => { + const organizationFields = [ + { key: "representative", label: "대표" }, + { key: "sales", label: "영업" }, + { key: "design", label: "설계" }, + { key: "procurement", label: "구매" }, + { key: "production", label: "생산" }, + { key: "quality", label: "품질" }, + ]; + + return ( + <div className="space-y-4"> + <div className="text-sm font-semibold text-center">조직도</div> + <div className="grid grid-cols-3 gap-4"> + {organizationFields.map((field) => ( + <div key={field.key} className="flex flex-col items-center space-y-2"> + <div className="text-sm font-medium text-center whitespace-nowrap"> + {field.label} + </div> + <div className="text-center"> + {editMode ? ( + <Input + value={data?.[field.key]?.toString() || ""} + onChange={(e) => onChange?.(field.key, e.target.value)} + className="h-8 w-16 text-center" + placeholder="0" + /> + ) : ( + <span className="text-sm text-muted-foreground"> + {data?.[field.key]?.toString() || noDataString} + </span> + )} + </div> + </div> + ))} + </div> + </div> + ); +}; + +const InfoSection = ({ + title, + subtitle, + column1, + column2, + column3, + additionalContent, +}: { + title: string; + subtitle?: string; + column1: React.ReactNode; + column2: React.ReactNode; + column3: React.ReactNode; + additionalContent?: React.ReactNode; +}) => ( + <div className="border"> + <div className="flex"> + <div className="w-32 bg-muted p-4 border-r flex flex-col"> + <div className="text-sm font-semibold text-center w-full">{title}</div> + {subtitle && ( + <div className="text-sm text-muted-foreground text-center w-full mt-1"> + {subtitle} + </div> + )} + </div> + <div className="flex-1 grid grid-cols-3 min-w-0"> + <div className="p-4 border-r min-w-0 overflow-hidden">{column1}</div> + <div className="p-4 border-r min-w-0 overflow-hidden">{column2}</div> + <div className="p-4 min-w-0 overflow-hidden">{column3}</div> + </div> + </div> + {additionalContent && ( + <div className="flex"> + <div className="w-32 bg-muted border-r"></div> + <div className="flex-1 p-4 border-t">{additionalContent}</div> + </div> + )} + </div> +); + +const WideInfoSection = ({ + title, + subtitle, + content, + noPadding = false, +}: { + title?: string; + subtitle?: string; + content: React.ReactNode; + noPadding?: boolean; +}) => ( + <div className="border"> + <div className="flex"> + <div className="w-32 bg-muted p-4 border-r flex flex-col"> + <div className="text-sm font-semibold text-center w-full">{title}</div> + {subtitle && ( + <div className="text-sm text-muted-foreground text-center w-full mt-1"> + {subtitle} + </div> + )} + </div> + <div className={`flex-1 min-w-0 overflow-x-auto ${noPadding ? '' : 'p-4'}`}> + {content} + </div> + </div> + </div> +); + +export default function BasicInfoClient({ + initialData, + vendorId, +}: BasicInfoClientProps) { + const [editMode, setEditMode] = useState(false); + const [isPending, startTransition] = useTransition(); + const [formData, setFormData] = useState<VendorFormData>({ + vendorName: initialData?.vendorName || "", + representativeName: initialData?.representativeName || "", + representativeWorkExperience: + initialData?.representativeWorkExperience || false, + representativeBirth: initialData?.representativeBirth || "", + representativePhone: initialData?.representativePhone || "", + representativeEmail: initialData?.representativeEmail || "", + phone: initialData?.phone || "", + fax: initialData?.fax || "", + email: initialData?.email || "", + address: initialData?.address || "", + businessSize: initialData?.businessSize || "", + country: initialData?.country || "", + website: initialData?.website || "", + businessType: initialData?.additionalInfo?.businessType || "", + employeeCount: initialData?.additionalInfo?.employeeCount || 0, + mainBusiness: initialData?.additionalInfo?.mainBusiness || "", + }); + + const handleSave = () => { + startTransition(async () => { + try { + const result = await updateVendorData(vendorId, formData); + if (result.success) { + toast.success("[개발중] 저장되지 않습니다. 업데이트는 구현중입니다."); + setEditMode(false); + } else { + toast.error(result.message || "저장에 실패했습니다."); + } + } catch { + toast.error("저장 중 오류가 발생했습니다."); + } + }); + }; + + const handleCancel = () => { + setFormData({ + vendorName: initialData?.vendorName || "", + representativeName: initialData?.representativeName || "", + representativeWorkExperience: + initialData?.representativeWorkExperience || false, + representativeBirth: initialData?.representativeBirth || "", + representativePhone: initialData?.representativePhone || "", + representativeEmail: initialData?.representativeEmail || "", + phone: initialData?.phone || "", + fax: initialData?.fax || "", + email: initialData?.email || "", + address: initialData?.address || "", + businessSize: initialData?.businessSize || "", + country: initialData?.country || "", + website: initialData?.website || "", + businessType: initialData?.additionalInfo?.businessType || "", + employeeCount: initialData?.additionalInfo?.employeeCount || 0, + mainBusiness: initialData?.additionalInfo?.mainBusiness || "", + }); + setEditMode(false); + }; + + const updateField = ( + field: keyof VendorFormData, + value: string | number | boolean + ) => { + setFormData((prev) => ({ ...prev, [field]: value })); + }; + + const handleFileManagement = (attachmentType: string) => { + // TODO: 파일 관리 다이얼로그 열기 + toast.info( + `[개발중] ${attachmentType} [필요시] 조회/삭제/추가 기능을 구현 예정입니다.` + ); + }; + + if (!initialData) { + return ( + <div className="p-6 bg-background max-w-full"> + <div className="text-center py-8"> + <p className="text-muted-foreground">{noDataString}</p> + </div> + </div> + ); + } + + const attachmentsByType = initialData.attachments.reduce( + (acc: Record<string, VendorAttachment[]>, attachment: VendorAttachment) => { + if (!acc[attachment.attachmentType]) { + acc[attachment.attachmentType] = []; + } + acc[attachment.attachmentType].push(attachment); + return acc; + }, + {} + ); + + return ( + <div className="p-6 bg-background w-full overflow-x-auto"> + <div className="mb-6 flex justify-between items-center"> + <h2 className="text-xl font-bold">협력업체 기본정보</h2> + <div className="flex gap-2"> + {editMode ? ( + <> + <Button + onClick={handleSave} + disabled={isPending} + className="flex items-center gap-1" + > + <Save className="w-4 h-4" /> + 저장 + </Button> + <Button + variant="outline" + onClick={handleCancel} + disabled={isPending} + className="flex items-center gap-1" + > + <X className="w-4 h-4" /> + 취소 + </Button> + </> + ) : ( + <Button + onClick={() => setEditMode(true)} + className="flex items-center gap-1" + > + <Edit className="w-4 h-4" /> + 수정 + </Button> + )} + </div> + </div> + + <div className="space-y-4"> + {/* 업체정보 */} + <InfoSection + title="업체정보" + column1={ + <div className="space-y-2"> + <InfoItem + title="업체명" + value={formData.vendorName} + isEditable={true} + editMode={editMode} + fieldKey="vendorName" + onChange={(value) => updateField("vendorName", value)} + /> + <InfoItem + title="설립일" + // 현재 필드 없고 linter error 나도 무시. createdAt은 데이터베이스 생성시점이므로 잘못된 필드. + value={initialData.establishmentDate} + type="readonly" + /> + <InfoItem + title="대표전화" + value={formData.phone} + isEditable={true} + editMode={editMode} + fieldKey="phone" + onChange={(value) => updateField("phone", value)} + /> + <InfoItem + title="팩스" + value={formData.fax} + isEditable={true} + editMode={editMode} + fieldKey="fax" + onChange={(value) => updateField("fax", value)} + /> + <InfoItem + title="업체유형" + value={formData.businessType} + isEditable={true} + editMode={editMode} + fieldKey="businessType" + onChange={(value) => updateField("businessType", value)} + /> + <InfoItem + title="소개자료" + value={`회사: ${ + attachmentsByType.COMPANY_INTRO?.length || 0 + }건 / 제품: ${attachmentsByType.PRODUCT_INTRO?.length || 0}건`} + isEditable={true} + editMode={editMode} + type="file-button" + onFileButtonClick={() => handleFileManagement("소개자료")} + /> + <InfoItem + title="정기평가 등급" + value={ + initialData.evaluationInfo?.regularEvaluationGrade || null + } + type="readonly" + /> + </div> + } + column2={ + <div className="space-y-2"> + <InfoItem + title="사업자번호" + value={initialData.taxId} + type="readonly" + /> + <InfoItem + title="법인등록번호" + value={initialData.corporateRegistrationNumber} + type="readonly" + /> + <InfoItem + title="회사주소" + value={formData.address} + isEditable={true} + editMode={editMode} + fieldKey="address" + onChange={(value) => updateField("address", value)} + /> + <InfoItem + title="E-mail" + value={formData.email} + isEditable={true} + editMode={editMode} + fieldKey="email" + onChange={(value) => updateField("email", value)} + /> + <InfoItem + title="사업유형" + value={formData.businessType} + isEditable={true} + /> + <InfoItem + title="기업규모" + value={formData.businessSize} + isEditable={true} + editMode={editMode} + fieldKey="businessSize" + type="dropdown" + options={[ + { value: "A", label: "대기업 (A)" }, + { value: "B", label: "중견기업 (B)" }, + { value: "C", label: "중소기업 (C)" }, + { value: "D", label: "소상공인 (D)" }, + ]} + onChange={(value) => updateField("businessSize", value)} + placeholder="기업규모를 선택하세요" + /> + <InfoItem + title="사업자등록증" + value={`${ + attachmentsByType.BUSINESS_REGISTRATION?.length || 0 + }건`} + isEditable={true} + editMode={editMode} + type="file-button" + onFileButtonClick={() => handleFileManagement("사업자등록증")} + /> + <InfoItem + title="안전적격성평가" + value={ + initialData.evaluationInfo?.safetyQualificationEvaluation || + null + } + type="readonly" + /> + </div> + } + column3={ + <div className="space-y-2"> + <InfoItem + title="업체분류" + value={ + initialData.classificationInfo?.vendorClassification || null + } + isEditable={true} + /> + <InfoItem + title="그룹사" + value={initialData.classificationInfo?.groupCompany || null} + isEditable={true} + /> + <InfoItem + title="국가" + value={formData.country} + isEditable={true} + editMode={editMode} + fieldKey="country" + onChange={(value) => updateField("country", value)} + /> + <InfoItem + title="선호언어" + value={ + initialData.classificationInfo?.preferredLanguage || null + } + isEditable={true} + /> + <InfoItem + title="산업유형" + value={initialData.classificationInfo?.industryType || null} + isEditable={true} + /> + <InfoItem + title="ISO Cert" + value={`${attachmentsByType.ISO_CERTIFICATION?.length || 0}건`} + isEditable={true} + editMode={editMode} + type="file-button" + onFileButtonClick={() => handleFileManagement("ISO 인증서")} + /> + <InfoItem + title="당사거래비중" + value={ + initialData.evaluationInfo?.companyTransactionRatio || null + } + type="readonly" + /> + </div> + } + /> + + <Separator /> + + {/* 상세정보 */} + <InfoSection + title="상세정보" + column1={ + <div className="space-y-2"> + <InfoItem + title="대표자명" + value={formData.representativeName} + isEditable={true} + editMode={editMode} + fieldKey="representativeName" + onChange={(value) => updateField("representativeName", value)} + /> + <InfoItem + title="대표자 당사근무경험" + value={formData.representativeWorkExperience} + isEditable={true} + editMode={editMode} + fieldKey="representativeWorkExperience" + type="checkbox" + onChange={(value) => + updateField("representativeWorkExperience", value) + } + /> + <InfoItem + title="대표자 생년월일" + value={formData.representativeBirth || null} + isEditable={true} + /> + <InfoItem + title="임직원수" + value={formData.employeeCount.toString() || null} + isEditable={true} + /> + </div> + } + column2={ + <div className="space-y-2"> + <InfoItem + title="대표자Tel" + value={formData.representativePhone} + isEditable={true} + editMode={editMode} + fieldKey="representativePhone" + onChange={(value) => updateField("representativePhone", value)} + /> + <InfoItem + title="대표자 주소" + value={formData.address || null} + isEditable={true} + /> + <InfoItem + title="연간 매출" + value={initialData.capacityInfo?.annualSales || null} + isEditable={true} + /> + </div> + } + column3={ + <div className="space-y-2"> + <InfoItem + title="대표자 E-mail" + value={formData.representativeEmail} + isEditable={true} + editMode={editMode} + fieldKey="representativeEmail" + onChange={(value) => updateField("representativeEmail", value)} + /> + <InfoItem + title="생산능력" + value={initialData.capacityInfo?.productionCapacity || null} + isEditable={true} + /> + </div> + } + additionalContent={ + <div className="grid grid-cols-2 gap-8 py-4 min-w-0 overflow-x-auto"> + <OrganizationChart + data={initialData.organization} + editMode={editMode} + onChange={(field, value) => { + // TODO: 조직도 업데이트 로직 구현 + toast.info( + `[개발중] 조직도 ${field} 필드 업데이트 기능을 구현 예정입니다.` + ); + }} + /> + <div className="flex flex-col items-center gap-3"> + <div className="text-sm font-semibold text-center"> + 관련 정보 + </div> + <div className="space-y-2"> + <Button + variant="outline" + className="text-xs w-32 flex items-center gap-2" + onClick={() => handleFileManagement("협력업체정보")} + > + 협력업체정보 + </Button> + <Button + variant="outline" + className="text-xs w-32 flex items-center gap-2" + onClick={() => handleFileManagement("외주화정보")} + > + 외주화정보 + </Button> + <Button + variant="outline" + className="text-xs w-32 flex items-center gap-2" + onClick={() => handleFileManagement("A/S 네트워크")} + > + A/S 네트워크 + </Button> + </div> + </div> + </div> + } + /> + + <Separator /> + + {/* 매출정보 */} + <WideInfoSection + title="매출정보" + subtitle="(3개년)" + noPadding={true} + content={ + <Table> + <TableHeader> + <TableRow> + <TableHead + rowSpan={2} + className="text-center border-r align-middle" + > + 기준일 + </TableHead> + <TableHead colSpan={3} className="text-center border-r"> + 자산 구성 + </TableHead> + <TableHead + rowSpan={2} + className="text-center border-r align-middle" + > + 영업이익 + <br /> + (백만원) + </TableHead> + <TableHead + rowSpan={2} + className="text-center border-r align-middle" + > + 당기순이익 + <br /> + (백만원) + </TableHead> + <TableHead + rowSpan={2} + className="text-center border-r align-middle" + > + 부채비율 + <br /> + (%) + </TableHead> + <TableHead + rowSpan={2} + className="text-center border-r align-middle" + > + 차입금의존도 + <br /> + (%) + </TableHead> + <TableHead + rowSpan={2} + className="text-center border-r align-middle" + > + 영업이익률 + <br /> + (%) + </TableHead> + <TableHead + rowSpan={2} + className="text-center border-r align-middle" + > + 순이익률 + <br /> + (%) + </TableHead> + <TableHead + rowSpan={2} + className="text-center border-r align-middle" + > + 매출액증감 + <br /> + (%) + </TableHead> + <TableHead rowSpan={2} className="text-center align-middle"> + 유동비율 + <br /> + (%) + </TableHead> + </TableRow> + <TableRow> + <TableHead className="text-center border-r">총자산</TableHead> + <TableHead className="text-center border-r"> + 부채총계 + </TableHead> + <TableHead className="text-center border-r"> + 자본총계 + </TableHead> + </TableRow> + </TableHeader> + <TableBody> + {["20231231", "20221231", "20211231"].map((dateKey) => { + const year = dateKey; + const salesData = initialData.salesInfo?.[year]; + const metricsData = initialData.calculatedMetrics?.[dateKey]; + + return ( + <TableRow key={dateKey}> + <TableCell className="text-center font-medium border-r bg-yellow-50"> + {year} + </TableCell> + <TableCell className="text-right border-r"> + {salesData + ? ( + parseInt(salesData.totalDebt.replace(/,/g, "")) + + parseInt(salesData.totalEquity.replace(/,/g, "")) + ).toLocaleString() + : "-"} + </TableCell> + <TableCell className="text-right border-r"> + {salesData?.totalDebt || "-"} + </TableCell> + <TableCell className="text-right border-r"> + {salesData?.totalEquity || "-"} + </TableCell> + <TableCell className="text-right border-r"> + {salesData?.operatingProfit || "-"} + </TableCell> + <TableCell className="text-right border-r"> + {salesData?.netIncome || "-"} + </TableCell> + <TableCell className="text-right border-r"> + {metricsData?.debtRatio?.toFixed(1) || "-"} + </TableCell> + <TableCell className="text-right border-r"> + {metricsData?.borrowingDependency?.toFixed(1) || "-"} + </TableCell> + <TableCell className="text-right border-r"> + {metricsData?.operatingMargin?.toFixed(1) || "-"} + </TableCell> + <TableCell className="text-right border-r"> + {metricsData?.netMargin?.toFixed(1) || "-"} + </TableCell> + <TableCell className="text-right border-r"> + {metricsData?.salesGrowth?.toFixed(1) || "-"} + </TableCell> + <TableCell className="text-right"> + {metricsData?.currentRatio?.toFixed(1) || "-"} + </TableCell> + </TableRow> + ); + })} + </TableBody> + </Table> + } + /> + + <Separator /> + + {/* 실사정보 */} + <InfoSection + title="실사정보" + subtitle="(3년)" + column1={ + <div className="space-y-2"> + <InfoItem + title="공장주소" + value={initialData.factoryInfo?.factoryAddress || null} + /> + <InfoItem + title="공장설립일" + value={ + initialData.factoryInfo?.factoryEstablishmentDate || null + } + /> + </div> + } + column2={ + <div className="space-y-2"> + <InfoItem + title="공장 PIC" + value={ + initialData.factoryInfo?.factoryPIC + ? `${initialData.factoryInfo.factoryPIC} [${ + initialData.factoryInfo.factoryPICContact || "" + }] [${initialData.factoryInfo.factoryPICEmail || ""}]` + : null + } + /> + <InfoItem + title="실사결과" + value={ + initialData.inspectionInfo?.inspectionResult + ? `${initialData.inspectionInfo.inspectionResult} (${ + initialData.inspectionInfo.inspectionDate || "" + })` + : null + } + /> + </div> + } + column3={ + <div className="flex flex-col gap-2"> + <div className="space-y-2"> + <InfoItem + title="대표공급품목" + value={initialData.capacityInfo?.mainSupplyItems || null} + /> + </div> + <Button + variant="outline" + onClick={() => handleFileManagement("대표공급품목")} + > + 대표 공급품목 상세보기 + </Button> + </div> + } + additionalContent={ + <div className="grid grid-cols-5 gap-4 min-w-0 overflow-x-auto"> + <div className="text-center min-w-0"> + <div className="text-sm font-medium mb-2 break-words"> + 공정소개자료 + </div> + <div className="text-sm text-muted-foreground"> + {attachmentsByType.BUSINESS_REGISTRATION?.length || 0}건 + </div> + </div> + <div className="text-center min-w-0"> + <div className="text-sm font-medium mb-2 break-words"> + QMS Cert + </div> + <div className="text-sm text-muted-foreground"> + {attachmentsByType.ISO_CERTIFICATION?.length || 0}건 + </div> + </div> + <div className="text-center min-w-0"> + <div className="text-sm font-medium mb-2 break-words"> + Product Cert + </div> + <div className="text-sm text-muted-foreground"> + {attachmentsByType.PRODUCT_CERT?.length || 0}건 + </div> + </div> + <div className="text-center min-w-0"> + <div className="text-sm font-medium mb-2 break-words"> + Ex. Cert + </div> + <div className="text-sm text-muted-foreground"> + {attachmentsByType.EX_CERT?.length || 0}건 + </div> + </div> + <div className="text-center min-w-0"> + <div className="text-sm font-medium mb-2 break-words"> + HSE Cert + </div> + <div className="text-sm text-muted-foreground"> + {attachmentsByType.HSE_CERT?.length || 0}건 + </div> + </div> + </div> + } + /> + + <Separator /> + + {/* 계약정보 */} + <InfoSection + title="계약정보" + column1={ + <div className="space-y-2"> + <InfoItem + title="정규등록현황" + value={ + initialData.contractDetails?.regularRegistrationStatus || null + } + /> + </div> + } + column2={ + <div className="space-y-2"> + <InfoItem + title="선호 계약조건" + value={ + initialData.contractDetails?.preferredContractTerms || null + } + /> + </div> + } + column3={ + <div className="space-y-2"> + <InfoItem + title="최근 거래현황" + value={ + initialData.contractDetails?.recentTransactionStatus || null + } + /> + </div> + } + additionalContent={ + <div className="grid grid-cols-10 gap-4 min-w-0 overflow-x-auto"> + {[ + { + title: "준법서약", + value: + initialData.contractDetails?.compliancePledgeDate || null, + }, + { + title: "기술자료", + value: initialData.contractDetails?.technicalDataDate || null, + }, + { + title: "비밀유지", + value: + initialData.contractDetails?.confidentialityDate || null, + }, + { + title: "GTC", + value: initialData.contractDetails?.gtcDate || null, + }, + { + title: "표준하도급", + value: + initialData.contractDetails?.standardSubcontractDate || + null, + }, + { + title: "안전보건", + value: initialData.contractDetails?.safetyHealthDate || null, + }, + { + title: "직납자재", + value: + initialData.contractDetails?.directMaterialDate || null, + }, + { + title: "내국신용장", + value: initialData.contractDetails?.domesticLCDate || null, + }, + { + title: "동반성장", + value: initialData.contractDetails?.mutualGrowthDate || null, + }, + { + title: "윤리규범", + value: initialData.contractDetails?.ethicsDate || null, + }, + ].map((item, index) => ( + <div key={index} className="text-center min-w-0"> + <div className="text-sm font-medium mb-2 break-words"> + {item.title} + </div> + <div className="text-sm text-muted-foreground"> + {item.value || "-"} + </div> + </div> + ))} + </div> + } + /> + </div> + </div> + ); +} diff --git a/app/[lng]/evcp/(evcp)/vendors/[id]/info/basic/constants.ts b/app/[lng]/evcp/(evcp)/vendors/[id]/info/basic/constants.ts new file mode 100644 index 00000000..3a73e941 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/vendors/[id]/info/basic/constants.ts @@ -0,0 +1 @@ +export const noDataString = "데이터 없음";
\ No newline at end of file diff --git a/app/[lng]/evcp/(evcp)/vendors/[id]/info/basic/page.tsx b/app/[lng]/evcp/(evcp)/vendors/[id]/info/basic/page.tsx index d8f04095..ae63d77d 100644 --- a/app/[lng]/evcp/(evcp)/vendors/[id]/info/basic/page.tsx +++ b/app/[lng]/evcp/(evcp)/vendors/[id]/info/basic/page.tsx @@ -1,52 +1,20 @@ -import { vendorMdgService } from "@/lib/vendors/mdg-service" -import { getVendorDetailById } from "@/lib/vendors/service" -import { VendorBasicInfo } from "./vendor-basic-info" -import { VendorEvCpInfo } from "./vendor-evcp-info" +import { getVendorData } from "./actions"; +import BasicInfoClient from "./basic-info-client"; interface VendorBasicPageProps { params: { - lng: string + lng: string; // 협력업체 ID: 여기서는 eVCP의 벤더 ID를 사용 - id: string - } + id: string; + }; } export default async function VendorBasicPage(props: VendorBasicPageProps) { - const resolvedParams = await props.params - const vendorId = resolvedParams.id + const resolvedParams = await props.params; + const vendorId = resolvedParams.id; - // eVCP 벤더 정보 조회 - const evcpVendorDetails = await getVendorDetailById(parseInt(vendorId)) - - // Oracle에서 벤더 상세 정보 조회 (ID로 조회) - const oracleVendorDetails = await vendorMdgService.getVendorDetailsByVendorId(vendorId) + // 데이터베이스에서 벤더 기본정보 가져오기 + const vendorData = await getVendorData(vendorId); - if (!evcpVendorDetails && !oracleVendorDetails) { - return ( - <div className="space-y-6"> - <div className="text-center py-12"> - <h3 className="text-lg font-medium text-gray-900 mb-2"> - 벤더 정보를 찾을 수 없습니다 - </h3> - <p className="text-gray-500"> - 벤더 ID: {vendorId} - </p> - </div> - </div> - ) - } - - return ( - <div className="space-y-8"> - {/* eVCP 벤더 정보 */} - {evcpVendorDetails && ( - <VendorEvCpInfo vendorDetails={evcpVendorDetails} /> - )} - - {/* Oracle 벤더 정보 */} - {oracleVendorDetails && ( - <VendorBasicInfo vendorDetails={oracleVendorDetails} /> - )} - </div> - ) -}
\ No newline at end of file + return <BasicInfoClient initialData={vendorData} vendorId={vendorId} />; +} diff --git a/app/[lng]/evcp/(evcp)/vendors/[id]/info/basic/types.ts b/app/[lng]/evcp/(evcp)/vendors/[id]/info/basic/types.ts new file mode 100644 index 00000000..510ae361 --- /dev/null +++ b/app/[lng]/evcp/(evcp)/vendors/[id]/info/basic/types.ts @@ -0,0 +1,176 @@ +export interface VendorContact { + id: number; + contactName: string; + contactPosition: string; + contactEmail: string; + contactPhone: string; + isPrimary: boolean; +} + +export interface VendorAttachment { + id: number; + fileName: string; + filePath: string; + attachmentType: string; + createdAt: string; +} + +export interface VendorProcessInfo { + processCount: number; + processPIC: string; + processApprovalDate: string; + implementationApproval: string; +} + +export interface VendorContractInfo { + contractRegistrationNumber: string; + contractPeriod: string; + lastEquipmentInspection: string; +} + +export interface VendorSalesData { + totalSales: string; + totalDebt: string; + totalEquity: string; + operatingProfit: string; + netIncome: string; +} + +export interface VendorAdditionalInfo { + postalCode: string; + detailAddress: string; + mainBusiness: string; + employeeCount: number; + businessType: string; +} + +export interface VendorOrganization { + representative: number; + sales: number; + design: number; + procurement: number; + production: number; + quality: number; +} + +export interface VendorFactoryInfo { + factoryAddress: string; + factoryEstablishmentDate: string; + factoryPIC: string; + factoryPICContact: string; + factoryPICEmail: string; +} + +export interface VendorInspectionInfo { + inspectionResult: string; + inspectionDate: string; + inspectionReportUrl?: string; +} + +export interface VendorEvaluationInfo { + regularEvaluationGrade: string; + safetyQualificationEvaluation: string; + companyTransactionRatio: string; +} + +export interface VendorClassificationInfo { + vendorClassification: string; + groupCompany: string; + preferredLanguage: string; + industryType: string; + isoCertification: string; +} + +export interface VendorContractDetails { + regularRegistrationStatus: string; + preferredContractTerms: string; + recentTransactionStatus: string; + compliancePledgeDate: string; + technicalDataDate: string; + confidentialityDate: string; + gtcDate: string; + standardSubcontractDate: string; + safetyHealthDate: string; + directMaterialDate: string; + domesticLCDate: string; + mutualGrowthDate: string; + ethicsDate: string; +} + +export interface VendorCapacityInfo { + annualSales: string; + productionCapacity: string; + mainSupplyItems: string; +} + +export interface VendorCalculatedMetrics { + debtRatio: number; + borrowingDependency: number; + operatingMargin: number; + netMargin: number; + salesGrowth: number; + currentRatio: number; +} + +export interface VendorData { + id: number; + vendorName: string; + vendorCode: string; + taxId: string; + address: string; + businessSize: string; + country: string; + phone: string; + fax: string; + email: string; + website: string; + status: string; + representativeName: string; + representativeBirth: string; + representativeEmail: string; + representativePhone: string; + representativeWorkExperience: boolean; + corporateRegistrationNumber: string; + creditAgency: string; + creditRating: string; + cashFlowRating: string; + createdAt: string; + updatedAt: string; + contacts: VendorContact[]; + attachments: VendorAttachment[]; + processInfo: VendorProcessInfo; + contractInfo: VendorContractInfo; + salesInfo: { + [year: string]: VendorSalesData; + }; + additionalInfo: VendorAdditionalInfo; + organization: VendorOrganization; + factoryInfo: VendorFactoryInfo; + inspectionInfo: VendorInspectionInfo; + evaluationInfo: VendorEvaluationInfo; + classificationInfo: VendorClassificationInfo; + contractDetails: VendorContractDetails; + capacityInfo: VendorCapacityInfo; + calculatedMetrics: { + [year: string]: VendorCalculatedMetrics; + }; +} + +export interface VendorFormData { + vendorName: string; + representativeName: string; + representativeWorkExperience: boolean; + representativeBirth: string; + representativePhone: string; + representativeEmail: string; + phone: string; + fax: string; + email: string; + address: string; + businessSize: string; + country: string; + website: string; + businessType: string; + employeeCount: number; + mainBusiness: string; +}
\ No newline at end of file diff --git a/app/[lng]/evcp/(evcp)/vendors/[id]/info/basic/vendor-basic-info.tsx b/app/[lng]/evcp/(evcp)/vendors/[id]/info/basic/vendor-basic-info.tsx deleted file mode 100644 index e14b7b51..00000000 --- a/app/[lng]/evcp/(evcp)/vendors/[id]/info/basic/vendor-basic-info.tsx +++ /dev/null @@ -1,743 +0,0 @@ -"use client" - -import { useState, useTransition, useMemo } from "react" -import { useParams } from "next/navigation" -import { toast } from "sonner" -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { Input } from "@/components/ui/input" -import { Textarea } from "@/components/ui/textarea" -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select" -import { AddressDisplay } from "./text-utils" -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog" -import { - Phone, - Mail, - Calendar, - CheckCircle, - XCircle, - AlertCircle, - Edit, - Save, - X, - Building2, - Eye -} from "lucide-react" -import { updateMdgVendorBasicInfo } from "@/lib/vendors/mdg-actions" - -// 구매조직별 정보 타입 -interface PurchasingOrgInfo { - PUR_ORG_CD: string - PUR_ORD_CUR: string | null - SPLY_COND: string | null - DL_COND_1: string | null - DL_COND_2: string | null - GR_BSE_INVC_VR: string | null - ORD_CNFM_REQ_ORDR: string | null - CNFM_CTL_KEY: string | null - PUR_HOLD_ORDR: string | null - DEL_ORDR: string | null - AT_PUR_ORD_ORDR: string | null - SALE_CHRGR_NM: string | null - VNDR_TELNO: string | null - PUR_HOLD_DT: string | null - PUR_HOLD_CAUS: string | null -} - -interface VendorDetails { - VNDRCD: string - VNDRNM_1: string | null - VNDRNM_2: string | null - VNDRNM_ABRV_1: string | null - CO_VLM: string | null - BIZR_NO: string | null - CO_REG_NO: string | null - REPR_NM: string | null - REP_TEL_NO: string | null - REPR_RESNO: string | null - REPRESENTATIVE_EMAIL: string | null - BIZTP: string | null - BIZCON: string | null - NTN_CD: string | null - REG_DT: string | null - ADR_1: string | null - ADR_2: string | null - POSTAL_CODE: string | null - ADDR_DETAIL_1: string | null - PREVIOUS_VENDOR_CODE: string | null - PRTNR_GB: string | null - PURCHASING_ORGS: PurchasingOrgInfo[] - DEL_ORDR: string | null - PUR_HOLD_ORDR: string | null -} - -interface VendorBasicInfoProps { - vendorDetails: VendorDetails -} - -export function VendorBasicInfo({ vendorDetails }: VendorBasicInfoProps) { - const params = useParams() - const vendorId = params?.id as string - const [isEditing, setIsEditing] = useState(false) - const [editData, setEditData] = useState(vendorDetails) - const [isPending, startTransition] = useTransition() - const [showConfirmDialog, setShowConfirmDialog] = useState(false) - const [selectedPurchasingOrg, setSelectedPurchasingOrg] = useState<string>(() => { - // 구매조직이 1개면 자동 선택, 여러개면 첫 번째 선택, 없으면 'none' - if (vendorDetails.PURCHASING_ORGS.length === 1) { - return vendorDetails.PURCHASING_ORGS[0].PUR_ORG_CD - } else if (vendorDetails.PURCHASING_ORGS.length > 1) { - return vendorDetails.PURCHASING_ORGS[0].PUR_ORG_CD - } - return 'none' - }) - const [showAllOrgs, setShowAllOrgs] = useState(false) - - // 변경사항 감지 - const changes = useMemo(() => { - const changedFields: Array<{ label: string; before: string; after: string }> = [] - - const fieldLabels: Record<string, string> = { - VNDRNM_1: "업체명", - VNDRNM_2: "영문명", - VNDRNM_ABRV_1: "업체약어", - BIZR_NO: "사업자번호", - CO_REG_NO: "법인등록번호", - CO_VLM: "기업규모", - REPR_NM: "대표자명", - REP_TEL_NO: "대표자 전화번호", - REPR_RESNO: "대표자 생년월일", - REPRESENTATIVE_EMAIL: "대표자 이메일", - BIZTP: "사업유형", - BIZCON: "산업유형", - NTN_CD: "국가코드", - ADR_1: "주소", - ADR_2: "영문주소", - POSTAL_CODE: "우편번호", - ADDR_DETAIL_1: "상세주소" - } - - Object.keys(fieldLabels).forEach(field => { - const originalValue = vendorDetails[field as keyof VendorDetails] as string || '' - const editedValue = editData[field as keyof VendorDetails] as string || '' - - if (originalValue !== editedValue) { - changedFields.push({ - label: fieldLabels[field], - before: originalValue || '(없음)', - after: editedValue || '(없음)' - }) - } - }) - - return changedFields - }, [vendorDetails, editData]) - - // 선택된 구매조직 정보 - const currentPurchasingOrg = vendorDetails.PURCHASING_ORGS.find( - org => org.PUR_ORG_CD === selectedPurchasingOrg - ) - - // 상태에 따른 뱃지 스타일 결정 - const getStatusBadge = (status: string | null) => { - if (!status || status === 'N') { - return <Badge variant="default" className="bg-green-100 text-green-800"><CheckCircle className="w-3 h-3 mr-1" />활성</Badge> - } - return <Badge variant="destructive"><XCircle className="w-3 h-3 mr-1" />비활성</Badge> - } - - const handleEditStart = () => { - setIsEditing(true) - } - - const handleEditCancel = () => { - setIsEditing(false) - setEditData(vendorDetails) - } - - const handleEditSave = () => { - if (isPending) return - - // 변경사항이 없으면 바로 편집 모드 종료 - if (changes.length === 0) { - setIsEditing(false) - toast.info("변경된 내용이 없습니다.") - return - } - - // 변경사항이 있으면 확인 Dialog 표시 - setShowConfirmDialog(true) - } - - const handleConfirmSave = () => { - setShowConfirmDialog(false) - - startTransition(async () => { - try { - const result = await updateMdgVendorBasicInfo({ - vendorId, - updateData: { - VNDRNM_1: editData.VNDRNM_1 || undefined, - VNDRNM_2: editData.VNDRNM_2 || undefined, - VNDRNM_ABRV_1: editData.VNDRNM_ABRV_1 || undefined, - BIZR_NO: editData.BIZR_NO || undefined, - CO_REG_NO: editData.CO_REG_NO || undefined, - CO_VLM: editData.CO_VLM || undefined, - REPR_NM: editData.REPR_NM || undefined, - REP_TEL_NO: editData.REP_TEL_NO || undefined, - REPR_RESNO: editData.REPR_RESNO || undefined, - REPRESENTATIVE_EMAIL: editData.REPRESENTATIVE_EMAIL || undefined, - BIZTP: editData.BIZTP || undefined, - BIZCON: editData.BIZCON || undefined, - NTN_CD: editData.NTN_CD || undefined, - ADR_1: editData.ADR_1 || undefined, - ADR_2: editData.ADR_2 || undefined, - POSTAL_CODE: editData.POSTAL_CODE || undefined, - ADDR_DETAIL_1: editData.ADDR_DETAIL_1 || undefined, - } - }) - - if (result.success) { - toast.success(result.message || "벤더 정보가 성공적으로 업데이트되었습니다.") - setIsEditing(false) - // 필요한 경우 페이지 리로드 또는 데이터 갱신 - window.location.reload() - } else { - toast.error(result.error || "벤더 정보 업데이트에 실패했습니다.") - } - } catch (error) { - console.error('벤더 정보 업데이트 중 오류:', error) - toast.error("벤더 정보 업데이트 중 오류가 발생했습니다.") - } - }) - } - - const handleInputChange = (field: keyof VendorDetails, value: string) => { - setEditData(prev => ({ - ...prev, - [field]: value - })) - } - - const renderField = ( - label: string, - value: string | null, - field?: keyof VendorDetails, - isTextarea = false, - isMono = false - ) => { - if (isEditing && field) { - return ( - <div> - <label className="text-sm font-medium text-muted-foreground">{label}</label> - {isTextarea ? ( - <Textarea - value={editData[field] as string || ''} - onChange={(e) => handleInputChange(field, e.target.value)} - className="mt-1" - /> - ) : ( - <Input - value={editData[field] as string || ''} - onChange={(e) => handleInputChange(field, e.target.value)} - className={`mt-1 ${isMono ? 'font-mono' : ''}`} - /> - )} - </div> - ) - } - - return ( - <div> - <label className="text-sm font-medium text-muted-foreground">{label}</label> - <p className={`text-sm ${isMono ? 'font-mono' : ''} break-words ${isTextarea ? 'whitespace-pre-wrap' : ''}`}> - {value || '-'} - </p> - </div> - ) - } - - // 구매조직별 정보 필드 렌더링 - const renderPurchasingOrgField = ( - label: string, - value: string | null | undefined, - isBadge = false, - badgeType?: 'status' | 'confirm' | 'hold' - ) => { - if (isBadge) { - let badgeContent - switch (badgeType) { - case 'status': - badgeContent = value === 'X' ? ( - <Badge variant="outline" className="text-xs bg-green-50 text-green-700">활성</Badge> - ) : ( - <Badge variant="secondary" className="text-xs">비활성</Badge> - ) - break - case 'confirm': - badgeContent = value === 'X' ? ( - <Badge variant="outline" className="text-xs bg-blue-50 text-blue-700">요청</Badge> - ) : ( - <Badge variant="secondary" className="text-xs">미요청</Badge> - ) - break - case 'hold': - badgeContent = value ? ( - <Badge variant="destructive" className="text-xs"> - <AlertCircle className="w-3 h-3 mr-1" />정지 - </Badge> - ) : ( - <Badge variant="outline" className="text-xs bg-green-50 text-green-700"> - <CheckCircle className="w-3 h-3 mr-1" />정상 - </Badge> - ) - break - default: - badgeContent = <Badge variant="outline">{value || '-'}</Badge> - } - - return ( - <div> - <label className="text-sm font-medium text-muted-foreground">{label}</label> - <p className="text-sm">{badgeContent}</p> - </div> - ) - } - - return ( - <div> - <label className="text-sm font-medium text-muted-foreground">{label}</label> - <p className="text-sm break-words">{value || '-'}</p> - </div> - ) - } - - // 구매조직 정보 카드 컴포넌트 - const PurchasingOrgCard = ({ org }: { org: PurchasingOrgInfo }) => ( - <Card key={org.PUR_ORG_CD} className="border-l-4 border-l-blue-500"> - <CardHeader className="pb-3"> - <CardTitle className="text-lg flex items-center gap-2"> - 구매조직: {org.PUR_ORG_CD} - </CardTitle> - </CardHeader> - <CardContent> - <div className="grid grid-cols-3 gap-4"> - {renderPurchasingOrgField("오더통화", org.PUR_ORD_CUR)} - <div> - <label className="text-sm font-medium text-muted-foreground">내외자구분</label> - <p className="text-sm"> - {editData.PRTNR_GB ? ( - <Badge variant="outline" className="text-xs"> - {editData.PRTNR_GB === '1' ? '사내' : editData.PRTNR_GB === '2' ? '사외' : editData.PRTNR_GB} - </Badge> - ) : '-'} - </p> - </div> - {renderPurchasingOrgField("인도조건", org.DL_COND_1)} - {renderPurchasingOrgField("GR송장검증", org.GR_BSE_INVC_VR, true, 'status')} - {renderPurchasingOrgField("P/O 확인요청", org.ORD_CNFM_REQ_ORDR, true, 'confirm')} - {renderPurchasingOrgField("확정제어", org.CNFM_CTL_KEY)} - {renderPurchasingOrgField("지급조건", org.SPLY_COND)} - {renderPurchasingOrgField("거래정지", org.PUR_HOLD_ORDR, true, 'hold')} - {renderPurchasingOrgField("삭제상태", org.DEL_ORDR)} - {renderPurchasingOrgField("영업담당자", org.SALE_CHRGR_NM)} - {renderPurchasingOrgField("전화번호", org.VNDR_TELNO)} - {renderPurchasingOrgField("보류일자", org.PUR_HOLD_DT)} - </div> - {org.PUR_HOLD_CAUS && ( - <div className="mt-4"> - {renderPurchasingOrgField("보류사유", org.PUR_HOLD_CAUS)} - </div> - )} - </CardContent> - </Card> - ) - - return ( - <> - {/* 헤더 */} - <Card className="border-l-4 border-l-orange-500"> - <CardHeader> - <div className="flex items-center justify-between"> - <div className="min-w-0 flex-1"> - <CardTitle className="text-2xl font-bold tracking-tight break-words flex items-center gap-2"> - <Building2 className="w-6 h-6 text-orange-600" /> - {editData.VNDRNM_1 || '업체명 없음'} - <Badge variant="outline" className="text-xs bg-orange-50 text-orange-700">NONSAP</Badge> - </CardTitle> - <CardDescription className="text-base"> - 벤더 코드: {editData.VNDRCD} - </CardDescription> - </div> - <div className="flex items-center space-x-4"> - {/* 상태 배지 */} - <div className="flex items-center space-x-2"> - {getStatusBadge(editData.DEL_ORDR)} - </div> - - {/* 액션 버튼들 */} - <div className="flex items-center space-x-2"> - {isEditing ? ( - <> - <Button onClick={handleEditSave} size="sm" disabled={showConfirmDialog}> - <Save className="w-4 h-4 mr-2" /> - 저장 - </Button> - <Button onClick={handleEditCancel} variant="outline" size="sm" disabled={showConfirmDialog || isPending}> - <X className="w-4 h-4 mr-2" /> - 취소 - </Button> - </> - ) : ( - <> - <Button onClick={handleEditStart} variant="outline" size="sm"> - <Edit className="w-4 h-4 mr-2" /> - 수정 - </Button> - </> - )} - </div> - </div> - </div> - </CardHeader> - <CardContent> - <div className="text-sm text-muted-foreground"> - From NONSAP - </div> - </CardContent> - </Card> - - <div className="grid gap-6 md:grid-cols-2"> - {/* 기본 정보 */} - <Card> - <CardHeader> - <CardTitle className="flex items-center gap-2"> - 기본 정보 - </CardTitle> - <CardDescription> - 업체의 기본적인 정보입니다. - </CardDescription> - </CardHeader> - <CardContent className="space-y-4"> - <div className="grid grid-cols-1 gap-4"> - {renderField("업체명", editData.VNDRNM_1, "VNDRNM_1")} - {renderField("영문명", editData.VNDRNM_2, "VNDRNM_2")} - <div className="grid grid-cols-2 gap-4"> - {renderField("업체약어", editData.VNDRNM_ABRV_1, "VNDRNM_ABRV_1")} - {renderField("기업규모", editData.CO_VLM, "CO_VLM")} - </div> - <div className="grid grid-cols-2 gap-4"> - {renderField("사업자번호", editData.BIZR_NO, "BIZR_NO", false, true)} - {renderField("법인등록번호", editData.CO_REG_NO, "CO_REG_NO", false, true)} - </div> - </div> - </CardContent> - </Card> - - {/* 대표자 정보 */} - <Card> - <CardHeader> - <CardTitle className="flex items-center gap-2"> - 대표자 정보 - </CardTitle> - </CardHeader> - <CardContent className="space-y-4"> - <div className="grid grid-cols-1 gap-4"> - {renderField("대표자명", editData.REPR_NM, "REPR_NM")} - - <div> - <label className="text-sm font-medium text-muted-foreground">대표자 전화번호</label> - {isEditing ? ( - <Input - value={editData.REP_TEL_NO || ''} - onChange={(e) => handleInputChange('REP_TEL_NO', e.target.value)} - className="mt-1 font-mono" - /> - ) : ( - <p className="text-sm flex items-center gap-1"> - {editData.REP_TEL_NO ? ( - <> - <Phone className="w-3 h-3" /> - <span className="font-mono">{editData.REP_TEL_NO}</span> - </> - ) : '-'} - </p> - )} - </div> - - <div> - <label className="text-sm font-medium text-muted-foreground">대표자 생년월일</label> - {isEditing ? ( - <Input - value={editData.REPR_RESNO || ''} - onChange={(e) => handleInputChange('REPR_RESNO', e.target.value)} - className="mt-1 font-mono" - /> - ) : ( - <p className="text-sm flex items-center gap-1"> - {editData.REPR_RESNO ? ( - <> - <Calendar className="w-3 h-3" /> - <span className="font-mono">{editData.REPR_RESNO}</span> - </> - ) : '-'} - </p> - )} - </div> - - <div> - <label className="text-sm font-medium text-muted-foreground">대표자 이메일</label> - {isEditing ? ( - <Input - type="email" - value={editData.REPRESENTATIVE_EMAIL || ''} - onChange={(e) => handleInputChange('REPRESENTATIVE_EMAIL', e.target.value)} - className="mt-1" - /> - ) : ( - <p className="text-sm flex items-center gap-1"> - {editData.REPRESENTATIVE_EMAIL ? ( - <> - <Mail className="w-3 h-3 flex-shrink-0" /> - <span className="break-all">{editData.REPRESENTATIVE_EMAIL}</span> - </> - ) : '-'} - </p> - )} - </div> - </div> - </CardContent> - </Card> - - {/* 사업 정보 */} - <Card> - <CardHeader> - <CardTitle className="flex items-center gap-2"> - 사업 정보 - </CardTitle> - </CardHeader> - <CardContent className="space-y-4"> - <div className="space-y-4"> - {renderField("사업유형", editData.BIZTP, "BIZTP", true)} - {renderField("산업유형", editData.BIZCON, "BIZCON", true)} - <div className="grid grid-cols-2 gap-4"> - {renderField("국가코드", editData.NTN_CD, "NTN_CD")} - {renderField("등록일자", editData.REG_DT, "REG_DT", false, true)} - </div> - </div> - </CardContent> - </Card> - - {/* 주소 정보 */} - <Card> - <CardHeader> - <CardTitle className="flex items-center gap-2"> - 주소 정보 - </CardTitle> - </CardHeader> - <CardContent className="space-y-4"> - <div className="space-y-4"> - {isEditing ? ( - <div className="space-y-4"> - {renderField("주소", editData.ADR_1, "ADR_1")} - {renderField("영문주소", editData.ADR_2, "ADR_2")} - {renderField("우편번호", editData.POSTAL_CODE, "POSTAL_CODE")} - {renderField("상세주소", editData.ADDR_DETAIL_1, "ADDR_DETAIL_1")} - </div> - ) : ( - <div> - <label className="text-sm font-medium text-muted-foreground mb-2 block">주소</label> - <AddressDisplay - address={editData.ADR_1} - addressEng={editData.ADR_2} - postalCode={editData.POSTAL_CODE} - addressDetail={editData.ADDR_DETAIL_1} - /> - </div> - )} - </div> - </CardContent> - </Card> - - {/* 구매조직 정보 */} - <Card className="md:col-span-2"> - <CardHeader> - <CardTitle className="flex items-center gap-2"> - 구매조직 정보 - {vendorDetails.PURCHASING_ORGS.length > 0 && ( - <Badge variant="secondary" className="ml-2"> - {vendorDetails.PURCHASING_ORGS.length}개 조직 - </Badge> - )} - </CardTitle> - <CardDescription> - 구매조직에 따른 상세 정보입니다. - </CardDescription> - </CardHeader> - <CardContent className="space-y-4"> - {vendorDetails.PURCHASING_ORGS.length === 0 ? ( - <div className="text-center py-8 text-muted-foreground"> - <Building2 className="w-12 h-12 mx-auto mb-4 opacity-50" /> - <p>구매조직 정보가 없습니다.</p> - </div> - ) : vendorDetails.PURCHASING_ORGS.length === 1 ? ( - // 구매조직이 1개인 경우 - <div className="grid grid-cols-3 gap-4"> - {renderPurchasingOrgField("구매조직", currentPurchasingOrg?.PUR_ORG_CD)} - {renderPurchasingOrgField("오더통화", currentPurchasingOrg?.PUR_ORD_CUR)} - <div> - <label className="text-sm font-medium text-muted-foreground">내외자구분</label> - <p className="text-sm"> - {editData.PRTNR_GB ? ( - <Badge variant="outline" className="text-xs"> - {editData.PRTNR_GB === '1' ? '사내' : editData.PRTNR_GB === '2' ? '사외' : editData.PRTNR_GB} - </Badge> - ) : '-'} - </p> - </div> - {renderPurchasingOrgField("인도조건", currentPurchasingOrg?.DL_COND_1)} - {renderPurchasingOrgField("GR송장검증", currentPurchasingOrg?.GR_BSE_INVC_VR, true, 'status')} - {renderPurchasingOrgField("P/O 확인요청", currentPurchasingOrg?.ORD_CNFM_REQ_ORDR, true, 'confirm')} - {renderPurchasingOrgField("확정제어", currentPurchasingOrg?.CNFM_CTL_KEY)} - {renderPurchasingOrgField("지급조건", currentPurchasingOrg?.SPLY_COND)} - {renderPurchasingOrgField("거래정지", currentPurchasingOrg?.PUR_HOLD_ORDR, true, 'hold')} - {renderPurchasingOrgField("이전업체코드", editData.PREVIOUS_VENDOR_CODE)} - </div> - ) : ( - // 구매조직이 여러개인 경우 - <div className="space-y-4"> - <div className="flex items-center gap-4"> - <div className="flex-1"> - <label className="text-sm font-medium text-muted-foreground">구매조직 선택</label> - <Select value={selectedPurchasingOrg} onValueChange={setSelectedPurchasingOrg}> - <SelectTrigger className="mt-1"> - <SelectValue placeholder="구매조직을 선택하세요" /> - </SelectTrigger> - <SelectContent> - {vendorDetails.PURCHASING_ORGS.map((org) => ( - <SelectItem key={org.PUR_ORG_CD} value={org.PUR_ORG_CD}> - {org.PUR_ORG_CD} - {org.SALE_CHRGR_NM || '담당자 미지정'} - </SelectItem> - ))} - </SelectContent> - </Select> - </div> - <div className="pt-6"> - <Button - variant={showAllOrgs ? "default" : "outline"} - onClick={() => setShowAllOrgs(!showAllOrgs)} - size="sm" - > - <Eye className="w-4 h-4 mr-2" /> - {showAllOrgs ? '선택 보기' : '전체 보기'} - </Button> - </div> - </div> - - {showAllOrgs ? ( - // 전체 구매조직 정보 표시 - <div className="space-y-4"> - {vendorDetails.PURCHASING_ORGS.map((org) => ( - <PurchasingOrgCard key={org.PUR_ORG_CD} org={org} /> - ))} - </div> - ) : ( - // 선택된 구매조직 정보만 표시 - currentPurchasingOrg && ( - <div className="grid grid-cols-3 gap-4"> - {renderPurchasingOrgField("구매조직", currentPurchasingOrg.PUR_ORG_CD)} - {renderPurchasingOrgField("오더통화", currentPurchasingOrg.PUR_ORD_CUR)} - <div> - <label className="text-sm font-medium text-muted-foreground">내외자구분</label> - <p className="text-sm"> - {editData.PRTNR_GB ? ( - <Badge variant="outline" className="text-xs"> - {editData.PRTNR_GB === '1' ? '사내' : editData.PRTNR_GB === '2' ? '사외' : editData.PRTNR_GB} - </Badge> - ) : '-'} - </p> - </div> - {renderPurchasingOrgField("인도조건", currentPurchasingOrg.DL_COND_1)} - {renderPurchasingOrgField("GR송장검증", currentPurchasingOrg.GR_BSE_INVC_VR, true, 'status')} - {renderPurchasingOrgField("P/O 확인요청", currentPurchasingOrg.ORD_CNFM_REQ_ORDR, true, 'confirm')} - {renderPurchasingOrgField("확정제어", currentPurchasingOrg.CNFM_CTL_KEY)} - {renderPurchasingOrgField("지급조건", currentPurchasingOrg.SPLY_COND)} - {renderPurchasingOrgField("거래정지", currentPurchasingOrg.PUR_HOLD_ORDR, true, 'hold')} - {renderPurchasingOrgField("이전업체코드", editData.PREVIOUS_VENDOR_CODE)} - </div> - ) - )} - </div> - )} - </CardContent> - </Card> - </div> - - {/* 변경사항 확인 Dialog */} - <Dialog open={showConfirmDialog} onOpenChange={setShowConfirmDialog}> - <DialogContent className="sm:max-w-[600px]"> - <DialogHeader> - <DialogTitle className="flex items-center gap-2"> - <AlertCircle className="w-5 h-5 text-amber-500" /> - 변경사항 확인 - </DialogTitle> - <DialogDescription> - 다음 정보가 변경됩니다. 저장하시겠습니까? - </DialogDescription> - </DialogHeader> - - <div className="max-h-[400px] overflow-y-auto"> - <div className="space-y-4"> - {changes.map((change, index) => ( - <div key={index} className="border rounded-lg p-4 space-y-2"> - <div className="font-medium text-sm text-muted-foreground"> - {change.label} - </div> - <div className="grid grid-cols-1 gap-2"> - <div className="flex items-start gap-2"> - <span className="text-xs bg-red-100 text-red-700 px-2 py-1 rounded font-mono">이전</span> - <span className="text-sm break-words flex-1 line-through text-muted-foreground"> - {change.before} - </span> - </div> - <div className="flex items-start gap-2"> - <span className="text-xs bg-green-100 text-green-700 px-2 py-1 rounded font-mono">변경</span> - <span className="text-sm break-words flex-1 font-medium"> - {change.after} - </span> - </div> - </div> - </div> - ))} - </div> - </div> - - <DialogFooter> - <Button - variant="outline" - onClick={() => setShowConfirmDialog(false)} - disabled={isPending} - > - 취소 - </Button> - <Button - onClick={handleConfirmSave} - disabled={isPending} - className="bg-blue-600 hover:bg-blue-700" - > - {isPending ? "저장 중..." : `${changes.length}개 항목 저장`} - </Button> - </DialogFooter> - </DialogContent> - </Dialog> - </> - ) -}
\ No newline at end of file diff --git a/app/[lng]/evcp/(evcp)/vendors/[id]/info/basic/vendor-evcp-info.tsx b/app/[lng]/evcp/(evcp)/vendors/[id]/info/basic/vendor-evcp-info.tsx deleted file mode 100644 index 4da3162a..00000000 --- a/app/[lng]/evcp/(evcp)/vendors/[id]/info/basic/vendor-evcp-info.tsx +++ /dev/null @@ -1,396 +0,0 @@ -"use client" - -import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" -import { Badge } from "@/components/ui/badge" -import { AddressDisplay } from "./text-utils" -import { - Phone, - Mail, - Calendar, - CheckCircle, - XCircle, - Building2, - Globe, - User, - FileText, - TrendingUp, - Hash, - MapPin, - Users, - Award, - Briefcase, - Shield, - Star, - DollarSign -} from "lucide-react" -import { VendorDetailView } from "@/db/schema/vendors" - -interface VendorEvCpInfoProps { - vendorDetails: VendorDetailView -} - -// 신용평가기관 표시 매핑 -const creditAgencyMap: Record<string, string> = { - NICE: "NICE평가정보", - KIS: "KIS (한국신용평가)", - KED: "KED (한국기업데이터)", - SCI: "SCI평가정보", -} - -// 사업규모 표시 개선 -const businessSizeMap: Record<string, { label: string; color: string }> = { - "LARGE": { label: "대기업", color: "text-purple-600" }, - "MEDIUM": { label: "중견기업", color: "text-blue-600" }, - "SMALL": { label: "중소기업", color: "text-green-600" }, - "STARTUP": { label: "스타트업", color: "text-orange-600" }, -} - -export function VendorEvCpInfo({ vendorDetails }: VendorEvCpInfoProps) { - // 연락처 정보 파싱 - const contacts = Array.isArray(vendorDetails.contacts) - ? vendorDetails.contacts - : typeof vendorDetails.contacts === 'string' - ? JSON.parse(vendorDetails.contacts || '[]') - : [] - - // 첨부파일 정보 파싱 - const attachments = Array.isArray(vendorDetails.attachments) - ? vendorDetails.attachments - : typeof vendorDetails.attachments === 'string' - ? JSON.parse(vendorDetails.attachments || '[]') - : [] - - // 상태에 따른 뱃지 스타일 결정 - const getStatusBadge = (status: string) => { - switch (status) { - case 'ACTIVE': - return <Badge variant="default" className="bg-green-100 text-green-800"><CheckCircle className="w-3 h-3 mr-1" />활성</Badge> - case 'INACTIVE': - return <Badge variant="secondary" className="bg-gray-100 text-gray-600"><XCircle className="w-3 h-3 mr-1" />비활성</Badge> - case 'PENDING_REVIEW': - return <Badge variant="outline" className="bg-yellow-50 text-yellow-700 border-yellow-300">검토중</Badge> - case 'APPROVED': - return <Badge variant="default" className="bg-blue-100 text-blue-800">승인됨</Badge> - case 'BLACKLISTED': - return <Badge variant="destructive">거래금지</Badge> - default: - return <Badge variant="outline">{status}</Badge> - } - } - - // 신용등급 색상 결정 - const getCreditRatingColor = (rating: string) => { - if (rating?.includes('AAA')) return "text-green-600 bg-green-50" - if (rating?.includes('AA')) return "text-blue-600 bg-blue-50" - if (rating?.includes('A')) return "text-indigo-600 bg-indigo-50" - if (rating?.includes('BBB')) return "text-yellow-600 bg-yellow-50" - if (rating?.includes('BB') || rating?.includes('B')) return "text-orange-600 bg-orange-50" - return "text-gray-600 bg-gray-50" - } - - // 필드 렌더링 헬퍼 - const renderField = (label: string, value: React.ReactNode, icon?: React.ReactNode) => { - if (!value) return null - return ( - <div className="space-y-1"> - <label className="text-sm font-medium text-muted-foreground flex items-center gap-1"> - {icon} - {label} - </label> - <div className="text-sm"> - {value} - </div> - </div> - ) - } - - return ( - <div className="space-y-6"> - {/* 헤더 */} - <Card className="border-l-4 border-l-blue-500"> - <CardHeader> - <div className="flex items-center justify-between"> - <div className="min-w-0 flex-1"> - <CardTitle className="text-2xl font-bold tracking-tight break-words flex items-center gap-2"> - <Building2 className="w-6 h-6 text-blue-600" /> - {vendorDetails.vendorName || '업체명 없음'} - <Badge variant="outline" className="text-xs bg-blue-50 text-blue-700 border-blue-300"> - eVCP - </Badge> - </CardTitle> - <CardDescription className="flex items-center gap-4 mt-2"> - <span>벤더 코드: {vendorDetails.vendorCode || 'N/A'}</span> - <span>사업자번호: {vendorDetails.taxId || 'N/A'}</span> - </CardDescription> - </div> - <div className="flex items-center space-x-2"> - {getStatusBadge(vendorDetails.status || 'ACTIVE')} - </div> - </div> - </CardHeader> - </Card> - - <div className="grid gap-6 md:grid-cols-2"> - {/* 기본 정보 */} - <Card> - <CardHeader> - <CardTitle className="text-lg flex items-center gap-2"> - 기본 정보 - </CardTitle> - </CardHeader> - <CardContent className="space-y-4"> - {renderField("업체명", vendorDetails.vendorName)} - {renderField("업체 코드", vendorDetails.vendorCode, <Hash className="w-3 h-3" />)} - {renderField("사업자등록번호", vendorDetails.taxId, <FileText className="w-3 h-3" />)} - {renderField("법인등록번호", vendorDetails.corporateRegistrationNumber, <FileText className="w-3 h-3" />)} - {renderField("국가", vendorDetails.country, <MapPin className="w-3 h-3" />)} - - {/* 사업규모 */} - {vendorDetails.businessSize && ( - <div className="space-y-1"> - <label className="text-sm font-medium text-muted-foreground flex items-center gap-1"> - <Briefcase className="w-3 h-3" /> - 사업규모 - </label> - <div className="text-sm"> - <span className={`font-medium ${businessSizeMap[vendorDetails.businessSize]?.color || 'text-gray-600'}`}> - {businessSizeMap[vendorDetails.businessSize]?.label || vendorDetails.businessSize} - </span> - </div> - </div> - )} - - {/* 등록일 */} - {renderField("등록일", - vendorDetails.createdAt ? new Date(vendorDetails.createdAt).toLocaleDateString('ko-KR') : null, - <Calendar className="w-3 h-3" /> - )} - </CardContent> - </Card> - - {/* 연락처 정보 */} - <Card> - <CardHeader> - <CardTitle className="text-lg flex items-center gap-2"> - 연락처 정보 - </CardTitle> - </CardHeader> - <CardContent className="space-y-4"> - {renderField("전화번호", vendorDetails.phone, <Phone className="w-3 h-3" />)} - {renderField("이메일", - vendorDetails.email && ( - <a - href={`mailto:${vendorDetails.email}`} - className="text-blue-600 hover:underline break-all" - > - {vendorDetails.email} - </a> - ), - <Mail className="w-3 h-3" /> - )} - - <div> - <label className="text-sm font-medium text-muted-foreground">웹사이트</label> - <p className="text-sm break-words"> - {vendorDetails.website ? ( - <div className="flex items-center gap-1"> - <Globe className="w-3 h-3" /> - <a - href={vendorDetails.website.startsWith('http') ? vendorDetails.website : `https://${vendorDetails.website}`} - target="_blank" - rel="noopener noreferrer" - className="text-blue-600 hover:underline break-all" - > - {vendorDetails.website} - </a> - </div> - ) : ( - <span className="text-muted-foreground">정보 없음</span> - )} - </p> - </div> - - {renderField("주소", - vendorDetails.address && <AddressDisplay address={vendorDetails.address} />, - <MapPin className="w-3 h-3" /> - )} - </CardContent> - </Card> - - {/* 대표자 정보 */} - {(vendorDetails.representativeName || vendorDetails.representativeEmail || vendorDetails.representativePhone) && ( - <Card> - <CardHeader> - <CardTitle className="text-lg flex items-center gap-2"> - 대표자 정보 - </CardTitle> - </CardHeader> - <CardContent className="space-y-4"> - {renderField("대표자명", vendorDetails.representativeName, <User className="w-3 h-3" />)} - {renderField("대표자 이메일", - vendorDetails.representativeEmail && ( - <a - href={`mailto:${vendorDetails.representativeEmail}`} - className="text-blue-600 hover:underline break-all" - > - {vendorDetails.representativeEmail} - </a> - ), - <Mail className="w-3 h-3" /> - )} - {renderField("대표자 전화번호", vendorDetails.representativePhone, <Phone className="w-3 h-3" />)} - {renderField("대표자 생년월일", vendorDetails.representativeBirth, <Calendar className="w-3 h-3" />)} - </CardContent> - </Card> - )} - - {/* 신용평가 정보 */} - {(vendorDetails.creditAgency || vendorDetails.creditRating || vendorDetails.cashFlowRating) && ( - <Card> - <CardHeader> - <CardTitle className="text-lg flex items-center gap-2"> - <Award className="w-5 h-5" /> - 신용평가 정보 - </CardTitle> - </CardHeader> - <CardContent className="space-y-4"> - {renderField("신용평가기관", - vendorDetails.creditAgency && creditAgencyMap[vendorDetails.creditAgency] || vendorDetails.creditAgency, - <Shield className="w-3 h-3" /> - )} - - {vendorDetails.creditRating && ( - <div className="space-y-1"> - <label className="text-sm font-medium text-muted-foreground flex items-center gap-1"> - <Star className="w-3 h-3" /> - 신용등급 - </label> - <div className="text-sm"> - <Badge className={`${getCreditRatingColor(vendorDetails.creditRating)} border-0`}> - {vendorDetails.creditRating} - </Badge> - </div> - </div> - )} - - {vendorDetails.cashFlowRating && ( - <div className="space-y-1"> - <label className="text-sm font-medium text-muted-foreground flex items-center gap-1"> - <TrendingUp className="w-3 h-3" /> - 현금흐름등급 - </label> - <div className="text-sm"> - <Badge variant="outline" className="bg-green-50 text-green-700 border-green-300"> - {vendorDetails.cashFlowRating} - </Badge> - </div> - </div> - )} - </CardContent> - </Card> - )} - - {/* 제공 서비스/품목 */} - {vendorDetails.items && ( - <Card className="md:col-span-2"> - <CardHeader> - <CardTitle className="text-lg flex items-center gap-2"> - <Briefcase className="w-5 h-5" /> - 제공 서비스/품목 - </CardTitle> - </CardHeader> - <CardContent> - <div className="text-sm whitespace-pre-wrap bg-gray-50 p-3 rounded-md border"> - {vendorDetails.items} - </div> - </CardContent> - </Card> - )} - - {/* 등록된 연락처 */} - {contacts.length > 0 && ( - <Card className="md:col-span-2"> - <CardHeader> - <CardTitle className="text-lg flex items-center gap-2"> - <Users className="w-5 h-5" /> - 등록된 연락처 ({contacts.length}개) - </CardTitle> - </CardHeader> - <CardContent> - <div className="grid gap-3 md:grid-cols-2"> - {contacts.map((contact: any, index: number) => ( - <div key={contact.id || index} className="p-3 bg-gray-50 rounded-md border"> - <div className="flex items-center justify-between mb-2"> - <span className="font-medium">{contact.contactName}</span> - {contact.isPrimary && ( - <Badge variant="default" className="text-xs">주 담당자</Badge> - )} - </div> - {contact.contactPosition && ( - <p className="text-sm text-muted-foreground mb-1"> - {contact.contactPosition} - </p> - )} - <div className="space-y-1 text-sm"> - {contact.contactEmail && ( - <div className="flex items-center gap-1"> - <Mail className="w-3 h-3" /> - <a - href={`mailto:${contact.contactEmail}`} - className="text-blue-600 hover:underline" - > - {contact.contactEmail} - </a> - </div> - )} - {contact.contactPhone && ( - <div className="flex items-center gap-1"> - <Phone className="w-3 h-3" /> - <span>{contact.contactPhone}</span> - </div> - )} - </div> - </div> - ))} - </div> - </CardContent> - </Card> - )} - - {/* 첨부파일 정보 */} - {attachments.length > 0 && ( - <Card className="md:col-span-2"> - <CardHeader> - <CardTitle className="text-lg flex items-center gap-2"> - <FileText className="w-5 h-5" /> - 첨부파일 ({attachments.length}개) - </CardTitle> - </CardHeader> - <CardContent> - <div className="space-y-2"> - {attachments.map((attachment: any, index: number) => ( - <div key={attachment.id || index} className="flex items-center justify-between p-2 bg-gray-50 rounded border"> - <div className="flex items-center gap-2"> - <FileText className="w-4 h-4 text-gray-500" /> - <span className="text-sm font-medium">{attachment.fileName}</span> - </div> - <div className="flex items-center gap-2"> - <Badge variant="outline" className="text-xs"> - {attachment.attachmentType || 'GENERAL'} - </Badge> - {attachment.createdAt && ( - <span className="text-xs text-muted-foreground"> - {new Date(attachment.createdAt).toLocaleDateString('ko-KR')} - </span> - )} - </div> - </div> - ))} - </div> - </CardContent> - </Card> - )} - </div> - </div> - ) -}
\ No newline at end of file diff --git a/app/[lng]/evcp/(evcp)/vendors/[id]/info/layout.tsx b/app/[lng]/evcp/(evcp)/vendors/[id]/info/layout.tsx index 50e2723e..0817b309 100644 --- a/app/[lng]/evcp/(evcp)/vendors/[id]/info/layout.tsx +++ b/app/[lng]/evcp/(evcp)/vendors/[id]/info/layout.tsx @@ -92,7 +92,7 @@ export default async function SettingsLayout({ <aside className="-mx-4 lg:w-1/5"> <SidebarNav items={sidebarNavItems} /> </aside> - <div className="flex-1">{children}</div> + <div className="flex-1 overflow-auto max-w-full">{children}</div> </div> </div> </section> diff --git a/db/schema/vendors.ts b/db/schema/vendors.ts index 78ecf83c..0e6c4d66 100644 --- a/db/schema/vendors.ts +++ b/db/schema/vendors.ts @@ -21,6 +21,7 @@ export const vendors = pgTable("vendors", { vendorName: varchar("vendor_name", { length: 255 }).notNull(), // 벤더 코드 유니크 아니어도 괜찮은지? vendorCode: varchar("vendor_code", { length: 100 }), + //사업자번호이고, 법인등록번호는 corporateRegistrationNumber taxId: varchar("tax_id", { length: 100 }).notNull(), address: text("address"), country: varchar("country", { length: 100 }), @@ -63,6 +64,25 @@ export const vendors = pgTable("vendors", { businessSize: varchar("business_size", { length: 255 }), + // // PQ 승인시 받을 정보 + // // 그룹사 + // groupCompany: varchar("group_company", { length: 255 }), + // // 설립일 + // establishmentDate: timestamp("establishment_date"), + // // Fax 주소 + // faxAddress: varchar("fax_address", { length: 255 }), + // // 임직원 수 + // employeeCount: varchar("employee_count", { length: 255 }), + // // 연간 매출액 + // annualSales: text("annual_sales"), + // // 자산 + // assets: text("assets"), + // // 생산능력 + // productionCapacity: text("production_capacity"), + // // 조직도 + // organizationChart: text("organization_chart"), + + createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), diff --git a/lib/soap/mdg/send/vendor-master/action.ts b/lib/soap/mdg/send/vendor-master/action.ts index e96b93fc..ae0c2c89 100644 --- a/lib/soap/mdg/send/vendor-master/action.ts +++ b/lib/soap/mdg/send/vendor-master/action.ts @@ -267,34 +267,34 @@ async function fetchVendorData(vendorCode: string) { // SOAP 데이터 생성 (WSDL 구조에 맞춤) function buildSoapData(vendorData: NonNullable<Awaited<ReturnType<typeof fetchVendorData>>>) { - const { vendorHeader, addresses, adEmails, adFaxes, adPostals, adTels, adUrls, bpTaxnums, bpVengens } = vendorData; + const { vendorHeader, addresses, adFaxes, adPostals, adTels, bpTaxnums, bpVengens } = vendorData; // 값 추출 매핑 ------------------------------------ const mapping: Record<string, string | undefined> = { // Header BP_HEADER: vendorHeader?.VNDRCD, ZZSRMCD: 'EVCP', - TITLE: vendorHeader?.TITLE ?? '', - BU_SORT1: adPostals[0]?.VNDRNM_ABRV_1, - NAME_ORG1: adPostals[0]?.VNDRNM_1, - KTOKK: bpVengens[0]?.ACNT_GRP, + TITLE: '', // vendorHeader에 TITLE 필드가 없음 + BU_SORT1: adPostals[0]?.VNDRNM_ABRV_1 ?? undefined, + NAME_ORG1: adPostals[0]?.VNDRNM_1 ?? undefined, + KTOKK: bpVengens[0]?.ACNT_GRP ?? undefined, MASTERFLAG: 'X', IBND_TYPE: 'U', // Address mandatory (first) ADDRNO: addresses[0]?.ADDRNO, - AD_NATION: adPostals[0]?.INTL_ADR_VER_ID, - COUNTRY: adPostals[0]?.NTN_CD, - LANGU_COM: adPostals[0]?.LANG_KEY, - POST_COD1: adPostals[0]?.CITY_ZIP_NO, - CITY1: adPostals[0]?.VNDRNM_1, - MC_STREET: adPostals[0]?.ADR_1, + AD_NATION: adPostals[0]?.INTL_ADR_VER_ID ?? undefined, + COUNTRY: adPostals[0]?.NTN_CD ?? undefined, + LANGU_COM: adPostals[0]?.LANG_KEY ?? undefined, + POST_COD1: adPostals[0]?.CITY_ZIP_NO ?? undefined, + CITY1: adPostals[0]?.VNDRNM_1 ?? undefined, + MC_STREET: adPostals[0]?.ADR_1 ?? undefined, // Phone/Fax mandatory fields AD_CONSNO: '001', - T_COUNTRY: adTels[0]?.CTRY_CD ?? 'KR', - F_COUNTRY: adFaxes[0]?.CTRY_CD ?? 'KR', + T_COUNTRY: adTels[0]?.NTN_CD ?? 'KR', + F_COUNTRY: adFaxes[0]?.NTN_CD ?? 'KR', // Tax BP_TX_TYP: bpTaxnums[0]?.TX_NO_CTG ?? 'KR2', - TAXNUM: bpVengens[0]?.VAT_REG_NO, + TAXNUM: bpVengens[0]?.VAT_REG_NO ?? undefined, // Default others can be added as needed }; @@ -306,7 +306,7 @@ function buildSoapData(vendorData: NonNullable<Awaited<ReturnType<typeof fetchVe return true; }); - const supplierMaster: Record<string, any> = {}; + const supplierMaster: Record<string, string> = {}; uniqueFields.forEach(f => { supplierMaster[f.field] = mapping[f.field] ?? ''; }); @@ -620,6 +620,98 @@ export async function getVendorSendStatistics(): Promise<{ } } +// 테스트용 폼 데이터 송신 함수 (SOAP 라이브러리 사용) +export async function sendTestVendorDataToMDG(formData: Record<string, string>): Promise<{ + success: boolean; + message: string; + responseData?: any; +}> { + try { + console.log('🧪 테스트용 VENDOR 데이터 SOAP 송신 시작'); + + // CSV 파일 동적 로드 (더 안전함) + let csvFields: CsvField[] = []; + try { + const csvRaw = fs.readFileSync(CSV_PATH, 'utf-8'); + csvFields = parseCsv(csvRaw); + } catch (e) { + console.error('CSV 로딩 실패:', e); + return { + success: false, + message: 'CSV 필드 정의 파일을 로드할 수 없습니다.' + }; + } + + // 필수 필드 검증 + const requiredFields = csvFields.filter(f => f.mandatory).map(f => f.field); + const missingFields = requiredFields.filter(field => !formData[field]?.trim()); + + if (missingFields.length > 0) { + return { + success: false, + message: `필수 필드가 누락되었습니다: ${missingFields.join(', ')}` + }; + } + + // 필드 순서에 따라 데이터 생성 + const seen = new Set<string>(); + const uniqueFields = csvFields.filter(f => { + if (seen.has(f.field)) return false; + seen.add(f.field); + return true; + }); + + const supplierMaster: Record<string, string> = {}; + uniqueFields.forEach(f => { + supplierMaster[f.field] = formData[f.field] ?? ''; + }); + + // SOAP 요청 구조 생성 + const soapData = { + P2MD3007_S: { + SUPPLIER_MASTER: supplierMaster + } + }; + + console.log('📄 테스트 SOAP 데이터 생성 완료'); + + // SOAP 클라이언트로 요청 전송 + const responseData = await withSoapLogging( + 'OUTBOUND', + 'MDG', + 'IF_MDZ_EVCP_VENDOR_MASTER_TEST', + JSON.stringify(soapData), + async () => { + const client = await createSoapClient(); + + return new Promise<SoapResponse>((resolve, reject) => { + client.P2MD3007_AO(soapData, (err: SoapError | null, result: SoapResponse) => { + if (err) { + reject(err); + } else { + console.log('✅ 테스트 MDG 전송 성공'); + resolve(result); + } + }); + }); + } + ); + + return { + success: true, + message: '테스트 송신이 완료되었습니다.', + responseData + }; + + } catch (error) { + console.error('❌ 테스트 송신 실패:', error); + return { + success: false, + message: error instanceof Error ? error.message : 'Unknown error' + }; + } +} + // 직접 XML 전송 함수 (기존 호환성 유지) export async function sendVendorEnvelopeToMDG(envelope: string): Promise<{ success: boolean; diff --git a/lib/vendors/service.ts b/lib/vendors/service.ts index 2328752b..853b3701 100644 --- a/lib/vendors/service.ts +++ b/lib/vendors/service.ts @@ -2584,4 +2584,125 @@ export async function searchVendors(searchTerm: string = "", limit: number = 100 console.error("벤더 검색 오류:", error); return []; } +} + +/** + * 벤더 기본정보 조회 (Basic Info 페이지용) + * vendorsWithTypesView를 사용하여 기본 정보 + contacts + attachments 조회 + */ +export async function getVendorBasicInfo(vendorId: number) { + unstable_noStore(); + + try { + return await db.transaction(async (tx) => { + // 1. 기본 벤더 정보 조회 (vendorsWithTypesView 사용) + const vendor = await tx + .select() + .from(vendorsWithTypesView) + .where(eq(vendorsWithTypesView.id, vendorId)) + .limit(1) + .then(rows => rows[0] || null); + + if (!vendor) { + return null; + } + + // 2. 연락처 정보 조회 + const contacts = await tx + .select() + .from(vendorContacts) + .where(eq(vendorContacts.vendorId, vendorId)) + .orderBy(desc(vendorContacts.isPrimary), asc(vendorContacts.contactName)); + + // 3. 첨부파일 정보 조회 + const attachments = await tx + .select() + .from(vendorAttachments) + .where(eq(vendorAttachments.vendorId, vendorId)) + .orderBy(asc(vendorAttachments.createdAt)); + + // 4. 타입 변환하여 반환 (추후 확장 가능하도록 구조화) + return { + // 기본 벤더 정보 + id: vendor.id, + vendorName: vendor.vendorName, + vendorCode: vendor.vendorCode, + taxId: vendor.taxId, + address: vendor.address, + businessSize: vendor.businessSize || "", // vendorsWithTypesView에 businessSize 필드가 없을 경우 대비 + country: vendor.country, + phone: vendor.phone, + fax: vendor.fax || null, // vendorsWithTypesView에 fax 필드가 없을 경우 대비 + email: vendor.email, + website: vendor.website, + status: vendor.status, + representativeName: vendor.representativeName, + representativeBirth: vendor.representativeBirth, + representativeEmail: vendor.representativeEmail, + representativePhone: vendor.representativePhone, + representativeWorkExperience: vendor.representativeWorkExperience ?? false, // vendorsWithTypesView에 해당 필드가 없을 경우 false로 기본값 + corporateRegistrationNumber: vendor.corporateRegistrationNumber, + creditAgency: vendor.creditAgency, + creditRating: vendor.creditRating, + cashFlowRating: vendor.cashFlowRating, + createdAt: vendor.createdAt, + updatedAt: vendor.updatedAt, + + // 연락처 정보 + contacts: contacts.map(contact => ({ + id: contact.id, + contactName: contact.contactName, + contactPosition: contact.contactPosition, + contactEmail: contact.contactEmail, + contactPhone: contact.contactPhone, + isPrimary: contact.isPrimary, + })), + + // 첨부파일 정보 + attachments: attachments.map(attachment => ({ + id: attachment.id, + fileName: attachment.fileName, + filePath: attachment.filePath, + attachmentType: attachment.attachmentType, + createdAt: attachment.createdAt, + })), + + // 추가 정보는 임시로 null (나중에 실제 데이터로 교체) + additionalInfo: { + businessType: vendor.vendorTypeId ? `Type ${vendor.vendorTypeId}` : null, + employeeCount: 0, // 실제 데이터가 있을 수 있으므로 유지 + mainBusiness: null, + }, + + // 매출 정보 (구현 예정 - 나중에 실제 테이블 연결) + salesInfo: null, // 구현 시 { "2023": { totalSales: "1000", totalDebt: "500", ... }, "2022": { ... } } 형태로 연도별 키 사용 + + // 추가 정보들 (구현 예정 - 나중에 실제 테이블 연결) + organization: null, + + factoryInfo: null, + + inspectionInfo: null, + + evaluationInfo: null, + + classificationInfo: { + vendorClassification: null, + groupCompany: null, + preferredLanguage: "한국어", // 기본값으로 유지 + industryType: "제조업", // 기본값으로 유지 + isoCertification: null, + }, + + contractDetails: null, + + capacityInfo: null, + + calculatedMetrics: null, // 구현 시 { "20231231": { debtRatio: 0, ... }, "20221231": { ... } } 형태로 YYYYMMDD 키 사용 + }; + }); + } catch (error) { + console.error("Error fetching vendor basic info:", error); + return null; + } }
\ No newline at end of file |
