feat(WEB): 파비콘 변경 및 거래처 신용분석 모달 추가

- 파비콘: SVG 형식으로 변경 (white 로고 + 파란 배경)
- 헤더 로고: SAM 로고 이미지로 교체
- 거래처 상세: 신용분석 모달 컴포넌트 추가
  - 신용등급, 리스크 지표, 레이더 차트
  - 프린트 기능 지원

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
유병철
2026-01-23 11:14:48 +09:00
parent af5fdcba88
commit ad063a1f01
12 changed files with 716 additions and 27 deletions

View File

@@ -0,0 +1,208 @@
'use client';
import { useState } from 'react';
import { AlertTriangle, CheckCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { cn } from '@/lib/utils';
import { CreditSignal } from './CreditSignal';
import { RiskRadarChart } from './RiskRadarChart';
import type { CreditAnalysisData } from './types';
import { CREDIT_LEVEL_CONFIG } from './types';
interface CreditAnalysisDocumentProps {
data: CreditAnalysisData;
onApprove?: () => void;
}
export function CreditAnalysisDocument({
data,
onApprove,
}: CreditAnalysisDocumentProps) {
const levelConfig = CREDIT_LEVEL_CONFIG[data.creditLevel];
const [activeTab, setActiveTab] = useState('shortTermOverdue');
// 탭 콘텐츠 렌더링
const renderTabContent = (content: string | null, emptyMessage: string) => {
if (content) {
return <p className="text-sm text-gray-700">{content}</p>;
}
return (
<div className="text-center py-4">
<p className="text-sm text-gray-500"> {emptyMessage}</p>
</div>
);
};
return (
<div className="bg-white p-8 min-h-full">
{/* 헤더 */}
<div className="flex items-center justify-between border-b pb-4 mb-6">
<div className="flex items-center gap-2">
<div className="w-8 h-8 bg-green-500 rounded flex items-center justify-center">
<span className="text-white font-bold text-sm">S</span>
</div>
<h1 className="text-lg font-semibold">
SAM
</h1>
</div>
<span className="text-sm text-gray-500">
{data.evaluationDate}
</span>
</div>
<div className="space-y-6">
{/* 기업 정보 */}
<div className="text-center">
<Badge variant="outline" className="mb-2 bg-blue-50 text-blue-600 border-blue-200">
</Badge>
<h2 className="text-xl font-bold text-gray-800 mt-2">
- -
</h2>
<p className="text-sm text-gray-600 mt-2">
: {data.businessNumber} | {data.companyName} | : {data.evaluationDate.split(' ')[0]}
</p>
<p className="text-xs text-gray-500 mt-1">
: {data.queryDate}
</p>
</div>
{/* 자료 효력기간 안내 */}
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4">
<div className="flex items-start gap-2">
<AlertTriangle className="w-5 h-5 text-yellow-600 flex-shrink-0 mt-0.5" />
<div className="text-sm">
<p className="font-semibold text-yellow-800"> </p>
<p className="text-yellow-700 mt-1">
( 6 {data.evaluationDate}) , . .
</p>
</div>
</div>
</div>
{/* 종합 신용 신호등 & 리스크 프로필 */}
<div className="grid md:grid-cols-2 gap-6">
{/* 종합 신용 신호등 */}
<div className="bg-gray-50 rounded-lg p-6">
<h3 className="text-lg font-semibold text-gray-800 mb-4 text-center">
</h3>
<CreditSignal
level={data.creditLevel}
taxStatus={data.taxStatus}
/>
<div className="mt-4 text-center">
<Button variant="outline" size="sm" className="text-blue-600 border-blue-300">
</Button>
</div>
</div>
{/* 신용 리스크 프로필 */}
<div className="bg-gray-50 rounded-lg p-6">
<RiskRadarChart data={data.riskProfile} creditDetailInfo={data.creditDetailInfo} />
</div>
</div>
{/* 신용 상세 정보 - 탭 */}
<div className="border rounded-lg p-4">
<h3 className="text-lg font-semibold text-gray-800 mb-4 text-center">
</h3>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="shortTermOverdue" className="text-xs sm:text-sm">
</TabsTrigger>
<TabsTrigger value="creditJudgment" className="text-xs sm:text-sm">
</TabsTrigger>
<TabsTrigger value="checkingSuspension" className="text-xs sm:text-sm">
</TabsTrigger>
<TabsTrigger value="courtManagement" className="text-xs sm:text-sm">
/
</TabsTrigger>
</TabsList>
<TabsContent value="shortTermOverdue" className="mt-4">
{renderTabContent(data.creditDetailTab.shortTermOverdue, '단기연체 정보가 없습니다')}
</TabsContent>
<TabsContent value="creditJudgment" className="mt-4">
{renderTabContent(data.creditDetailTab.creditJudgment, '신용도판단 정보가 없습니다')}
</TabsContent>
<TabsContent value="checkingSuspension" className="mt-4">
{renderTabContent(data.creditDetailTab.checkingSuspension, '당좌거래정지 정보가 없습니다')}
</TabsContent>
<TabsContent value="courtManagement" className="mt-4">
{renderTabContent(data.creditDetailTab.courtManagement, '법정관리/워크아웃 정보가 없습니다')}
</TabsContent>
</Tabs>
</div>
{/* 거래 승인 판정 */}
<div className="bg-gray-100 rounded-lg p-4">
<div className="flex items-center justify-between flex-wrap gap-4">
<div className="flex items-center gap-6 flex-wrap">
<div className="text-center">
<span className="text-sm text-gray-600"> </span>
<div className="mt-1">
<Badge
className={cn(
'text-base px-4 py-1',
data.approval.safety === '안전'
? 'bg-green-500 hover:bg-green-600'
: data.approval.safety === '주의'
? 'bg-yellow-500 hover:bg-yellow-600'
: 'bg-red-500 hover:bg-red-600'
)}
>
{data.approval.safety}
</Badge>
</div>
</div>
<div className="h-12 border-l border-gray-300 hidden sm:block" />
<div className="text-center">
<span className="text-sm text-gray-600"></span>
<p className={cn('font-bold', levelConfig.color)}>
Level {data.approval.level}
</p>
</div>
<div className="h-12 border-l border-gray-300 hidden sm:block" />
<div className="text-center">
<span className="text-sm text-gray-600"></span>
<p className="font-medium text-gray-800">{data.approval.businessType}</p>
</div>
<div className="h-12 border-l border-gray-300 hidden sm:block" />
<div className="text-center">
<span className="text-sm text-gray-600"></span>
<p className={cn(
'font-medium',
data.approval.creditAvailable ? 'text-green-600' : 'text-red-600'
)}>
{data.approval.creditAvailable ? '가능' : '불가'}
</p>
</div>
</div>
{onApprove && (
<Button
onClick={onApprove}
className="bg-green-500 hover:bg-green-600 gap-2"
>
<CheckCircle className="w-4 h-4" />
</Button>
)}
</div>
</div>
{/* 푸터 */}
<div className="text-center text-xs text-gray-400 pt-2 border-t">
<p>SAM Intelligence</p>
<p> </p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,57 @@
'use client';
import { cn } from '@/lib/utils';
import type { CreditLevel } from './types';
import { CREDIT_LEVEL_CONFIG } from './types';
interface CreditSignalProps {
level: CreditLevel;
taxStatus?: string;
showDescription?: boolean;
}
export function CreditSignal({
level,
taxStatus,
showDescription = false,
}: CreditSignalProps) {
const config = CREDIT_LEVEL_CONFIG[level];
return (
<div className="flex flex-col items-center gap-4">
{/* 신호등 */}
<div className="flex items-center gap-2">
{[1, 2, 3, 4, 5].map((l) => (
<div
key={l}
className={cn(
'w-8 h-8 rounded-full border-2 transition-all',
l <= level
? cn(config.bgColor, 'border-transparent')
: 'bg-gray-200 border-gray-300'
)}
/>
))}
</div>
{/* 등급 표시 */}
<div className="text-center">
<div className={cn('text-2xl font-bold', config.color)}>
{config.status} (Level {level})
</div>
{taxStatus && (
<div className="text-sm text-gray-500 mt-1">
{taxStatus}
</div>
)}
</div>
{/* 설명 */}
{showDescription && (
<p className="text-sm text-gray-600 text-center max-w-xs">
{config.description}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,94 @@
'use client';
import {
Radar,
RadarChart,
PolarGrid,
PolarAngleAxis,
PolarRadiusAxis,
ResponsiveContainer,
} from 'recharts';
import { cn } from '@/lib/utils';
import type { RiskProfile, CreditDetailInfo } from './types';
interface RiskRadarChartProps {
data: RiskProfile;
creditDetailInfo?: CreditDetailInfo;
}
const RISK_LABELS: Record<keyof RiskProfile, string> = {
corporateRisk: '기업 리스크',
publicRecord: '공공기록',
stability: '안정성',
growth: '성장성',
overdueHistory: '연체이력',
};
const DETAIL_LABELS: Record<keyof CreditDetailInfo, string> = {
koreaCreditRating: '한국신용평가등급',
financialRisk: '금융 종합 위험도',
purchasePayment: '매입 결제',
salesPayment: '매출 결제',
mortgageSetting: '저당권설정',
};
export function RiskRadarChart({ data, creditDetailInfo }: RiskRadarChartProps) {
const chartData = Object.entries(data).map(([key, value]) => ({
subject: RISK_LABELS[key as keyof RiskProfile],
value,
fullMark: 100,
}));
return (
<div className="flex flex-col items-center">
<h3 className="text-lg font-semibold text-gray-800 mb-4"> </h3>
<div className="w-full max-w-[300px] h-[220px]">
<ResponsiveContainer width="100%" height="100%">
<RadarChart cx="50%" cy="50%" outerRadius="70%" data={chartData}>
<PolarGrid stroke="#e5e7eb" />
<PolarAngleAxis
dataKey="subject"
tick={{ fill: '#6b7280', fontSize: 11 }}
/>
<PolarRadiusAxis
angle={90}
domain={[0, 100]}
tick={false}
axisLine={false}
/>
<Radar
name="신용 점수"
dataKey="value"
stroke="#3b82f6"
fill="#93c5fd"
fillOpacity={0.5}
strokeWidth={2}
/>
</RadarChart>
</ResponsiveContainer>
</div>
{/* 레이더 차트 하단 상세 정보 */}
{creditDetailInfo && (
<div className="w-full mt-4 grid grid-cols-2 gap-x-6 gap-y-2">
{Object.entries(creditDetailInfo).map(([key, value]) => (
<div key={key} className="flex items-center justify-between text-sm">
<span className="text-gray-600">{DETAIL_LABELS[key as keyof CreditDetailInfo]}</span>
<span className={cn(
'font-medium',
value === '우량' ? 'text-blue-600' :
value === '양호' ? 'text-green-600' :
value === '보통' ? 'text-yellow-600' :
value === '주의' ? 'text-orange-600' :
'text-red-600'
)}>
{value}
</span>
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,38 @@
'use client';
import { DocumentViewer } from '@/components/document-system';
import { CreditAnalysisDocument } from './CreditAnalysisDocument';
import type { CreditAnalysisData } from './types';
interface CreditAnalysisModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
data: CreditAnalysisData;
onApprove?: () => void;
}
export function CreditAnalysisModal({
open,
onOpenChange,
data,
onApprove,
}: CreditAnalysisModalProps) {
return (
<DocumentViewer
title="SAM 기업 신용분석 리포트"
subtitle={`${data.companyName} | ${data.businessNumber}`}
open={open}
onOpenChange={onOpenChange}
features={{
print: true,
zoom: true,
drag: true,
}}
>
<CreditAnalysisDocument data={data} onApprove={onApprove} />
</DocumentViewer>
);
}
export { MOCK_CREDIT_DATA } from './types';
export type { CreditAnalysisData } from './types';

View File

@@ -0,0 +1,142 @@
// ===== 기업 신용분석 데이터 타입 =====
export type CreditLevel = 1 | 2 | 3 | 4 | 5;
export type CreditStatus = '위험' | '주의' | '보통' | '양호' | '우량';
export type SafetyLevel = '안전' | '주의' | '위험';
export type RiskStatus = '우량' | '양호' | '보통' | '주의' | '위험';
// 레이더 차트용 - 신용 리스크 프로필 (5각형)
export interface RiskProfile {
corporateRisk: number; // 기업 리스크 (0~100)
publicRecord: number; // 공공기록 (0~100)
stability: number; // 안정성 (0~100)
growth: number; // 성장성 (0~100)
overdueHistory: number; // 연체이력 (0~100)
}
// 신호등 아래 표시될 상세 정보
export interface CreditDetailInfo {
koreaCreditRating: RiskStatus; // 한국신용평가등급
financialRisk: RiskStatus; // 금융 종합 위험도
purchasePayment: RiskStatus; // 매입 결제
salesPayment: RiskStatus; // 매출 결제
mortgageSetting: RiskStatus; // 저당권설정
}
// 신용 상세 정보 탭 데이터
export interface CreditDetailTabData {
shortTermOverdue: string | null; // 단기연체정보
creditJudgment: string | null; // 신용도판단정보
checkingSuspension: string | null; // 당좌거래정지
courtManagement: string | null; // 법정관리/워크아웃
}
export interface ApprovalInfo {
safety: SafetyLevel;
level: CreditLevel;
businessType: string; // 계속사업자, 신규거래 등
creditAvailable: boolean; // 외상 가능 여부
}
export interface CreditAnalysisData {
// 기업 정보
businessNumber: string; // 사업자번호
companyName: string; // 법인명
representativeName: string; // 대표자명
evaluationDate: string; // 평가기준일
queryDate: string; // 조회일자
// 신용 등급
creditLevel: CreditLevel;
creditStatus: CreditStatus;
taxStatus: string; // 국세청 상태 (예: "국세청 상태 기준")
// 리스크 프로필 (레이더 차트용)
riskProfile: RiskProfile;
// 신호등 아래 상세 정보
creditDetailInfo: CreditDetailInfo;
// 신용 상세 정보 탭 데이터
creditDetailTab: CreditDetailTabData;
// 거래 승인 판정
approval: ApprovalInfo;
}
// ===== 신용 레벨 설정 =====
export const CREDIT_LEVEL_CONFIG: Record<CreditLevel, {
status: CreditStatus;
color: string;
bgColor: string;
description: string;
}> = {
1: {
status: '위험',
color: 'text-red-600',
bgColor: 'bg-red-500',
description: '신용 위험 등급으로 거래 시 주의가 필요합니다.',
},
2: {
status: '주의',
color: 'text-orange-600',
bgColor: 'bg-orange-500',
description: '신용 주의 등급으로 거래 조건 검토가 필요합니다.',
},
3: {
status: '보통',
color: 'text-yellow-600',
bgColor: 'bg-yellow-500',
description: '신용 보통 등급으로 일반적인 거래가 가능합니다.',
},
4: {
status: '양호',
color: 'text-green-600',
bgColor: 'bg-green-500',
description: '신용 양호 등급으로 안정적인 거래가 가능합니다.',
},
5: {
status: '우량',
color: 'text-blue-600',
bgColor: 'bg-blue-500',
description: '신용 우량 등급으로 최상의 거래 조건이 가능합니다.',
},
};
// ===== 목업 데이터 =====
export const MOCK_CREDIT_DATA: CreditAnalysisData = {
businessNumber: '514-87-00635',
companyName: '(주)한가 양산공장',
representativeName: '홍길동',
evaluationDate: '2026-01-22 21:05:04',
queryDate: '2026-01-23 09:30:00',
creditLevel: 4,
creditStatus: '양호',
taxStatus: '국세청 상태 기준',
riskProfile: {
corporateRisk: 80,
publicRecord: 75,
stability: 85,
growth: 70,
overdueHistory: 90,
},
creditDetailInfo: {
koreaCreditRating: '양호',
financialRisk: '양호',
purchasePayment: '양호',
salesPayment: '양호',
mortgageSetting: '양호',
},
creditDetailTab: {
shortTermOverdue: null,
creditJudgment: null,
checkingSuspension: null,
courtManagement: null,
},
approval: {
safety: '안전',
level: 4,
businessType: '계속사업자',
creditAvailable: true,
},
};

View File

@@ -8,6 +8,7 @@ import { toast } from 'sonner';
import { getClientById, createClient, updateClient, deleteClient } from './actions';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { vendorConfig } from './vendorConfig';
import { CreditAnalysisModal, MOCK_CREDIT_DATA } from './CreditAnalysisModal';
// 필드명 매핑
const FIELD_NAME_MAP: Record<string, string> = {
@@ -145,6 +146,9 @@ export function VendorDetail({ mode, vendorId }: VendorDetailProps) {
// 새 메모 입력
const [newMemo, setNewMemo] = useState('');
// 신용분석 모달
const [isCreditModalOpen, setIsCreditModalOpen] = useState(false);
// Validation 함수
const validateForm = useCallback(() => {
const errors: Record<string, string> = {};
@@ -478,8 +482,17 @@ export function VendorDetail({ mode, vendorId }: VendorDetailProps) {
{/* 신용/거래 정보 */}
<Card>
<CardHeader>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-lg">/ </CardTitle>
{isViewMode && (
<Button
variant="outline"
size="sm"
onClick={() => setIsCreditModalOpen(true)}
>
</Button>
)}
</CardHeader>
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-4">
{renderSelectField('신용등급', 'creditRating', formData.creditRating, CREDIT_RATING_SELECTOR_OPTIONS)}
@@ -635,16 +648,25 @@ export function VendorDetail({ mode, vendorId }: VendorDetailProps) {
};
return (
<IntegratedDetailTemplate
config={dynamicConfig}
mode={templateMode}
initialData={formData as unknown as Record<string, unknown>}
itemId={vendorId}
isLoading={isLoading}
onSubmit={handleSubmit}
onDelete={vendorId ? handleDelete : undefined}
renderView={() => renderFormContent()}
renderForm={() => renderFormContent()}
/>
<>
<IntegratedDetailTemplate
config={dynamicConfig}
mode={templateMode}
initialData={formData as unknown as Record<string, unknown>}
itemId={vendorId}
isLoading={isLoading}
onSubmit={handleSubmit}
onDelete={vendorId ? handleDelete : undefined}
renderView={() => renderFormContent()}
renderForm={() => renderFormContent()}
/>
{/* 신용분석 모달 */}
<CreditAnalysisModal
open={isCreditModalOpen}
onOpenChange={setIsCreditModalOpen}
data={MOCK_CREDIT_DATA}
/>
</>
);
}

View File

@@ -20,6 +20,7 @@ import {
} from '@/components/ui/select';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { vendorConfig } from './vendorConfig';
import { CreditAnalysisModal, MOCK_CREDIT_DATA } from './CreditAnalysisModal';
import type { Vendor, VendorMemo } from './types';
import {
VENDOR_CATEGORY_SELECTOR_OPTIONS,
@@ -113,6 +114,9 @@ export function VendorDetailClient({ mode, vendorId, initialData }: VendorDetail
// 새 메모 입력
const [newMemo, setNewMemo] = useState('');
// 신용분석 모달
const [isCreditModalOpen, setIsCreditModalOpen] = useState(false);
// 상세/수정 모드에서 로고 목데이터 초기화
useEffect(() => {
if (initialData && !formData.logoUrl) {
@@ -428,8 +432,15 @@ export function VendorDetailClient({ mode, vendorId, initialData }: VendorDetail
{/* 신용/거래 정보 */}
<Card>
<CardHeader>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-lg">/ </CardTitle>
<Button
variant="outline"
size="sm"
onClick={() => setIsCreditModalOpen(true)}
>
</Button>
</CardHeader>
<CardContent className="grid grid-cols-1 md:grid-cols-2 gap-4">
{renderSelectField('신용등급', 'creditRating', formData.creditRating, CREDIT_RATING_SELECTOR_OPTIONS)}
@@ -549,15 +560,24 @@ export function VendorDetailClient({ mode, vendorId, initialData }: VendorDetail
};
return (
<IntegratedDetailTemplate
config={dynamicConfig}
mode={templateMode}
initialData={initialData as Record<string, unknown>}
itemId={vendorId}
onSubmit={handleSubmit}
onDelete={vendorId ? handleDelete : undefined}
renderView={() => renderFormContent()}
renderForm={() => renderFormContent()}
/>
<>
<IntegratedDetailTemplate
config={dynamicConfig}
mode={templateMode}
initialData={initialData as Record<string, unknown>}
itemId={vendorId}
onSubmit={handleSubmit}
onDelete={vendorId ? handleDelete : undefined}
renderView={() => renderFormContent()}
renderForm={() => renderFormContent()}
/>
{/* 신용분석 모달 */}
<CreditAnalysisModal
open={isCreditModalOpen}
onOpenChange={setIsCreditModalOpen}
data={MOCK_CREDIT_DATA}
/>
</>
);
}