diff --git a/app/src/app/globals.css b/app/src/app/globals.css index 89cac00..3ae6163 100644 --- a/app/src/app/globals.css +++ b/app/src/app/globals.css @@ -1735,7 +1735,9 @@ footer { margin-top: 6px; } .basis-links a, -.report-details summary { +.report-details summary, +.fund-columns a, +.excluded-list a { color: var(--report-accent); } .report-details { @@ -1759,6 +1761,170 @@ footer { font-weight: 800; } +/* 정책자금 보고서 */ +.fund-list { + display: grid; + gap: 16px; +} +.fund-card { + display: grid; + grid-template-columns: minmax(0, 1fr) 300px; + gap: 24px; + padding: 26px; + background: #fff; + border: 1px solid var(--report-line); + border-radius: 18px; +} +.fund-main { + min-width: 0; +} +.fund-kicker { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px; + margin: 0; + color: var(--muted); + font-size: 12px; +} +.fund-badge { + border-radius: 999px; + padding: 4px 10px; + background: #eceee9; + color: #4d5d57; + font-size: 11px; + font-weight: 800; + white-space: nowrap; +} +.fund-badge.status-current_candidate { + background: #e3f1ea; + color: #1f6b4c; +} +.fund-badge.status-needs_confirmation, +.fund-badge.status-post_registration { + background: #fbefd9; + color: #935612; +} +.fund-badge.status-not_eligible, +.fund-badge.status-closed, +.fund-badge.status-review_overdue { + background: #f7e6e1; + color: #9a3e24; +} +.fund-card h3 { + margin: 12px 0 14px; + font-size: 19px; + letter-spacing: -0.4px; +} +.fund-reason { + padding: 14px 16px; + border-radius: 12px; + background: var(--report-soft); + font-size: 13px; +} +.fund-reason.confirm { + background: var(--report-warm); +} +.fund-reason strong { + display: block; + margin-bottom: 4px; + font-size: 12px; +} +.fund-reason p, +.fund-reason ul { + margin: 0; + color: var(--report-text); +} +.fund-reason ul { + margin-top: 8px; + padding-left: 18px; +} +.fund-columns { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 20px; + margin-top: 18px; + font-size: 13px; +} +.fund-columns h4 { + margin: 0 0 6px; + font-size: 12px; +} +.fund-columns p, +.fund-columns ul { + margin: 0 0 4px; + color: var(--report-text); +} +.fund-columns ul { + display: grid; + gap: 4px; + padding-left: 18px; +} +.fund-columns a { + font-size: 12px; +} +.fund-terms { + display: grid; + align-content: start; + gap: 10px; + margin: 0; +} +.fund-terms > div { + padding: 14px 16px; + background: var(--report-panel); + border: 1px solid var(--report-line); + border-radius: 12px; +} +.fund-terms dt { + color: var(--muted); + font-size: 12px; +} +.fund-terms dd { + margin: 4px 0 0; + font-size: 15px; + font-weight: 800; +} +.fund-terms small { + display: block; + margin-top: 4px; + color: var(--muted); + font-size: 11px; +} +.excluded-list { + list-style: none; + margin: 14px 0 0; + padding: 6px 26px; + background: #fff; + border: 1px solid var(--report-line); + border-radius: 18px; +} +.excluded-list li { + display: grid; + grid-template-columns: minmax(0, 280px) minmax(0, 1fr); + gap: 24px; + padding: 18px 0; + border-bottom: 1px solid var(--report-line); +} +.excluded-list li:last-child { + border-bottom: 0; +} +.excluded-list li > div:first-child strong { + display: block; + font-size: 14px; +} +.excluded-list li > div:first-child span { + color: var(--muted); + font-size: 12px; +} +.excluded-list p { + margin: 6px 0 4px; + color: var(--report-text); + font-size: 13px; +} +.excluded-list a { + font-size: 12px; +} + /* 상권 분석 보고서 */ .report-split { display: grid; @@ -2065,8 +2231,13 @@ footer { .report-stats { grid-template-columns: repeat(2, minmax(0, 1fr)); } - .report-split { + .report-split, + .fund-card { + grid-template-columns: 1fr; + } + .excluded-list li { grid-template-columns: 1fr; + gap: 8px; } } @media (max-width: 560px) { @@ -2095,6 +2266,10 @@ footer { .report-section { padding: 20px 16px; } + .fund-card { + padding: 18px; + } + .fund-columns, .report-stats { grid-template-columns: 1fr; } @@ -2102,4 +2277,7 @@ footer { flex-direction: column; align-items: stretch; } + .excluded-list { + padding: 4px 16px; + } } diff --git a/app/src/features/funding/FundingCandidatesPanel.tsx b/app/src/features/funding/FundingCandidatesPanel.tsx index 336ccad..e1209bc 100644 --- a/app/src/features/funding/FundingCandidatesPanel.tsx +++ b/app/src/features/funding/FundingCandidatesPanel.tsx @@ -18,7 +18,7 @@ const numberFormat = new Intl.NumberFormat('ko-KR'); // 지원 유형은 대출과 다른 성격을 이름부터 구분한다. GRANT·SPACE·PROGRAM을 // 대출처럼 보이게 하는 표현을 쓰지 않는다. -const SUPPORT_TYPE_LABELS: Record = { +export const SUPPORT_TYPE_LABELS: Record = { GRANT: '지원금(상환 없음)', GUARANTEE: '보증(대출 아님)', LOAN: '대출(상환 대상)', @@ -26,7 +26,7 @@ const SUPPORT_TYPE_LABELS: Record = { PROGRAM: '프로그램(대출 아님)', }; -const STATUS_LABELS: Record = { +export const STATUS_LABELS: Record = { CURRENT_CANDIDATE: '검토 후보', NEEDS_CONFIRMATION: '추가 확인 필요', NOT_ELIGIBLE: '조건 미충족', @@ -35,15 +35,15 @@ const STATUS_LABELS: Record = { REVIEW_OVERDUE: '검수 기한 경과', }; -const VERDICT_LABELS: Record = { PASS: '충족', FAIL: '미충족', UNKNOWN: '미확인' }; +export const VERDICT_LABELS: Record = { PASS: '충족', FAIL: '미충족', UNKNOWN: '미확인' }; -const APPLICATION_STATUS_LABELS: Record = { +export const APPLICATION_STATUS_LABELS: Record = { OPEN: '접수 중으로 확인', CLOSED: '접수 종료로 확인', UNKNOWN: '접수 상태 미확인', }; -const REVIEW_STATE_LABELS: Record = { +export const REVIEW_STATE_LABELS: Record = { CURRENT: '검수 최신', UNREVIEWED: '검수자 미지정', REVIEW_OVERDUE: '검수 기한 경과', @@ -296,6 +296,40 @@ export function FundingCandidatesPanel({ ); } +export function ConditionTable({ evaluation }: { readonly evaluation: FundingCandidateEvaluation }) { + return ( +
+ + + + + + + + + + + + {evaluation.conditions.map((condition) => ( + + + + + + + ))} + +
조건별 판정과 이유
조건판정이유근거
{condition.label} + + {VERDICT_LABELS[condition.verdict]} + + {condition.detail} + {condition.evidenceIds.length === 0 ? '기록 없음' : condition.evidenceIds.join(', ')} +
+
+ ); +} + function CandidateCard({ evaluation, apply, @@ -359,35 +393,7 @@ function CandidateCard({ -
- - - - - - - - - - - - {evaluation.conditions.map((condition) => ( - - - - - - - ))} - -
조건별 판정과 이유
조건판정이유근거
{condition.label} - - {VERDICT_LABELS[condition.verdict]} - - {condition.detail} - {condition.evidenceIds.length === 0 ? '기록 없음' : condition.evidenceIds.join(', ')} -
-
+
diff --git a/app/src/features/funding/FundingMatcher.tsx b/app/src/features/funding/FundingMatcher.tsx index 1b4c3ab..ead4a0f 100644 --- a/app/src/features/funding/FundingMatcher.tsx +++ b/app/src/features/funding/FundingMatcher.tsx @@ -2,17 +2,19 @@ import Link from 'next/link'; import { useCallback, useEffect, useMemo, useState } from 'react'; +import ReportNav from '@/components/ReportNav'; import { authClient } from '@/lib/auth-client'; import type { FundingCandidatesResponse } from './candidates.ts'; -import { SEOUL_DISTRICTS } from './districts.ts'; -import { industryCodeIssue, purposeLabel, stageLabel } from './eligibility.ts'; +import { SEOUL_DISTRICTS, districtName } from './districts.ts'; +import { industryCodeIssue, purposeLabel, stageLabel, type FundingProfile } from './eligibility.ts'; import { FundingCandidatesErrorPanel, - FundingCandidatesPanel, describeFundingError, + formatFundingDate, fundingProfileSummary, type FundingLoadError, } from './FundingCandidatesPanel.tsx'; +import FundingReport from './FundingReport.tsx'; import { PURPOSES, type BusinessStage, type Purpose } from './types.ts'; type IndustryOption = { code: string; displayName: string }; @@ -92,169 +94,193 @@ export default function FundingMatcher() { [businessStage, districtCode, industryCode, purpose], ); - const profileSummary = useMemo( - () => - fundingProfileSummary({ - businessStage, - districtCode: districtCode === '' ? null : districtCode, - industryCode: industryCode.trim() === '' ? null : industryCode.trim().toUpperCase(), - purpose, - }), - [businessStage, districtCode, industryCode, purpose], - ); + // 제목·조건 칩은 입력 중인 값이 아니라 판정에 실제로 쓴 조건(result.profile)을 따른다. + const profile: FundingProfile | null = result?.profile ?? null; + const industryName = (code: string) => industries.find((industry) => industry.code === code)?.displayName ?? code; + const industryLabel = profile?.industryCode ? industryName(profile.industryCode) : null; + const districtLabel = profile?.districtCode ? (districtName(profile.districtCode) ?? profile.districtCode) : null; + const profileSummary = useMemo(() => (profile ? fundingProfileSummary(profile) : ''), [profile]); return ( -
-
- - TrendBench창업 준비의 기준 - -
- - 내 창업 계획 - - - 상권 탐색 - -
-
- -
-

자금 후보 조회

-

- 공식 공고만, -
- 상태와 이유까지. -

-

- 사업단계·자치구·업종·용도를 입력하면 활성 자금 카탈로그를 기준으로 검토 후보, 추가 확인, 등록 이후 검토, 종료, - 검수 만료, 미충족을 이유와 함께 보여줍니다. 후보가 0건이어도 정상 결과이며, 지원금·보증·공간 지원은 대출 - 상환으로 계산하지 않습니다. -

-
- - {isPending && ( -

- 로그인 상태를 확인하고 있습니다. -

- )} + <> + +
+
+

+ + {result ? `카탈로그 ${result.release.catalogVersion}` : '공식 공고 기준'} + + 금리·한도는 운영자가 정리한 공고 요약이며 바뀔 수 있어요. 신청 전 기관 공고를 꼭 확인하세요. +

+

+ {profile ? ( + <> + 서울시{districtLabel ? ` ${districtLabel}` : ''} + {industryLabel ? ( + <> + {' '} + {industryLabel} + + ) : null}{' '} + 창업 정책자금 추천 보고서 + + ) : ( + '정책자금 추천 보고서' + )} +

+ {profile ? ( +
    +
  • + 지역 + {districtLabel ? `서울시 ${districtLabel}` : '미입력'} +
  • +
  • + 업종 + {industryLabel ?? '미입력'} +
  • +
  • + 단계 + {profile.businessStage === 'UNKNOWN' ? '미입력' : stageLabel(profile.businessStage)} +
  • +
  • + 용도 + {profile.purpose === 'UNKNOWN' ? '미입력' : purposeLabel(profile.purpose)} +
  • +
+ ) : ( +

+ 조건을 고르면 활성 자금 카탈로그로 검토 후보·조건 확인·제외를 이유와 함께 정리해요. 검토 후보는 승인 + 확정이 아니에요. +

+ )} +
- {!isPending && !session && ( -
-

로그인이 필요합니다.

-

- 자금 후보 조회는 로그인한 사용자만 사용할 수 있습니다. 내 창업 계획에서 로그인한 뒤 - 다시 열어 주세요. + {isPending && ( +

+ 로그인 상태를 확인하고 있습니다.

-
- )} + )} - {!isPending && session && ( - <> -
-

이 결과가 무엇이고 무엇이 아닌지

-
    -
  • 검토 후보는 확인된 조건을 충족했다는 뜻이며 선정·승인이 확정된 것이 아닙니다.
  • -
  • 지원금·보증·공간·프로그램 지원은 대출 원금이나 월 상환액으로 바꾸지 않습니다.
  • -
  • 접수 상태(접수 중·종료·미확인)는 자격 판정과 별개로 저장된 관측값입니다.
  • -
  • 입력하지 않은 값은 0으로 채우지 않고 미확인(UNKNOWN)으로 남깁니다.
  • -
-
+ {!isPending && !session && ( +
+

로그인이 필요합니다.

+

+ 자금 후보 조회는 로그인한 사용자만 사용할 수 있습니다. 내 창업 계획에서 로그인한 뒤 + 다시 열어 주세요. +

+
+ )} + + {!isPending && session && ( + <> +
+

조건 선택

+
+
+ + + + +
+ + {industries.map((industry) => ( + + ))} + + {formError && ( +

+ {formError} +

+ )} +
+

+ 판정 기준일은 서버의 한국 시간 오늘이며 바꿀 수 없어요. 입력하지 않은 값은 0이 아니라 미확인으로 + 남아요. +

+ +
+
+
-
-

내 조건 입력

-
-
- - - - -
- - {industries.map((industry) => ( - - ))} - - {formError && ( -

- {formError} +

+ {loading && ( +

+ 활성 카탈로그를 기준으로 후보를 판정하는 중…

)} -
- -
-

- 판정 기준일은 서버의 한국 시간 오늘이며 요청에서 바꿀 수 없습니다. 업종 목록은 활성 상권 릴리스가 있을 - 때만 제안되고, 없어도 코드를 직접 입력할 수 있습니다. -

- -
+ {!loading && error && } + {!loading && !error && !result && ( +

조건을 고르고 보고서 만들기를 누르면 상태별 자금과 이유를 표시합니다.

+ )} + {!loading && !error && result && ( + + )} +
-
- {loading && ( -

- 활성 카탈로그를 기준으로 후보를 판정하는 중… -

- )} - {!loading && error && } - {!loading && !error && !result && ( -

조건을 입력하고 후보 조회를 누르면 상태별 후보와 이유를 표시합니다.

- )} - {!loading && !error && result && } -
- - )} +
+

이 결과가 무엇이고 무엇이 아닌지

+
    +
  • 검토 후보는 확인된 조건을 충족했다는 뜻이며 선정·승인이 확정된 것이 아닙니다.
  • +
  • 지원금·보증·공간·프로그램 지원은 대출 원금이나 월 상환액으로 바꾸지 않습니다.
  • +
  • 접수 상태(접수 중·종료·미확인)는 자격 판정과 별개로 저장된 관측값입니다.
  • +
  • 한도·금리·상환 조건 문장은 공고 요약이며, 확정 숫자로 기록된 금리만 비교합니다.
  • +
  • 입력하지 않은 값은 0으로 채우지 않고 미확인(UNKNOWN)으로 남깁니다.
  • +
+
+ + )} -
TrendBench · 공식 공고 기반 자금 카탈로그 · 내부 MVP · 공개 출시 준비 완료 상태가 아닙니다.
- +
TrendBench · 공식 공고 기반 자금 카탈로그 · 내부 MVP · 공개 출시 준비 완료 상태가 아닙니다.
+ + ); } diff --git a/app/src/features/funding/FundingReport.tsx b/app/src/features/funding/FundingReport.tsx new file mode 100644 index 0000000..1b08a03 --- /dev/null +++ b/app/src/features/funding/FundingReport.tsx @@ -0,0 +1,336 @@ +'use client'; + +import Decimal from 'decimal.js'; +import { formatKoreanWon } from '@/lib/format-won'; +import type { FundingCandidatesResponse } from './candidates.ts'; +import type { FundingCandidateEvaluation, FundingCandidateStatus } from './eligibility.ts'; +import { + APPLICATION_STATUS_LABELS, + ConditionTable, + REVIEW_STATE_LABELS, + STATUS_LABELS, + SUPPORT_TYPE_LABELS, + formatFundingDate, + isEmptyFundingProfile, +} from './FundingCandidatesPanel.tsx'; + +// /funding의 보고서 표시. 판정은 서버 결과를 그대로 쓰고, 여기서는 상태별로 묶어 +// 읽기 쉽게 배치만 한다. 계획 화면은 같은 판정을 FundingCandidatesPanel로 보여 준다. + +const numberFormat = new Intl.NumberFormat('ko-KR'); + +const REVIEWABLE: readonly FundingCandidateStatus[] = ['CURRENT_CANDIDATE', 'NEEDS_CONFIRMATION']; +const EXCLUDED: readonly FundingCandidateStatus[] = ['POST_REGISTRATION', 'NOT_ELIGIBLE', 'CLOSED', 'REVIEW_OVERDUE']; + +// 대출·보증만 금리와 상환 조건이 의미가 있다. 나머지는 금리 칸을 비워 두지 않고 대출이 +// 아니라고 쓴다. +const LENDING_TYPES = new Set(['LOAN', 'GUARANTEE']); + +function lowestConfirmedRate(evaluations: readonly FundingCandidateEvaluation[]) { + let best: { rate: string; name: string } | null = null; + for (const evaluation of evaluations) { + if (evaluation.candidateStatus !== 'CURRENT_CANDIDATE' || !evaluation.repayment.terms) continue; + const rate = evaluation.repayment.terms.annualInterestRatePercent; + if (best === null || new Decimal(rate).lessThan(best.rate)) best = { rate, name: evaluation.name }; + } + return best; +} + +function periodText(period: FundingCandidateEvaluation['announcement']['applicationPeriod']): string { + if (period.start === null && period.end === null) return '기간 기록 없음'; + return `${period.start ? formatFundingDate(period.start) : '시작일 기록 없음'} ~ ${ + period.end ? formatFundingDate(period.end) : '종료일 기록 없음' + }`; +} + +export default function FundingReport({ + result, + industryLabel, + profileSummary, +}: { + readonly result: FundingCandidatesResponse; + readonly industryLabel: string | null; + readonly profileSummary: string; +}) { + const { summary } = result; + const reviewable = REVIEWABLE.flatMap((status) => + result.evaluations.filter((evaluation) => evaluation.candidateStatus === status), + ); + const excluded = EXCLUDED.flatMap((status) => + result.evaluations.filter((evaluation) => evaluation.candidateStatus === status), + ); + const lowest = lowestConfirmedRate(result.evaluations); + const emptyProfile = isEmptyFundingProfile(result.profile); + + if (summary.total === 0) { + return ( +
+

활성 카탈로그에 상품이 없습니다.

+

카탈로그에 적재된 상품이 없습니다. 후보 0건과 카탈로그 없음은 다른 상태입니다.

+
+ ); + } + + return ( + <> +
+
+ 현재 검토 후보 + {numberFormat.format(summary.CURRENT_CANDIDATE)}개 + 확인된 조건을 충족하고 접수 중으로 확인된 자금이에요. 승인 확정이 아니에요. +
+
+ 조건 확인이 필요한 자금 + {numberFormat.format(summary.NEEDS_CONFIRMATION)}개 + 미확인 조건, 원문 근거 부족, 검수 미완료 중 하나가 있어요. +
+
+ 검토 후보 중 가장 낮은 확정 금리 + {lowest ? ( + <> + 연 {lowest.rate}% + {lowest.name} + + ) : ( + <> + 확정 금리 없음 + 금리가 확정 숫자로 기록된 검토 후보가 없어요. 금리 범위·변동금리 문장은 비교하지 않아요. + + )} +
+
+ 제외·분리한 자금 + {numberFormat.format(excluded.length)}개 + + 제외 이유 보기 + +
+
+ +
+

01 · 검토할 정책자금

+

+ {industryLabel ? `${industryLabel} 창업에 검토할 수 있는 정책자금` : '검토할 수 있는 정책자금'} +

+

+ 검토 후보를 먼저, 조건 확인이 필요한 자금을 다음에 정리했어요. 한도·금리·기간은 카탈로그에 기록한 공고 + 요약이에요. +

+ {summary.CURRENT_CANDIDATE === 0 && ( +
+

현재 신청 가능한 후보가 0건입니다.

+

+ 후보 0건은 정상 결과입니다. 접수 중으로 확인된 상품이 없거나, 확인된 조건이 모두 충족되지 않았거나, 원문 + 근거·검수가 부족해 추가 확인으로 남은 상태입니다. + {emptyProfile && + ' 입력한 조건이 없어 대부분의 조건이 미확인으로 남았습니다. 사업 단계·자치구·업종·용도를 입력하면 판정이 달라집니다.'} +

+
+ )} + {reviewable.length === 0 ? ( +

이번 조건에서 검토하거나 확인할 자금이 없어요.

+ ) : ( +
+ {reviewable.map((evaluation, index) => ( + + ))} +
+ )} +
+ +
+

02 · 해당되지 않는 자금

+

이번 조건에서는 제외했어요

+ {excluded.length === 0 ? ( +

제외하거나 분리한 자금이 없어요.

+ ) : ( +
    + {excluded.map((evaluation) => ( +
  • +
    + {evaluation.name} + {evaluation.organization} +
    +
    + + {STATUS_LABELS[evaluation.candidateStatus]} + +

    {evaluation.candidateReason}

    + + 공식 원문 열기 + +
    +
  • + ))} +
+ )} +
+ +
+

판정 기준

+
+
+
카탈로그
+
+ {result.release.catalogKey}@{result.release.catalogVersion} +
+
+
+
기준일
+
{formatFundingDate(result.release.basisDate)}
+
+
+
판정 기준일
+
{formatFundingDate(result.asOfDate)}
+
+
+
검수일
+
{formatFundingDate(result.release.reviewedAt)}
+
+
+
검수자
+
{result.release.reviewer}
+
+
+
적재 스키마
+
{result.release.schemaVersion}
+
+
+
활성화 시각
+
+ {result.release.activatedAt ? new Date(result.release.activatedAt).toLocaleString('ko-KR') : '기록 없음'} +
+
+
+
상품 수
+
{numberFormat.format(result.release.productCount)}건
+
+
+

판정에 쓴 조건: {profileSummary}

+
+ + ); +} + +function FundCard({ evaluation, order }: { readonly evaluation: FundingCandidateEvaluation; readonly order: number }) { + const { announcement, repayment } = evaluation; + const current = evaluation.candidateStatus === 'CURRENT_CANDIDATE'; + const lending = LENDING_TYPES.has(evaluation.supportType); + const unknownConditions = evaluation.conditions.filter((condition) => condition.verdict === 'UNKNOWN'); + return ( +
+
+

+ + {current ? '검토 후보' : '조건 확인'} + + {evaluation.organization} · {SUPPORT_TYPE_LABELS[evaluation.supportType]} +

+

+ {order}. {evaluation.name} +

+ +
+ {current ? '검토 이유' : '확인할 조건'} +

{evaluation.candidateReason}

+ {!current && unknownConditions.length > 0 && ( +
    + {unknownConditions.map((condition) => ( +
  • + {condition.label}: {condition.detail} +
  • + ))} +
+ )} +
+ +
+
+

신청처

+

{evaluation.organization}

+ + 공식 원문 열기 + +
+
+

신청 전 확인·유의사항

+ {evaluation.manualChecks.length === 0 ? ( +

기록된 추가 확인 항목이 없어요.

+ ) : ( +
    + {evaluation.manualChecks.map((check) => ( +
  • {check}
  • + ))} +
+ )} +
+
+ +
+ 판정 근거 자세히 +
+
+
상품 버전
+
+ {evaluation.productKey}@{evaluation.version} +
+
+
+
검수 상태
+
+ {REVIEW_STATE_LABELS[evaluation.reviewState]} · 검수일 {formatFundingDate(evaluation.reviewedAt)} · 다음 + 검토일 {formatFundingDate(evaluation.nextReviewAt)} +
+
+
+ +

+ {repayment.supported ? repayment.note : `상환 계산 대상이 아니에요. ${repayment.reasons.join(' ')}`} +

+
+
+ +
+
+
공개 한도
+
+ {announcement.publicLimitKrw === null + ? '공고에서 확인하지 못했어요' + : `최대 ${formatKoreanWon(announcement.publicLimitKrw)}`} +
+
+
+
금리
+
+ {repayment.terms + ? `연 ${repayment.terms.annualInterestRatePercent}% (확정)` + : !lending + ? '해당 없음 · 대출이 아니에요' + : (announcement.interestCondition ?? '공고에서 확정하지 못했어요')} +
+ {lending && !repayment.terms && 확정 숫자가 아니어서 상환 계산에 쓰지 않아요.} +
+
+
상환 조건
+
+ {!lending + ? '해당 없음 · 상환하지 않아요' + : (announcement.repaymentCondition ?? '공고에서 확정하지 못했어요')} +
+
+
+
접수
+
{APPLICATION_STATUS_LABELS[evaluation.observedApplicationStatus]}
+ + {periodText(announcement.applicationPeriod)} · {formatFundingDate(evaluation.observedAt)} 확인 + {announcement.applicationPeriod.note ? ` · ${announcement.applicationPeriod.note}` : ''} + +
+
+
+ ); +} diff --git a/app/src/features/funding/eligibility.ts b/app/src/features/funding/eligibility.ts index dc4847f..03be797 100644 --- a/app/src/features/funding/eligibility.ts +++ b/app/src/features/funding/eligibility.ts @@ -92,6 +92,16 @@ export type FundingCandidateEvaluation = { manualChecks: string[]; unsupportedCalculationReasons: string[]; repayment: ProductRepaymentAvailability; + // 카탈로그에 기록한 공고 요약 그대로다. 판정과 상환 계산에 쓰지 않고 화면 표시에만 + // 쓴다. 조건 문장은 숫자로 해석하지 않는다. + announcement: FundingAnnouncement; +}; + +export type FundingAnnouncement = { + publicLimitKrw: string | null; + interestCondition: string | null; + repaymentCondition: string | null; + applicationPeriod: { start: string | null; end: string | null; note: string | null }; }; export type FundingCandidateSummary = Record & { total: number }; @@ -402,6 +412,12 @@ export function evaluateProduct( manualChecks: [...product.additionalChecks], unsupportedCalculationReasons: [...product.unsupportedCalculationReasons], repayment, + announcement: { + publicLimitKrw: product.publicLimit, + interestCondition: product.interestCondition, + repaymentCondition: product.repaymentCondition, + applicationPeriod: { ...product.applicationPeriod }, + }, }; } diff --git a/app/src/features/funding/funding.test.ts b/app/src/features/funding/funding.test.ts index 4bf8281..b56e05c 100644 --- a/app/src/features/funding/funding.test.ts +++ b/app/src/features/funding/funding.test.ts @@ -420,6 +420,26 @@ describe('the reviewed 2026-09-20 catalog', () => { expect(repayment.reasons.length).toBeGreaterThan(0); } }); + + it('shows the announced limit as recorded without turning it into a repayment term', () => { + const parsed = catalogFrom(validate(REAL_CATALOG)); + const startupFund = parsed.products.find((entry) => entry.productKey === 'seoul-fund-2026-startup-company')!; + const evaluation = evaluateProduct( + startupFund, + { businessStage: 'POST_REGISTRATION', districtCode: '11440', industryCode: 'CS100001', purpose: 'UNKNOWN' }, + { asOfDate: '2026-09-20' }, + ); + expect(evaluation.announcement).toEqual({ + publicLimitKrw: startupFund.publicLimit, + interestCondition: startupFund.interestCondition, + repaymentCondition: startupFund.repaymentCondition, + applicationPeriod: startupFund.applicationPeriod, + }); + expect(evaluation.announcement.publicLimitKrw).toBe('100000000'); + expect(evaluation.repayment.publicLimitKrw).toBeNull(); + expect(evaluation.repayment.supported).toBe(false); + expect(evaluation.candidateStatus).not.toBe('CURRENT_CANDIDATE'); + }); }); describe('condition verdicts and candidate status', () => { diff --git a/docs/PRODUCT.md b/docs/PRODUCT.md index 67ab277..5051eb2 100644 --- a/docs/PRODUCT.md +++ b/docs/PRODUCT.md @@ -23,7 +23,7 @@ | 프랜차이즈 참고 | 업종별 브랜드 검색, 가맹점 수·개폐점, 연 평균매출·3.3㎡당 평균매출, 정보공개서 창업 금액(가맹금·교육비·보증금·기타), 연도별 추이 | 공정위 정보공개서(공공데이터포털 가맹정보), 최근 3개 기준년도(각각 전년도 실적). 전국 평균이며 재무 입력에 자동으로 넣지 않음 | | 재무 계획(`/`, 로그인) | 초기 비용, 자기자금, 매출·비용 가정, 부족자금, 운영수지, 손익분기, 상환 일정, 3개 시나리오 | 사용자가 입력한 가정만 계산 | | 계산 결과 | 서버 재계산, 입력·버전·출처를 보존한 불변 결과 | 과거 결과를 수정하지 않음 | -| 자금 후보(`/funding`, 로그인) | 사업 단계·지역·업종·용도로 검토 후보·추가 확인 필요·조건 미충족·사업자등록 이후 검토·접수 종료·검수 기한 경과로 분류 | 운영자가 적재한 카탈로그만 사용. 검수자 미지정 상품은 현재 후보로 표시하지 않음 | +| 자금 후보(`/funding`, 로그인) | 사업 단계·지역·업종·용도로 검토 후보·추가 확인 필요·조건 미충족·사업자등록 이후 검토·접수 종료·검수 기한 경과로 분류하고, 공고 요약(한도·금리·상환 조건)과 함께 보고서로 표시 | 운영자가 적재한 카탈로그만 사용. 검수자 미지정 상품은 현재 후보로 표시하지 않음 | | 대출 가정 적용 | 확정 조건을 갖춘 현재 후보를 사용자가 선택해 재무계획에 적용 | 미확정·비대출 상품은 불가 사유 표시 | 상위 업종은 한식(`CS100001`)과 커피·음료(`CS100010`)만 노출한다. diff --git a/docs/domains/funding.md b/docs/domains/funding.md index c5fb5b3..febce6f 100644 --- a/docs/domains/funding.md +++ b/docs/domains/funding.md @@ -7,7 +7,8 @@ - `app/src/features/funding`: 판정 함수는 React·DB·외부 API에 의존하지 않는다. - 카탈로그 Zod 스키마(`schema.ts`), 규칙 검사(`validation.ts`), 고정 조건 판정(`eligibility.ts`) - 릴리스·상품 버전 적재, 활성 카탈로그 읽기(`read.ts`), 요청 스키마·응답 조립(`candidates.ts`) - - 페이지 단위 후보 화면(`FundingMatcher.tsx`), 두 화면이 함께 쓰는 결과 표시(`FundingCandidatesPanel.tsx`) + - `/funding` 보고서(`FundingMatcher.tsx`, `FundingReport.tsx`), 계획 화면의 결과 표시(`FundingCandidatesPanel.tsx`). 두 화면은 라벨과 조건별 판정표를 함께 쓴다. + - `/funding` 보고서의 "가장 낮은 확정 금리"는 현재 검토 후보 중 확정 금리(`interestRatePercent`)가 있는 상품만 비교한다. 금리 문장은 비교하지 않는다. - `app/catalog/funding`: 운영자가 공식 공고를 정리한 JSON 카탈로그와 갱신 절차 문서. ## 카탈로그 @@ -66,6 +67,7 @@ - 상품명·기관·지원 유형·후보 상태, 포함·제외·추가 확인 이유 - 조건별 PASS/FAIL/UNKNOWN과 근거 id, 공식 링크·접수·검수 상태 - 상환 계산 가능 여부와 불가 사유 + - 카탈로그에 기록한 공고 요약(`announcement`): 공개 한도, 금리·상환 조건 문장, 신청기간. 화면 표시에만 쓰고 판정·상환 계산에는 쓰지 않는다. ### 계획에 저장한 조건으로 판정