o2o-negosium-original/negodata/front/src/features/quotations/components/QuotationDetailSheet/TargetPriceModal.tsx

171 lines
9.1 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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>
);
}