summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorjoonhoekim <26rote@gmail.com>2025-07-09 12:16:43 +0000
committerjoonhoekim <26rote@gmail.com>2025-07-09 12:25:40 +0000
commitad4855620f4aa80841c4d7b1aa39d19ab2205f0e (patch)
tree24dabc8e98390937b75967faa6c6e25a23a0f894
parente328f73e5c23df7af5096d31e80fd74d9070ebc7 (diff)
(김준회) 벤더 상세페이지 - 기본정보 구현중 (구매 정의서)
-rw-r--r--app/[lng]/evcp/(evcp)/vendors/[id]/info/basic/actions.ts44
-rw-r--r--app/[lng]/evcp/(evcp)/vendors/[id]/info/basic/basic-info-client.tsx1108
-rw-r--r--app/[lng]/evcp/(evcp)/vendors/[id]/info/basic/constants.ts1
-rw-r--r--app/[lng]/evcp/(evcp)/vendors/[id]/info/basic/page.tsx54
-rw-r--r--app/[lng]/evcp/(evcp)/vendors/[id]/info/basic/types.ts176
-rw-r--r--app/[lng]/evcp/(evcp)/vendors/[id]/info/basic/vendor-basic-info.tsx743
-rw-r--r--app/[lng]/evcp/(evcp)/vendors/[id]/info/basic/vendor-evcp-info.tsx396
-rw-r--r--app/[lng]/evcp/(evcp)/vendors/[id]/info/layout.tsx2
-rw-r--r--db/schema/vendors.ts20
-rw-r--r--lib/soap/mdg/send/vendor-master/action.ts122
-rw-r--r--lib/vendors/service.ts121
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