[refactor] negodata/front: 견적등록 모달 훅 분리 + 시장가 모순 카드 차단(10번), 엑셀 업로드 xlsx 파싱·CP949 폴백(7번)
This commit is contained in:
parent
3c3f60c7d4
commit
2bf91d64f5
104
negodata/front/package-lock.json
generated
104
negodata/front/package-lock.json
generated
@ -36,6 +36,7 @@
|
|||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"vite": "^6.2.3",
|
"vite": "^6.2.3",
|
||||||
|
"xlsx": "^0.18.5",
|
||||||
"zod": "^4.4.3",
|
"zod": "^4.4.3",
|
||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
@ -4421,6 +4422,15 @@
|
|||||||
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
"acorn": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/adler-32": {
|
||||||
|
"version": "1.3.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/adler-32/-/adler-32-1.3.1.tgz",
|
||||||
|
"integrity": "sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/agent-base": {
|
"node_modules/agent-base": {
|
||||||
"version": "7.1.4",
|
"version": "7.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz",
|
||||||
@ -4940,6 +4950,19 @@
|
|||||||
],
|
],
|
||||||
"license": "CC-BY-4.0"
|
"license": "CC-BY-4.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/cfb": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cfb/-/cfb-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-KfdUZsSOw19/ObEWasvBP/Ac4reZvAGauZhs6S/gqNhXhI7cKwvlH7ulj+dOEYnca4bm4SGo8C1bTAQvnTjgQA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"adler-32": "~1.3.0",
|
||||||
|
"crc-32": "~1.2.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/chalk": {
|
"node_modules/chalk": {
|
||||||
"version": "5.6.2",
|
"version": "5.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
|
||||||
@ -5059,6 +5082,15 @@
|
|||||||
"integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==",
|
"integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/codepage": {
|
||||||
|
"version": "1.15.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/codepage/-/codepage-1.15.0.tgz",
|
||||||
|
"integrity": "sha512-3g6NUTPd/YtuuGrhMnOMRjFc+LJw/bnMp3+0r/Wcz3IXUuCosKRJvMphm5+Q+bvTVGcJJuRvVLuYba+WojaFaA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@ -5193,6 +5225,18 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/crc-32": {
|
||||||
|
"version": "1.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz",
|
||||||
|
"integrity": "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"crc32": "bin/crc32.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.6",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
@ -6672,6 +6716,15 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/frac": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/frac/-/frac-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fraction.js": {
|
"node_modules/fraction.js": {
|
||||||
"version": "5.3.4",
|
"version": "5.3.4",
|
||||||
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz",
|
||||||
@ -10949,6 +11002,18 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ssf": {
|
||||||
|
"version": "0.11.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/ssf/-/ssf-0.11.2.tgz",
|
||||||
|
"integrity": "sha512-+idbmIXoYET47hH+d7dfm2epdOMUDjqcB4648sTZ+t2JwoyBFL/insLfB/racrDmsKB3diwsDA696pZMieAC5g==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"frac": "~1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/statuses": {
|
"node_modules/statuses": {
|
||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz",
|
||||||
@ -12501,6 +12566,24 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wmf": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/wmf/-/wmf-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-/p9K7bEh0Dj6WbXg4JG0xvLQmIadrner1bi45VMJTfnbVHsc7yIajZyoSoK60/dtVBs12Fm6WkUI5/3WAVsNMw==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/word": {
|
||||||
|
"version": "0.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/word/-/word-0.3.0.tgz",
|
||||||
|
"integrity": "sha512-OELeY0Q61OXpdUfTp+oweA/vtLVg5VDOXh+3he3PNzLGG/y0oylSOC1xRVj0+l4vQ3tj/bB1HVHv1ocXkQceFA==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/word-wrap": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
@ -12594,6 +12677,27 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/xlsx": {
|
||||||
|
"version": "0.18.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/xlsx/-/xlsx-0.18.5.tgz",
|
||||||
|
"integrity": "sha512-dmg3LCjBPHZnQp5/F/+nnTa+miPJxUXB6vtk42YjBBKayDNagxGEeIdWApkYPOf3Z3pm3k62Knjzp7lMeTEtFQ==",
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"adler-32": "~1.3.0",
|
||||||
|
"cfb": "~1.2.1",
|
||||||
|
"codepage": "~1.15.0",
|
||||||
|
"crc-32": "~1.2.1",
|
||||||
|
"ssf": "~0.11.2",
|
||||||
|
"wmf": "~1.0.1",
|
||||||
|
"word": "~0.3.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"xlsx": "bin/xlsx.njs"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/y18n": {
|
"node_modules/y18n": {
|
||||||
"version": "5.0.8",
|
"version": "5.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
|
||||||
|
|||||||
@ -40,6 +40,7 @@
|
|||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"vaul": "^1.1.2",
|
"vaul": "^1.1.2",
|
||||||
"vite": "^6.2.3",
|
"vite": "^6.2.3",
|
||||||
|
"xlsx": "^0.18.5",
|
||||||
"zod": "^4.4.3",
|
"zod": "^4.4.3",
|
||||||
"zustand": "^5.0.14"
|
"zustand": "^5.0.14"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { useMemo, useRef, useState } from 'react';
|
|||||||
import { Upload, X, FileSpreadsheet, CheckCircle2, Trash2 } from 'lucide-react';
|
import { Upload, X, FileSpreadsheet, CheckCircle2, Trash2 } from 'lucide-react';
|
||||||
import { useScrollLock } from '@/lib/useScrollLock';
|
import { useScrollLock } from '@/lib/useScrollLock';
|
||||||
import { showToast } from '@/lib/notify';
|
import { showToast } from '@/lib/notify';
|
||||||
import { downloadExcel, parseCsv, todayStamp, type BulkFailure } from '@/lib/excel';
|
import { downloadExcel, readSpreadsheetRows, todayStamp, type BulkFailure } from '@/lib/excel';
|
||||||
import { Typography } from '@/components/ui/typography';
|
import { Typography } from '@/components/ui/typography';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
|
||||||
@ -102,7 +102,13 @@ export function CardExcelUploadModal({ open, onConfirm, onClose }: CardExcelUplo
|
|||||||
|
|
||||||
// 업로드된 CSV를 파싱해 원본 행으로 적재(검증은 자동 파생). 헤더는 양식과 동일해야 함.
|
// 업로드된 CSV를 파싱해 원본 행으로 적재(검증은 자동 파생). 헤더는 양식과 동일해야 함.
|
||||||
const handleFile = async (file: File) => {
|
const handleFile = async (file: File) => {
|
||||||
const parsed = parseCsv(await file.text());
|
let parsed: Record<string, string>[];
|
||||||
|
try {
|
||||||
|
parsed = await readSpreadsheetRows(file);
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err instanceof Error ? err.message : '파일을 읽을 수 없습니다.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const loaded: RawRow[] = parsed.map((r, i) => ({
|
const loaded: RawRow[] = parsed.map((r, i) => ({
|
||||||
id: `row-${i + 1}`,
|
id: `row-${i + 1}`,
|
||||||
rowNum: i + 2,
|
rowNum: i + 2,
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { Upload, X, FileSpreadsheet, CheckCircle2, Trash2 } from 'lucide-react';
|
|||||||
import { useScrollLock } from '@/lib/useScrollLock';
|
import { useScrollLock } from '@/lib/useScrollLock';
|
||||||
import type { ReqCreateSupplier as SupplierCreate } from '@/api/generated/model/reqCreateSupplier';
|
import type { ReqCreateSupplier as SupplierCreate } from '@/api/generated/model/reqCreateSupplier';
|
||||||
import { showToast } from '@/lib/notify';
|
import { showToast } from '@/lib/notify';
|
||||||
import { downloadExcel, parseCsv, todayStamp, type BulkFailure } from '@/lib/excel';
|
import { downloadExcel, readSpreadsheetRows, todayStamp, type BulkFailure } from '@/lib/excel';
|
||||||
import { customFetch } from '@/api/mutator/custom-fetch';
|
import { customFetch } from '@/api/mutator/custom-fetch';
|
||||||
import { Typography } from '@/components/ui/typography';
|
import { Typography } from '@/components/ui/typography';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@ -144,7 +144,13 @@ export function ExcelUploadModal({ open, partners, onConfirm, onClose }: ExcelUp
|
|||||||
|
|
||||||
// 업로드된 CSV를 파싱해 원본 행으로 적재(검증은 자동 파생). 헤더는 양식과 동일해야 함.
|
// 업로드된 CSV를 파싱해 원본 행으로 적재(검증은 자동 파생). 헤더는 양식과 동일해야 함.
|
||||||
const handleFile = async (file: File) => {
|
const handleFile = async (file: File) => {
|
||||||
const parsed = parseCsv(await file.text());
|
let parsed: Record<string, string>[];
|
||||||
|
try {
|
||||||
|
parsed = await readSpreadsheetRows(file);
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err instanceof Error ? err.message : '파일을 읽을 수 없습니다.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const loaded: RawRow[] = parsed.map((r, i) => ({
|
const loaded: RawRow[] = parsed.map((r, i) => ({
|
||||||
id: `row-${i + 1}`,
|
id: `row-${i + 1}`,
|
||||||
rowNum: i + 2,
|
rowNum: i + 2,
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { Upload, X, FileSpreadsheet, CheckCircle2, Trash2 } from 'lucide-react';
|
|||||||
import { useScrollLock } from '@/lib/useScrollLock';
|
import { useScrollLock } from '@/lib/useScrollLock';
|
||||||
import type { ReqCreateItem as ItemCreate } from '@/api/generated/model/reqCreateItem';
|
import type { ReqCreateItem as ItemCreate } from '@/api/generated/model/reqCreateItem';
|
||||||
import { showToast } from '@/lib/notify';
|
import { showToast } from '@/lib/notify';
|
||||||
import { downloadExcel, parseCsv, todayStamp, type BulkFailure } from '@/lib/excel';
|
import { downloadExcel, readSpreadsheetRows, todayStamp, type BulkFailure } from '@/lib/excel';
|
||||||
import { customFetch } from '@/api/mutator/custom-fetch';
|
import { customFetch } from '@/api/mutator/custom-fetch';
|
||||||
import { Typography } from '@/components/ui/typography';
|
import { Typography } from '@/components/ui/typography';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@ -344,7 +344,13 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp
|
|||||||
// 업로드된 CSV를 파싱해 원본 행으로 적재(검증은 자동 파생).
|
// 업로드된 CSV를 파싱해 원본 행으로 적재(검증은 자동 파생).
|
||||||
// 헤더는 회사 라벨과 기본 헤더(구양식) 둘 다 인식한다(aliases).
|
// 헤더는 회사 라벨과 기본 헤더(구양식) 둘 다 인식한다(aliases).
|
||||||
const handleFile = async (file: File) => {
|
const handleFile = async (file: File) => {
|
||||||
const parsed = parseCsv(await file.text());
|
let parsed: Record<string, string>[];
|
||||||
|
try {
|
||||||
|
parsed = await readSpreadsheetRows(file);
|
||||||
|
} catch (err) {
|
||||||
|
showToast(err instanceof Error ? err.message : '파일을 읽을 수 없습니다.', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
const pick = (r: Record<string, string>, c: UploadColumn): string => {
|
const pick = (r: Record<string, string>, c: UploadColumn): string => {
|
||||||
for (const a of c.aliases) if (r[a] !== undefined) return r[a];
|
for (const a of c.aliases) if (r[a] !== undefined) return r[a];
|
||||||
return '';
|
return '';
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import { useState, useMemo, useEffect, useRef } from 'react';
|
import { useState, useMemo, useEffect } from 'react';
|
||||||
import { X, PlusSquare, ArrowRight, Loader2, Gavel, CheckCheck } from 'lucide-react';
|
import { X, PlusSquare, ArrowRight, Loader2, Gavel, CheckCheck } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import { useListItemSupplyTypes } from '@/api/generated/supplier-item/supplier-item';
|
import { useListItemSupplyTypes } from '@/api/generated/supplier-item/supplier-item';
|
||||||
@ -6,8 +6,7 @@ import { useListItems, useGetItem } from '@/api/generated/item/item';
|
|||||||
import { useListSuppliers } from '@/api/generated/supplier/supplier';
|
import { useListSuppliers } from '@/api/generated/supplier/supplier';
|
||||||
import { useListCards } from '@/api/generated/card/card';
|
import { useListCards } from '@/api/generated/card/card';
|
||||||
import { mapCardData } from '@/features/cards/types';
|
import { mapCardData } from '@/features/cards/types';
|
||||||
import { CONDITION_VARIABLE, CONDITION_LABEL } from '@/features/cards/editor/variables';
|
import { useLabels } from '@/features/settings/useCompanySettings';
|
||||||
import { useLabels, useHiddenFields } from '@/features/settings/useCompanySettings';
|
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Typography, typographyVariants } from '@/components/ui/typography';
|
import { Typography, typographyVariants } from '@/components/ui/typography';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@ -20,42 +19,16 @@ import type { CreateQuotationInput } from '../hooks/useQuotations';
|
|||||||
import {
|
import {
|
||||||
is1v1,
|
is1v1,
|
||||||
toQuotationType,
|
toQuotationType,
|
||||||
awardStrategySummary,
|
|
||||||
PriceGateAction,
|
PriceGateAction,
|
||||||
DEFAULT_MID_ACTION,
|
DEFAULT_MID_ACTION,
|
||||||
DEFAULT_OVER_ACTION,
|
DEFAULT_OVER_ACTION,
|
||||||
type QuotationMode,
|
type QuotationMode,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
import { supplierTypeLabel } from '@/lib/enumLabels';
|
|
||||||
import { showToast } from '@/lib/notify';
|
import { showToast } from '@/lib/notify';
|
||||||
|
import { defaultDueDateLocalInput, nowKstLocalInput, isFutureLocalInput } from './quotationForm.utils';
|
||||||
const INTERNET_AVERAGE_FEE = 0.078;
|
import { SupplyTypeBadge, SelectedPartnerTable, SelectedCardTable, Segmented, AwardLinePicker } from './QuotationFormParts';
|
||||||
const TARGET_PRICE_UNIT_LIMIT_MULTIPLIER = 2;
|
import { useTargetPrice } from '../hooks/useTargetPrice';
|
||||||
|
import { useCardGating } from '../hooks/useCardGating';
|
||||||
// datetime-local 값: 한국시간(Asia/Seoul)의 'YYYY-MM-DDTHH:mm'.
|
|
||||||
// sv-SE 로케일이 'YYYY-MM-DD HH:mm:ss' 를 주고, timeZone 명시로 브라우저 TZ 와 무관하게 KST 로 고정한다.
|
|
||||||
function toKstLocalInput(date: Date): string {
|
|
||||||
const s = date.toLocaleString('sv-SE', { timeZone: 'Asia/Seoul' });
|
|
||||||
return s.slice(0, 16).replace(' ', 'T');
|
|
||||||
}
|
|
||||||
|
|
||||||
function nowKstLocalInput(): string {
|
|
||||||
return toKstLocalInput(new Date());
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultDueDateLocalInput(): string {
|
|
||||||
return toKstLocalInput(new Date(Date.now() + 60 * 60 * 1000));
|
|
||||||
}
|
|
||||||
|
|
||||||
function isFutureLocalInput(value: string): boolean {
|
|
||||||
const time = new Date(value).getTime();
|
|
||||||
return Number.isFinite(time) && time > Date.now();
|
|
||||||
}
|
|
||||||
|
|
||||||
function parsePercent(value: string | undefined): number {
|
|
||||||
const n = Number(String(value ?? '').replace('%', '').trim());
|
|
||||||
return Number.isFinite(n) ? n / 100 : 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
type QuotationCreateModalProps = {
|
type QuotationCreateModalProps = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@ -141,7 +114,6 @@ export function QuotationCreateModal({
|
|||||||
// 선택 상품의 협력사별 공급유형(제조/유통/총판/없음) — 협력사 리스트에 배지로 덧붙인다(리스트 자체는 재조회 안 함).
|
// 선택 상품의 협력사별 공급유형(제조/유통/총판/없음) — 협력사 리스트에 배지로 덧붙인다(리스트 자체는 재조회 안 함).
|
||||||
const supplyTypeQuery = useListItemSupplyTypes(productId, { query: { enabled: !!productId } });
|
const supplyTypeQuery = useListItemSupplyTypes(productId, { query: { enabled: !!productId } });
|
||||||
const label = useLabels(); // 회사 설정 용어(목표 마진 등)
|
const label = useLabels(); // 회사 설정 용어(목표 마진 등)
|
||||||
const isHidden = useHiddenFields(); // 회사설정으로 감춘 상품 기본필드 — 후보 리스트에서도 제외
|
|
||||||
const supplyTypeBySupplier = useMemo(() => {
|
const supplyTypeBySupplier = useMemo(() => {
|
||||||
const m = new Map<string, number>();
|
const m = new Map<string, number>();
|
||||||
(supplyTypeQuery.data?.suppliers ?? []).forEach((s) => m.set(s.supplier_id, s.supply_type));
|
(supplyTypeQuery.data?.suppliers ?? []).forEach((s) => m.set(s.supplier_id, s.supply_type));
|
||||||
@ -177,153 +149,49 @@ export function QuotationCreateModal({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const cardRows = cardQ ? (cardSearch.data?.cards ?? []).map(mapCardData) : cards;
|
const cardRows = cardQ ? (cardSearch.data?.cards ?? []).map(mapCardData) : cards;
|
||||||
// ── 카드 선택 게이팅: 협상 멘트에 변수명이 노출될 카드를 선택 단계에서 막는다 ──
|
|
||||||
// (1) 조건 전략(customer_condition) 미작성 — 저장 시 조건 내용이 있으면 script 에 실제 문구가
|
// ── 목표가 산정 (카드 게이팅이 목표가를 참조하므로 게이팅보다 먼저 계산한다) ──
|
||||||
// 주입되고(slate.serialize), 없으면 {customer_condition} 토큰이 그대로 남는다.
|
const {
|
||||||
const conditionUnfilled = (c: NegotiationCard) =>
|
settingMarginPct,
|
||||||
(c.scriptPreview ?? '').includes(`{${CONDITION_VARIABLE}}`);
|
negoCandidateKey,
|
||||||
// (2) 인터넷 최저가({internet_lowest_price}) 인용 카드는 선택 상품에 최저가가 수집돼 있을 때만 —
|
targetBreakdown,
|
||||||
// 최저가 없는 상품(items.internet_lowest_price=NULL/0)의 견적에 넣으면 협상 시 토큰이 노출된다.
|
autoTarget,
|
||||||
// 상품 미선택 상태에선 판정 불가라 막지 않는다(상품 선택 후에만 게이팅).
|
activeCandidateKey,
|
||||||
const lowestUnavailable = !!productId && !(internetLowest && internetLowest > 0);
|
effectiveApplyNego,
|
||||||
const lowestPriceLeak = (c: NegotiationCard) =>
|
effectiveMdPrice,
|
||||||
lowestUnavailable && (c.scriptPreview ?? '').includes('{internet_lowest_price}');
|
mdRequired,
|
||||||
// (3) 미승인 와일드카드(INACTIVE) — 목록·순위엔 보이되 선택은 막는다(수동 승인 전).
|
targetReady,
|
||||||
const blockReason = (c: NegotiationCard): string | null =>
|
targetLimitExceeded,
|
||||||
c.isWildcard && c.status !== 'ACTIVE'
|
estimatedTargetPrice,
|
||||||
? '미승인 와일드카드'
|
submitMdPrice,
|
||||||
: conditionUnfilled(c)
|
} = useTargetPrice({
|
||||||
? `${CONDITION_LABEL} 미작성`
|
quotationSettings,
|
||||||
: lowestPriceLeak(c)
|
settingId,
|
||||||
? '인터넷 최저가 미수집'
|
productId,
|
||||||
: null;
|
isReType,
|
||||||
// 성공률(사용 세션 중 타결 비율) 내림차순 — 표본 없는 카드는 뒤로. 상위 3개에 1·2·3위 배지가 붙는다.
|
internetLowest,
|
||||||
// 미승인 와일드카드도 목록·순위엔 노출(선택은 blockReason 으로 disabled).
|
purchase,
|
||||||
const rankedCards = cardRows
|
selling,
|
||||||
.slice()
|
unitPrice,
|
||||||
.sort((a, b) => b.successRate - a.successRate || b.usedCount - a.usedCount);
|
mdPrice,
|
||||||
const cardOptions: ComboOption[] = rankedCards
|
mdTouched,
|
||||||
.map((card, i) => {
|
negoTouched,
|
||||||
const reason = blockReason(card);
|
applyNego,
|
||||||
return {
|
selectedCandidateKey,
|
||||||
id: card.id,
|
|
||||||
label: card.title,
|
|
||||||
disabled: !!reason,
|
|
||||||
node: (
|
|
||||||
<div className={reason ? 'opacity-60' : undefined}>
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{card.usedCount > 0 && (
|
|
||||||
<span className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${i < 3 ? 'bg-indigo-50 text-indigo-700' : 'bg-zinc-100 text-zinc-500'}`}>
|
|
||||||
{i + 1}위 · 성공률 {Math.round(card.successRate * 100)}%
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<Typography as="span" variant="small" className="text-muted-foreground font-mono block leading-none">{card.code}</Typography>
|
|
||||||
<span className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${card.isWildcard ? 'bg-amber-50 text-amber-700' : 'bg-zinc-100 text-zinc-600'}`}>
|
|
||||||
{card.isWildcard ? '와일드' : '협상'}
|
|
||||||
</span>
|
|
||||||
{reason && (
|
|
||||||
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded leading-none bg-rose-50 text-rose-600">
|
|
||||||
{reason}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<Typography as="span" variant="small" className="mt-1 block leading-tight">{card.title}</Typography>
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 1·2·3위 배지가 붙는 카드(상위 3개, 사용이력 있는 것만) — 기본 선택 대상. 선택 불가(조건 미작성·최저가 미수집) 카드는 제외.
|
// ── 카드 선택 게이팅 — 부적합 카드 disabled·자동 선택/해제(useCardGating 이 소유) ──
|
||||||
const topRankedCards = rankedCards.filter((c) => !blockReason(c)).slice(0, 3).filter((c) => c.usedCount > 0);
|
const { blockReason, cardOptions, selectedCardRows } = useCardGating({
|
||||||
const topRankedKey = topRankedCards.map((c) => c.id).join(',');
|
cardRows,
|
||||||
const autoSelectedRef = useRef(false);
|
productId,
|
||||||
|
internetLowest,
|
||||||
// 모달을 열면 추천 상위 3개를 기본 선택해 둔다. 열려 있는 동안 1회만 — 이후 사용자의 추가/해제는 건드리지 않는다.
|
estimatedTargetPrice,
|
||||||
useEffect(() => {
|
open,
|
||||||
if (!open) {
|
selectedCardIds,
|
||||||
autoSelectedRef.current = false;
|
cardDetails,
|
||||||
return;
|
setSelectedCardIds,
|
||||||
}
|
setCardDetails,
|
||||||
if (autoSelectedRef.current || topRankedCards.length === 0) return; // 목록 로드 전이면 다음 렌더에 재시도
|
|
||||||
autoSelectedRef.current = true;
|
|
||||||
setCardDetails((m) => {
|
|
||||||
const next = new Map(m);
|
|
||||||
topRankedCards.forEach((c) => next.set(c.id, { code: c.code, title: c.title, isWildcard: c.isWildcard }));
|
|
||||||
return next;
|
|
||||||
});
|
});
|
||||||
setSelectedCardIds((prev) => (prev.length > 0 ? prev : topRankedCards.map((c) => c.id)));
|
|
||||||
// topRankedKey = 목록이 확정된 시점만 감지 (배열 재생성으로 매 렌더 도는 것 방지)
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [open, topRankedKey]);
|
|
||||||
|
|
||||||
// 이미 고른 카드 중, 상품을 최저가 미수집 상품으로 바꾸면 {internet_lowest_price} 인용 카드는 자동 해제.
|
|
||||||
// (picklist disabled 는 신규 선택만 막으므로, 상품 변경 후 잔존 선택분을 여기서 정리해 노출을 막는다.)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!lowestUnavailable) return;
|
|
||||||
const leakIds = new Set(
|
|
||||||
cardRows.filter((c) => (c.scriptPreview ?? '').includes('{internet_lowest_price}')).map((c) => c.id),
|
|
||||||
);
|
|
||||||
if (leakIds.size === 0) return;
|
|
||||||
setSelectedCardIds((prev) => (prev.some((id) => leakIds.has(id)) ? prev.filter((id) => !leakIds.has(id)) : prev));
|
|
||||||
// productId 변경 시점에만 정리 (cardRows 재생성으로 매 렌더 도는 것 방지)
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [lowestUnavailable, productId]);
|
|
||||||
|
|
||||||
// 선택된 카드 표시행 — 캐시에서 번호/유형/카드명을 읽어 검색어와 무관하게 유지한다.
|
|
||||||
const selectedCardRows = selectedCardIds.map((id) => {
|
|
||||||
const d = cardDetails.get(id);
|
|
||||||
return { id, code: d?.code ?? '', title: d?.title ?? id, isWildcard: d?.isWildcard ?? false };
|
|
||||||
});
|
|
||||||
const selectedSetting = quotationSettings.find((s) => s.qt_setting_id === settingId);
|
|
||||||
const settingMargin = parsePercent(selectedSetting?.target_margin); // 세팅 네고율(비율)
|
|
||||||
const settingMarginPct = +(settingMargin * 100).toFixed(1);
|
|
||||||
// 매입가 네고율 차감 여부 — 안 건드리면 세팅값(>0이면 차감), 건드리면 체크박스값. 네고율 값은 세팅값 고정.
|
|
||||||
const negoAvailable = settingMargin > 0;
|
|
||||||
const effectiveApplyNego = negoTouched ? applyNego : negoAvailable;
|
|
||||||
const margin = effectiveApplyNego ? settingMargin : 0; // 미적용이면 0 → 매입가/판매가 그대로
|
|
||||||
const negoLabel = (base: string) =>
|
|
||||||
margin > 0 ? `${base} × (1−네고율 ${+(margin * 100).toFixed(1)}%)` : `${base} (네고율 미적용)`;
|
|
||||||
// 목표가 산정 후보(계산식+결과값) — 인터넷최저가×(1−수수료)·매입가/판매가×(1−네고율). 회사설정 숨김필드는 제외(백엔드와 동일).
|
|
||||||
const targetBreakdown = [
|
|
||||||
{ key: 'internet_lowest_price', label: `${label('item.internet_lowest_price')} × (1−수수료 ${+(INTERNET_AVERAGE_FEE * 100).toFixed(1)}%)`, raw: internetLowest, rate: INTERNET_AVERAGE_FEE, show: internetLowest != null },
|
|
||||||
{ key: 'purchase_price', label: negoLabel(label('item.purchase_price')), raw: purchase, rate: margin, show: isReType && purchase != null },
|
|
||||||
{ key: 'selling_price', label: negoLabel(label('item.selling_price')), raw: selling, rate: margin, show: isReType && selling != null },
|
|
||||||
]
|
|
||||||
.filter((c) => c.show && c.raw != null && c.raw > 0 && !isHidden(c.key))
|
|
||||||
// 서버 _candidates 와 동일하게 10원 단위 반올림(IMK #11) — 후보·목표가·저장값이 다 일치.
|
|
||||||
.map((c) => ({ key: c.key, label: c.label, raw: c.raw as number, value: Math.round(((c.raw as number) * (1 - c.rate)) / 10) * 10 }));
|
|
||||||
const autoTarget = targetBreakdown.length ? Math.min(...targetBreakdown.map((c) => c.value)) : null;
|
|
||||||
// 기본 채택 후보 = 최저(동률이면 첫 후보). 네고율 체크박스는 매입가 후보(없으면 판매가)에 붙인다.
|
|
||||||
const minCandidateKey = targetBreakdown.find((c) => c.value === autoTarget)?.key ?? null;
|
|
||||||
const negoCandidateKey = negoAvailable
|
|
||||||
? (targetBreakdown.find((c) => c.key === 'purchase_price')?.key ?? targetBreakdown.find((c) => c.key === 'selling_price')?.key ?? null)
|
|
||||||
: null;
|
|
||||||
// 채택 후보 — 사용자가 고르면 그 후보, 아니면 최저. 상품 변경 등으로 선택 키가 사라지면 최저로 폴백.
|
|
||||||
const pickedCandidate = selectedCandidateKey ? targetBreakdown.find((c) => c.key === selectedCandidateKey) : undefined;
|
|
||||||
const activeCandidateKey = pickedCandidate ? pickedCandidate.key : minCandidateKey;
|
|
||||||
const targetFromCandidate = pickedCandidate ? pickedCandidate.value : autoTarget;
|
|
||||||
// 구매담당자 제시가 필드엔 채택 후보값을 미리 보여주되(IMK #4), 담당자가 직접 건드렸을 때만 md_price 로 전송한다.
|
|
||||||
// (자동값을 md 로 보내면 서버가 'MD 입력가'로 저장해 산정내역이 매입가 대신 MD로 잡히고 후보가 안 보인다.)
|
|
||||||
const effectiveMdPrice = mdTouched ? mdPrice : (targetFromCandidate != null ? String(targetFromCandidate) : mdPrice);
|
|
||||||
const mdNum = Number(effectiveMdPrice) || 0;
|
|
||||||
// 기본(최저·세팅네고)에서 벗어난 선택/토글이면 그 목표가를 md_price 로 박아 서버 저장값과 화면을 일치시킨다.
|
|
||||||
// (서버는 세팅 rate·최저로 재계산하므로, 오버라이드를 안 보내면 후보 화면과 저장 목표가가 어긋난다.)
|
|
||||||
const divergedFromDefault = negoTouched || (!!pickedCandidate && pickedCandidate.key !== minCandidateKey);
|
|
||||||
const submitMdPrice = mdTouched && mdPrice
|
|
||||||
? Number(mdPrice)
|
|
||||||
: divergedFromDefault && targetFromCandidate != null
|
|
||||||
? targetFromCandidate
|
|
||||||
: null;
|
|
||||||
const hasItemCandidate = internetLowest != null || (isReType && (purchase != null || selling != null));
|
|
||||||
const mdRequired = !!productId && !hasItemCandidate;
|
|
||||||
const targetReady = mdNum > 0 || hasItemCandidate;
|
|
||||||
// 최종 목표가 = 제시가(자동/수동) 있으면 그 값, 없으면 채택 후보값.
|
|
||||||
const estimatedTargetPrice = mdNum > 0 ? mdNum : targetFromCandidate;
|
|
||||||
const targetPriceLimit = unitPrice != null && unitPrice > 0
|
|
||||||
? unitPrice * TARGET_PRICE_UNIT_LIMIT_MULTIPLIER
|
|
||||||
: null;
|
|
||||||
const targetLimitExceeded = targetPriceLimit != null && estimatedTargetPrice != null && estimatedTargetPrice > targetPriceLimit;
|
|
||||||
|
|
||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
const selectMode = (next: QuotationMode) => {
|
const selectMode = (next: QuotationMode) => {
|
||||||
@ -815,235 +683,3 @@ export function QuotationCreateModal({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── 헬퍼 컴포넌트 (메인 아래) ──────────────────────────────────────────────
|
|
||||||
|
|
||||||
// 협력사 상품조달유형 배지 — type undefined = 유형 미지정(매핑 없음). 초청 협력사는 그 상품을 공급하므로 '미취급'이 아니라 '미정'.
|
|
||||||
function SupplyTypeBadge({ type }: { type?: number }) {
|
|
||||||
if (type === undefined) {
|
|
||||||
return (
|
|
||||||
<Typography as="span" variant="small" className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground shrink-0">
|
|
||||||
미정
|
|
||||||
</Typography>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<Typography as="span" variant="small" className="text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary font-semibold shrink-0">
|
|
||||||
{supplierTypeLabel(type)}
|
|
||||||
</Typography>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 선택된 협력사 테이블 — 콤보로 담은 협력사를 검색어와 무관하게 고정 노출한다. 취급유형 컬럼은 상품 선택 시에만.
|
|
||||||
function SelectedPartnerTable({
|
|
||||||
rows,
|
|
||||||
supplyTypeBySupplier,
|
|
||||||
showSupplyType,
|
|
||||||
onRemove,
|
|
||||||
}: {
|
|
||||||
rows: { id: string; name: string; email: string }[];
|
|
||||||
supplyTypeBySupplier: Map<string, number>;
|
|
||||||
showSupplyType: boolean;
|
|
||||||
onRemove: (id: string) => void;
|
|
||||||
}) {
|
|
||||||
if (rows.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="rounded border border-dashed border-border px-2 py-3 text-center">
|
|
||||||
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground">
|
|
||||||
담긴 협력사가 없습니다 — 위에서 검색해 담아주세요.
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="rounded border border-border overflow-hidden">
|
|
||||||
<table className="w-full table-fixed text-xs">
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-muted/40">
|
|
||||||
<th className="px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">협력사명</Typography></th>
|
|
||||||
<th className="px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">담당자 이메일</Typography></th>
|
|
||||||
{showSupplyType && <th className="w-24 px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">상품조달유형</Typography></th>}
|
|
||||||
<th className="w-9 px-2 py-1.5" />
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{rows.map((r) => (
|
|
||||||
<tr key={r.id} className="border-t border-border">
|
|
||||||
<td className="px-2 py-1.5"><Typography as="span" variant="small" className="block truncate font-semibold">{r.name}</Typography></td>
|
|
||||||
<td className="px-2 py-1.5"><Typography as="span" variant="small" className="block truncate text-muted-foreground">{r.email || '-'}</Typography></td>
|
|
||||||
{showSupplyType && <td className="px-2 py-1.5"><SupplyTypeBadge type={supplyTypeBySupplier.get(r.id)} /></td>}
|
|
||||||
<td className="px-2 py-1.5 text-right">
|
|
||||||
<button type="button" onClick={() => onRemove(r.id)} title="제외" className="rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-rose-600 cursor-pointer">
|
|
||||||
<X size={13} />
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 선택된 카드 테이블 — 콤보로 담은 협상/와일드카드를 검색어와 무관하게 고정 노출한다.
|
|
||||||
function SelectedCardTable({
|
|
||||||
rows,
|
|
||||||
onRemove,
|
|
||||||
}: {
|
|
||||||
rows: { id: string; code: string; title: string; isWildcard: boolean }[];
|
|
||||||
onRemove: (id: string) => void;
|
|
||||||
}) {
|
|
||||||
if (rows.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="rounded border border-dashed border-border px-2 py-3 text-center">
|
|
||||||
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground">
|
|
||||||
담긴 카드가 없습니다 — 위에서 검색해 담아주세요.
|
|
||||||
</Typography>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="rounded border border-border overflow-hidden">
|
|
||||||
<table className="w-full table-fixed text-xs">
|
|
||||||
<thead>
|
|
||||||
<tr className="bg-muted/40">
|
|
||||||
<th className="w-24 px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">카드번호</Typography></th>
|
|
||||||
<th className="w-16 px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">유형</Typography></th>
|
|
||||||
<th className="px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">카드명</Typography></th>
|
|
||||||
<th className="w-9 px-2 py-1.5" />
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{rows.map((r) => (
|
|
||||||
<tr key={r.id} className="border-t border-border">
|
|
||||||
<td className="px-2 py-1.5"><Typography as="span" variant="small" className="block truncate font-mono text-muted-foreground">{r.code}</Typography></td>
|
|
||||||
<td className="px-2 py-1.5">
|
|
||||||
<span className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${r.isWildcard ? 'bg-amber-50 text-amber-700' : 'bg-zinc-100 text-zinc-600'}`}>
|
|
||||||
{r.isWildcard ? '와일드' : '협상'}
|
|
||||||
</span>
|
|
||||||
</td>
|
|
||||||
<td className="px-2 py-1.5"><Typography as="span" variant="small" className="block truncate">{r.title}</Typography></td>
|
|
||||||
<td className="px-2 py-1.5 text-right">
|
|
||||||
<button type="button" onClick={() => onRemove(r.id)} title="제외" className="rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-rose-600 cursor-pointer">
|
|
||||||
<X size={13} />
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 세그먼트 컨트롤 — 소수의 명명된 이산 선택(진행 방식·대상)에 라디오보다 명확. 값은 문자열.
|
|
||||||
function Segmented({
|
|
||||||
options,
|
|
||||||
value,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
options: { value: string; label: string; sub?: string }[];
|
|
||||||
value: string;
|
|
||||||
onChange: (v: string) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="grid gap-2" style={{ gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))` }}>
|
|
||||||
{options.map((o) => {
|
|
||||||
const active = o.value === value;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={o.value}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onChange(o.value)}
|
|
||||||
className={cn(
|
|
||||||
'rounded border p-2.5 text-left transition-all cursor-pointer',
|
|
||||||
active ? 'bg-primary/5 border-primary' : 'bg-background border-border hover:bg-muted/20',
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Typography as="span" variant="small" className={cn('block font-semibold', active && 'text-primary')}>{o.label}</Typography>
|
|
||||||
{o.sub && <Typography as="span" variant="small" className="block text-[10px] text-muted-foreground mt-0.5 leading-tight">{o.sub}</Typography>}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 낙찰 기준 컨트롤 — 스펙트럼 선(線) 위 구간을 눌러 낙찰↔개찰 전환. 앵커 이하는 항상 낙찰(고정, 표시만) —
|
|
||||||
// '앵커~목표가'(mid_action)·'목표가 초과'(over_action) 두 구간만 사용자가 각각 낙찰/개찰로 정한다.
|
|
||||||
function AwardLinePicker({
|
|
||||||
mid, over, onMid, onOver,
|
|
||||||
}: {
|
|
||||||
mid: number;
|
|
||||||
over: number;
|
|
||||||
onMid: (v: number) => void;
|
|
||||||
onOver: (v: number) => void;
|
|
||||||
}) {
|
|
||||||
const A = PriceGateAction.AWARD;
|
|
||||||
const O = PriceGateAction.OPEN;
|
|
||||||
const zones = [
|
|
||||||
{ label: '앵커링가 이하', win: true, locked: true, toggle: undefined },
|
|
||||||
{ label: '앵커링가~목표가', win: mid === A, locked: false, toggle: () => onMid(mid === A ? O : A) },
|
|
||||||
{ label: '목표가 초과', win: over === A, locked: false, toggle: () => onOver(over === A ? O : A) },
|
|
||||||
];
|
|
||||||
return (
|
|
||||||
<div className="space-y-1.5 pt-1">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<Typography as="span" variant="label">낙찰 기준</Typography>
|
|
||||||
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground">구간을 눌러 낙찰↔개찰 · 싸다◀▶비싸다</Typography>
|
|
||||||
</div>
|
|
||||||
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground leading-snug">
|
|
||||||
협력사 <span className="font-semibold text-foreground">최저 투찰가</span>가 어느 구간에 오느냐로 낙찰/개찰이 정해집니다.
|
|
||||||
</Typography>
|
|
||||||
{/* 스펙트럼 선: 구간이 곧 선택 버튼 */}
|
|
||||||
<div className="flex rounded-md overflow-hidden border border-border text-center">
|
|
||||||
{zones.map((z, i) => {
|
|
||||||
const body = (
|
|
||||||
<>
|
|
||||||
<Typography as="span" variant="small" className="block text-[9px] leading-tight text-muted-foreground">{z.label}</Typography>
|
|
||||||
<Typography as="span" variant="small" className={cn('block text-[12px] font-bold leading-tight', z.win ? 'text-emerald-700 dark:text-emerald-400' : 'text-zinc-500')}>
|
|
||||||
{z.win ? '낙찰' : '개찰'}{z.locked ? ' 🔒' : ''}
|
|
||||||
</Typography>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
const cls = cn('flex-1 px-1 py-2', i > 0 && 'border-l border-border', z.win ? 'bg-emerald-50 dark:bg-emerald-950/30' : 'bg-muted');
|
|
||||||
return z.locked ? (
|
|
||||||
<div key={z.label} className={cls} title="앵커링가 이하는 항상 낙찰(고정)">{body}</div>
|
|
||||||
) : (
|
|
||||||
<button key={z.label} type="button" onClick={z.toggle} className={cn(cls, 'cursor-pointer transition-[filter] hover:brightness-95')}>{body}</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
{/* 경계 마커 — 구간 경계(1/3·2/3)에 ▲ 중앙 정렬(앵커링가·목표가) */}
|
|
||||||
<div className="relative h-6">
|
|
||||||
{[
|
|
||||||
{ left: '33.3333%', label: '앵커링가' },
|
|
||||||
{ left: '66.6667%', label: '목표가' },
|
|
||||||
].map((mk) => (
|
|
||||||
<span
|
|
||||||
key={mk.label}
|
|
||||||
className="absolute top-0 flex -translate-x-1/2 flex-col items-center text-[9px] text-muted-foreground"
|
|
||||||
style={{ left: mk.left }}
|
|
||||||
>
|
|
||||||
<span className="leading-none">▲</span>
|
|
||||||
<span className="leading-tight whitespace-nowrap">{mk.label}</span>
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{/* 전략 한 줄 요약(관대/기본/엄격 전략) — 기본 전략(목표가까지 낙찰·초과 개찰)일 때만 경계가 애매하니 '목표가 포함' 부기 */}
|
|
||||||
{(() => {
|
|
||||||
const t = awardStrategySummary(mid, over);
|
|
||||||
const isBasic = mid === PriceGateAction.AWARD && over === PriceGateAction.OPEN;
|
|
||||||
return (
|
|
||||||
<Typography as="p" variant="small" className="text-[11px]">
|
|
||||||
<span className="font-bold text-primary">{t.strategy}</span>
|
|
||||||
<span className="text-muted-foreground"> · {t.desc}</span>
|
|
||||||
{isBasic && (
|
|
||||||
<span className="text-emerald-700 dark:text-emerald-400 font-semibold"> · 목표가 포함</span>
|
|
||||||
)}
|
|
||||||
</Typography>
|
|
||||||
);
|
|
||||||
})()}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -0,0 +1,237 @@
|
|||||||
|
import { X } from 'lucide-react';
|
||||||
|
import { Typography } from '@/components/ui/typography';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
import { supplierTypeLabel } from '@/lib/enumLabels';
|
||||||
|
import { awardStrategySummary, PriceGateAction } from '../types';
|
||||||
|
|
||||||
|
// 견적 등록 모달의 프레젠테이션 하위 컴포넌트 모음 — 상태 없음(순수 props). QuotationCreateModal 에서 분리.
|
||||||
|
|
||||||
|
// 협력사 상품조달유형 배지 — type undefined = 유형 미지정(매핑 없음). 초청 협력사는 그 상품을 공급하므로 '미취급'이 아니라 '미정'.
|
||||||
|
export function SupplyTypeBadge({ type }: { type?: number }) {
|
||||||
|
if (type === undefined) {
|
||||||
|
return (
|
||||||
|
<Typography as="span" variant="small" className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground shrink-0">
|
||||||
|
미정
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Typography as="span" variant="small" className="text-[10px] px-1.5 py-0.5 rounded bg-primary/10 text-primary font-semibold shrink-0">
|
||||||
|
{supplierTypeLabel(type)}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 선택된 협력사 테이블 — 콤보로 담은 협력사를 검색어와 무관하게 고정 노출한다. 취급유형 컬럼은 상품 선택 시에만.
|
||||||
|
export function SelectedPartnerTable({
|
||||||
|
rows,
|
||||||
|
supplyTypeBySupplier,
|
||||||
|
showSupplyType,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
rows: { id: string; name: string; email: string }[];
|
||||||
|
supplyTypeBySupplier: Map<string, number>;
|
||||||
|
showSupplyType: boolean;
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded border border-dashed border-border px-2 py-3 text-center">
|
||||||
|
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground">
|
||||||
|
담긴 협력사가 없습니다 — 위에서 검색해 담아주세요.
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="rounded border border-border overflow-hidden">
|
||||||
|
<table className="w-full table-fixed text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-muted/40">
|
||||||
|
<th className="px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">협력사명</Typography></th>
|
||||||
|
<th className="px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">담당자 이메일</Typography></th>
|
||||||
|
{showSupplyType && <th className="w-24 px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">상품조달유형</Typography></th>}
|
||||||
|
<th className="w-9 px-2 py-1.5" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<tr key={r.id} className="border-t border-border">
|
||||||
|
<td className="px-2 py-1.5"><Typography as="span" variant="small" className="block truncate font-semibold">{r.name}</Typography></td>
|
||||||
|
<td className="px-2 py-1.5"><Typography as="span" variant="small" className="block truncate text-muted-foreground">{r.email || '-'}</Typography></td>
|
||||||
|
{showSupplyType && <td className="px-2 py-1.5"><SupplyTypeBadge type={supplyTypeBySupplier.get(r.id)} /></td>}
|
||||||
|
<td className="px-2 py-1.5 text-right">
|
||||||
|
<button type="button" onClick={() => onRemove(r.id)} title="제외" className="rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-rose-600 cursor-pointer">
|
||||||
|
<X size={13} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 선택된 카드 테이블 — 콤보로 담은 협상/와일드카드를 검색어와 무관하게 고정 노출한다.
|
||||||
|
export function SelectedCardTable({
|
||||||
|
rows,
|
||||||
|
onRemove,
|
||||||
|
}: {
|
||||||
|
rows: { id: string; code: string; title: string; isWildcard: boolean }[];
|
||||||
|
onRemove: (id: string) => void;
|
||||||
|
}) {
|
||||||
|
if (rows.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="rounded border border-dashed border-border px-2 py-3 text-center">
|
||||||
|
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground">
|
||||||
|
담긴 카드가 없습니다 — 위에서 검색해 담아주세요.
|
||||||
|
</Typography>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<div className="rounded border border-border overflow-hidden">
|
||||||
|
<table className="w-full table-fixed text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="bg-muted/40">
|
||||||
|
<th className="w-24 px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">카드번호</Typography></th>
|
||||||
|
<th className="w-16 px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">유형</Typography></th>
|
||||||
|
<th className="px-2 py-1.5 text-left"><Typography as="span" variant="small" className="text-[10px] font-semibold text-muted-foreground">카드명</Typography></th>
|
||||||
|
<th className="w-9 px-2 py-1.5" />
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{rows.map((r) => (
|
||||||
|
<tr key={r.id} className="border-t border-border">
|
||||||
|
<td className="px-2 py-1.5"><Typography as="span" variant="small" className="block truncate font-mono text-muted-foreground">{r.code}</Typography></td>
|
||||||
|
<td className="px-2 py-1.5">
|
||||||
|
<span className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${r.isWildcard ? 'bg-amber-50 text-amber-700' : 'bg-zinc-100 text-zinc-600'}`}>
|
||||||
|
{r.isWildcard ? '와일드' : '협상'}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5"><Typography as="span" variant="small" className="block truncate">{r.title}</Typography></td>
|
||||||
|
<td className="px-2 py-1.5 text-right">
|
||||||
|
<button type="button" onClick={() => onRemove(r.id)} title="제외" className="rounded p-0.5 text-muted-foreground hover:bg-muted hover:text-rose-600 cursor-pointer">
|
||||||
|
<X size={13} />
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 세그먼트 컨트롤 — 소수의 명명된 이산 선택(진행 방식·대상)에 라디오보다 명확. 값은 문자열.
|
||||||
|
export function Segmented({
|
||||||
|
options,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
options: { value: string; label: string; sub?: string }[];
|
||||||
|
value: string;
|
||||||
|
onChange: (v: string) => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-2" style={{ gridTemplateColumns: `repeat(${options.length}, minmax(0, 1fr))` }}>
|
||||||
|
{options.map((o) => {
|
||||||
|
const active = o.value === value;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={o.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onChange(o.value)}
|
||||||
|
className={cn(
|
||||||
|
'rounded border p-2.5 text-left transition-all cursor-pointer',
|
||||||
|
active ? 'bg-primary/5 border-primary' : 'bg-background border-border hover:bg-muted/20',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Typography as="span" variant="small" className={cn('block font-semibold', active && 'text-primary')}>{o.label}</Typography>
|
||||||
|
{o.sub && <Typography as="span" variant="small" className="block text-[10px] text-muted-foreground mt-0.5 leading-tight">{o.sub}</Typography>}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 낙찰 기준 컨트롤 — 스펙트럼 선(線) 위 구간을 눌러 낙찰↔개찰 전환. 앵커 이하는 항상 낙찰(고정, 표시만) —
|
||||||
|
// '앵커~목표가'(mid_action)·'목표가 초과'(over_action) 두 구간만 사용자가 각각 낙찰/개찰로 정한다.
|
||||||
|
export function AwardLinePicker({
|
||||||
|
mid, over, onMid, onOver,
|
||||||
|
}: {
|
||||||
|
mid: number;
|
||||||
|
over: number;
|
||||||
|
onMid: (v: number) => void;
|
||||||
|
onOver: (v: number) => void;
|
||||||
|
}) {
|
||||||
|
const A = PriceGateAction.AWARD;
|
||||||
|
const O = PriceGateAction.OPEN;
|
||||||
|
const zones = [
|
||||||
|
{ label: '앵커링가 이하', win: true, locked: true, toggle: undefined },
|
||||||
|
{ label: '앵커링가~목표가', win: mid === A, locked: false, toggle: () => onMid(mid === A ? O : A) },
|
||||||
|
{ label: '목표가 초과', win: over === A, locked: false, toggle: () => onOver(over === A ? O : A) },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className="space-y-1.5 pt-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Typography as="span" variant="label">낙찰 기준</Typography>
|
||||||
|
<Typography as="span" variant="small" className="text-[10px] text-muted-foreground">구간을 눌러 낙찰↔개찰 · 싸다◀▶비싸다</Typography>
|
||||||
|
</div>
|
||||||
|
<Typography as="p" variant="small" className="text-[10px] text-muted-foreground leading-snug">
|
||||||
|
협력사 <span className="font-semibold text-foreground">최저 투찰가</span>가 어느 구간에 오느냐로 낙찰/개찰이 정해집니다.
|
||||||
|
</Typography>
|
||||||
|
{/* 스펙트럼 선: 구간이 곧 선택 버튼 */}
|
||||||
|
<div className="flex rounded-md overflow-hidden border border-border text-center">
|
||||||
|
{zones.map((z, i) => {
|
||||||
|
const body = (
|
||||||
|
<>
|
||||||
|
<Typography as="span" variant="small" className="block text-[9px] leading-tight text-muted-foreground">{z.label}</Typography>
|
||||||
|
<Typography as="span" variant="small" className={cn('block text-[12px] font-bold leading-tight', z.win ? 'text-emerald-700 dark:text-emerald-400' : 'text-zinc-500')}>
|
||||||
|
{z.win ? '낙찰' : '개찰'}{z.locked ? ' 🔒' : ''}
|
||||||
|
</Typography>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
const cls = cn('flex-1 px-1 py-2', i > 0 && 'border-l border-border', z.win ? 'bg-emerald-50 dark:bg-emerald-950/30' : 'bg-muted');
|
||||||
|
return z.locked ? (
|
||||||
|
<div key={z.label} className={cls} title="앵커링가 이하는 항상 낙찰(고정)">{body}</div>
|
||||||
|
) : (
|
||||||
|
<button key={z.label} type="button" onClick={z.toggle} className={cn(cls, 'cursor-pointer transition-[filter] hover:brightness-95')}>{body}</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{/* 경계 마커 — 구간 경계(1/3·2/3)에 ▲ 중앙 정렬(앵커링가·목표가) */}
|
||||||
|
<div className="relative h-6">
|
||||||
|
{[
|
||||||
|
{ left: '33.3333%', label: '앵커링가' },
|
||||||
|
{ left: '66.6667%', label: '목표가' },
|
||||||
|
].map((mk) => (
|
||||||
|
<span
|
||||||
|
key={mk.label}
|
||||||
|
className="absolute top-0 flex -translate-x-1/2 flex-col items-center text-[9px] text-muted-foreground"
|
||||||
|
style={{ left: mk.left }}
|
||||||
|
>
|
||||||
|
<span className="leading-none">▲</span>
|
||||||
|
<span className="leading-tight whitespace-nowrap">{mk.label}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* 전략 한 줄 요약(관대/기본/엄격 전략) — 기본 전략(목표가까지 낙찰·초과 개찰)일 때만 경계가 애매하니 '목표가 포함' 부기 */}
|
||||||
|
{(() => {
|
||||||
|
const t = awardStrategySummary(mid, over);
|
||||||
|
const isBasic = mid === PriceGateAction.AWARD && over === PriceGateAction.OPEN;
|
||||||
|
return (
|
||||||
|
<Typography as="p" variant="small" className="text-[11px]">
|
||||||
|
<span className="font-bold text-primary">{t.strategy}</span>
|
||||||
|
<span className="text-muted-foreground"> · {t.desc}</span>
|
||||||
|
{isBasic && (
|
||||||
|
<span className="text-emerald-700 dark:text-emerald-400 font-semibold"> · 목표가 포함</span>
|
||||||
|
)}
|
||||||
|
</Typography>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -0,0 +1,26 @@
|
|||||||
|
// 견적 등록 폼 공용 헬퍼 — datetime-local(KST) 변환과 퍼센트 파싱. QuotationCreateModal 에서 분리.
|
||||||
|
|
||||||
|
// datetime-local 값: 한국시간(Asia/Seoul)의 'YYYY-MM-DDTHH:mm'.
|
||||||
|
// sv-SE 로케일이 'YYYY-MM-DD HH:mm:ss' 를 주고, timeZone 명시로 브라우저 TZ 와 무관하게 KST 로 고정한다.
|
||||||
|
function toKstLocalInput(date: Date): string {
|
||||||
|
const s = date.toLocaleString('sv-SE', { timeZone: 'Asia/Seoul' });
|
||||||
|
return s.slice(0, 16).replace(' ', 'T');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nowKstLocalInput(): string {
|
||||||
|
return toKstLocalInput(new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
export function defaultDueDateLocalInput(): string {
|
||||||
|
return toKstLocalInput(new Date(Date.now() + 60 * 60 * 1000));
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isFutureLocalInput(value: string): boolean {
|
||||||
|
const time = new Date(value).getTime();
|
||||||
|
return Number.isFinite(time) && time > Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePercent(value: string | undefined): number {
|
||||||
|
const n = Number(String(value ?? '').replace('%', '').trim());
|
||||||
|
return Number.isFinite(n) ? n / 100 : 0;
|
||||||
|
}
|
||||||
138
negodata/front/src/features/quotations/hooks/useCardGating.tsx
Normal file
138
negodata/front/src/features/quotations/hooks/useCardGating.tsx
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
import { useEffect, useRef, type Dispatch, type SetStateAction } from 'react';
|
||||||
|
import { Typography } from '@/components/ui/typography';
|
||||||
|
import { type ComboOption } from '@/components/ui/combobox';
|
||||||
|
import { CONDITION_VARIABLE, CONDITION_LABEL } from '@/features/cards/editor/variables';
|
||||||
|
import type { NegotiationCard } from '../types';
|
||||||
|
|
||||||
|
type CardDetail = { code: string; title: string; isWildcard: boolean };
|
||||||
|
|
||||||
|
type UseCardGatingParams = {
|
||||||
|
cardRows: NegotiationCard[];
|
||||||
|
productId: string;
|
||||||
|
internetLowest: number | null;
|
||||||
|
estimatedTargetPrice: number | null;
|
||||||
|
open: boolean;
|
||||||
|
selectedCardIds: string[];
|
||||||
|
cardDetails: Map<string, CardDetail>;
|
||||||
|
setSelectedCardIds: Dispatch<SetStateAction<string[]>>;
|
||||||
|
setCardDetails: Dispatch<SetStateAction<Map<string, CardDetail>>>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 카드 선택 게이팅 — 협상 멘트에 변수 토큰이 그대로 노출되거나 논리가 모순되는 카드를 선택 단계에서 막는다.
|
||||||
|
// 추천 상위 3개 자동 선택·부적합 카드 자동 해제 effect 도 여기서 소유한다(상태는 컴포넌트가 보유, 세터로 갱신).
|
||||||
|
export function useCardGating({
|
||||||
|
cardRows,
|
||||||
|
productId,
|
||||||
|
internetLowest,
|
||||||
|
estimatedTargetPrice,
|
||||||
|
open,
|
||||||
|
selectedCardIds,
|
||||||
|
cardDetails,
|
||||||
|
setSelectedCardIds,
|
||||||
|
setCardDetails,
|
||||||
|
}: UseCardGatingParams) {
|
||||||
|
// (1) 조건 전략(customer_condition) 미작성 — 저장 시 조건 내용이 있으면 script 에 실제 문구가
|
||||||
|
// 주입되고(slate.serialize), 없으면 {customer_condition} 토큰이 그대로 남는다.
|
||||||
|
const conditionUnfilled = (c: NegotiationCard) =>
|
||||||
|
(c.scriptPreview ?? '').includes(`{${CONDITION_VARIABLE}}`);
|
||||||
|
// 시장가(인터넷최저가) 인용 카드({internet_lowest_price} 토큰)인지 — (2)·(4) 공용 술어.
|
||||||
|
const citesInternetLowest = (c: NegotiationCard) => (c.scriptPreview ?? '').includes('{internet_lowest_price}');
|
||||||
|
// (2) 인터넷 최저가 인용 카드는 선택 상품에 최저가가 수집돼 있을 때만 —
|
||||||
|
// 최저가 없는 상품(items.internet_lowest_price=NULL/0)의 견적에 넣으면 협상 시 토큰이 노출된다.
|
||||||
|
// 상품 미선택 상태에선 판정 불가라 막지 않는다(상품 선택 후에만 게이팅).
|
||||||
|
const lowestUnavailable = !!productId && !(internetLowest && internetLowest > 0);
|
||||||
|
const lowestPriceLeak = (c: NegotiationCard) => lowestUnavailable && citesInternetLowest(c);
|
||||||
|
// (4) 시장가 논리 모순 — 인터넷최저가 ≥ 목표가면 "시장가가 더 싸다" 근거가 성립 안 한다.
|
||||||
|
// 이런 상품에 시장가 인용 카드를 넣으면 목표가보다 높은 값을 근거로 깎으라는 모순 멘트가 나간다.
|
||||||
|
const lowestAboveTarget = internetLowest != null && estimatedTargetPrice != null && internetLowest >= estimatedTargetPrice;
|
||||||
|
const marketContradiction = (c: NegotiationCard) => lowestAboveTarget && citesInternetLowest(c);
|
||||||
|
// (3) 미승인 와일드카드(INACTIVE) — 목록·순위엔 보이되 선택은 막는다(수동 승인 전).
|
||||||
|
const blockReason = (c: NegotiationCard): string | null =>
|
||||||
|
c.isWildcard && c.status !== 'ACTIVE'
|
||||||
|
? '미승인 와일드카드'
|
||||||
|
: conditionUnfilled(c)
|
||||||
|
? `${CONDITION_LABEL} 미작성`
|
||||||
|
: lowestPriceLeak(c)
|
||||||
|
? '인터넷 최저가 미수집'
|
||||||
|
: marketContradiction(c)
|
||||||
|
? '시장가 근거 성립 불가'
|
||||||
|
: null;
|
||||||
|
// 성공률(사용 세션 중 타결 비율) 내림차순 — 표본 없는 카드는 뒤로. 상위 3개에 1·2·3위 배지가 붙는다.
|
||||||
|
// 미승인 와일드카드도 목록·순위엔 노출(선택은 blockReason 으로 disabled).
|
||||||
|
const rankedCards = cardRows
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => b.successRate - a.successRate || b.usedCount - a.usedCount);
|
||||||
|
const cardOptions: ComboOption[] = rankedCards
|
||||||
|
.map((card, i) => {
|
||||||
|
const reason = blockReason(card);
|
||||||
|
return {
|
||||||
|
id: card.id,
|
||||||
|
label: card.title,
|
||||||
|
disabled: !!reason,
|
||||||
|
node: (
|
||||||
|
<div className={reason ? 'opacity-60' : undefined}>
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{card.usedCount > 0 && (
|
||||||
|
<span className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${i < 3 ? 'bg-indigo-50 text-indigo-700' : 'bg-zinc-100 text-zinc-500'}`}>
|
||||||
|
{i + 1}위 · 성공률 {Math.round(card.successRate * 100)}%
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<Typography as="span" variant="small" className="text-muted-foreground font-mono block leading-none">{card.code}</Typography>
|
||||||
|
<span className={`text-[9px] font-mono px-1.5 py-0.5 rounded leading-none ${card.isWildcard ? 'bg-amber-50 text-amber-700' : 'bg-zinc-100 text-zinc-600'}`}>
|
||||||
|
{card.isWildcard ? '와일드' : '협상'}
|
||||||
|
</span>
|
||||||
|
{reason && (
|
||||||
|
<span className="text-[9px] font-mono px-1.5 py-0.5 rounded leading-none bg-rose-50 text-rose-600">
|
||||||
|
{reason}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Typography as="span" variant="small" className="mt-1 block leading-tight">{card.title}</Typography>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// 1·2·3위 배지가 붙는 카드(상위 3개, 사용이력 있는 것만) — 기본 선택 대상. 선택 불가(조건 미작성·최저가 미수집) 카드는 제외.
|
||||||
|
const topRankedCards = rankedCards.filter((c) => !blockReason(c)).slice(0, 3).filter((c) => c.usedCount > 0);
|
||||||
|
const topRankedKey = topRankedCards.map((c) => c.id).join(',');
|
||||||
|
const autoSelectedRef = useRef(false);
|
||||||
|
|
||||||
|
// 모달을 열면 추천 상위 3개를 기본 선택해 둔다. 열려 있는 동안 1회만 — 이후 사용자의 추가/해제는 건드리지 않는다.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
autoSelectedRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (autoSelectedRef.current || topRankedCards.length === 0) return; // 목록 로드 전이면 다음 렌더에 재시도
|
||||||
|
autoSelectedRef.current = true;
|
||||||
|
setCardDetails((m) => {
|
||||||
|
const next = new Map(m);
|
||||||
|
topRankedCards.forEach((c) => next.set(c.id, { code: c.code, title: c.title, isWildcard: c.isWildcard }));
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
setSelectedCardIds((prev) => (prev.length > 0 ? prev : topRankedCards.map((c) => c.id)));
|
||||||
|
// topRankedKey = 목록이 확정된 시점만 감지 (배열 재생성으로 매 렌더 도는 것 방지)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [open, topRankedKey]);
|
||||||
|
|
||||||
|
// 이미 고른 카드 중, 시장가 인용 카드({internet_lowest_price})가 부적합해지면 자동 해제.
|
||||||
|
// 부적합 = 최저가 미수집(lowestUnavailable) 또는 인터넷최저가 ≥ 목표가(lowestAboveTarget, 시장가 근거 모순).
|
||||||
|
// (picklist disabled 는 신규 선택만 막으므로, 상품·목표가 변경 후 잔존 선택분을 여기서 정리해 노출·모순을 막는다.)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lowestUnavailable && !lowestAboveTarget) return;
|
||||||
|
const leakIds = new Set(cardRows.filter(citesInternetLowest).map((c) => c.id));
|
||||||
|
if (leakIds.size === 0) return;
|
||||||
|
setSelectedCardIds((prev) => (prev.some((id) => leakIds.has(id)) ? prev.filter((id) => !leakIds.has(id)) : prev));
|
||||||
|
// 상품·목표가 변경 시점에만 정리 (cardRows 재생성으로 매 렌더 도는 것 방지)
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [lowestUnavailable, lowestAboveTarget, productId]);
|
||||||
|
|
||||||
|
// 선택된 카드 표시행 — 캐시에서 번호/유형/카드명을 읽어 검색어와 무관하게 유지한다.
|
||||||
|
const selectedCardRows = selectedCardIds.map((id) => {
|
||||||
|
const d = cardDetails.get(id);
|
||||||
|
return { id, code: d?.code ?? '', title: d?.title ?? id, isWildcard: d?.isWildcard ?? false };
|
||||||
|
});
|
||||||
|
|
||||||
|
return { blockReason, cardOptions, selectedCardRows };
|
||||||
|
}
|
||||||
110
negodata/front/src/features/quotations/hooks/useTargetPrice.ts
Normal file
110
negodata/front/src/features/quotations/hooks/useTargetPrice.ts
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
import { useLabels, useHiddenFields } from '@/features/settings/useCompanySettings';
|
||||||
|
import type { QuotationSetting } from '../types';
|
||||||
|
import { parsePercent } from '../components/quotationForm.utils';
|
||||||
|
|
||||||
|
const INTERNET_AVERAGE_FEE = 0.078;
|
||||||
|
const TARGET_PRICE_UNIT_LIMIT_MULTIPLIER = 2;
|
||||||
|
|
||||||
|
type UseTargetPriceParams = {
|
||||||
|
quotationSettings: QuotationSetting[];
|
||||||
|
settingId: string;
|
||||||
|
productId: string;
|
||||||
|
isReType: boolean;
|
||||||
|
// 상품 가격 속성(읽기전용) — 상품 미선택/미수집이면 null.
|
||||||
|
internetLowest: number | null;
|
||||||
|
purchase: number | null;
|
||||||
|
selling: number | null;
|
||||||
|
unitPrice: number | null;
|
||||||
|
// 목표가 입력·후보 선택 상태.
|
||||||
|
mdPrice: string;
|
||||||
|
mdTouched: boolean;
|
||||||
|
negoTouched: boolean;
|
||||||
|
applyNego: boolean;
|
||||||
|
selectedCandidateKey: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 견적 목표가 산정 — 세팅 네고율·상품 후보(인터넷최저가/매입가/판매가)에서 목표가 후보와 채택값을 파생한다.
|
||||||
|
// 순수 파생(부작용 없음). 카드 게이팅이 estimatedTargetPrice 를 참조하므로 그보다 먼저 호출한다.
|
||||||
|
export function useTargetPrice({
|
||||||
|
quotationSettings,
|
||||||
|
settingId,
|
||||||
|
productId,
|
||||||
|
isReType,
|
||||||
|
internetLowest,
|
||||||
|
purchase,
|
||||||
|
selling,
|
||||||
|
unitPrice,
|
||||||
|
mdPrice,
|
||||||
|
mdTouched,
|
||||||
|
negoTouched,
|
||||||
|
applyNego,
|
||||||
|
selectedCandidateKey,
|
||||||
|
}: UseTargetPriceParams) {
|
||||||
|
const label = useLabels(); // 회사 설정 용어(목표 마진 등)
|
||||||
|
const isHidden = useHiddenFields(); // 회사설정으로 감춘 상품 기본필드 — 후보 리스트에서도 제외
|
||||||
|
|
||||||
|
const selectedSetting = quotationSettings.find((s) => s.qt_setting_id === settingId);
|
||||||
|
const settingMargin = parsePercent(selectedSetting?.target_margin); // 세팅 네고율(비율)
|
||||||
|
const settingMarginPct = +(settingMargin * 100).toFixed(1);
|
||||||
|
// 매입가 네고율 차감 여부 — 안 건드리면 세팅값(>0이면 차감), 건드리면 체크박스값. 네고율 값은 세팅값 고정.
|
||||||
|
const negoAvailable = settingMargin > 0;
|
||||||
|
const effectiveApplyNego = negoTouched ? applyNego : negoAvailable;
|
||||||
|
const margin = effectiveApplyNego ? settingMargin : 0; // 미적용이면 0 → 매입가/판매가 그대로
|
||||||
|
const negoLabel = (base: string) =>
|
||||||
|
margin > 0 ? `${base} × (1−네고율 ${+(margin * 100).toFixed(1)}%)` : `${base} (네고율 미적용)`;
|
||||||
|
// 목표가 산정 후보(계산식+결과값) — 인터넷최저가×(1−수수료)·매입가/판매가×(1−네고율). 회사설정 숨김필드는 제외(백엔드와 동일).
|
||||||
|
const targetBreakdown = [
|
||||||
|
{ key: 'internet_lowest_price', label: `${label('item.internet_lowest_price')} × (1−수수료 ${+(INTERNET_AVERAGE_FEE * 100).toFixed(1)}%)`, raw: internetLowest, rate: INTERNET_AVERAGE_FEE, show: internetLowest != null },
|
||||||
|
{ key: 'purchase_price', label: negoLabel(label('item.purchase_price')), raw: purchase, rate: margin, show: isReType && purchase != null },
|
||||||
|
{ key: 'selling_price', label: negoLabel(label('item.selling_price')), raw: selling, rate: margin, show: isReType && selling != null },
|
||||||
|
]
|
||||||
|
.filter((c) => c.show && c.raw != null && c.raw > 0 && !isHidden(c.key))
|
||||||
|
// 서버 _candidates 와 동일하게 10원 단위 반올림(IMK #11) — 후보·목표가·저장값이 다 일치.
|
||||||
|
.map((c) => ({ key: c.key, label: c.label, raw: c.raw as number, value: Math.round(((c.raw as number) * (1 - c.rate)) / 10) * 10 }));
|
||||||
|
const autoTarget = targetBreakdown.length ? Math.min(...targetBreakdown.map((c) => c.value)) : null;
|
||||||
|
// 기본 채택 후보 = 최저(동률이면 첫 후보). 네고율 체크박스는 매입가 후보(없으면 판매가)에 붙인다.
|
||||||
|
const minCandidateKey = targetBreakdown.find((c) => c.value === autoTarget)?.key ?? null;
|
||||||
|
const negoCandidateKey = negoAvailable
|
||||||
|
? (targetBreakdown.find((c) => c.key === 'purchase_price')?.key ?? targetBreakdown.find((c) => c.key === 'selling_price')?.key ?? null)
|
||||||
|
: null;
|
||||||
|
// 채택 후보 — 사용자가 고르면 그 후보, 아니면 최저. 상품 변경 등으로 선택 키가 사라지면 최저로 폴백.
|
||||||
|
const pickedCandidate = selectedCandidateKey ? targetBreakdown.find((c) => c.key === selectedCandidateKey) : undefined;
|
||||||
|
const activeCandidateKey = pickedCandidate ? pickedCandidate.key : minCandidateKey;
|
||||||
|
const targetFromCandidate = pickedCandidate ? pickedCandidate.value : autoTarget;
|
||||||
|
// 구매담당자 제시가 필드엔 채택 후보값을 미리 보여주되(IMK #4), 담당자가 직접 건드렸을 때만 md_price 로 전송한다.
|
||||||
|
// (자동값을 md 로 보내면 서버가 'MD 입력가'로 저장해 산정내역이 매입가 대신 MD로 잡히고 후보가 안 보인다.)
|
||||||
|
const effectiveMdPrice = mdTouched ? mdPrice : (targetFromCandidate != null ? String(targetFromCandidate) : mdPrice);
|
||||||
|
const mdNum = Number(effectiveMdPrice) || 0;
|
||||||
|
// 기본(최저·세팅네고)에서 벗어난 선택/토글이면 그 목표가를 md_price 로 박아 서버 저장값과 화면을 일치시킨다.
|
||||||
|
// (서버는 세팅 rate·최저로 재계산하므로, 오버라이드를 안 보내면 후보 화면과 저장 목표가가 어긋난다.)
|
||||||
|
const divergedFromDefault = negoTouched || (!!pickedCandidate && pickedCandidate.key !== minCandidateKey);
|
||||||
|
const submitMdPrice = mdTouched && mdPrice
|
||||||
|
? Number(mdPrice)
|
||||||
|
: divergedFromDefault && targetFromCandidate != null
|
||||||
|
? targetFromCandidate
|
||||||
|
: null;
|
||||||
|
const hasItemCandidate = internetLowest != null || (isReType && (purchase != null || selling != null));
|
||||||
|
const mdRequired = !!productId && !hasItemCandidate;
|
||||||
|
const targetReady = mdNum > 0 || hasItemCandidate;
|
||||||
|
// 최종 목표가 = 제시가(자동/수동) 있으면 그 값, 없으면 채택 후보값.
|
||||||
|
const estimatedTargetPrice = mdNum > 0 ? mdNum : targetFromCandidate;
|
||||||
|
const targetPriceLimit = unitPrice != null && unitPrice > 0
|
||||||
|
? unitPrice * TARGET_PRICE_UNIT_LIMIT_MULTIPLIER
|
||||||
|
: null;
|
||||||
|
const targetLimitExceeded = targetPriceLimit != null && estimatedTargetPrice != null && estimatedTargetPrice > targetPriceLimit;
|
||||||
|
|
||||||
|
return {
|
||||||
|
settingMarginPct,
|
||||||
|
negoCandidateKey,
|
||||||
|
targetBreakdown,
|
||||||
|
autoTarget,
|
||||||
|
activeCandidateKey,
|
||||||
|
effectiveApplyNego,
|
||||||
|
effectiveMdPrice,
|
||||||
|
mdRequired,
|
||||||
|
targetReady,
|
||||||
|
targetLimitExceeded,
|
||||||
|
estimatedTargetPrice,
|
||||||
|
submitMdPrice,
|
||||||
|
};
|
||||||
|
}
|
||||||
@ -1,6 +1,5 @@
|
|||||||
// 범용 엑셀(CSV) 내보내기. 클라이언트에서 Blob 다운로드 — 의존성 없음.
|
// 범용 엑셀 내보내기(CSV, UTF-8 BOM)와 업로드 읽기(.xlsx/.xls/.csv). 여러 페이지에서 재사용.
|
||||||
// 한글이 Excel에서 깨지지 않도록 UTF-8 BOM을 붙인다. 여러 페이지에서 재사용.
|
import * as XLSX from 'xlsx';
|
||||||
// 진짜 .xlsx(서식/다중시트)가 필요해지면 이 함수 시그니처 유지한 채 SheetJS로 교체 가능.
|
|
||||||
|
|
||||||
export type ExcelColumn<T> = {
|
export type ExcelColumn<T> = {
|
||||||
header: string;
|
header: string;
|
||||||
@ -63,6 +62,24 @@ function tokenizeCsv(text: string): string[][] {
|
|||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 업로드 파일 → 행 객체 배열. 진짜 엑셀(.xlsx/.xls)은 SheetJS 로, CSV 는 CP949 폴백으로 읽는다.
|
||||||
|
export async function readSpreadsheetRows(file: File): Promise<Record<string, string>[]> {
|
||||||
|
const buf = new Uint8Array(await file.arrayBuffer());
|
||||||
|
const isBinary = (buf[0] === 0x50 && buf[1] === 0x4b) || (buf[0] === 0xd0 && buf[1] === 0xcf); // .xlsx=ZIP, .xls=OLE
|
||||||
|
if (isBinary) {
|
||||||
|
const wb = XLSX.read(buf, { type: 'array' });
|
||||||
|
const sheet = wb.Sheets[wb.SheetNames[0]];
|
||||||
|
return sheet ? parseCsv(XLSX.utils.sheet_to_csv(sheet)) : [];
|
||||||
|
}
|
||||||
|
let text: string;
|
||||||
|
try {
|
||||||
|
text = new TextDecoder('utf-8', { fatal: true }).decode(buf);
|
||||||
|
} catch {
|
||||||
|
text = new TextDecoder('euc-kr').decode(buf); // 한국 Excel 의 CSV 기본 인코딩
|
||||||
|
}
|
||||||
|
return parseCsv(text);
|
||||||
|
}
|
||||||
|
|
||||||
// CSV 텍스트 → 헤더 키로 매핑된 객체 배열. 첫 행을 헤더로 본다. 여러 페이지 업로드에서 재사용.
|
// CSV 텍스트 → 헤더 키로 매핑된 객체 배열. 첫 행을 헤더로 본다. 여러 페이지 업로드에서 재사용.
|
||||||
export function parseCsv(text: string): Record<string, string>[] {
|
export function parseCsv(text: string): Record<string, string>[] {
|
||||||
const rows = tokenizeCsv(text);
|
const rows = tokenizeCsv(text);
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user