171 lines
9.1 KiB
TypeScript
171 lines
9.1 KiB
TypeScript
import { X, Check } from 'lucide-react';
|
||
import { Typography } from '@/components/ui/typography';
|
||
import { useGetTargetBreakdown } from '@/api/generated/quotation/quotation';
|
||
import { useScrollLock } from '@/lib/useScrollLock';
|
||
import { useLabels, useVatMode } from '@/features/settings/useCompanySettings';
|
||
|
||
// 세션 목표가 산정내역 모달. 후보·채택·앵커링가는 백엔드 /target-breakdown 이 산정한 값을 '표시만' 한다.
|
||
// (프론트 재계산 없음 → 저장된 목표가와 항상 일치. 산정 로직은 백엔드 _candidates 단일 출처.)
|
||
type TargetPriceModalProps = {
|
||
onClose: () => void;
|
||
sessionId: string;
|
||
qtNumber: string;
|
||
itemName: string;
|
||
vatYn?: boolean | null;
|
||
deliveryFeeYn?: boolean | null;
|
||
category?: string | null;
|
||
};
|
||
|
||
const won = (n?: number | null) => (n != null ? `₩${n.toLocaleString()}` : '-');
|
||
|
||
// 후보 basis 별 부가 설명(수수료/마진 적용 표시). 백엔드 라벨에 없는 보조 문구만 프론트가 덧붙인다.
|
||
// 회사 설정 용어(목표 마진)를 반영해 컴포넌트 안에서 만든다.
|
||
const candidateSub = (marginLabel: string): Record<string, string> => ({
|
||
internet: '인터넷 평균 수수료 적용',
|
||
purchase: `${marginLabel} 적용`,
|
||
selling: `${marginLabel} 적용`,
|
||
purchase_raw: `${marginLabel} 미적용`,
|
||
selling_raw: `${marginLabel} 미적용`,
|
||
});
|
||
|
||
export function TargetPriceModal({
|
||
onClose,
|
||
sessionId,
|
||
qtNumber,
|
||
itemName,
|
||
vatYn,
|
||
deliveryFeeYn,
|
||
category,
|
||
}: TargetPriceModalProps) {
|
||
useScrollLock(); // 모달은 열릴 때만 마운트(부모 게이트) → 배경 스크롤 잠금
|
||
const label = useLabels(); // 회사 설정 용어
|
||
// 부가세 전체 통일 회사는 상품 vat_yn 잔존값과 무관하게 'VAT별도' 고정.
|
||
const vatUnified = useVatMode() === 'unified_excluded';
|
||
const CANDIDATE_SUB = candidateSub(`${label('target_margin')}`);
|
||
const { data: bd, isLoading } = useGetTargetBreakdown(sessionId, { query: { enabled: !!sessionId } });
|
||
const candidates = bd?.candidates ?? [];
|
||
const hiddenPrice = bd?.hidden_price_fields ?? []; // 회사 설정으로 감춰 후보에서 뺀 가격 필드
|
||
|
||
return (
|
||
<div
|
||
className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/40 backdrop-blur-xs"
|
||
onClick={onClose}
|
||
>
|
||
<div
|
||
className="w-full max-w-md bg-card border border-border rounded-lg shadow-2xl p-6 max-h-[90vh] overflow-y-auto font-mono text-xs animate-scale-up"
|
||
onClick={(e) => e.stopPropagation()}
|
||
>
|
||
{/* 헤더 */}
|
||
<div className="flex items-center justify-between pb-3 border-b border-border">
|
||
<Typography variant="h3">목표가</Typography>
|
||
<button onClick={onClose} className="p-1 rounded text-muted-foreground hover:bg-muted cursor-pointer">
|
||
<X size={18} />
|
||
</button>
|
||
</div>
|
||
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground mt-3">
|
||
견적번호: {qtNumber} · 상품: {itemName}
|
||
</Typography>
|
||
<Typography as="p" variant="small" className="text-[11px] font-semibold text-rose-500 mt-1">
|
||
배송비: {deliveryFeeYn ? '배송비포함' : '배송비별도'} · 부가세: {!vatUnified && vatYn ? 'VAT포함' : 'VAT별도'}
|
||
</Typography>
|
||
|
||
{isLoading || !bd ? (
|
||
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground mt-6">
|
||
산정내역을 불러오는 중…
|
||
</Typography>
|
||
) : (
|
||
<>
|
||
{/* 목표가 */}
|
||
<div className="flex items-center justify-between gap-3 mt-5">
|
||
<Typography variant="label" className="font-bold">목표가</Typography>
|
||
<div className="flex-1 text-right bg-muted/50 border border-border rounded px-3 py-2 font-bold text-base text-foreground">
|
||
{won(bd.target_price)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 선정방식 — 목표가가 어느 후보들 중에서 산정되는지 설명. 매입가·판매가는 견적등록 네고율 적용/미적용에 따라 원가일 수 있어 문구에 반영. */}
|
||
<div className="mt-4 bg-muted/40 border border-border rounded p-3 space-y-1">
|
||
<Typography as="p" variant="small" className="font-bold text-foreground text-[11px]">목표가 선정방식</Typography>
|
||
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">1. 구매담당자 제시가 존재 시, 최우선 적용</Typography>
|
||
{/* 설명 문구는 회사 설정(숨김 필드)에 따라 실제 후보와 일치하게 바꾼다. */}
|
||
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">
|
||
2. 다음 중 가장 작은 값 — {[
|
||
!hiddenPrice.includes('internet_lowest_price') && `${label('item.internet_lowest_price')}×(1−수수료)`,
|
||
!hiddenPrice.includes('purchase_price') && `${label('item.purchase_price')}×(1−${label('target_margin')}) 또는 원가`,
|
||
!hiddenPrice.includes('selling_price') && `${label('item.selling_price')}×(1−${label('target_margin')}) 또는 원가`,
|
||
].filter(Boolean).join(' | ')}
|
||
</Typography>
|
||
{/* 네고 적용/미적용 안내 — 숨김 안 된 매입가·판매가만 언급(신규견적은 인터넷최저가만이라 생략). */}
|
||
{!bd.is_new && (() => {
|
||
const negoSources = [
|
||
!hiddenPrice.includes('purchase_price') && label('item.purchase_price'),
|
||
!hiddenPrice.includes('selling_price') && label('item.selling_price'),
|
||
].filter(Boolean);
|
||
return negoSources.length > 0 ? (
|
||
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground">
|
||
* {negoSources.join('·')}는 견적등록의 네고율 적용 여부에 따라 원가(미적용)로도 산정됩니다.
|
||
</Typography>
|
||
) : null;
|
||
})()}
|
||
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground">
|
||
* 인터넷 평균 수수료: {bd.fee} · {label('target_margin')}: {bd.margin}{bd.is_new ? ' · 신규견적이라 인터넷최저가만 적용' : ''}
|
||
</Typography>
|
||
{bd.is_inherited && (
|
||
<Typography as="p" variant="small" className="text-[10px] text-amber-600">
|
||
* 저장된 목표가와 일치하는 현재 후보 없음 — 재생성 상속 또는 산정 후 상품·세팅 변경(아래 후보는 현재값 기준 참고용)
|
||
</Typography>
|
||
)}
|
||
</div>
|
||
|
||
{/* 후보 */}
|
||
<div className="mt-4 space-y-2">
|
||
{candidates.map((c) => {
|
||
const on = c.basis === bd.chosen_basis;
|
||
const sub = CANDIDATE_SUB[c.basis];
|
||
return (
|
||
<div key={c.basis} className="flex items-center gap-2">
|
||
<span className={`w-4 shrink-0 ${on ? 'text-emerald-600' : 'text-transparent'}`}>
|
||
{on ? <Check size={14} /> : null}
|
||
</span>
|
||
<div className="flex-1">
|
||
<Typography as="span" variant="small" className={`text-[11px] ${on ? 'font-bold text-foreground' : 'text-muted-foreground'}`}>
|
||
{c.label}
|
||
</Typography>
|
||
{sub && (
|
||
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground ml-1">
|
||
({sub})
|
||
</Typography>
|
||
)}
|
||
</div>
|
||
<div
|
||
className={`text-right min-w-[96px] px-2 py-1 rounded border ${
|
||
on
|
||
? 'border-emerald-300 bg-emerald-50 dark:bg-emerald-950/20 font-bold text-foreground'
|
||
: 'border-border bg-muted/30 text-muted-foreground'
|
||
}`}
|
||
>
|
||
{won(c.value)}
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* 앵커링 (negodata: 설정 anchoring_value 고정율) */}
|
||
<div className="mt-4 bg-muted/40 border border-border rounded p-3 space-y-1">
|
||
<Typography as="p" variant="small" className="font-bold text-foreground text-[11px]">앵커링</Typography>
|
||
{category && (
|
||
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">서비스 카테고리: {category}</Typography>
|
||
)}
|
||
<Typography as="p" variant="small" className="text-[11px] text-muted-foreground">앵커링 값: {bd.anchoring_value}</Typography>
|
||
<Typography as="p" variant="small" className="text-[11px] font-bold text-foreground">
|
||
앵커링가: {won(bd.anchoring_price)} <span className="font-normal text-[10px] text-muted-foreground">= 목표가×(1−{bd.anchoring_value})</span>
|
||
</Typography>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|