From e8d2acaade17b2442b21fe5127b77f7a21454f15 Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 22 Jul 2026 15:19:14 +0900 Subject: [PATCH 1/5] =?UTF-8?q?[feat]=20negodata:=20=ED=86=B5=EA=B3=84=20?= =?UTF-8?q?=EC=9D=B8=EC=83=81=EC=96=B5=EC=A0=9C=EC=9C=A8(=EC=9E=AC?= =?UTF-8?q?=ED=98=91=EC=83=81)=C2=B7=EC=9B=94=EB=B3=84=20=EC=B0=A8?= =?UTF-8?q?=ED=8A=B8=20=E2=80=94=20=EC=A7=81=EC=A0=84=20=EB=9D=BC=EC=9A=B4?= =?UTF-8?q?=EB=93=9C=20=ED=88=AC=EC=B0=B0=EA=B0=80=20=EB=8C=80=EB=B9=84=20?= =?UTF-8?q?=EC=9D=B8=ED=95=98=EC=9C=A8=20=ED=8C=8C=EC=83=9D=20=EC=A7=91?= =?UTF-8?q?=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- negodata/backend/crud/statistics_crud.py | 90 +++++++++++++++++++ .../backend/router/v1/statistics/protocol.py | 9 +- .../backend/services/statistics_service.py | 9 +- .../front/src/api/generated/model/statKpi.ts | 1 + .../api/generated/model/statMarkupPoint.ts | 11 +++ .../src/api/generated/model/statScope.ts | 2 + .../features/statistics/StatisticsView.tsx | 9 +- negodata/front/src/features/statistics/api.ts | 2 + .../components/MarkupTrendChart.tsx | 36 ++++++++ .../front/src/features/statistics/types.ts | 7 ++ 10 files changed, 172 insertions(+), 4 deletions(-) create mode 100644 negodata/front/src/api/generated/model/statMarkupPoint.ts create mode 100644 negodata/front/src/features/statistics/components/MarkupTrendChart.tsx diff --git a/negodata/backend/crud/statistics_crud.py b/negodata/backend/crud/statistics_crud.py index 2e07174..f6b73ab 100644 --- a/negodata/backend/crud/statistics_crud.py +++ b/negodata/backend/crud/statistics_crud.py @@ -2,6 +2,7 @@ from abc import ABC, abstractmethod from typing import Tuple from sqlalchemy import select, func, and_, case +from sqlalchemy.orm import aliased from sqlalchemy.ext.asyncio import AsyncSession from common.database.db_session_manager import DB_SESSION_MNG @@ -54,6 +55,14 @@ class IStatisticsCRUD(ABC): async def regen_avg_round(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, float]: pass + @abstractmethod + async def markup_suppression(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, float]: + pass + + @abstractmethod + async def markup_suppression_monthly(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]: + pass + @abstractmethod async def card_usage(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]: pass @@ -187,6 +196,87 @@ class StatisticsCRUD(IStatisticsCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, 0.0 + async def markup_suppression(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, float]: + # 인상억제율(재협상 전용): 같은 견적번호(qt_number)의 직전 라운드 투찰가 대비 이번 라운드 투찰가가 + # 얼마나 안 올랐나 = avg((직전투찰 − 이번투찰) / 직전투찰). 양수=인하(억제 성공), 음수=인상 허용. + # 직전·이번 둘 다 유효 투찰(bid_price)이 있는 재협상 쌍만 대상(직전이 개찰/거부면 비교 불가 → 제외). + # 새 컬럼 없이 sessions.qt_number+qt_round+bid_price 로만 파생. + try: + prev = aliased(sessions) + stmt = ( + select(func.avg((prev.bid_price - sessions.bid_price) * 1.0 / prev.bid_price)) + .select_from(sessions) + .join( + prev, + and_( + prev.qt_number == sessions.qt_number, + prev.item_id == sessions.item_id, + prev.supplier_id == sessions.supplier_id, + prev.qt_round == sessions.qt_round - 1, + prev.bid_price.isnot(None), + prev.bid_price > 0, + prev.deleted == False, # noqa: E712 + ), + ) + .join(quotations, quotations.qt_id == sessions.quotation_id) + .where( + and_( + *_company_scope(company_id, owner), + sessions.bid_price.isnot(None), + sessions.qt_round >= 2, + sessions.deleted == False, # noqa: E712 + quotations.updated_at >= since, + ) + ) + ) + err, rows = await DB_SESSION_MNG.execute(cdb, stmt) + if err != ErrorType.SUCCESS: + return err, 0.0 + val = rows[0] if rows else None + return ErrorType.SUCCESS, float(val) if val is not None else 0.0 + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, 0.0 + + async def markup_suppression_monthly(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]: + # 월별 인상억제율: 이번 라운드 마감월(quotations.updated_at)별 avg((직전투찰 − 이번투찰)/직전투찰). + try: + prev = aliased(sessions) + month = func.to_char(quotations.updated_at, "YYYY-MM") + stmt = ( + select(month.label("m"), func.avg((prev.bid_price - sessions.bid_price) * 1.0 / prev.bid_price)) + .select_from(sessions) + .join( + prev, + and_( + prev.qt_number == sessions.qt_number, + prev.item_id == sessions.item_id, + prev.supplier_id == sessions.supplier_id, + prev.qt_round == sessions.qt_round - 1, + prev.bid_price.isnot(None), + prev.bid_price > 0, + prev.deleted == False, # noqa: E712 + ), + ) + .join(quotations, quotations.qt_id == sessions.quotation_id) + .where( + and_( + *_company_scope(company_id, owner), + sessions.bid_price.isnot(None), + sessions.qt_round >= 2, + sessions.deleted == False, # noqa: E712 + quotations.updated_at >= since, + ) + ) + .group_by(month) + .order_by(month) + ) + err, rows = await DB_SESSION_MNG.execute(cdb, stmt) + return (err, list(rows) if err == ErrorType.SUCCESS else []) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, [] + async def card_usage(self, cdb: AsyncSession, company_id, owner, since) -> Tuple[ErrorType, list]: # 카드 유형별 사용 빈도: card_used_yn=True 채팅을 card_type 별 집계(협상형 견적에서만 채팅 생성). try: diff --git a/negodata/backend/router/v1/statistics/protocol.py b/negodata/backend/router/v1/statistics/protocol.py index b4c22c9..b67eca2 100644 --- a/negodata/backend/router/v1/statistics/protocol.py +++ b/negodata/backend/router/v1/statistics/protocol.py @@ -15,12 +15,18 @@ class StatKpi(WebPacketProtocol): savings_delta_mom: int = 0 # 전월 대비 절감액 증감 closed_count: int = 0 # 마감 견적 수(창) regen_avg_round: float = 0.0 # 평균 재견적 라운드 + markup_suppression_rate: float = 0.0 # 인상억제율(재협상: 직전 라운드 투찰가 대비 이번 투찰가 인하율, 파생) class StatMonthPoint(WebPacketProtocol): month: str # 'YYYY-MM' savings: int = 0 - rate: float = 0.0 + rate: float = 0.0 # 절감률 + + +class StatMarkupPoint(WebPacketProtocol): + month: str # 'YYYY-MM' + rate: float = 0.0 # 인상억제율(직전 라운드 투찰가 대비 인하율) class StatOutcome(WebPacketProtocol): @@ -60,6 +66,7 @@ class StatCardUsage(WebPacketProtocol): class StatScope(WebPacketProtocol): kpi: StatKpi = Field(default_factory=StatKpi) trend: list[StatMonthPoint] = [] + markup_trend: list[StatMarkupPoint] = [] # 월별 인상억제율(재협상) outcome: StatOutcome = Field(default_factory=StatOutcome) participation: StatParticipation = Field(default_factory=StatParticipation) type_split: list[StatTypeRow] = [] diff --git a/negodata/backend/services/statistics_service.py b/negodata/backend/services/statistics_service.py index b612651..b0f64b3 100644 --- a/negodata/backend/services/statistics_service.py +++ b/negodata/backend/services/statistics_service.py @@ -12,6 +12,7 @@ from router.v1.statistics.protocol import ( StatScope, StatKpi, StatMonthPoint, + StatMarkupPoint, StatOutcome, StatParticipation, StatTypeRow, @@ -51,19 +52,22 @@ class StatisticsService: type_rows = await self._read(lambda s: self.stat_crud.type_counts(s, company_uuid, owner_uuid, since)) part_rows = await self._read(lambda s: self.stat_crud.participation_counts(s, company_uuid, owner_uuid, since)) regen = await self._read_scalar(lambda s: self.stat_crud.regen_avg_round(s, company_uuid, owner_uuid, since)) + markup = await self._read_scalar(lambda s: self.stat_crud.markup_suppression(s, company_uuid, owner_uuid, since)) + markup_rows = await self._read(lambda s: self.stat_crud.markup_suppression_monthly(s, company_uuid, owner_uuid, since)) card_rows = await self._read(lambda s: self.stat_crud.card_usage(s, company_uuid, owner_uuid, since)) scope.trend = self._trend(win_rows, labels) + scope.markup_trend = [StatMarkupPoint(month=r[0], rate=float(r[1] or 0.0)) for r in markup_rows] scope.categories = self._categories(win_rows) scope.outcome = self._outcome(outcome_rows) scope.participation = self._participation(part_rows) scope.type_split = self._type_split(type_rows, win_rows) scope.cards = self._cards(card_rows) - scope.kpi = self._kpi(win_rows, scope.trend, scope.outcome, regen) + scope.kpi = self._kpi(win_rows, scope.trend, scope.outcome, regen, markup) return scope # ── 파생 계산 ─────────────────────────────────────────────── - def _kpi(self, win_rows, trend, outcome, regen) -> StatKpi: + def _kpi(self, win_rows, trend, outcome, regen, markup) -> StatKpi: k = StatKpi() total_saving = sum(int(r.target_price) - int(r.bid_price) for r in win_rows) total_target = sum(int(r.target_price) for r in win_rows) @@ -75,6 +79,7 @@ class StatisticsService: k.closed_count = closed k.award_rate = (outcome.awarded / closed) if closed else 0.0 k.regen_avg_round = round(regen, 2) + k.markup_suppression_rate = round(markup, 4) # 인상억제율(재협상 직전 라운드 투찰가 대비, 파생) # 전월 대비: 마지막 두 달 절감액 차(창에 2개월 미만이면 0). k.savings_delta_mom = (trend[-1].savings - trend[-2].savings) if len(trend) >= 2 else 0 return k diff --git a/negodata/front/src/api/generated/model/statKpi.ts b/negodata/front/src/api/generated/model/statKpi.ts index df2e526..36c46a9 100644 --- a/negodata/front/src/api/generated/model/statKpi.ts +++ b/negodata/front/src/api/generated/model/statKpi.ts @@ -13,4 +13,5 @@ export interface StatKpi { savings_delta_mom?: number; closed_count?: number; regen_avg_round?: number; + markup_suppression_rate?: number; } diff --git a/negodata/front/src/api/generated/model/statMarkupPoint.ts b/negodata/front/src/api/generated/model/statMarkupPoint.ts new file mode 100644 index 0000000..9dc4b6f --- /dev/null +++ b/negodata/front/src/api/generated/model/statMarkupPoint.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export interface StatMarkupPoint { + month: string; + rate?: number; +} diff --git a/negodata/front/src/api/generated/model/statScope.ts b/negodata/front/src/api/generated/model/statScope.ts index 0d5a4af..ce425f5 100644 --- a/negodata/front/src/api/generated/model/statScope.ts +++ b/negodata/front/src/api/generated/model/statScope.ts @@ -6,6 +6,7 @@ */ import type { StatKpi } from './statKpi'; import type { StatMonthPoint } from './statMonthPoint'; +import type { StatMarkupPoint } from './statMarkupPoint'; import type { StatOutcome } from './statOutcome'; import type { StatParticipation } from './statParticipation'; import type { StatTypeRow } from './statTypeRow'; @@ -15,6 +16,7 @@ import type { StatCardUsage } from './statCardUsage'; export interface StatScope { kpi?: StatKpi; trend?: StatMonthPoint[]; + markup_trend?: StatMarkupPoint[]; outcome?: StatOutcome; participation?: StatParticipation; type_split?: StatTypeRow[]; diff --git a/negodata/front/src/features/statistics/StatisticsView.tsx b/negodata/front/src/features/statistics/StatisticsView.tsx index 3c26cf0..349f80a 100644 --- a/negodata/front/src/features/statistics/StatisticsView.tsx +++ b/negodata/front/src/features/statistics/StatisticsView.tsx @@ -2,6 +2,7 @@ import { Award, Percent, RefreshCw, Target, TrendingDown, CircleCheckBig } from import { Panel } from './components/Panel'; import { StatTile } from './components/StatTile'; import { SavingsTrendChart } from './components/SavingsTrendChart'; +import { MarkupTrendChart } from './components/MarkupTrendChart'; import { OutcomeChart } from './components/OutcomeChart'; import { ParticipationChart } from './components/ParticipationChart'; import { TypeSplitChart } from './components/TypeSplitChart'; @@ -9,9 +10,11 @@ import { CategoryChart } from './components/CategoryChart'; import { CardEffectChart } from './components/CardEffectChart'; import { wonCompact, pct, signedWonCompact } from './fmt'; import type { StatData } from './types'; +import { useLabels } from '@/features/settings/useCompanySettings'; // 통계 본문. KPI 요약 + 절감 분석 + 성사/프로세스 + 카드 효과. scope(회사/내견적)별로 동일 레이아웃. export function StatisticsView({ data }: { data: StatData }) { + const label = useLabels(); // 회사 설정 용어(카테고리 등) const k = data.kpi; return (
@@ -40,6 +43,10 @@ export function StatisticsView({ data }: { data: StatData }) { > + + + + @@ -57,7 +64,7 @@ export function StatisticsView({ data }: { data: StatData }) { {/* 카테고리 · 카드 */}
- + diff --git a/negodata/front/src/features/statistics/api.ts b/negodata/front/src/features/statistics/api.ts index dc6458e..a5825e4 100644 --- a/negodata/front/src/features/statistics/api.ts +++ b/negodata/front/src/features/statistics/api.ts @@ -17,8 +17,10 @@ function mapScope(s?: ApiScope): StatData { savingsDeltaMoM: k.savings_delta_mom ?? 0, closedCount: k.closed_count ?? 0, regenAvgRound: k.regen_avg_round ?? 0, + markupSuppressionRate: k.markup_suppression_rate ?? 0, }, trend: (s?.trend ?? []).map((t) => ({ month: t.month, savings: t.savings ?? 0, rate: t.rate ?? 0 })), + markupTrend: (s?.markup_trend ?? []).map((t) => ({ month: t.month, rate: t.rate ?? 0 })), outcome: { awarded: o.awarded ?? 0, openPrice: o.open_price ?? 0, diff --git a/negodata/front/src/features/statistics/components/MarkupTrendChart.tsx b/negodata/front/src/features/statistics/components/MarkupTrendChart.tsx new file mode 100644 index 0000000..18ece5b --- /dev/null +++ b/negodata/front/src/features/statistics/components/MarkupTrendChart.tsx @@ -0,0 +1,36 @@ +import { Bar, BarChart, CartesianGrid, XAxis, YAxis } from 'recharts'; +import { ChartContainer, ChartTooltip, type ChartConfig } from '@/components/ui/chart'; +import { Typography } from '@/components/ui/typography'; +import { themeOf } from '../palette'; +import { pct, monthLabel } from '../fmt'; +import type { MarkupPoint } from '../types'; + +// 월별 인상억제율(재협상: 직전 라운드 투찰가 대비 이번 투찰가 인하율). +const config = { + rate: { label: '인상억제율', theme: themeOf('rose') }, +} satisfies ChartConfig; + +export function MarkupTrendChart({ data }: { data: MarkupPoint[] }) { + return ( + + + + + pct(Number(v))} /> + } /> + + + + ); +} + +function MarkupTooltip({ active, payload }: { active?: boolean; payload?: { payload: MarkupPoint }[] }) { + if (!active || !payload?.length) return null; + const p = payload[0].payload; + return ( +
+ {monthLabel(p.month)} + 인상억제율 {pct(p.rate)} +
+ ); +} diff --git a/negodata/front/src/features/statistics/types.ts b/negodata/front/src/features/statistics/types.ts index 3a631c8..3909913 100644 --- a/negodata/front/src/features/statistics/types.ts +++ b/negodata/front/src/features/statistics/types.ts @@ -11,6 +11,7 @@ export interface StatKpi { savingsDeltaMoM: number; // 전월 대비 절감액 증감 closedCount: number; // 마감 견적 수(창) regenAvgRound: number; // 평균 재견적 라운드(1=재견적 없음) + markupSuppressionRate: number; // 인상억제율(재협상: 직전 라운드 투찰가 대비 이번 투찰가 인하율) } export interface MonthPoint { @@ -19,6 +20,11 @@ export interface MonthPoint { rate: number; } +export interface MarkupPoint { + month: string; // 'YYYY-MM' + rate: number; // 인상억제율 +} + export interface OutcomeBreakdown { awarded: number; openPrice: number; // 가격 미달 @@ -57,6 +63,7 @@ export interface CardTypeUsage { export interface StatData { kpi: StatKpi; trend: MonthPoint[]; + markupTrend: MarkupPoint[]; outcome: OutcomeBreakdown; participation: ParticipationBreakdown; typeSplit: TypeSplitRow[]; From 1e4726f4ccd2f898b393a40ccad9e40c684c10cd Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 22 Jul 2026 15:19:41 +0900 Subject: [PATCH 2/5] =?UTF-8?q?[feat]=20=EC=8A=A4=ED=82=A4=EB=A7=88:=20com?= =?UTF-8?q?pany.settings=C2=B7items/suppliers/sessions.custom=C2=B7nego=5F?= =?UTF-8?q?cards=20=EC=BB=AC=EB=9F=BC=20=EC=B6=94=EA=B0=80(=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=C2=B7init.sql)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/common/database/model/models.py | 30 +++++++++++++++++++ .../backend/common/database/model/models.py | 7 +++++ postgres-init/init-data/init.sql | 12 ++++++++ 3 files changed, 49 insertions(+) diff --git a/backend/common/database/model/models.py b/backend/common/database/model/models.py index c1e5ea2..6418d31 100644 --- a/backend/common/database/model/models.py +++ b/backend/common/database/model/models.py @@ -58,6 +58,21 @@ class suppliers(MAIN_BASE): deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부 +class companies(MAIN_BASE): + # company.companies (고객사). 공급사 포털 브랜딩(settings.branding) 조회 전용 미러. + @staticmethod + def DBType(): + return DBType.PARTNER.value + + __tablename__ = "companies" + __table_args__ = {"schema": "company"} + + company_id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")) # 회사 식별자(PK) + name = Column(String(100), nullable=False) # 회사명 + settings = Column(JSONB, nullable=True) # 회사별 커스터마이징(branding/labels 등, negodata 소유) + deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부 + + class items(MAIN_BASE): # partner.items (상품). @staticmethod @@ -123,6 +138,7 @@ class sessions(MAIN_BASE): reject_reason = Column(String(255), nullable=True) # 거절 사유 reject_price = Column(BigInteger, nullable=True) # 거절 시 제시가(원) reject_delivery_type = Column(SmallInteger, nullable=True) # 거절 시 배송 유형 (코드) + custom = Column(JSONB, nullable=True) # 협상완료 부가정보 값 {key: value} (정의는 companies.settings.session_fields) created_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')")) # 생성 시각(UTC) updated_at = Column(DateTime(timezone=True), nullable=False, server_default=text("(now() AT TIME ZONE 'utc')"), onupdate=text("(now() AT TIME ZONE 'utc')")) # 수정 시각(UTC, UPDATE 시 자동 갱신) deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부 @@ -183,6 +199,20 @@ class quotation_settings(MAIN_BASE): deleted = Column(Boolean, nullable=False, server_default=text("false")) # 소프트 삭제 여부 +class nego_cards(MAIN_BASE): + # card.nego_cards (협상카드). backend 는 번호→UUID 변환(chats.card_id 저장)만 위해 최소 컬럼 미러. + @staticmethod + def DBType(): + return DBType.NEGOTIATION.value # 같은 negosium_db — chats 와 동일 세션풀로 조회 + + __tablename__ = "nego_cards" + __table_args__ = {"schema": "card"} + + nego_card_id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()")) + number = Column(String(10), nullable=True) # 카드 번호(agent turn.card_id 와 매칭) + deleted = Column(Boolean, nullable=False, server_default=text("false")) + + class chats(MAIN_BASE): # negotiation.chats (협상 채팅 메시지 로그). session 1 : N chats. (session_id, seq) 유니크. @staticmethod diff --git a/negodata/backend/common/database/model/models.py b/negodata/backend/common/database/model/models.py index 583b3d6..b50663d 100644 --- a/negodata/backend/common/database/model/models.py +++ b/negodata/backend/common/database/model/models.py @@ -47,6 +47,8 @@ class companies(MainTableMixin, MAIN_BASE): website_url = Column(String(255), nullable=True) industry = Column(SmallInteger, nullable=True) # 업종 코드 (스키마 SMALLINT) status = Column(SmallInteger, nullable=False, default=CompanyStatus.ACTIVE.value) # CompanyStatus + # 회사별 커스터마이징 설정. branding(CI)/labels(용어)/features(동작)/item_fields·supplier_fields(커스텀필드 정의) + settings = Column(JSONB, nullable=True) class users(MainTableMixin, MAIN_BASE): @@ -109,6 +111,8 @@ class items(MainTableMixin, MAIN_BASE): delivery_type = Column(SmallInteger, nullable=True) # 배송 유형 코드 vat_yn = Column(Boolean, nullable=True) delivery_fee_yn = Column(Boolean, nullable=True) + # 회사 커스텀필드 값. 정의(키/라벨/타입)는 companies.settings.item_fields, 여기는 {key: value}만 + custom = Column(JSONB, nullable=True) class item_internet_lowest_prices(MainTableMixin, MAIN_BASE): @@ -145,6 +149,8 @@ class suppliers(MainTableMixin, MAIN_BASE): manager_email = Column(String(255), nullable=True) manager_contact_number = Column(String(20), nullable=True) # ERD 오타(manger) 교정 total_revenue = Column(BigInteger, nullable=True) # 총매출액 + # 회사 커스텀필드 값. 정의는 companies.settings.supplier_fields, 여기는 {key: value}만 + custom = Column(JSONB, nullable=True) class supplier_users(MainTableMixin, MAIN_BASE): @@ -316,6 +322,7 @@ class sessions(MainTableMixin, MAIN_BASE): reject_price = Column(BigInteger, nullable=True) reject_delivery_type = Column(SmallInteger, nullable=True) # DeliveryType 코드 email_sent_at = Column(DateTime(timezone=True), nullable=True) # 협상 초청 메일 발송 시각(NULL=미발송) + custom = Column(JSONB, nullable=True) # 협상완료 부가정보 값 {key: value} (정의는 companies.settings.session_fields) class chats(MainTableMixin, MAIN_BASE): diff --git a/postgres-init/init-data/init.sql b/postgres-init/init-data/init.sql index e747210..0140522 100644 --- a/postgres-init/init-data/init.sql +++ b/postgres-init/init-data/init.sql @@ -60,6 +60,7 @@ CREATE TABLE IF NOT EXISTS company.companies ( website_url VARCHAR(255) NULL, -- 홈페이지 URL industry SMALLINT NULL, -- 업종 ( 필요한 만큼 숫자에 매핑하여 사용 ) status SMALLINT NOT NULL DEFAULT 1, -- 상태: 1=active(활성), 2=inactive(비활성) + settings JSONB NULL, -- 회사별 커스터마이징: branding(CI)/labels(용어)/features(동작)/item_fields·supplier_fields(커스텀필드 정의) created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC) updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신) deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부 @@ -138,6 +139,7 @@ CREATE TABLE IF NOT EXISTS partner.suppliers ( manager_email VARCHAR(255) NULL, -- 담당자 이메일 manager_contact_number VARCHAR(20) NULL, -- 담당자 연락처 total_revenue BIGINT NULL, -- 총매출액(원). KTC suppliers.total_revenue 미러 + custom JSONB NULL, -- 회사 커스텀필드 값 {key: value} (정의는 companies.settings.supplier_fields) created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC) updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신) deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부 @@ -167,6 +169,7 @@ CREATE TABLE IF NOT EXISTS partner.items ( purchase_price BIGINT NULL, -- 매입가 selling_price BIGINT NULL, -- 판매가 category_type INTEGER NOT NULL DEFAULT 1, -- 자동으로 늘어나는 숫자 ( 카테고리 찾을때 유용한 컬럼) + custom JSONB NULL, -- 회사 커스텀필드 값 {key: value} (정의는 companies.settings.item_fields) created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC) updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신) deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부 @@ -330,6 +333,7 @@ CREATE TABLE IF NOT EXISTS negotiation.sessions ( reject_price BIGINT NULL, -- 거절 시 제시가(원) reject_delivery_type SMALLINT NULL, -- 거절 시 배송 유형(DeliveryType): 1=supplier(협력사배송), 2=courier(지정택배배송), 3=pickup(픽업배송) email_sent_at TIMESTAMPTZ NULL, -- 협상 초청 메일 발송 시각(NULL=미발송). 수동 발송 버튼이 채움 + custom JSONB NULL, -- 협상완료 부가정보 값 {key: value} (정의는 companies.settings.session_fields: 표준납기/MOQ/발주배수/배송유형) created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC) updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신) deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부 @@ -669,6 +673,14 @@ ALTER TABLE company.users ALTER COLUMN last_accessed_at SET DEFAULT now UPDATE supplier.supplier_users SET last_accessed_at = now() WHERE last_accessed_at IS NULL; ALTER TABLE supplier.supplier_users ALTER COLUMN last_accessed_at SET DEFAULT now(); +-- [2026-07-22] 회사별 커스터마이징(브랜딩/용어 라벨/커스텀필드) — IMK 요청 대응 +ALTER TABLE company.companies ADD COLUMN IF NOT EXISTS settings JSONB NULL; +ALTER TABLE partner.items ADD COLUMN IF NOT EXISTS custom JSONB NULL; +ALTER TABLE partner.suppliers ADD COLUMN IF NOT EXISTS custom JSONB NULL; + +-- [2026-07-22] 협상완료 부가정보(표준납기/MOQ/발주배수/배송유형) — 회사 정의(session_fields) 값 저장(#5) +ALTER TABLE negotiation.sessions ADD COLUMN IF NOT EXISTS custom JSONB NULL; + -- ============================================================ -- LPS (인터넷 최저가 검색) — 별도 database lps_db From 88edcfed6313ad991dcdab4dd2c693eba33e714d Mon Sep 17 00:00:00 2001 From: Mina Choi Date: Wed, 22 Jul 2026 15:20:24 +0900 Subject: [PATCH 3/5] =?UTF-8?q?[feat]=20negodata:=20=ED=9A=8C=EC=82=AC=20?= =?UTF-8?q?=EC=BB=A4=EC=8A=A4=ED=84=B0=EB=A7=88=EC=9D=B4=EC=A7=95(?= =?UTF-8?q?=EC=84=A4=EC=A0=95=C2=B7=EB=9D=BC=EB=B2=A8=C2=B7=EC=BB=A4?= =?UTF-8?q?=EC=8A=A4=ED=85=80=ED=95=84=EB=93=9C=C2=B7=EC=97=91=EC=85=80?= =?UTF-8?q?=C2=B7=EC=83=81=ED=92=88=EA=B3=B5=EA=B8=89=EC=82=AC#20=C2=B7?= =?UTF-8?q?=EB=B6=84=EB=A5=98=EC=B9=B4=ED=85=8C=EA=B3=A0=EB=A6=AC#17)?= =?UTF-8?q?=C2=B7=EC=9D=BC=EA=B4=84=EC=82=AD=EC=A0=9C=C2=B7=EB=A1=9C?= =?UTF-8?q?=EA=B7=B8export=C2=B7=EA=B5=AC=EB=B6=84=C2=B7=EC=B9=B4=EB=93=9C?= =?UTF-8?q?=20=EC=A0=84=EC=B2=B4=EC=84=A0=ED=83=9D=C2=B7=EB=B6=80=EA=B0=80?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20=ED=91=9C=EC=8B=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- negodata/backend/crud/item_crud.py | 32 +- negodata/backend/crud/supplier_item_crud.py | 26 +- negodata/backend/crud/user_crud.py | 16 + negodata/backend/router/router.py | 2 + .../backend/router/v1/company/protocol.py | 13 + .../backend/router/v1/company/settings.py | 21 + negodata/backend/router/v1/item/protocol.py | 4 + .../backend/router/v1/quotation/protocol.py | 1 + .../backend/router/v1/supplier/protocol.py | 3 + .../router/v1/supplier_item/protocol.py | 4 + .../services/company_settings_service.py | 45 +++ negodata/backend/services/item_service.py | 10 + .../backend/services/quotation_service.py | 1 + .../backend/services/supplier_item_service.py | 11 +- negodata/backend/services/supplier_service.py | 1 + .../company-settings/company-settings.ts | 194 +++++++++ .../front/src/api/generated/model/index.ts | 25 ++ .../front/src/api/generated/model/itemData.ts | 3 + .../src/api/generated/model/itemDataCustom.ts | 9 + .../generated/model/itemDataCustomAnyOf.ts | 8 + .../src/api/generated/model/itemSupplyType.ts | 4 + .../model/itemSupplyTypeSupplierItemId.ts | 8 + .../model/itemSupplyTypeSupplierName.ts | 8 + .../src/api/generated/model/reqCreateItem.ts | 2 + .../generated/model/reqCreateItemCustom.ts | 9 + .../model/reqCreateItemCustomAnyOf.ts | 8 + .../api/generated/model/reqCreateSupplier.ts | 2 + .../model/reqCreateSupplierCustom.ts | 9 + .../model/reqCreateSupplierCustomAnyOf.ts | 8 + .../model/reqUpdateCompanySettings.ts | 11 + .../model/reqUpdateCompanySettingsSettings.ts | 8 + .../src/api/generated/model/reqUpdateItem.ts | 2 + .../generated/model/reqUpdateItemCustom.ts | 9 + .../model/reqUpdateItemCustomAnyOf.ts | 8 + .../api/generated/model/reqUpdateSupplier.ts | 2 + .../model/reqUpdateSupplierCustom.ts | 9 + .../model/reqUpdateSupplierCustomAnyOf.ts | 8 + .../api/generated/model/resCompanySettings.ts | 15 + .../generated/model/resCompanySettingsMsg.ts | 8 + .../model/resCompanySettingsSettings.ts | 9 + .../model/resCompanySettingsSettingsAnyOf.ts | 8 + .../src/api/generated/model/sessionData.ts | 2 + .../api/generated/model/sessionDataCustom.ts | 9 + .../generated/model/sessionDataCustomAnyOf.ts | 8 + .../src/api/generated/model/supplierData.ts | 2 + .../api/generated/model/supplierDataCustom.ts | 9 + .../model/supplierDataCustomAnyOf.ts | 8 + .../api/generated/model/supplierItemData.ts | 4 + .../model/supplierItemDataItemCategory.ts | 8 + .../model/supplierItemDataItemManufacturer.ts | 8 + negodata/front/src/app/router.tsx | 7 + .../components/layout/AuthenticatedLayout.tsx | 1 + .../front/src/components/layout/Layout.tsx | 20 +- .../partners/components/PartnerFormSheet.tsx | 11 + .../partners/components/PartnerTable.tsx | 5 + .../components/SupplierItemsManager.tsx | 5 +- .../products/components/ExcelUploadModal.tsx | 246 ++++++++---- .../components/ItemSuppliersManager.tsx | 146 +++++++ .../products/components/ProductFormSheet.tsx | 24 +- .../products/components/ProductTable.tsx | 20 +- .../products/hooks/useItemSuppliers.ts | 46 +++ .../components/QuotationCreateModal.tsx | 33 +- .../QuotationDetailSheet/ChatTab.tsx | 76 +++- .../DrawerHeaderCards.tsx | 4 +- .../QuotationDetailSheet/TargetPriceModal.tsx | 14 +- .../components/QuotationSettingsModal.tsx | 6 +- .../quotations/hooks/useQuotations.ts | 4 +- .../features/settings/CustomFieldInputs.tsx | 70 ++++ .../src/features/settings/SettingsView.tsx | 367 ++++++++++++++++++ .../front/src/features/settings/catalog.ts | 51 +++ .../features/settings/useCompanySettings.ts | 36 ++ negodata/front/src/pages/partners.tsx | 41 +- negodata/front/src/pages/products.tsx | 40 +- negodata/front/src/pages/settings.tsx | 11 + negodata/front/src/types.ts | 2 +- 75 files changed, 1802 insertions(+), 126 deletions(-) create mode 100644 negodata/backend/router/v1/company/settings.py create mode 100644 negodata/backend/services/company_settings_service.py create mode 100644 negodata/front/src/api/generated/company-settings/company-settings.ts create mode 100644 negodata/front/src/api/generated/model/itemDataCustom.ts create mode 100644 negodata/front/src/api/generated/model/itemDataCustomAnyOf.ts create mode 100644 negodata/front/src/api/generated/model/itemSupplyTypeSupplierItemId.ts create mode 100644 negodata/front/src/api/generated/model/itemSupplyTypeSupplierName.ts create mode 100644 negodata/front/src/api/generated/model/reqCreateItemCustom.ts create mode 100644 negodata/front/src/api/generated/model/reqCreateItemCustomAnyOf.ts create mode 100644 negodata/front/src/api/generated/model/reqCreateSupplierCustom.ts create mode 100644 negodata/front/src/api/generated/model/reqCreateSupplierCustomAnyOf.ts create mode 100644 negodata/front/src/api/generated/model/reqUpdateCompanySettings.ts create mode 100644 negodata/front/src/api/generated/model/reqUpdateCompanySettingsSettings.ts create mode 100644 negodata/front/src/api/generated/model/reqUpdateItemCustom.ts create mode 100644 negodata/front/src/api/generated/model/reqUpdateItemCustomAnyOf.ts create mode 100644 negodata/front/src/api/generated/model/reqUpdateSupplierCustom.ts create mode 100644 negodata/front/src/api/generated/model/reqUpdateSupplierCustomAnyOf.ts create mode 100644 negodata/front/src/api/generated/model/resCompanySettings.ts create mode 100644 negodata/front/src/api/generated/model/resCompanySettingsMsg.ts create mode 100644 negodata/front/src/api/generated/model/resCompanySettingsSettings.ts create mode 100644 negodata/front/src/api/generated/model/resCompanySettingsSettingsAnyOf.ts create mode 100644 negodata/front/src/api/generated/model/sessionDataCustom.ts create mode 100644 negodata/front/src/api/generated/model/sessionDataCustomAnyOf.ts create mode 100644 negodata/front/src/api/generated/model/supplierDataCustom.ts create mode 100644 negodata/front/src/api/generated/model/supplierDataCustomAnyOf.ts create mode 100644 negodata/front/src/api/generated/model/supplierItemDataItemCategory.ts create mode 100644 negodata/front/src/api/generated/model/supplierItemDataItemManufacturer.ts create mode 100644 negodata/front/src/features/products/components/ItemSuppliersManager.tsx create mode 100644 negodata/front/src/features/products/hooks/useItemSuppliers.ts create mode 100644 negodata/front/src/features/settings/CustomFieldInputs.tsx create mode 100644 negodata/front/src/features/settings/SettingsView.tsx create mode 100644 negodata/front/src/features/settings/catalog.ts create mode 100644 negodata/front/src/features/settings/useCompanySettings.ts create mode 100644 negodata/front/src/pages/settings.tsx diff --git a/negodata/backend/crud/item_crud.py b/negodata/backend/crud/item_crud.py index 622d0b6..832ca59 100644 --- a/negodata/backend/crud/item_crud.py +++ b/negodata/backend/crud/item_crud.py @@ -5,7 +5,7 @@ from sqlalchemy import select, func, and_, or_, update from sqlalchemy.ext.asyncio import AsyncSession from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import items, users +from common.database.model.models import items, users, supplier_items, suppliers from common.enums import ErrorType from common.logger import LOG from common.utils.gtime import GTime @@ -49,6 +49,10 @@ class IItemCRUD(ABC): async def user_name_map(self, cdb: AsyncSession, user_ids) -> Tuple[ErrorType, dict]: pass + @abstractmethod + async def supplier_name_map(self, cdb: AsyncSession, item_ids) -> Tuple[ErrorType, dict]: + pass + class ItemCRUD(IItemCRUD): async def search( @@ -184,3 +188,29 @@ class ItemCRUD(IItemCRUD): except Exception as ex: LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, {} + + async def supplier_name_map(self, cdb: AsyncSession, item_ids) -> Tuple[ErrorType, dict]: + """item_id 목록 → {item_id: [공급사명...]}. 상품 목록 '공급사' 컬럼용(supplier_items→suppliers 배치 조인).""" + try: + if not item_ids: + return ErrorType.SUCCESS, {} + query = ( + select(supplier_items.item_id, suppliers.name) + .join(suppliers, suppliers.supplier_id == supplier_items.supplier_id) + .where( + supplier_items.item_id.in_(item_ids), + supplier_items.deleted == False, # noqa: E712 + suppliers.deleted == False, # noqa: E712 + ) + .order_by(suppliers.name) + ) + err_type, rows = await DB_SESSION_MNG.execute(cdb, query) + if err_type != ErrorType.SUCCESS: + return err_type, {} + out: dict = {} + for item_id, name in rows: + out.setdefault(item_id, []).append(name) + return ErrorType.SUCCESS, out + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED, {} diff --git a/negodata/backend/crud/supplier_item_crud.py b/negodata/backend/crud/supplier_item_crud.py index b372cd6..11dff4c 100644 --- a/negodata/backend/crud/supplier_item_crud.py +++ b/negodata/backend/crud/supplier_item_crud.py @@ -5,7 +5,7 @@ from sqlalchemy import select, func, update from sqlalchemy.ext.asyncio import AsyncSession from common.database.db_session_manager import DB_SESSION_MNG -from common.database.model.models import supplier_items, items +from common.database.model.models import supplier_items, items, suppliers from common.enums import ErrorType from common.logger import LOG from common.utils.gtime import GTime @@ -48,7 +48,8 @@ class ISupplierItemCRUD(ABC): class SupplierItemCRUD(ISupplierItemCRUD): async def list_by_supplier(self, cdb: AsyncSession, supplier_id) -> Tuple[ErrorType, list]: - # 협력사 상세용: 매핑 + 상품명/코드 조인. Row(supplier_item_id, item_id, name, code, supply_type) + # 협력사 상세용: 매핑 + 상품명/코드/카테고리/제조사 조인. + # Row(supplier_item_id, item_id, name, code, supply_type, category, manufacturer) try: query = ( select( @@ -57,6 +58,8 @@ class SupplierItemCRUD(ISupplierItemCRUD): items.name, items.code, supplier_items.supply_type, + items.category, + items.manufacturer, ) .join(items, items.item_id == supplier_items.item_id) .where( @@ -75,11 +78,22 @@ class SupplierItemCRUD(ISupplierItemCRUD): return ErrorType.DB_RUN_FAILED, [] async def list_by_item(self, cdb: AsyncSession, item_id) -> Tuple[ErrorType, list]: - # 견적생성 모달용: 이 상품을 취급하는 협력사별 공급유형. Row(supplier_id, supply_type) + # 견적생성 모달·상품 상세 공용: 이 상품을 취급하는 협력사별 공급유형. + # Row(supplier_id, supply_type, name, supplier_item_id) try: - query = select(supplier_items.supplier_id, supplier_items.supply_type).where( - supplier_items.item_id == item_id, - supplier_items.deleted == False, # noqa: E712 + query = ( + select( + supplier_items.supplier_id, + supplier_items.supply_type, + suppliers.name, + supplier_items.supplier_item_id, + ) + .join(suppliers, suppliers.supplier_id == supplier_items.supplier_id) + .where( + supplier_items.item_id == item_id, + supplier_items.deleted == False, # noqa: E712 + suppliers.deleted == False, # noqa: E712 + ) ) err_type, rows = await DB_SESSION_MNG.execute(cdb, query) if err_type != ErrorType.SUCCESS: diff --git a/negodata/backend/crud/user_crud.py b/negodata/backend/crud/user_crud.py index 25ba03e..5d8d414 100644 --- a/negodata/backend/crud/user_crud.py +++ b/negodata/backend/crud/user_crud.py @@ -35,6 +35,10 @@ class IUserCRUD(ABC): async def get_company(self, cdb: AsyncSession, company_id) -> Tuple[ErrorType, companies]: pass + @abstractmethod + async def update_company_settings(self, cdb: AsyncSession, company_id, settings: dict) -> ErrorType: + pass + @abstractmethod async def list_by_company(self, cdb: AsyncSession, company_id, search, skip, limit) -> Tuple[ErrorType, list, int]: pass @@ -103,6 +107,18 @@ class UserCRUD(IUserCRUD): LOG.e_no_callstack(ex) return ErrorType.DB_RUN_FAILED, None + async def update_company_settings(self, cdb: AsyncSession, company_id, settings: dict) -> ErrorType: + try: + query = ( + update(companies) + .where(companies.company_id == company_id, companies.deleted == False) # noqa: E712 + .values(settings=settings, updated_at=GTime.UTC()) + ) + return await DB_SESSION_MNG.add(cdb, query) + except Exception as ex: + LOG.e_no_callstack(ex) + return ErrorType.DB_RUN_FAILED + async def list_by_company( self, cdb: AsyncSession, company_id, search: Optional[str], skip: int, limit: int ) -> Tuple[ErrorType, list, int]: diff --git a/negodata/backend/router/router.py b/negodata/backend/router/router.py index 93fbf05..4338509 100644 --- a/negodata/backend/router/router.py +++ b/negodata/backend/router/router.py @@ -12,6 +12,7 @@ from config.server_configs import web_server_config from scheduler import shutdown_scheduler, start_scheduler import router.v1.auth.account import router.v1.company.user +import router.v1.company.settings import router.v1.item.item import router.v1.supplier.supplier import router.v1.supplier_item.supplier_item @@ -68,6 +69,7 @@ async def healthz(): # 각 도메인 라우터를 등록한다. 새 기능 추가 시 router.v1.. 를 import 후 include. app.include_router(router.v1.auth.account.router) app.include_router(router.v1.company.user.router) +app.include_router(router.v1.company.settings.router) app.include_router(router.v1.item.item.router) app.include_router(router.v1.supplier.supplier.router) app.include_router(router.v1.supplier_item.supplier_item.router) diff --git a/negodata/backend/router/v1/company/protocol.py b/negodata/backend/router/v1/company/protocol.py index 91bd450..b11b69a 100644 --- a/negodata/backend/router/v1/company/protocol.py +++ b/negodata/backend/router/v1/company/protocol.py @@ -56,3 +56,16 @@ class Res_CompanyUserList(Res_PageProtocol): class Res_DeleteCompanyUser(Res_WebPacketProtocol): pass + + +class CompanySettingsProtocol(WebPacketProtocol): + pass + + +class Req_UpdateCompanySettings(CompanySettingsProtocol): + # settings 전체 치환. 서브키: branding/labels/features/item_fields/supplier_fields/session_fields + settings: dict = {} + + +class Res_CompanySettings(Res_WebPacketProtocol): + settings: Optional[dict] = None diff --git a/negodata/backend/router/v1/company/settings.py b/negodata/backend/router/v1/company/settings.py new file mode 100644 index 0000000..8409806 --- /dev/null +++ b/negodata/backend/router/v1/company/settings.py @@ -0,0 +1,21 @@ +from fastapi import APIRouter, Depends + +from common.models.gmodel import UserInfo +from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse, RequireOwner +from services.company_settings_service import CompanySettingsService +from .protocol import Req_UpdateCompanySettings, Res_CompanySettings + +# 회사별 커스터마이징 설정. 조회=로그인 유저 전원(앱 부팅 시 브랜딩/라벨 로드), 수정=최고관리자(OWNER) 전용. +router = APIRouter(prefix="/v1/company/settings", tags=["CompanySettings"], responses={404: {"description": "Not found"}}) + + +@router.get(path="", response_model=Res_CompanySettings, summary="회사 커스터마이징 설정 조회") +async def get_settings(service: CompanySettingsService = Depends(), user: UserInfo = Depends(IsValidAccessToken)): + return RemoveNoneResponse(await service.get_settings(user.company_id)) + + +@router.put(path="/update", response_model=Res_CompanySettings, summary="회사 커스터마이징 설정 수정(최고관리자)") +async def update_settings( + req: Req_UpdateCompanySettings, service: CompanySettingsService = Depends(), owner: UserInfo = Depends(RequireOwner) +): + return RemoveNoneResponse(await service.update_settings(owner.company_id, req)) diff --git a/negodata/backend/router/v1/item/protocol.py b/negodata/backend/router/v1/item/protocol.py index 8663a74..17ae3b2 100644 --- a/negodata/backend/router/v1/item/protocol.py +++ b/negodata/backend/router/v1/item/protocol.py @@ -33,6 +33,7 @@ class Req_CreateItem(ItemProtocol): delivery_type: Optional[int] = None vat_yn: Optional[bool] = None delivery_fee_yn: Optional[bool] = None + custom: Optional[dict] = None # 회사 커스텀필드 값 {key: value} (정의는 companies.settings.item_fields) class Req_UpdateItem(ItemProtocol): @@ -56,6 +57,7 @@ class Req_UpdateItem(ItemProtocol): delivery_type: Optional[int] = None vat_yn: Optional[bool] = None delivery_fee_yn: Optional[bool] = None + custom: Optional[dict] = None # 회사 커스텀필드 값 {key: value} class ItemData(WebPacketProtocol): @@ -85,6 +87,8 @@ class ItemData(WebPacketProtocol): delivery_type: Optional[DeliveryType] = None vat_yn: Optional[bool] = None delivery_fee_yn: Optional[bool] = None + custom: Optional[dict] = None # 회사 커스텀필드 값 {key: value} + supplier_names: list[str] = [] # 이 상품을 취급(계약)하는 공급사명 목록(목록 컬럼용, 배치 조인) created_at: Optional[datetime] = None updated_at: Optional[datetime] = None diff --git a/negodata/backend/router/v1/quotation/protocol.py b/negodata/backend/router/v1/quotation/protocol.py index 571862f..b794017 100644 --- a/negodata/backend/router/v1/quotation/protocol.py +++ b/negodata/backend/router/v1/quotation/protocol.py @@ -111,6 +111,7 @@ class SessionData(WebPacketProtocol): reject_price: Optional[int] = None reject_delivery_type: Optional[DeliveryType] = None email_sent_at: Optional[datetime] = None # 협상 초청 메일 발송 시각(None=미발송). 프론트 발송배지/재발송 판단 + custom: Optional[dict] = None # 협상완료 부가정보 값 {key: value} (공급사가 타결 후 입력, 정의는 companies.settings.session_fields) url: str = "" # 세션 chat 실행 URL(공급사 협상 프론트). DB 미저장 — session_id 로 구성 diff --git a/negodata/backend/router/v1/supplier/protocol.py b/negodata/backend/router/v1/supplier/protocol.py index 88ba098..6c59c27 100644 --- a/negodata/backend/router/v1/supplier/protocol.py +++ b/negodata/backend/router/v1/supplier/protocol.py @@ -18,6 +18,7 @@ class Req_CreateSupplier(SupplierProtocol): manager_email: Optional[str] = None manager_contact_number: Optional[str] = None total_revenue: Optional[int] = None # 총매출액(원) + custom: Optional[dict] = None # 회사 커스텀필드 값 {key: value} (정의는 companies.settings.supplier_fields) class Req_UpdateSupplier(SupplierProtocol): @@ -27,6 +28,7 @@ class Req_UpdateSupplier(SupplierProtocol): manager_email: Optional[str] = None manager_contact_number: Optional[str] = None total_revenue: Optional[int] = None + custom: Optional[dict] = None # 회사 커스텀필드 값 {key: value} class SupplierData(WebPacketProtocol): @@ -42,6 +44,7 @@ class SupplierData(WebPacketProtocol): manager_email: Optional[str] = None manager_contact_number: Optional[str] = None total_revenue: Optional[int] = None # 총매출액(원) + custom: Optional[dict] = None # 회사 커스텀필드 값 {key: value} account_login_id: Optional[str] = None # 채팅(협상) 계정 로그인 ID. None=미발급 account_status: Optional[int] = None # SupplierUserStatus. None=미발급 created_at: Optional[datetime] = None diff --git a/negodata/backend/router/v1/supplier_item/protocol.py b/negodata/backend/router/v1/supplier_item/protocol.py index 5fda395..a072fd9 100644 --- a/negodata/backend/router/v1/supplier_item/protocol.py +++ b/negodata/backend/router/v1/supplier_item/protocol.py @@ -33,6 +33,8 @@ class SupplierItemData(WebPacketProtocol): item_name: str item_code: Optional[str] = None supply_type: int + item_category: Optional[str] = None + item_manufacturer: Optional[str] = None created_at: Optional[datetime] = None updated_at: Optional[datetime] = None @@ -40,6 +42,8 @@ class SupplierItemData(WebPacketProtocol): class ItemSupplyType(WebPacketProtocol): supplier_id: uuid.UUID supply_type: int + supplier_name: Optional[str] = None + supplier_item_id: Optional[uuid.UUID] = None # 상품측 매핑 편집(수정/삭제)용 class Res_SupplierItem(Res_WebPacketProtocol): diff --git a/negodata/backend/services/company_settings_service.py b/negodata/backend/services/company_settings_service.py new file mode 100644 index 0000000..188d725 --- /dev/null +++ b/negodata/backend/services/company_settings_service.py @@ -0,0 +1,45 @@ +import uuid + +from fastapi import Depends + +from common.database.db_session_manager import DB_SESSION_MNG +from common.database.model.models import companies +from common.enums import DBWRType, ErrorType +from crud.user_crud import IUserCRUD, UserCRUD +from router.v1.company.protocol import Req_UpdateCompanySettings, Res_CompanySettings + + +class CompanySettingsService: + """회사별 커스터마이징 설정(companies.settings JSONB) 조회/수정. + + - 조회는 로그인 유저 전원(브랜딩/라벨을 앱 부팅 시 로드), 수정은 라우터에서 RequireOwner 로 게이트. + - company_id 는 토큰값만 쓴다 → 남의 회사 설정 접근 불가. + """ + + def __init__(self, user_crud: IUserCRUD = Depends(UserCRUD)): + self.user_crud = user_crud + + async def get_settings(self, company_id: str) -> Res_CompanySettings: + res = Res_CompanySettings() + err_type, company = await DB_SESSION_MNG.execute_lambda( + companies.DBType(), + DBWRType.DB_READ.value, + lambda s: self.user_crud.get_company(s, uuid.UUID(company_id)), + ) + if err_type != ErrorType.SUCCESS or company is None: + res.result.SetResult(err_type if err_type != ErrorType.SUCCESS else ErrorType.ACCOUNT_NOT_FOUND) + return res + res.settings = company.settings + return res + + async def update_settings(self, company_id: str, req: Req_UpdateCompanySettings) -> Res_CompanySettings: + res = Res_CompanySettings() + err_type = await DB_SESSION_MNG.execute_lambda_run( + [companies.DBType()], + [lambda s: self.user_crud.update_company_settings(s, uuid.UUID(company_id), req.settings)], + ) + if err_type != ErrorType.SUCCESS: + res.result.SetResult(err_type) + return res + res.settings = req.settings + return res diff --git a/negodata/backend/services/item_service.py b/negodata/backend/services/item_service.py index 684e2fe..5890d5a 100644 --- a/negodata/backend/services/item_service.py +++ b/negodata/backend/services/item_service.py @@ -67,6 +67,16 @@ class ItemService: if nm_err == ErrorType.SUCCESS: for d in res.items: d.creator_name = name_map.get(d.user_id) + # 공급사명 배치 조인 — 페이지 상품의 item_id를 모아 IN 쿼리 1회로 {item_id:[공급사명]} 맵을 만들어 매핑. + item_ids = [r.item_id for r in rows] + if item_ids: + sn_err, supplier_map = await DB_SESSION_MNG.execute_lambda( + items.DBType(), DBWRType.DB_READ.value, + lambda s: self.item_crud.supplier_name_map(s, item_ids), + ) + if sn_err == ErrorType.SUCCESS: + for d in res.items: + d.supplier_names = supplier_map.get(d.item_id, []) res.total = total return res diff --git a/negodata/backend/services/quotation_service.py b/negodata/backend/services/quotation_service.py index afd947f..7ea2b7c 100644 --- a/negodata/backend/services/quotation_service.py +++ b/negodata/backend/services/quotation_service.py @@ -877,6 +877,7 @@ class QuotationService: reject_price=r.reject_price, reject_delivery_type=r.reject_delivery_type, email_sent_at=r.email_sent_at, + custom=r.custom, url=self._session_chat_url(r.session_id), ) for r in rows diff --git a/negodata/backend/services/supplier_item_service.py b/negodata/backend/services/supplier_item_service.py index aff5f35..f39f7dd 100644 --- a/negodata/backend/services/supplier_item_service.py +++ b/negodata/backend/services/supplier_item_service.py @@ -85,10 +85,11 @@ class SupplierItemService: if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res - # Row(supplier_item_id, item_id, name, code, supply_type) + # Row(supplier_item_id, item_id, name, code, supply_type, category, manufacturer) res.supplier_items = [ SupplierItemData( - supplier_item_id=r[0], item_id=r[1], item_name=r[2], item_code=r[3], supply_type=r[4] + supplier_item_id=r[0], item_id=r[1], item_name=r[2], item_code=r[3], supply_type=r[4], + item_category=r[5], item_manufacturer=r[6], ) for r in rows ] @@ -109,8 +110,10 @@ class SupplierItemService: if err_type != ErrorType.SUCCESS: res.result.SetResult(err_type) return res - # Row(supplier_id, supply_type) - res.suppliers = [ItemSupplyType(supplier_id=r[0], supply_type=r[1]) for r in rows] + # Row(supplier_id, supply_type, name, supplier_item_id) + res.suppliers = [ + ItemSupplyType(supplier_id=r[0], supply_type=r[1], supplier_name=r[2], supplier_item_id=r[3]) for r in rows + ] return res async def create(self, company_id: str, req: Req_CreateSupplierItem) -> Res_SupplierItem: diff --git a/negodata/backend/services/supplier_service.py b/negodata/backend/services/supplier_service.py index 7508d51..4bca786 100644 --- a/negodata/backend/services/supplier_service.py +++ b/negodata/backend/services/supplier_service.py @@ -170,6 +170,7 @@ class SupplierService: manager_email=req.manager_email, manager_contact_number=req.manager_contact_number, total_revenue=req.total_revenue, + custom=req.custom, ) err_type = await DB_SESSION_MNG.execute_lambda_run( [suppliers.DBType()], diff --git a/negodata/front/src/api/generated/company-settings/company-settings.ts b/negodata/front/src/api/generated/company-settings/company-settings.ts new file mode 100644 index 0000000..7196052 --- /dev/null +++ b/negodata/front/src/api/generated/company-settings/company-settings.ts @@ -0,0 +1,194 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import { + useMutation, + useQuery +} from '@tanstack/react-query'; +import type { + DataTag, + DefinedInitialDataOptions, + DefinedUseQueryResult, + MutationFunction, + QueryClient, + QueryFunction, + QueryKey, + UndefinedInitialDataOptions, + UseMutationOptions, + UseMutationResult, + UseQueryOptions, + UseQueryResult +} from '@tanstack/react-query'; + +import type { + HTTPValidationError, + ReqUpdateCompanySettings, + ResCompanySettings +} from '.././model'; + +import { customFetch } from '../../mutator/custom-fetch'; + + +type SecondParameter unknown> = Parameters[1]; + + + +/** + * @summary 회사 커스터마이징 설정 조회 + */ +export const getSettings = ( + + options?: SecondParameter,signal?: AbortSignal +) => { + + + return customFetch( + {url: `/v1/company/settings`, method: 'GET', signal + }, + options); + } + + + + +export const getGetSettingsQueryKey = () => { + return [ + `/v1/company/settings` + ] as const; + } + + +export const getGetSettingsQueryOptions = >, TError = void>( options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} +) => { + +const {query: queryOptions, request: requestOptions} = options ?? {}; + + const queryKey = queryOptions?.queryKey ?? getGetSettingsQueryKey(); + + + + const queryFn: QueryFunction>> = ({ signal }) => getSettings(requestOptions, signal); + + + + + + return { queryKey, queryFn, ...queryOptions} as UseQueryOptions>, TError, TData> & { queryKey: DataTag } +} + +export type GetSettingsQueryResult = NonNullable>> +export type GetSettingsQueryError = void + + +export function useGetSettings>, TError = void>( + options: { query:Partial>, TError, TData>> & Pick< + DefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): DefinedUseQueryResult & { queryKey: DataTag } +export function useGetSettings>, TError = void>( + options?: { query?:Partial>, TError, TData>> & Pick< + UndefinedInitialDataOptions< + Awaited>, + TError, + Awaited> + > , 'initialData' + >, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +export function useGetSettings>, TError = void>( + options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } +/** + * @summary 회사 커스터마이징 설정 조회 + */ + +export function useGetSettings>, TError = void>( + options?: { query?:Partial>, TError, TData>>, request?: SecondParameter} + , queryClient?: QueryClient + ): UseQueryResult & { queryKey: DataTag } { + + const queryOptions = getGetSettingsQueryOptions(options) + + const query = useQuery(queryOptions, queryClient) as UseQueryResult & { queryKey: DataTag }; + + query.queryKey = queryOptions.queryKey ; + + return query; +} + + + + +/** + * @summary 회사 커스터마이징 설정 수정(최고관리자) + */ +export const updateSettings = ( + reqUpdateCompanySettings: ReqUpdateCompanySettings, + options?: SecondParameter,) => { + + + return customFetch( + {url: `/v1/company/settings/update`, method: 'PUT', + headers: {'Content-Type': 'application/json', }, + data: reqUpdateCompanySettings + }, + options); + } + + + +export const getUpdateSettingsMutationOptions = (options?: { mutation?:UseMutationOptions>, TError,{data: ReqUpdateCompanySettings}, TContext>, request?: SecondParameter} +): UseMutationOptions>, TError,{data: ReqUpdateCompanySettings}, TContext> => { + +const mutationKey = ['updateSettings']; +const {mutation: mutationOptions, request: requestOptions} = options ? + options.mutation && 'mutationKey' in options.mutation && options.mutation.mutationKey ? + options + : {...options, mutation: {...options.mutation, mutationKey}} + : {mutation: { mutationKey, }, request: undefined}; + + + + + const mutationFn: MutationFunction>, {data: ReqUpdateCompanySettings}> = (props) => { + const {data} = props ?? {}; + + return updateSettings(data,requestOptions) + } + + + + + return { mutationFn, ...mutationOptions }} + + export type UpdateSettingsMutationResult = NonNullable>> + export type UpdateSettingsMutationBody = ReqUpdateCompanySettings + export type UpdateSettingsMutationError = void | HTTPValidationError + + /** + * @summary 회사 커스터마이징 설정 수정(최고관리자) + */ +export const useUpdateSettings = (options?: { mutation?:UseMutationOptions>, TError,{data: ReqUpdateCompanySettings}, TContext>, request?: SecondParameter} + , queryClient?: QueryClient): UseMutationResult< + Awaited>, + TError, + {data: ReqUpdateCompanySettings}, + TContext + > => { + + const mutationOptions = getUpdateSettingsMutationOptions(options); + + return useMutation(mutationOptions, queryClient); + } + \ No newline at end of file diff --git a/negodata/front/src/api/generated/model/index.ts b/negodata/front/src/api/generated/model/index.ts index b752a35..2079843 100644 --- a/negodata/front/src/api/generated/model/index.ts +++ b/negodata/front/src/api/generated/model/index.ts @@ -55,6 +55,8 @@ export * from './itemDataCategory'; export * from './itemDataCode'; export * from './itemDataCreatedAt'; export * from './itemDataCreatorName'; +export * from './itemDataCustom'; +export * from './itemDataCustomAnyOf'; export * from './itemDataDeliveryFeeYn'; export * from './itemDataDeliveryType'; export * from './itemDataImageUrl'; @@ -72,6 +74,8 @@ export * from './itemDataSpec'; export * from './itemDataUpdatedAt'; export * from './itemDataVatYn'; export * from './itemSupplyType'; +export * from './itemSupplyTypeSupplierItemId'; +export * from './itemSupplyTypeSupplierName'; export * from './listCardsParams'; export * from './listItemsParams'; export * from './listNotificationsParams'; @@ -139,6 +143,8 @@ export * from './reqCreateCompanyUser'; export * from './reqCreateItem'; export * from './reqCreateItemCategory'; export * from './reqCreateItemCode'; +export * from './reqCreateItemCustom'; +export * from './reqCreateItemCustomAnyOf'; export * from './reqCreateItemDeliveryFeeYn'; export * from './reqCreateItemDeliveryType'; export * from './reqCreateItemImageUrl'; @@ -168,6 +174,8 @@ export * from './reqCreateQuotationVersionId'; export * from './reqCreateSupplier'; export * from './reqCreateSupplierAccount'; export * from './reqCreateSupplierCode'; +export * from './reqCreateSupplierCustom'; +export * from './reqCreateSupplierCustomAnyOf'; export * from './reqCreateSupplierItem'; export * from './reqCreateSupplierManagerContactNumber'; export * from './reqCreateSupplierManagerEmail'; @@ -186,6 +194,8 @@ export * from './reqUpdateCardNumber'; export * from './reqUpdateCardScript'; export * from './reqUpdateCardStatus'; export * from './reqUpdateCardUsageType'; +export * from './reqUpdateCompanySettings'; +export * from './reqUpdateCompanySettingsSettings'; export * from './reqUpdateCompanyUser'; export * from './reqUpdateCompanyUserContactNumber'; export * from './reqUpdateCompanyUserEmail'; @@ -196,6 +206,8 @@ export * from './reqUpdateItem'; export * from './reqUpdateItemCategory'; export * from './reqUpdateItemCategoryType'; export * from './reqUpdateItemCode'; +export * from './reqUpdateItemCustom'; +export * from './reqUpdateItemCustomAnyOf'; export * from './reqUpdateItemDeliveryFeeYn'; export * from './reqUpdateItemDeliveryType'; export * from './reqUpdateItemImageUrl'; @@ -224,6 +236,8 @@ export * from './reqUpdateQuotationSettingTargetMarginRate'; export * from './reqUpdateSupplier'; export * from './reqUpdateSupplierAccountStatus'; export * from './reqUpdateSupplierCode'; +export * from './reqUpdateSupplierCustom'; +export * from './reqUpdateSupplierCustomAnyOf'; export * from './reqUpdateSupplierManagerContactNumber'; export * from './reqUpdateSupplierManagerEmail'; export * from './reqUpdateSupplierManagerName'; @@ -239,6 +253,10 @@ export * from './resCardListMsg'; export * from './resCardMsg'; export * from './resCheckCodes'; export * from './resCheckCodesMsg'; +export * from './resCompanySettings'; +export * from './resCompanySettingsMsg'; +export * from './resCompanySettingsSettings'; +export * from './resCompanySettingsSettingsAnyOf'; export * from './resCompanyUser'; export * from './resCompanyUserList'; export * from './resCompanyUserListMsg'; @@ -361,6 +379,8 @@ export * from './sessionData'; export * from './sessionDataAnchoringPrice'; export * from './sessionDataBidAt'; export * from './sessionDataBidPrice'; +export * from './sessionDataCustom'; +export * from './sessionDataCustomAnyOf'; export * from './sessionDataEmailSentAt'; export * from './sessionDataRejectDeliveryType'; export * from './sessionDataRejectPrice'; @@ -369,6 +389,7 @@ export * from './sessionStatus'; export * from './statCardUsage'; export * from './statCategory'; export * from './statKpi'; +export * from './statMarkupPoint'; export * from './statMonthPoint'; export * from './statOutcome'; export * from './statParticipation'; @@ -382,6 +403,8 @@ export * from './supplierDataAccountStatus'; export * from './supplierDataCode'; export * from './supplierDataCreatedAt'; export * from './supplierDataCreatorName'; +export * from './supplierDataCustom'; +export * from './supplierDataCustomAnyOf'; export * from './supplierDataManagerContactNumber'; export * from './supplierDataManagerEmail'; export * from './supplierDataManagerName'; @@ -389,7 +412,9 @@ export * from './supplierDataTotalRevenue'; export * from './supplierDataUpdatedAt'; export * from './supplierItemData'; export * from './supplierItemDataCreatedAt'; +export * from './supplierItemDataItemCategory'; export * from './supplierItemDataItemCode'; +export * from './supplierItemDataItemManufacturer'; export * from './supplierItemDataUpdatedAt'; export * from './targetCandidate'; export * from './userRole'; diff --git a/negodata/front/src/api/generated/model/itemData.ts b/negodata/front/src/api/generated/model/itemData.ts index c27d404..797746e 100644 --- a/negodata/front/src/api/generated/model/itemData.ts +++ b/negodata/front/src/api/generated/model/itemData.ts @@ -22,6 +22,7 @@ import type { ItemDataQuantityUnit } from './itemDataQuantityUnit'; import type { ItemDataDeliveryType } from './itemDataDeliveryType'; import type { ItemDataVatYn } from './itemDataVatYn'; import type { ItemDataDeliveryFeeYn } from './itemDataDeliveryFeeYn'; +import type { ItemDataCustom } from './itemDataCustom'; import type { ItemDataCreatedAt } from './itemDataCreatedAt'; import type { ItemDataUpdatedAt } from './itemDataUpdatedAt'; @@ -50,6 +51,8 @@ export interface ItemData { delivery_type?: ItemDataDeliveryType; vat_yn?: ItemDataVatYn; delivery_fee_yn?: ItemDataDeliveryFeeYn; + custom?: ItemDataCustom; + supplier_names?: string[]; created_at?: ItemDataCreatedAt; updated_at?: ItemDataUpdatedAt; } diff --git a/negodata/front/src/api/generated/model/itemDataCustom.ts b/negodata/front/src/api/generated/model/itemDataCustom.ts new file mode 100644 index 0000000..8668531 --- /dev/null +++ b/negodata/front/src/api/generated/model/itemDataCustom.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { ItemDataCustomAnyOf } from './itemDataCustomAnyOf'; + +export type ItemDataCustom = ItemDataCustomAnyOf | null; diff --git a/negodata/front/src/api/generated/model/itemDataCustomAnyOf.ts b/negodata/front/src/api/generated/model/itemDataCustomAnyOf.ts new file mode 100644 index 0000000..14e2b76 --- /dev/null +++ b/negodata/front/src/api/generated/model/itemDataCustomAnyOf.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ItemDataCustomAnyOf = { [key: string]: unknown }; diff --git a/negodata/front/src/api/generated/model/itemSupplyType.ts b/negodata/front/src/api/generated/model/itemSupplyType.ts index 47c4381..19e38b6 100644 --- a/negodata/front/src/api/generated/model/itemSupplyType.ts +++ b/negodata/front/src/api/generated/model/itemSupplyType.ts @@ -4,8 +4,12 @@ * Negodata Api Server * OpenAPI spec version: 0.1.0 */ +import type { ItemSupplyTypeSupplierName } from './itemSupplyTypeSupplierName'; +import type { ItemSupplyTypeSupplierItemId } from './itemSupplyTypeSupplierItemId'; export interface ItemSupplyType { supplier_id: string; supply_type: number; + supplier_name?: ItemSupplyTypeSupplierName; + supplier_item_id?: ItemSupplyTypeSupplierItemId; } diff --git a/negodata/front/src/api/generated/model/itemSupplyTypeSupplierItemId.ts b/negodata/front/src/api/generated/model/itemSupplyTypeSupplierItemId.ts new file mode 100644 index 0000000..a4ad3f1 --- /dev/null +++ b/negodata/front/src/api/generated/model/itemSupplyTypeSupplierItemId.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ItemSupplyTypeSupplierItemId = string | null; diff --git a/negodata/front/src/api/generated/model/itemSupplyTypeSupplierName.ts b/negodata/front/src/api/generated/model/itemSupplyTypeSupplierName.ts new file mode 100644 index 0000000..80d5c38 --- /dev/null +++ b/negodata/front/src/api/generated/model/itemSupplyTypeSupplierName.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ItemSupplyTypeSupplierName = string | null; diff --git a/negodata/front/src/api/generated/model/reqCreateItem.ts b/negodata/front/src/api/generated/model/reqCreateItem.ts index 6c90917..1c76474 100644 --- a/negodata/front/src/api/generated/model/reqCreateItem.ts +++ b/negodata/front/src/api/generated/model/reqCreateItem.ts @@ -21,6 +21,7 @@ import type { ReqCreateItemQuantityUnit } from './reqCreateItemQuantityUnit'; import type { ReqCreateItemDeliveryType } from './reqCreateItemDeliveryType'; import type { ReqCreateItemVatYn } from './reqCreateItemVatYn'; import type { ReqCreateItemDeliveryFeeYn } from './reqCreateItemDeliveryFeeYn'; +import type { ReqCreateItemCustom } from './reqCreateItemCustom'; export interface ReqCreateItem { name?: string; @@ -43,4 +44,5 @@ export interface ReqCreateItem { delivery_type?: ReqCreateItemDeliveryType; vat_yn?: ReqCreateItemVatYn; delivery_fee_yn?: ReqCreateItemDeliveryFeeYn; + custom?: ReqCreateItemCustom; } diff --git a/negodata/front/src/api/generated/model/reqCreateItemCustom.ts b/negodata/front/src/api/generated/model/reqCreateItemCustom.ts new file mode 100644 index 0000000..b19aff4 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqCreateItemCustom.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { ReqCreateItemCustomAnyOf } from './reqCreateItemCustomAnyOf'; + +export type ReqCreateItemCustom = ReqCreateItemCustomAnyOf | null; diff --git a/negodata/front/src/api/generated/model/reqCreateItemCustomAnyOf.ts b/negodata/front/src/api/generated/model/reqCreateItemCustomAnyOf.ts new file mode 100644 index 0000000..ec2a50a --- /dev/null +++ b/negodata/front/src/api/generated/model/reqCreateItemCustomAnyOf.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ReqCreateItemCustomAnyOf = { [key: string]: unknown }; diff --git a/negodata/front/src/api/generated/model/reqCreateSupplier.ts b/negodata/front/src/api/generated/model/reqCreateSupplier.ts index 6a47f23..f258842 100644 --- a/negodata/front/src/api/generated/model/reqCreateSupplier.ts +++ b/negodata/front/src/api/generated/model/reqCreateSupplier.ts @@ -9,6 +9,7 @@ import type { ReqCreateSupplierManagerName } from './reqCreateSupplierManagerNam import type { ReqCreateSupplierManagerEmail } from './reqCreateSupplierManagerEmail'; import type { ReqCreateSupplierManagerContactNumber } from './reqCreateSupplierManagerContactNumber'; import type { ReqCreateSupplierTotalRevenue } from './reqCreateSupplierTotalRevenue'; +import type { ReqCreateSupplierCustom } from './reqCreateSupplierCustom'; export interface ReqCreateSupplier { name?: string; @@ -17,4 +18,5 @@ export interface ReqCreateSupplier { manager_email?: ReqCreateSupplierManagerEmail; manager_contact_number?: ReqCreateSupplierManagerContactNumber; total_revenue?: ReqCreateSupplierTotalRevenue; + custom?: ReqCreateSupplierCustom; } diff --git a/negodata/front/src/api/generated/model/reqCreateSupplierCustom.ts b/negodata/front/src/api/generated/model/reqCreateSupplierCustom.ts new file mode 100644 index 0000000..3ce63d2 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqCreateSupplierCustom.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { ReqCreateSupplierCustomAnyOf } from './reqCreateSupplierCustomAnyOf'; + +export type ReqCreateSupplierCustom = ReqCreateSupplierCustomAnyOf | null; diff --git a/negodata/front/src/api/generated/model/reqCreateSupplierCustomAnyOf.ts b/negodata/front/src/api/generated/model/reqCreateSupplierCustomAnyOf.ts new file mode 100644 index 0000000..6a68288 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqCreateSupplierCustomAnyOf.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ReqCreateSupplierCustomAnyOf = { [key: string]: unknown }; diff --git a/negodata/front/src/api/generated/model/reqUpdateCompanySettings.ts b/negodata/front/src/api/generated/model/reqUpdateCompanySettings.ts new file mode 100644 index 0000000..6544fdf --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateCompanySettings.ts @@ -0,0 +1,11 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { ReqUpdateCompanySettingsSettings } from './reqUpdateCompanySettingsSettings'; + +export interface ReqUpdateCompanySettings { + settings?: ReqUpdateCompanySettingsSettings; +} diff --git a/negodata/front/src/api/generated/model/reqUpdateCompanySettingsSettings.ts b/negodata/front/src/api/generated/model/reqUpdateCompanySettingsSettings.ts new file mode 100644 index 0000000..4983a40 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateCompanySettingsSettings.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ReqUpdateCompanySettingsSettings = { [key: string]: unknown }; diff --git a/negodata/front/src/api/generated/model/reqUpdateItem.ts b/negodata/front/src/api/generated/model/reqUpdateItem.ts index 2b0dca9..32de250 100644 --- a/negodata/front/src/api/generated/model/reqUpdateItem.ts +++ b/negodata/front/src/api/generated/model/reqUpdateItem.ts @@ -24,6 +24,7 @@ import type { ReqUpdateItemQuantityUnit } from './reqUpdateItemQuantityUnit'; import type { ReqUpdateItemDeliveryType } from './reqUpdateItemDeliveryType'; import type { ReqUpdateItemVatYn } from './reqUpdateItemVatYn'; import type { ReqUpdateItemDeliveryFeeYn } from './reqUpdateItemDeliveryFeeYn'; +import type { ReqUpdateItemCustom } from './reqUpdateItemCustom'; export interface ReqUpdateItem { name?: ReqUpdateItemName; @@ -46,4 +47,5 @@ export interface ReqUpdateItem { delivery_type?: ReqUpdateItemDeliveryType; vat_yn?: ReqUpdateItemVatYn; delivery_fee_yn?: ReqUpdateItemDeliveryFeeYn; + custom?: ReqUpdateItemCustom; } diff --git a/negodata/front/src/api/generated/model/reqUpdateItemCustom.ts b/negodata/front/src/api/generated/model/reqUpdateItemCustom.ts new file mode 100644 index 0000000..a722686 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateItemCustom.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { ReqUpdateItemCustomAnyOf } from './reqUpdateItemCustomAnyOf'; + +export type ReqUpdateItemCustom = ReqUpdateItemCustomAnyOf | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateItemCustomAnyOf.ts b/negodata/front/src/api/generated/model/reqUpdateItemCustomAnyOf.ts new file mode 100644 index 0000000..bb8ec3f --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateItemCustomAnyOf.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ReqUpdateItemCustomAnyOf = { [key: string]: unknown }; diff --git a/negodata/front/src/api/generated/model/reqUpdateSupplier.ts b/negodata/front/src/api/generated/model/reqUpdateSupplier.ts index 312fe03..c28e1ad 100644 --- a/negodata/front/src/api/generated/model/reqUpdateSupplier.ts +++ b/negodata/front/src/api/generated/model/reqUpdateSupplier.ts @@ -10,6 +10,7 @@ import type { ReqUpdateSupplierManagerName } from './reqUpdateSupplierManagerNam import type { ReqUpdateSupplierManagerEmail } from './reqUpdateSupplierManagerEmail'; import type { ReqUpdateSupplierManagerContactNumber } from './reqUpdateSupplierManagerContactNumber'; import type { ReqUpdateSupplierTotalRevenue } from './reqUpdateSupplierTotalRevenue'; +import type { ReqUpdateSupplierCustom } from './reqUpdateSupplierCustom'; export interface ReqUpdateSupplier { name?: ReqUpdateSupplierName; @@ -18,4 +19,5 @@ export interface ReqUpdateSupplier { manager_email?: ReqUpdateSupplierManagerEmail; manager_contact_number?: ReqUpdateSupplierManagerContactNumber; total_revenue?: ReqUpdateSupplierTotalRevenue; + custom?: ReqUpdateSupplierCustom; } diff --git a/negodata/front/src/api/generated/model/reqUpdateSupplierCustom.ts b/negodata/front/src/api/generated/model/reqUpdateSupplierCustom.ts new file mode 100644 index 0000000..b736f51 --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateSupplierCustom.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { ReqUpdateSupplierCustomAnyOf } from './reqUpdateSupplierCustomAnyOf'; + +export type ReqUpdateSupplierCustom = ReqUpdateSupplierCustomAnyOf | null; diff --git a/negodata/front/src/api/generated/model/reqUpdateSupplierCustomAnyOf.ts b/negodata/front/src/api/generated/model/reqUpdateSupplierCustomAnyOf.ts new file mode 100644 index 0000000..dd30e8e --- /dev/null +++ b/negodata/front/src/api/generated/model/reqUpdateSupplierCustomAnyOf.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ReqUpdateSupplierCustomAnyOf = { [key: string]: unknown }; diff --git a/negodata/front/src/api/generated/model/resCompanySettings.ts b/negodata/front/src/api/generated/model/resCompanySettings.ts new file mode 100644 index 0000000..171065d --- /dev/null +++ b/negodata/front/src/api/generated/model/resCompanySettings.ts @@ -0,0 +1,15 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { ErrorInfo } from './errorInfo'; +import type { ResCompanySettingsMsg } from './resCompanySettingsMsg'; +import type { ResCompanySettingsSettings } from './resCompanySettingsSettings'; + +export interface ResCompanySettings { + result?: ErrorInfo; + msg?: ResCompanySettingsMsg; + settings?: ResCompanySettingsSettings; +} diff --git a/negodata/front/src/api/generated/model/resCompanySettingsMsg.ts b/negodata/front/src/api/generated/model/resCompanySettingsMsg.ts new file mode 100644 index 0000000..95b2d2f --- /dev/null +++ b/negodata/front/src/api/generated/model/resCompanySettingsMsg.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ResCompanySettingsMsg = string | null; diff --git a/negodata/front/src/api/generated/model/resCompanySettingsSettings.ts b/negodata/front/src/api/generated/model/resCompanySettingsSettings.ts new file mode 100644 index 0000000..97cfd88 --- /dev/null +++ b/negodata/front/src/api/generated/model/resCompanySettingsSettings.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { ResCompanySettingsSettingsAnyOf } from './resCompanySettingsSettingsAnyOf'; + +export type ResCompanySettingsSettings = ResCompanySettingsSettingsAnyOf | null; diff --git a/negodata/front/src/api/generated/model/resCompanySettingsSettingsAnyOf.ts b/negodata/front/src/api/generated/model/resCompanySettingsSettingsAnyOf.ts new file mode 100644 index 0000000..e6dc24f --- /dev/null +++ b/negodata/front/src/api/generated/model/resCompanySettingsSettingsAnyOf.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type ResCompanySettingsSettingsAnyOf = { [key: string]: unknown }; diff --git a/negodata/front/src/api/generated/model/sessionData.ts b/negodata/front/src/api/generated/model/sessionData.ts index ac9ff1a..f0fe2ea 100644 --- a/negodata/front/src/api/generated/model/sessionData.ts +++ b/negodata/front/src/api/generated/model/sessionData.ts @@ -13,6 +13,7 @@ import type { SessionDataRejectReason } from './sessionDataRejectReason'; import type { SessionDataRejectPrice } from './sessionDataRejectPrice'; import type { SessionDataRejectDeliveryType } from './sessionDataRejectDeliveryType'; import type { SessionDataEmailSentAt } from './sessionDataEmailSentAt'; +import type { SessionDataCustom } from './sessionDataCustom'; export interface SessionData { session_id: string; @@ -32,5 +33,6 @@ export interface SessionData { reject_price?: SessionDataRejectPrice; reject_delivery_type?: SessionDataRejectDeliveryType; email_sent_at?: SessionDataEmailSentAt; + custom?: SessionDataCustom; url?: string; } diff --git a/negodata/front/src/api/generated/model/sessionDataCustom.ts b/negodata/front/src/api/generated/model/sessionDataCustom.ts new file mode 100644 index 0000000..79ad8bd --- /dev/null +++ b/negodata/front/src/api/generated/model/sessionDataCustom.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { SessionDataCustomAnyOf } from './sessionDataCustomAnyOf'; + +export type SessionDataCustom = SessionDataCustomAnyOf | null; diff --git a/negodata/front/src/api/generated/model/sessionDataCustomAnyOf.ts b/negodata/front/src/api/generated/model/sessionDataCustomAnyOf.ts new file mode 100644 index 0000000..15c8024 --- /dev/null +++ b/negodata/front/src/api/generated/model/sessionDataCustomAnyOf.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type SessionDataCustomAnyOf = { [key: string]: unknown }; diff --git a/negodata/front/src/api/generated/model/supplierData.ts b/negodata/front/src/api/generated/model/supplierData.ts index 7afc969..ab8e18d 100644 --- a/negodata/front/src/api/generated/model/supplierData.ts +++ b/negodata/front/src/api/generated/model/supplierData.ts @@ -10,6 +10,7 @@ import type { SupplierDataManagerName } from './supplierDataManagerName'; import type { SupplierDataManagerEmail } from './supplierDataManagerEmail'; import type { SupplierDataManagerContactNumber } from './supplierDataManagerContactNumber'; import type { SupplierDataTotalRevenue } from './supplierDataTotalRevenue'; +import type { SupplierDataCustom } from './supplierDataCustom'; import type { SupplierDataAccountLoginId } from './supplierDataAccountLoginId'; import type { SupplierDataAccountStatus } from './supplierDataAccountStatus'; import type { SupplierDataCreatedAt } from './supplierDataCreatedAt'; @@ -26,6 +27,7 @@ export interface SupplierData { manager_email?: SupplierDataManagerEmail; manager_contact_number?: SupplierDataManagerContactNumber; total_revenue?: SupplierDataTotalRevenue; + custom?: SupplierDataCustom; account_login_id?: SupplierDataAccountLoginId; account_status?: SupplierDataAccountStatus; created_at?: SupplierDataCreatedAt; diff --git a/negodata/front/src/api/generated/model/supplierDataCustom.ts b/negodata/front/src/api/generated/model/supplierDataCustom.ts new file mode 100644 index 0000000..a40a22f --- /dev/null +++ b/negodata/front/src/api/generated/model/supplierDataCustom.ts @@ -0,0 +1,9 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ +import type { SupplierDataCustomAnyOf } from './supplierDataCustomAnyOf'; + +export type SupplierDataCustom = SupplierDataCustomAnyOf | null; diff --git a/negodata/front/src/api/generated/model/supplierDataCustomAnyOf.ts b/negodata/front/src/api/generated/model/supplierDataCustomAnyOf.ts new file mode 100644 index 0000000..295744c --- /dev/null +++ b/negodata/front/src/api/generated/model/supplierDataCustomAnyOf.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type SupplierDataCustomAnyOf = { [key: string]: unknown }; diff --git a/negodata/front/src/api/generated/model/supplierItemData.ts b/negodata/front/src/api/generated/model/supplierItemData.ts index 2912517..3eb059b 100644 --- a/negodata/front/src/api/generated/model/supplierItemData.ts +++ b/negodata/front/src/api/generated/model/supplierItemData.ts @@ -5,6 +5,8 @@ * OpenAPI spec version: 0.1.0 */ import type { SupplierItemDataItemCode } from './supplierItemDataItemCode'; +import type { SupplierItemDataItemCategory } from './supplierItemDataItemCategory'; +import type { SupplierItemDataItemManufacturer } from './supplierItemDataItemManufacturer'; import type { SupplierItemDataCreatedAt } from './supplierItemDataCreatedAt'; import type { SupplierItemDataUpdatedAt } from './supplierItemDataUpdatedAt'; @@ -14,6 +16,8 @@ export interface SupplierItemData { item_name: string; item_code?: SupplierItemDataItemCode; supply_type: number; + item_category?: SupplierItemDataItemCategory; + item_manufacturer?: SupplierItemDataItemManufacturer; created_at?: SupplierItemDataCreatedAt; updated_at?: SupplierItemDataUpdatedAt; } diff --git a/negodata/front/src/api/generated/model/supplierItemDataItemCategory.ts b/negodata/front/src/api/generated/model/supplierItemDataItemCategory.ts new file mode 100644 index 0000000..ef29224 --- /dev/null +++ b/negodata/front/src/api/generated/model/supplierItemDataItemCategory.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type SupplierItemDataItemCategory = string | null; diff --git a/negodata/front/src/api/generated/model/supplierItemDataItemManufacturer.ts b/negodata/front/src/api/generated/model/supplierItemDataItemManufacturer.ts new file mode 100644 index 0000000..735300e --- /dev/null +++ b/negodata/front/src/api/generated/model/supplierItemDataItemManufacturer.ts @@ -0,0 +1,8 @@ +/** + * Generated by orval v7.21.0 🍺 + * Do not edit manually. + * Negodata Api Server + * OpenAPI spec version: 0.1.0 + */ + +export type SupplierItemDataItemManufacturer = string | null; diff --git a/negodata/front/src/app/router.tsx b/negodata/front/src/app/router.tsx index c63580b..8419440 100644 --- a/negodata/front/src/app/router.tsx +++ b/negodata/front/src/app/router.tsx @@ -13,6 +13,7 @@ import PartnersPage from '../pages/partners'; import QuotationPage from '../pages/quotation'; import CardsPage from '../pages/cards'; import MembersPage from '../pages/members'; +import SettingsPage from '../pages/settings'; import NotificationsPage from '../pages/notifications'; import OnboardingPage from '../pages/onboarding'; @@ -88,6 +89,12 @@ export const router = createBrowserRouter([ loader: () => (hasRole('최고관리자') ? null : redirect('/forbidden')), Component: MembersPage, }, + { + // 최고관리자 전용. 회사 브랜딩/용어/커스텀필드 설정. + path: 'settings', + loader: () => (hasRole('최고관리자') ? null : redirect('/forbidden')), + Component: SettingsPage, + }, ], }, { diff --git a/negodata/front/src/components/layout/AuthenticatedLayout.tsx b/negodata/front/src/components/layout/AuthenticatedLayout.tsx index 7c40180..720cac1 100644 --- a/negodata/front/src/components/layout/AuthenticatedLayout.tsx +++ b/negodata/front/src/components/layout/AuthenticatedLayout.tsx @@ -12,6 +12,7 @@ const PAGE_TO_PATH: Record = { QUOTATION: '/quotation', CARDS: '/cards', MEMBERS: '/members', + SETTINGS: '/settings', NOTIFICATIONS: '/notifications', }; diff --git a/negodata/front/src/components/layout/Layout.tsx b/negodata/front/src/components/layout/Layout.tsx index eef9f3c..8d12d3c 100644 --- a/negodata/front/src/components/layout/Layout.tsx +++ b/negodata/front/src/components/layout/Layout.tsx @@ -1,6 +1,7 @@ import { useEffect, useState, type ReactNode, type ElementType } from 'react'; import { PageType } from '@/types'; import { useAuth } from '@/features/auth/useAuth'; +import { useBranding } from '@/features/settings/useCompanySettings'; import { ProfileSheet } from '@/features/auth/components/ProfileSheet'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -58,7 +59,10 @@ const menuGroups: { label?: string; items: MenuItem[] }[] = [ }, { label: '관리', - items: [{ type: 'MEMBERS', label: '회원관리', icon: UserCog, id: 'sidebar-members', ownerOnly: true }], + items: [ + { type: 'MEMBERS', label: '회원관리', icon: UserCog, id: 'sidebar-members', ownerOnly: true }, + { type: 'SETTINGS', label: '회사 설정', icon: Building, id: 'sidebar-settings', ownerOnly: true }, + ], }, ]; @@ -72,11 +76,13 @@ const pageLabelMap: Record = { QUOTATION: '견적관리', CARDS: '협상카드관리', MEMBERS: '회원관리', + SETTINGS: '회사 설정', NOTIFICATIONS: '알림', }; export default function Layout({ children, currentPage, setPage, onLogout }: LayoutProps) { const { user } = useAuth(); + const branding = useBranding(); // 회사 설정 브랜딩(서비스명/로고). 미설정 시 기본 NegoData // 기준일시(오늘) — 로컬 타임존 기준 YYYY-MM-DD const today = new Date().toLocaleDateString('sv-SE'); @@ -142,8 +148,16 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
{expanded && ( - - NegoData + {branding.logoUrl ? ( + {branding.serviceName} + ) : ( + + )} + {branding.serviceName} )} diff --git a/negodata/front/src/features/partners/components/PartnerFormSheet.tsx b/negodata/front/src/features/partners/components/PartnerFormSheet.tsx index b3c3d9c..a9895b7 100644 --- a/negodata/front/src/features/partners/components/PartnerFormSheet.tsx +++ b/negodata/front/src/features/partners/components/PartnerFormSheet.tsx @@ -12,6 +12,8 @@ import { Input } from '@/components/ui/input'; import { PhoneInput } from '@/components/ui/phone-input'; import { Sheet } from '@/components/ui/sheet'; import { useAuthStore } from '@/stores/auth'; +import { useCompanySettings } from '@/features/settings/useCompanySettings'; +import { CustomFieldInputs, useCustomFieldValues } from '@/features/settings/CustomFieldInputs'; import { SupplierItemsManager } from './SupplierItemsManager'; import { SupplierAccountManager } from './SupplierAccountManager'; import { type Partner } from '../types'; @@ -83,6 +85,11 @@ export function PartnerFormSheet({ // 협력사 명부는 회사 공유 자원 — 파괴적 삭제는 최고관리자만(백엔드 RequireOwner 와 동일 규칙). const isSuperAdmin = useAuthStore((s) => s.user?.role === '최고관리자'); + // 회사 협력사 커스텀필드(정의=companies.settings.supplier_fields, 값=suppliers.custom) + const { settings } = useCompanySettings(); + const supplierFields = settings.supplier_fields ?? []; + const customValues = useCustomFieldValues(supplierFields, partner?.custom); + const onValid = async (v: FormValues) => { const common = { name: v.name, @@ -91,6 +98,7 @@ export function PartnerFormSheet({ manager_email: v.managerEmail, manager_contact_number: v.managerPhone, total_revenue: v.totalRevenue?.trim() ? Number(v.totalRevenue.replace(/[^0-9]/g, '')) : undefined, + ...(supplierFields.length > 0 ? { custom: customValues.values } : {}), }; if (mode === 'create') { @@ -213,6 +221,9 @@ export function PartnerFormSheet({ {errors.managerPhone &&

{errors.managerPhone.message}

}
+ {/* 회사 커스텀 필드 — companies.settings.supplier_fields 정의대로 렌더, suppliers.custom 에 저장 */} + + {/* 취급상품 관리 — 수정 모드(협력사 확정)에서만. 추가/삭제/유형변경은 즉시 서버 반영. */} {mode === 'edit' && partner && } diff --git a/negodata/front/src/features/partners/components/PartnerTable.tsx b/negodata/front/src/features/partners/components/PartnerTable.tsx index 1b77af8..b03d015 100644 --- a/negodata/front/src/features/partners/components/PartnerTable.tsx +++ b/negodata/front/src/features/partners/components/PartnerTable.tsx @@ -5,6 +5,8 @@ import type { Partner } from '../types'; type PartnerTableProps = { data: Partner[]; + selectedIds: string[]; + onSelectionChange: (ids: string[]) => void; onRowClick: (part: Partner) => void; page: number; totalPages: number; @@ -15,6 +17,8 @@ type PartnerTableProps = { export function PartnerTable({ data, + selectedIds, + onSelectionChange, onRowClick, page, totalPages, @@ -27,6 +31,7 @@ export function PartnerTable({ data={data} rowKey={(part) => part.supplier_id} onRowClick={onRowClick} + selection={{ selectedKeys: selectedIds, onSelectionChange }} empty="협약된 가용 B2B 파트너사가 존재하지 않습니다." footer={ 취급상품 ({items.length}) @@ -110,7 +111,9 @@ export function SupplierItemsManager({ supplierId }: { supplierId: string }) { items.map((m) => (
- {m.item_name} + + {m.item_category ? `${m.item_category} - ${m.item_name}` : m.item_name} + {m.item_code && ( {m.item_code} )} diff --git a/negodata/front/src/features/products/components/ExcelUploadModal.tsx b/negodata/front/src/features/products/components/ExcelUploadModal.tsx index 9db16af..ce29697 100644 --- a/negodata/front/src/features/products/components/ExcelUploadModal.tsx +++ b/negodata/front/src/features/products/components/ExcelUploadModal.tsx @@ -8,6 +8,8 @@ import { customFetch } from '@/api/mutator/custom-fetch'; import { Typography } from '@/components/ui/typography'; import { Input } from '@/components/ui/input'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import { useCompanySettings, useLabels } from '@/features/settings/useCompanySettings'; +import type { CustomFieldDef } from '@/features/settings/catalog'; import type { Product } from '../types'; // 엑셀에서 읽어온 원본 행(입력값만). status/message는 저장하지 않고 검증에서 파생한다. @@ -29,20 +31,16 @@ type RawRow = { moq: string; lead_time: number; quantity_unit: string; - delivery_type: string; // 한글 라벨로 입력 → 전송 시 코드 변환 + delivery_type: string; // 라벨로 입력 → 전송 시 코드 변환 vat_yn: string; // Y/N delivery_fee_yn: string; // Y/N + custom: Record; // 회사 커스텀필드(item_fields) 값 — 전송 시 타입 변환 }; // 검증 결과가 붙은 행. UI는 이걸 그린다. type ValidatedRow = RawRow & { status: '정상' | '오류'; message: string }; -// 배송형태 라벨 ↔ delivery_type 코드(공용 enum 과 동일 집합) -const DELIVERY_LABEL_TO_CODE: Record = { - 협력사배송: 1, - 지정택배배송: 2, - 픽업배송: 3, -}; +type LabelFn = (key: string) => string; // 자유로운 Y/N 표기 → boolean (Y·예·true·O·포함·1 = true) const parseYn = (s: string): boolean => /^(y|yes|true|1|예|o|포함)$/i.test(s.trim()); @@ -51,31 +49,71 @@ const parseYn = (s: string): boolean => /^(y|yes|true|1|예|o|포함)$/i.test(s. const isYnToken = (s: string): boolean => /^(y|yes|true|1|예|o|포함|n|no|false|0|아니오|x|미포함)$/i.test(s.trim()); -// 업로드 양식(.csv) 컬럼 정의 — 헤더 ↔ RawRow 필드. 양식/예시/파싱이 이 한 곳을 공유한다. -const UPLOAD_COLUMNS: { header: string; key: keyof RawRow }[] = [ - { header: '상품명', key: 'name' }, - { header: '상품코드', key: 'code' }, - { header: '모델번호', key: 'model_name' }, - { header: '카테고리', key: 'category' }, - { header: '규격', key: 'spec' }, - { header: '제조사', key: 'manufacturer' }, - { header: '원산지', key: 'made_in' }, - { header: '상품 단가', key: 'price' }, - { header: '최저한도', key: 'minPrice' }, - { header: '매입가', key: 'purchase_price' }, - { header: '판매가', key: 'selling_price' }, - { header: '이미지URL', key: 'image_url' }, - { header: '최소주문수량', key: 'moq' }, - { header: '리드타임(일)', key: 'lead_time' }, - { header: '단위', key: 'quantity_unit' }, - { header: '배송형태', key: 'delivery_type' }, - { header: '부가세포함(Y/N)', key: 'vat_yn' }, - { header: '배송비포함(Y/N)', key: 'delivery_fee_yn' }, +// 표준 컬럼(고정 18개 — 삭제 없음). labelKey 가 있으면 헤더를 회사 설정 용어로 치환하고, +// base 헤더는 구양식 파일 호환용 별칭으로 계속 인식한다. +const STANDARD_COLUMNS: { base: string; key: Exclude; labelKey?: string; suffix?: string }[] = [ + { base: '상품명', key: 'name' }, + { base: '상품코드', key: 'code' }, + { base: '모델번호', key: 'model_name' }, + { base: '카테고리', key: 'category', labelKey: 'category' }, + { base: '규격', key: 'spec' }, + { base: '제조사', key: 'manufacturer' }, + { base: '원산지', key: 'made_in' }, + { base: '상품 단가', key: 'price', labelKey: 'item.price' }, + { base: '최저한도', key: 'minPrice' }, + { base: '매입가', key: 'purchase_price' }, + { base: '판매가', key: 'selling_price' }, + { base: '이미지URL', key: 'image_url' }, + { base: '최소주문수량', key: 'moq' }, + { base: '리드타임(일)', key: 'lead_time', labelKey: 'lead_time', suffix: '(일)' }, + { base: '단위', key: 'quantity_unit' }, + { base: '배송형태', key: 'delivery_type' }, + { base: '부가세포함(Y/N)', key: 'vat_yn' }, + { base: '배송비포함(Y/N)', key: 'delivery_fee_yn' }, ]; -// 숫자 입력 컬럼 / 필수 컬럼(헤더에 * 표기) -const NUMERIC_KEYS = new Set(['price', 'minPrice', 'purchase_price', 'selling_price', 'lead_time']); -const REQUIRED_KEYS = new Set(['name', 'code', 'price']); +// 렌더/파싱/양식이 공유하는 컬럼(헤더는 회사 설정 반영). customKey 있으면 커스텀필드 컬럼. +type UploadColumn = { + header: string; + aliases: string[]; // 파싱 시 인식할 헤더 후보(회사 라벨 + 기본 헤더) + key?: Exclude; + customKey?: string; + numeric: boolean; + required: boolean; +}; + +const NUMERIC_KEYS = new Set(['price', 'minPrice', 'purchase_price', 'selling_price', 'lead_time']); +const REQUIRED_KEYS = new Set(['name', 'code', 'price']); + +// 회사 설정(용어 라벨 + item_fields 커스텀필드)으로 업로드 컬럼을 만든다. +// 기존 18컬럼은 전부 유지, 커스텀필드는 뒤에 추가된다. +function buildColumns(label: LabelFn, itemFields: CustomFieldDef[]): UploadColumn[] { + const standard: UploadColumn[] = STANDARD_COLUMNS.map((c) => { + const header = c.labelKey ? `${label(c.labelKey)}${c.suffix ?? ''}` : c.base; + return { + header, + aliases: [...new Set([header, c.base])], + key: c.key, + numeric: NUMERIC_KEYS.has(c.key), + required: REQUIRED_KEYS.has(c.key), + }; + }); + const custom: UploadColumn[] = itemFields.map((f) => ({ + header: f.type === 'boolean' ? `${f.label}(Y/N)` : f.label, + aliases: [f.type === 'boolean' ? `${f.label}(Y/N)` : f.label, f.label], + customKey: f.key, + numeric: f.type === 'number', + required: false, + })); + return [...standard, ...custom]; +} + +// 배송형태 라벨 → 코드. 기본 라벨과 회사 설정 라벨(직납 등)을 모두 인식한다. +function buildDeliveryMap(label: LabelFn): Record { + const map: Record = { 협력사배송: 1, 지정택배배송: 2, 픽업배송: 3 }; + for (const code of [1, 2, 3]) map[label(`delivery_type.${code}`)] = code; + return map; +} // 양식에 채워 넣는 예시 행(시드 상품과 동일 셋). 다운로드 양식에 그대로 들어간다. const EXAMPLE_ROWS: Record[] = [ @@ -83,23 +121,32 @@ const EXAMPLE_ROWS: Record[] = [ name: '리튬인산철 배터리 모듈', code: 'BAT-LFP-100', model_name: 'LFP-100A', category: '에너지/배터리', spec: '3.2V 100Ah', manufacturer: '한성에너지', made_in: '대한민국', price: 1250000, minPrice: 1037500, purchase_price: 1000000, selling_price: 1250000, image_url: 'https://example.com/img/lfp-100a.jpg', - moq: '10 EA', lead_time: 14, quantity_unit: 'EA', delivery_type: '협력사배송', + moq: '10 EA', lead_time: 14, quantity_unit: 'EA', delivery_type: 1, vat_yn: 'Y', delivery_fee_yn: 'N', }, { name: '산업용 6축 로봇암', code: 'ROB-6AX-22', model_name: 'RX-6A', category: '자동화설비', spec: '가반하중 12kg', manufacturer: '오토메카', made_in: '일본', price: 18900000, minPrice: 16065000, purchase_price: 15000000, selling_price: 18900000, image_url: 'https://example.com/img/rx-6a.jpg', - moq: '1 EA', lead_time: 30, quantity_unit: 'EA', delivery_type: '지정택배배송', + moq: '1 EA', lead_time: 30, quantity_unit: 'EA', delivery_type: 2, vat_yn: 'Y', delivery_fee_yn: 'N', }, ]; -// 업로드 양식(.csv) 다운로드 — 전체 컬럼 헤더 + 예시 행(시드 상품 셋). UPLOAD_COLUMNS 단일 정의 공유. 툴바·모달이 공유한다. -export function downloadProductTemplate() { +// 업로드 양식(.csv) 다운로드 — 회사 설정 헤더(라벨 치환) + 커스텀필드 컬럼 + 예시 행. +// 파싱(buildColumns)과 같은 정의를 공유하므로 받은 양식이 그대로 다시 업로드된다. 툴바·모달이 공유한다. +export function downloadProductTemplate(label: LabelFn, itemFields: CustomFieldDef[]) { + const columns = buildColumns(label, itemFields); downloadExcel>( '상품_업로드_양식', - UPLOAD_COLUMNS.map((c) => ({ header: c.header, value: (r) => r[c.key] })), + columns.map((c) => ({ + header: c.header, + value: (r) => { + if (c.customKey) return c.numeric ? 10 : ''; // 커스텀 예시값(숫자=10, 그 외 빈칸) + if (c.key === 'delivery_type') return label(`delivery_type.${r.delivery_type}`); // 예시도 회사 라벨로 + return r[c.key as string]; + }, + })), EXAMPLE_ROWS, ); } @@ -117,6 +164,10 @@ function validateRows( rows: RawRow[], products: Product[], serverErrors: Record, + deliveryMap: Record, + deliveryLabels: string[], + priceLabel: string, + itemFields: CustomFieldDef[], ): ValidatedRow[] { return rows.map((row) => { const fail = (message: string): ValidatedRow => ({ ...row, status: '오류', message }); @@ -128,12 +179,12 @@ function validateRows( const dupInExcel = rows.some((other) => other.id !== row.id && other.code === row.code); if (dupInProducts || dupInExcel) return fail('코드 중복 - 이미 존재하거나 목록 내 중복된 코드입니다.'); - if (row.price <= 0) return fail('유효성 위반 - 상품 단가는 0보다 커야 합니다.'); - if (row.minPrice > row.price) return fail('유효성 위반 - 최저 한도가 상품 단가보다 큽니다.'); + if (row.price <= 0) return fail(`유효성 위반 - ${priceLabel}는 0보다 커야 합니다.`); + if (row.minPrice > row.price) return fail(`유효성 위반 - 최저 한도가 ${priceLabel}보다 큽니다.`); // 선택 필드 형식 검증(값이 있을 때만). 배송형태/부가세/배송비/이미지URL. - if (row.delivery_type.trim() && !(row.delivery_type.trim() in DELIVERY_LABEL_TO_CODE)) { - return fail('배송형태 - 협력사배송 / 지정택배배송 / 픽업배송 중 하나여야 합니다.'); + if (row.delivery_type.trim() && !(row.delivery_type.trim() in deliveryMap)) { + return fail(`배송형태 - ${deliveryLabels.join(' / ')} 중 하나여야 합니다.`); } if (row.vat_yn.trim() && !isYnToken(row.vat_yn)) { return fail('부가세포함 - Y 또는 N(예/아니오)으로 입력해 주십시오.'); @@ -144,6 +195,13 @@ function validateRows( if (row.image_url.trim() && !/^https?:\/\//i.test(row.image_url.trim())) { return fail('이미지URL - http:// 또는 https:// 로 시작하는 주소여야 합니다.'); } + // 커스텀필드 형식 검증(값이 있을 때만). boolean=Y/N 토큰, number=숫자. + for (const f of itemFields) { + const v = (row.custom[f.key] ?? '').trim(); + if (!v) continue; + if (f.type === 'boolean' && !isYnToken(v)) return fail(`${f.label} - Y 또는 N(예/아니오)으로 입력해 주십시오.`); + if (f.type === 'number' && Number.isNaN(Number(v))) return fail(`${f.label} - 숫자로 입력해 주십시오.`); + } // 프론트 검증 통과 후, 직전 전송에서 서버가 거부한 코드면 그 사유로 오류 처리. if (serverErrors[row.code]) return fail(serverErrors[row.code]); @@ -153,7 +211,14 @@ function validateRows( } // 검증된(정상) 행 → 서버 생성 payload. 엑셀 업로드 공통 기본값 적용. -function toItemCreate(row: RawRow): ItemCreate { +function toItemCreate(row: RawRow, deliveryMap: Record, itemFields: CustomFieldDef[]): ItemCreate { + // 커스텀필드 문자열 → 정의 타입대로 변환(빈 값은 제외) + const custom: Record = {}; + for (const f of itemFields) { + const v = (row.custom[f.key] ?? '').trim(); + if (!v) continue; + custom[f.key] = f.type === 'number' ? Number(v) : f.type === 'boolean' ? parseYn(v) : v; + } return { name: row.name, code: row.code, @@ -169,19 +234,27 @@ function toItemCreate(row: RawRow): ItemCreate { moq: row.moq || undefined, lead_time: row.lead_time || undefined, quantity_unit: row.quantity_unit || undefined, - delivery_type: DELIVERY_LABEL_TO_CODE[row.delivery_type.trim()] ?? undefined, + delivery_type: deliveryMap[row.delivery_type.trim()] ?? undefined, vat_yn: row.vat_yn.trim() ? parseYn(row.vat_yn) : undefined, delivery_fee_yn: row.delivery_fee_yn.trim() ? parseYn(row.delivery_fee_yn) : undefined, // minPrice(최저한도) = 인터넷 최저가(실값) → internet_lowest_price 로 저장(수기 폼과 동일). internet_lowest_price: row.minPrice, internet_lowest_price_yn: row.minPrice > 0, + ...(Object.keys(custom).length > 0 ? { custom } : {}), }; } -// 상품 엑셀 일괄 업로드 모달. 파일 파싱(목업)·원본 행 state는 이 컴포넌트가 소유하고, +// 상품 엑셀 일괄 업로드 모달. 파일 파싱·원본 행 state는 이 컴포넌트가 소유하고, // 검증은 렌더 시 validateRows로 파생한다. 실제 서버 등록은 onConfirm(검증된 행)으로 위임. +// 컬럼(헤더 라벨·커스텀필드)은 회사 설정을 따른다 — 양식 다운로드와 동일 정의. export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUploadModalProps) { useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금 + const label = useLabels(); + const { settings } = useCompanySettings(); + const itemFields = useMemo(() => settings.item_fields ?? [], [settings.item_fields]); + const columns = useMemo(() => buildColumns(label, itemFields), [label, itemFields]); + const deliveryMap = useMemo(() => buildDeliveryMap(label), [label]); + const [excelFile, setExcelFile] = useState(null); const [rows, setRows] = useState([]); const [serverErrors, setServerErrors] = useState>({}); // 서버(DB) 거부 code→사유 @@ -190,8 +263,12 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp // 파생: 검증 결과 + 카운트 (state로 저장하지 않음) const validated = useMemo( - () => validateRows(rows, products, serverErrors), - [rows, products, serverErrors], + () => validateRows( + rows, products, serverErrors, deliveryMap, + [1, 2, 3].map((c) => label(`delivery_type.${c}`)), + label('item.price'), itemFields, + ), + [rows, products, serverErrors, deliveryMap, label, itemFields], ); const validRows = validated.filter((r) => r.status === '정상'); const validCount = validRows.length; @@ -206,31 +283,31 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp onClose(); }; - // 업로드된 CSV를 파싱해 원본 행으로 적재(검증은 자동 파생). 헤더는 양식과 동일해야 함. + // 업로드된 CSV를 파싱해 원본 행으로 적재(검증은 자동 파생). + // 헤더는 회사 라벨과 기본 헤더(구양식) 둘 다 인식한다(aliases). const handleFile = async (file: File) => { const parsed = parseCsv(await file.text()); - const loaded: RawRow[] = parsed.map((r, i) => ({ - id: `row-${i + 1}`, - rowNum: i + 2, - name: r['상품명'] ?? '', - code: r['상품코드'] ?? '', - model_name: r['모델번호'] ?? '', - category: r['카테고리'] ?? '', - spec: r['규격'] ?? '', - manufacturer: r['제조사'] ?? '', - made_in: r['원산지'] ?? '', - price: Number(r['상품 단가']) || 0, - minPrice: Number(r['최저한도']) || 0, - purchase_price: Number(r['매입가']) || 0, - selling_price: Number(r['판매가']) || 0, - image_url: r['이미지URL'] ?? '', - moq: r['최소주문수량'] ?? '', - lead_time: Number(r['리드타임(일)']) || 0, - quantity_unit: r['단위'] ?? '', - delivery_type: r['배송형태'] ?? '', - vat_yn: r['부가세포함(Y/N)'] ?? '', - delivery_fee_yn: r['배송비포함(Y/N)'] ?? '', - })); + const pick = (r: Record, c: UploadColumn): string => { + for (const a of c.aliases) if (r[a] !== undefined) return r[a]; + return ''; + }; + const loaded: RawRow[] = parsed.map((r, i) => { + const row: RawRow = { + id: `row-${i + 1}`, + rowNum: i + 2, + name: '', code: '', model_name: '', category: '', spec: '', manufacturer: '', made_in: '', + price: 0, minPrice: 0, purchase_price: 0, selling_price: 0, + image_url: '', moq: '', lead_time: 0, quantity_unit: '', delivery_type: '', vat_yn: '', delivery_fee_yn: '', + custom: {}, + }; + for (const c of columns) { + const v = pick(r, c); + if (c.customKey) row.custom[c.customKey] = v; + else if (c.numeric) (row[c.key!] as number) = Number(v) || 0; + else (row[c.key!] as string) = v; + } + return row; + }); setExcelFile(file.name); setRows(loaded); setServerErrors({}); // 새 파일 → 직전 서버사유 초기화 @@ -252,15 +329,14 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp } }; - // 인라인 편집 — 원본 필드만 갱신(재검증은 파생이 처리). 숫자 컬럼은 정수화. - const handleUpdateField = (id: string, field: keyof RawRow, value: string) => { + // 인라인 편집 — 원본 필드만 갱신(재검증은 파생이 처리). 표준 숫자 컬럼은 정수화, 커스텀은 문자열 보관. + const handleUpdateField = (id: string, c: UploadColumn, value: string) => { setRows((cur) => cur.map((row) => { if (row.id !== id) return row; - if (NUMERIC_KEYS.has(field)) { - return { ...row, [field]: Math.max(0, parseInt(value, 10) || 0) }; - } - return { ...row, [field]: value }; + if (c.customKey) return { ...row, custom: { ...row.custom, [c.customKey]: value } }; + if (c.numeric) return { ...row, [c.key!]: Math.max(0, parseInt(value, 10) || 0) }; + return { ...row, [c.key!]: value }; }), ); }; @@ -276,7 +352,7 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp return; } try { - const failures = await onConfirm(validRows.map(toItemCreate)); + const failures = await onConfirm(validRows.map((r) => toItemCreate(r, deliveryMap, itemFields))); const okCount = validRows.length - failures.length; if (failures.length === 0) { showToast(`총 ${okCount}개 상품이 서버에 일괄 등록되었습니다.`, 'success'); @@ -381,12 +457,12 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp 삭제 자격 진단 내용 - {UPLOAD_COLUMNS.map((c) => ( + {columns.map((c) => ( - {c.header}{REQUIRED_KEYS.has(c.key) ? ' *' : ''} + {c.header}{c.required ? ' *' : ''} ))} @@ -417,16 +493,16 @@ export function ExcelUploadModal({ open, products, onConfirm, onClose }: ExcelUp {row.message} - {UPLOAD_COLUMNS.map((c) => { - const isNum = NUMERIC_KEYS.has(c.key); + {columns.map((c) => { + const value = c.customKey ? row.custom[c.customKey] ?? '' : row[c.key!]; return ( - + handleUpdateField(row.id, c.key, e.target.value)} + className={`bg-muted/20 hover:bg-muted/50 ${c.numeric ? 'w-24 text-right font-mono' : 'min-w-[110px] font-mono'}`} + value={String(value ?? '')} + onChange={(e) => handleUpdateField(row.id, c, e.target.value)} /> ); diff --git a/negodata/front/src/features/products/components/ItemSuppliersManager.tsx b/negodata/front/src/features/products/components/ItemSuppliersManager.tsx new file mode 100644 index 0000000..eb55f26 --- /dev/null +++ b/negodata/front/src/features/products/components/ItemSuppliersManager.tsx @@ -0,0 +1,146 @@ +import { useState } from 'react'; +import { Plus, Trash2 } from 'lucide-react'; +import { useListSuppliers } from '@/api/generated/supplier/supplier'; +import { Typography } from '@/components/ui/typography'; +import { Button } from '@/components/ui/button'; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Combobox, type ComboOption } from '@/components/ui/combobox'; +import { SUPPLIER_TYPE_OPTIONS, SupplierType, supplierTypeLabel } from '@/lib/enumLabels'; +import { showToast } from '@/lib/notify'; +import { useItemSuppliers } from '../hooks/useItemSuppliers'; + +// 상품 상세의 공급사 관리 섹션(IMK #20) — SupplierItemsManager(협력사측)의 상품측 미러. +// 공급사 추가/삭제 + 공급유형(제조/유통/총판/없음) 수정. 각 조작은 즉시 서버 반영(상품 기본정보 저장과 독립). +export function ItemSuppliersManager({ itemId }: { itemId: string }) { + const { suppliers, isLoading, addSupplier, changeType, removeSupplier } = useItemSuppliers(itemId); + const [q, setQ] = useState(''); + const catalogQuery = useListSuppliers({ search: q || undefined, size: 30 }); // 서버검색 + + const [pickSupplierId, setPickSupplierId] = useState(''); + const [pickLabel, setPickLabel] = useState(''); + const [pickType, setPickType] = useState(String(SupplierType.NONE)); // 기본 없음(0) + const [busy, setBusy] = useState(false); + + const mappedIds = new Set(suppliers.map((m) => m.supplier_id)); + const options: ComboOption[] = (catalogQuery.data?.suppliers ?? []) + .filter((s) => !mappedIds.has(s.supplier_id)) + .map((s) => ({ id: s.supplier_id, label: `${s.name}${s.code ? ` [${s.code}]` : ''}` })); + + const handleAdd = async () => { + if (!pickSupplierId) { + showToast('추가할 공급사를 선택하세요.', 'error'); + return; + } + setBusy(true); + try { + await addSupplier(pickSupplierId, Number(pickType)); + setPickSupplierId(''); + setPickLabel(''); + setQ(''); + showToast('공급사가 추가되었습니다.', 'success'); + } catch (err) { + showToast(err instanceof Error ? err.message : '공급사 추가 실패', 'error'); + } finally { + setBusy(false); + } + }; + + const handleChangeType = async (supplierItemId: string, v: string) => { + try { + await changeType(supplierItemId, Number(v)); + } catch { + showToast('공급유형 변경에 실패했습니다.', 'error'); + } + }; + + const handleRemove = async (supplierItemId: string) => { + try { + await removeSupplier(supplierItemId); + showToast('공급사가 삭제되었습니다.', 'info'); + } catch { + showToast('공급사 삭제에 실패했습니다.', 'error'); + } + }; + + return ( +
+ 공급사 ({suppliers.length}) + + {/* 추가 행 — 공급사 + 공급유형 선택 후 추가 */} +
+
+ { setPickSupplierId(opt.id); setPickLabel(opt.label); }} + placeholder="공급사로 추가할 협력사 검색..." + searchPlaceholder="협력사명·코드로 검색..." + emptyText="일치하는 협력사가 없습니다" + /> +
+
+ +
+ +
+ + {/* 현재 공급사 목록 */} +
+ {isLoading ? ( + 불러오는 중… + ) : suppliers.length === 0 ? ( + 등록된 공급사가 없습니다. + ) : ( + suppliers.map((m) => ( +
+
+ + {m.supplier_name ?? '-'} + +
+
+ +
+ +
+ )) + )} +
+
+ ); +} diff --git a/negodata/front/src/features/products/components/ProductFormSheet.tsx b/negodata/front/src/features/products/components/ProductFormSheet.tsx index 31ae1a8..f20eb81 100644 --- a/negodata/front/src/features/products/components/ProductFormSheet.tsx +++ b/negodata/front/src/features/products/components/ProductFormSheet.tsx @@ -13,6 +13,9 @@ import { Input } from '@/components/ui/input'; import { Sheet } from '@/components/ui/sheet'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { useAuthStore } from '@/stores/auth'; +import { useCompanySettings, useLabels } from '@/features/settings/useCompanySettings'; +import { CustomFieldInputs, useCustomFieldValues } from '@/features/settings/CustomFieldInputs'; +import { ItemSuppliersManager } from './ItemSuppliersManager'; import { type Product } from '../types'; // 폼 검증 스키마. 필수: 상품명/상품코드/단가/최저가. 나머지는 선택. @@ -128,7 +131,13 @@ export function ProductFormSheet({ defaultValues: buildDefaults(mode, product), }); - const deliveryTypes = DELIVERY_TYPE_OPTIONS; + const label = useLabels(); // 회사 설정 용어 + // 배송유형 선택지 — 회사 설정 용어(delivery_type.N)로 라벨만 치환(코드값 불변) + const deliveryTypes = DELIVERY_TYPE_OPTIONS.map((o) => ({ ...o, label: label(`delivery_type.${o.value}`) })); + // 회사 상품 커스텀필드(정의=companies.settings.item_fields, 값=items.custom) + const { settings } = useCompanySettings(); + const itemFields = settings.item_fields ?? []; + const customValues = useCustomFieldValues(itemFields, product?.custom); // 소유자 게이팅 — 본인이 등록한 상품 또는 최고관리자만 수정·삭제(프론트 1차 차단, 백엔드도 강제). const myUserId = useAuthStore((s) => s.user?.userId); @@ -162,6 +171,7 @@ export function ProductFormSheet({ internet_lowest_price: v.minPrice, purchase_price: v.purchasePrice, selling_price: v.sellingPrice, + ...(itemFields.length > 0 ? { custom: customValues.values } : {}), }; if (mode === 'create') { @@ -235,7 +245,7 @@ export function ProductFormSheet({
{/* Category — 기존(서버 items distinct) 선택 또는 새 카테고리 직접 입력(datalist 콤보) */}
- 분류 카테고리 + {label('category')} {/* Price */}
- 상품 단가 (₩) + {label('item.price')} (₩) {/* Lead Time */}
- 배송 리드타임 (일) + {label('lead_time')} (일)
+ {/* 공급사 관리(IMK #20) — 수정 모드(상품 확정)에서만. 추가/삭제/유형변경은 즉시 서버 반영. */} + {mode === 'edit' && product && } + + {/* 회사 커스텀 필드 — companies.settings.item_fields 정의대로 렌더, items.custom 에 저장 */} + + {/* Drag-and-Drop Image Dropzone */} prod.code, }, { - header: '카테고리', + header: label('category'), align: 'left', cell: (prod) => ( @@ -82,7 +84,21 @@ export function ProductTable({ ), }, { - header: '상품 단가', + header: '공급사', + align: 'left', + cell: (prod) => { + const names = prod.supplier_names ?? []; + if (names.length === 0) return -; + return ( + + {names[0]} + {names.length > 1 && 외 {names.length - 1}} + + ); + }, + }, + { + header: label('item.price'), align: 'right', cellClassName: 'font-mono font-bold text-foreground', cell: (prod) => `₩${(prod.price || 0).toLocaleString()}`, diff --git a/negodata/front/src/features/products/hooks/useItemSuppliers.ts b/negodata/front/src/features/products/hooks/useItemSuppliers.ts new file mode 100644 index 0000000..a761f3f --- /dev/null +++ b/negodata/front/src/features/products/hooks/useItemSuppliers.ts @@ -0,0 +1,46 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { + useListItemSupplyTypes, + createSupplierItem, + updateSupplyType, + deleteSupplierItem, + getListItemSupplyTypesQueryKey, +} from '@/api/generated/supplier-item/supplier-item'; +import type { ItemSupplyType } from '@/api/generated/model/itemSupplyType'; + +// 상품 취급 공급사(매핑) 서버 데이터 + CRUD — useSupplierItems(협력사측)의 상품측 미러. +// 같은 partner.supplier_items 매핑을 상품 상세(ProductFormSheet)에서 편집한다. +// itemId 가 없으면(신규 등록 폼) 쿼리는 비활성. +export function useItemSuppliers(itemId: string | undefined) { + const queryClient = useQueryClient(); + const listQuery = useListItemSupplyTypes(itemId ?? '', { query: { enabled: !!itemId } }); + + const refresh = () => + itemId + ? queryClient.invalidateQueries({ queryKey: getListItemSupplyTypesQueryKey(itemId) }) + : Promise.resolve(); + + const suppliers: ItemSupplyType[] = listQuery.data?.suppliers ?? []; + + const addSupplier = async (supplierId: string, supplyType: number) => { + if (!itemId) return; + const res = await createSupplierItem({ supplier_id: supplierId, item_id: itemId, supply_type: supplyType }); + const r = res.result; + if (r && r.success === false) { + throw new Error(r.desc === 'DB_ALREADY_SAME_KEY' ? '이미 등록된 공급사입니다.' : r.desc || '공급사 추가 실패'); + } + await refresh(); + }; + + const changeType = async (supplierItemId: string, supplyType: number) => { + await updateSupplyType(supplierItemId, { supply_type: supplyType }); + await refresh(); + }; + + const removeSupplier = async (supplierItemId: string) => { + await deleteSupplierItem(supplierItemId); + await refresh(); + }; + + return { suppliers, isLoading: listQuery.isLoading, addSupplier, changeType, removeSupplier }; +} diff --git a/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx b/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx index 4997939..9f5dd16 100644 --- a/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx +++ b/negodata/front/src/features/quotations/components/QuotationCreateModal.tsx @@ -1,11 +1,12 @@ import { useState, useMemo } from 'react'; -import { X, PlusSquare, ArrowRight, Loader2, Gavel } from 'lucide-react'; +import { X, PlusSquare, ArrowRight, Loader2, Gavel, CheckCheck } from 'lucide-react'; import { useNavigate } from 'react-router'; import { useListItemSupplyTypes } from '@/api/generated/supplier-item/supplier-item'; import { useListItems, useGetItem } from '@/api/generated/item/item'; import { useListSuppliers } from '@/api/generated/supplier/supplier'; import { useListCards } from '@/api/generated/card/card'; import { mapCardData } from '@/features/cards/types'; +import { useLabels } from '@/features/settings/useCompanySettings'; import { Button } from '@/components/ui/button'; import { Typography, typographyVariants } from '@/components/ui/typography'; import { cn } from '@/lib/utils'; @@ -125,6 +126,7 @@ export function QuotationCreateModal({ // 선택 상품의 협력사별 공급유형(제조/유통/총판/없음) — 협력사 리스트에 배지로 덧붙인다(리스트 자체는 재조회 안 함). const supplyTypeQuery = useListItemSupplyTypes(productId, { query: { enabled: !!productId } }); + const label = useLabels(); // 회사 설정 용어(목표 마진 등) const supplyTypeBySupplier = useMemo(() => { const m = new Map(); (supplyTypeQuery.data?.suppliers ?? []).forEach((s) => m.set(s.supplier_id, s.supply_type)); @@ -232,6 +234,17 @@ export function QuotationCreateModal({ if (row) setCardDetails((m) => new Map(m).set(id, { code: row.code, title: row.title, isWildcard: row.isWildcard })); setSelectedCardIds((prev) => (prev.includes(id) ? prev.filter((c) => c !== id) : [...prev, id])); }; + // 일괄 선택(IMK #22) — 현재 목록(검색 결과)의 카드를 전부 담는다. 이미 담긴 카드는 유지. + const selectAllCards = () => { + const rows = cardRows.filter((c) => !c.isWildcard || c.status === 'ACTIVE'); + setCardDetails((m) => { + const next = new Map(m); + rows.forEach((r) => next.set(r.id, { code: r.code, title: r.title, isWildcard: r.isWildcard })); + return next; + }); + setSelectedCardIds((prev) => [...new Set([...prev, ...rows.map((r) => r.id)])]); + }; + const clearAllCards = () => setSelectedCardIds([]); const handleSubmit = async () => { if (submitting) return; @@ -496,7 +509,7 @@ export function QuotationCreateModal({ {(value) => { const qs = quotationSettings.find((s) => s.qt_setting_id === value); return qs - ? `[목표 마진: ${qs.target_margin}] 카드 ${qs.card_use_count}` + ? `[${label('target_margin')}: ${qs.target_margin}] 카드 ${qs.card_use_count}` : ''; }} @@ -504,7 +517,7 @@ export function QuotationCreateModal({ {quotationSettings.map((qs) => ( - [목표 마진: {qs.target_margin}] 카드 {qs.card_use_count} + [{label('target_margin')}: {qs.target_margin}] 카드 {qs.card_use_count} ))} @@ -559,7 +572,19 @@ export function QuotationCreateModal({ {/* Step 4 — 협상카드(1:1 협상 전용, 별도 스텝으로 분리해 과밀 방지) */} {step === 4 && oneToOne && (
- 협상카드 및 와일드카드 선택 (선택) +
+ 협상카드 및 와일드카드 선택 (선택) +
+ + +
+
1:1 협상에서 AI 협상봇이 발동할 카드입니다. diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx index c4795cf..22c21e3 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/ChatTab.tsx @@ -1,4 +1,4 @@ -import { Sparkles } from 'lucide-react'; +import { Download, Sparkles } from 'lucide-react'; import type { SessionData } from '@/api/generated/model/sessionData'; import type { QuotationCardData } from '@/api/generated/model/quotationCardData'; import type { ChatMessageData } from '@/api/generated/model/chatMessageData'; @@ -8,8 +8,53 @@ import { Typography } from '@/components/ui/typography'; import { StatusPill, sessionStatusTone } from './StatusPill'; import { type Product, type Partner, sessionStatusLabel } from '../../types'; import { maskPrices } from '@/lib/utils'; +import { useCompanySettings } from '@/features/settings/useCompanySettings'; import { renderEmphasis } from '@/lib/emphasis'; +// 협상로그 JSON 다운로드(IMK #9). 가격·비율 숫자는 maskPrices 로 가려 내보낸다(화면 표기와 동일 규칙). +// target_price 등 숫자 필드는 아예 제외 — 양식은 대화 흐름(순번/발화자/스텝/멘트/카드사용) 중심. +function exportChatJson( + session: SessionData | undefined, + supplierName: string, + productName: string | undefined, + messages: ChatMessageData[], + serverCards: QuotationCardData[], +) { + const payload = { + exported_at: new Date().toISOString(), + session_id: session?.session_id ?? null, + qt_number: session?.qt_number ?? null, + supplier: supplierName || null, + product: productName ?? null, + status: session ? sessionStatusLabel(session.status) : null, + message_count: messages.length, + messages: messages.map((m) => { + // 사용 카드는 화면 버블과 동일하게 chat_id 로 매칭(card_type 만으론 어느 카드인지 알 수 없음). + const card = m.card_used_yn ? serverCards.find((c) => c.session_card_id === m.chat_id) : undefined; + return { + seq: m.index, + sender: m.sender === ChatSender.BOT ? 'BOT' : 'PARTNER', + step: m.step ?? null, + script: maskPrices(String(m.script ?? '')), + card: card + ? { + number: card.number ?? null, + name: card.name ?? null, + type: card.type === CardType.WILD ? 'wild' : 'nego', + } + : null, + }; + }), + }; + const blob = new Blob([JSON.stringify(payload, null, 2)], { type: 'application/json' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `협상로그_${session?.qt_number ?? 'session'}_${(supplierName || '').replace(/\s+/g, '')}.json`; + a.click(); + URL.revokeObjectURL(url); +} + export function ChatTab({ serverSessions, partners, @@ -32,6 +77,13 @@ export function ChatTab({ // 목표가는 협상(세션) 단위 고정값(sessions.target_price)이라 메시지마다가 아니라 헤더에 한 번만 표시한다. const currentSession = serverSessions.find((s) => s.session_id === effectiveSessionId); const targetPrice = currentSession?.target_price; + // 협상완료 부가정보(sessions.custom) — 공급사가 타결 후 입력. 라벨은 회사 설정(session_fields)에서. + const { settings } = useCompanySettings(); + const sessionFields = settings.session_fields ?? []; + const custom = (currentSession?.custom ?? {}) as Record; + const extraRows = sessionFields + .map((f) => ({ label: f.label, value: custom[f.key] })) + .filter((r) => r.value !== undefined && r.value !== null && r.value !== ''); return (
{/* Sessions list */} @@ -90,9 +142,31 @@ export function ChatTab({ 기록: {chatMessages.length} 메시지 +
+ {/* 협상완료 부가정보(공급사 입력) — 값이 있을 때만 노출 */} + {extraRows.length > 0 && ( +
+ 협상완료 부가정보 + {extraRows.map((r) => ( + + {r.label}: {String(r.value)} + + ))} +
+ )} +
{!effectiveSessionId ? ( diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/DrawerHeaderCards.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/DrawerHeaderCards.tsx index c3c8390..d8bf97c 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/DrawerHeaderCards.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/DrawerHeaderCards.tsx @@ -8,6 +8,7 @@ import { InfoField } from './InfoField'; import { QuotationStatusBadge } from './StatusPill'; import { ResultSummaryBand } from './ResultSummaryBand'; import type { QuotationData } from '@/api/generated/model/quotationData'; +import { useLabels } from '@/features/settings/useCompanySettings'; import { type Product, type QuotationSetting, @@ -50,6 +51,7 @@ export function DrawerHeaderCards({ onShowTarget, collapsed = false, }: DrawerHeaderCardsProps) { + const label = useLabels(); // 회사 설정 용어(목표 마진 등) // 접으면 결과 요약 밴드만 노출(목표가·낙찰·절감). 상세 그리드 계산은 건너뛴다. if (collapsed) { return ( @@ -178,7 +180,7 @@ export function DrawerHeaderCards({ {selectedSettingObj ? (
diff --git a/negodata/front/src/features/quotations/components/QuotationDetailSheet/TargetPriceModal.tsx b/negodata/front/src/features/quotations/components/QuotationDetailSheet/TargetPriceModal.tsx index 7873c68..336da44 100644 --- a/negodata/front/src/features/quotations/components/QuotationDetailSheet/TargetPriceModal.tsx +++ b/negodata/front/src/features/quotations/components/QuotationDetailSheet/TargetPriceModal.tsx @@ -2,6 +2,7 @@ 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 } from '@/features/settings/useCompanySettings'; // 세션 목표가 산정내역 모달. 후보·채택·앵커링가는 백엔드 /target-breakdown 이 산정한 값을 '표시만' 한다. // (프론트 재계산 없음 → 저장된 목표가와 항상 일치. 산정 로직은 백엔드 _candidates 단일 출처.) @@ -18,10 +19,11 @@ type TargetPriceModalProps = { const won = (n?: number | null) => (n != null ? `₩${n.toLocaleString()}` : '-'); // 후보 basis 별 부가 설명(수수료/마진 적용 표시). 백엔드 라벨에 없는 보조 문구만 프론트가 덧붙인다. -const CANDIDATE_SUB: Record = { +// 회사 설정 용어(목표 마진)를 반영해 컴포넌트 안에서 만든다. +const candidateSub = (marginLabel: string): Record => ({ internet: '인터넷 평균 수수료 적용', - selling: '목표 마진율 적용', -}; + selling: `${marginLabel} 적용`, +}); export function TargetPriceModal({ onClose, @@ -33,6 +35,8 @@ export function TargetPriceModal({ category, }: TargetPriceModalProps) { useScrollLock(); // 모달은 열릴 때만 마운트(부모 게이트) → 배경 스크롤 잠금 + const label = useLabels(); // 회사 설정 용어 + const CANDIDATE_SUB = candidateSub(`${label('target_margin')}`); const { data: bd, isLoading } = useGetTargetBreakdown(sessionId, { query: { enabled: !!sessionId } }); const candidates = bd?.candidates ?? []; @@ -77,9 +81,9 @@ export function TargetPriceModal({
목표가 선정방식 1. MD 입력값 존재 시, 최우선 적용 - 2. 다음 중 가장 작은 값 — 인터넷최저가×(1−수수료) | 매입가 | 판매가×(1−목표 마진율) + 2. 다음 중 가장 작은 값 — 인터넷최저가×(1−수수료) | 매입가 | 판매가×(1−{label('target_margin')}) - * 인터넷 평균 수수료: {bd.fee} · 목표 마진율: {bd.margin}{bd.is_new ? ' · 신규견적이라 인터넷최저가만 적용' : ''} + * 인터넷 평균 수수료: {bd.fee} · {label('target_margin')}: {bd.margin}{bd.is_new ? ' · 신규견적이라 인터넷최저가만 적용' : ''} {bd.is_inherited && ( diff --git a/negodata/front/src/features/quotations/components/QuotationSettingsModal.tsx b/negodata/front/src/features/quotations/components/QuotationSettingsModal.tsx index d77c06c..c0efe1c 100644 --- a/negodata/front/src/features/quotations/components/QuotationSettingsModal.tsx +++ b/negodata/front/src/features/quotations/components/QuotationSettingsModal.tsx @@ -5,6 +5,7 @@ import { Button } from '@/components/ui/button'; import { Typography } from '@/components/ui/typography'; import { Input } from '@/components/ui/input'; import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table'; +import { useLabels } from '@/features/settings/useCompanySettings'; import { type QuotationSetting } from '../types'; import type { SettingInput } from '../hooks/useQuotations'; @@ -24,6 +25,7 @@ export function QuotationSettingsModal({ onClose, }: QuotationSettingsModalProps) { useScrollLock(open); // 모달 열린 동안 배경(부모) 스크롤 잠금 + const label = useLabels(); // 회사 설정 용어(목표 마진 등) const [targetMargin, setTargetMargin] = useState(''); const [cardUseCount, setCardUseCount] = useState(''); @@ -61,7 +63,7 @@ export function QuotationSettingsModal({ - 목표 마진율 + {label('target_margin')} 카드 사용 횟수 삭제 @@ -100,7 +102,7 @@ export function QuotationSettingsModal({
- 목표 마진율 (%) + {label('target_margin')} (%) setTargetMargin(e.target.value)} placeholder="예: 12" />
diff --git a/negodata/front/src/features/quotations/hooks/useQuotations.ts b/negodata/front/src/features/quotations/hooks/useQuotations.ts index cf48167..c1aac7a 100644 --- a/negodata/front/src/features/quotations/hooks/useQuotations.ts +++ b/negodata/front/src/features/quotations/hooks/useQuotations.ts @@ -26,6 +26,7 @@ import type { ReqCreateQuotation } from '@/api/generated/model/reqCreateQuotatio import { showToast } from '@/lib/notify'; import { confirm } from '@/lib/confirm'; import { useAuthStore } from '@/stores/auth'; +import { useLabels } from '@/features/settings/useCompanySettings'; import type { Estimate } from '../types'; import { mapItem, mapSupplier, mapSetting, mapQuotation } from '../types'; import { QuotationStatus } from '@/api/generated/model'; @@ -54,6 +55,7 @@ export type SettingInput = { // 상품/협력사/세팅/견적은 서버(orval)에서 읽고, 견적·세팅·채팅은 로컬 state로 낙관적 갱신한다. // (협상카드 카탈로그/채팅은 백엔드 미연동 → 빈 상태) export function useQuotations(params: ListQuotationsParams) { + const label = useLabels(); // 회사 설정 용어(목표 마진율 등) const queryClient = useQueryClient(); const itemsQuery = useListItems({ size: 100 }); const suppliersQuery = useListSuppliers({ size: 100 }); @@ -149,7 +151,7 @@ export function useQuotations(params: ListQuotationsParams) { const marginPct = Number(String(input.targetMargin).replace('%', '').trim()); const cardCount = parseInt(String(input.cardUseCount).replace(/[^0-9-]/g, ''), 10); if (!Number.isFinite(marginPct) || !Number.isInteger(cardCount)) { - showToast('목표 마진율·카드 사용 횟수를 숫자로 입력해야 합니다.', 'error'); + showToast(`${label('target_margin')}·카드 사용 횟수를 숫자로 입력해야 합니다.`, 'error'); return false; } createSettingMutation.mutate( diff --git a/negodata/front/src/features/settings/CustomFieldInputs.tsx b/negodata/front/src/features/settings/CustomFieldInputs.tsx new file mode 100644 index 0000000..b2c7e38 --- /dev/null +++ b/negodata/front/src/features/settings/CustomFieldInputs.tsx @@ -0,0 +1,70 @@ +import { useState } from 'react'; +import { Typography } from '@/components/ui/typography'; +import { Input } from '@/components/ui/input'; +import type { CustomFieldDef } from './catalog'; + +// 커스텀필드 값 상태. 정의(fields)와 기존 값(initial=엔티티의 custom JSONB)으로 초기화하고, +// 저장 시 state.values 를 payload 의 custom 으로 보낸다. +export function useCustomFieldValues(fields: CustomFieldDef[], initial?: Record | null) { + const [values, setValues] = useState>(() => { + const out: Record = {}; + for (const f of fields) out[f.key] = initial?.[f.key] ?? (f.type === 'boolean' ? false : ''); + return out; + }); + const set = (key: string, value: unknown) => setValues((v) => ({ ...v, [key]: value })); + return { values, set }; +} + +type CustomFieldInputsProps = { + fields: CustomFieldDef[]; + state: ReturnType; + title?: string; +}; + +// 회사 설정(item_fields/supplier_fields) 정의대로 입력 UI 를 렌더한다. 정의가 없으면 아무것도 그리지 않는다. +export function CustomFieldInputs({ fields, state, title = '회사 추가 항목' }: CustomFieldInputsProps) { + if (fields.length === 0) return null; + return ( +
+ {title} +
+ {fields.map((f) => ( +
+ {f.type === 'boolean' ? ( +
+ {f.label}: + +
+ ) : ( + <> + {f.label} + + state.set(f.key, f.type === 'number' ? (e.target.value === '' ? '' : Number(e.target.value)) : e.target.value) + } + /> + + )} +
+ ))} +
+
+ ); +} diff --git a/negodata/front/src/features/settings/SettingsView.tsx b/negodata/front/src/features/settings/SettingsView.tsx new file mode 100644 index 0000000..1d3e00b --- /dev/null +++ b/negodata/front/src/features/settings/SettingsView.tsx @@ -0,0 +1,367 @@ +import { useEffect, useMemo, useState } from 'react'; +import { Palette, Tags, ListPlus, Plus, Trash2, RotateCcw } from 'lucide-react'; +import { showToast } from '@/lib/notify'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import ImageDropzone from '@/components/ImageDropzone'; +import { uploadItemImage } from '@/api/generated/item/item'; +import { Badge } from '@/components/ui/badge'; +import { Typography } from '@/components/ui/typography'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; +import { Select, SelectValue, SelectTrigger, SelectContent, SelectItem } from '@/components/ui/select'; +import { Table, TableHeader, TableBody, TableRow, TableHead, TableCell } from '@/components/ui/table'; +import { + LABEL_CATALOG, + CUSTOM_FIELD_TYPE_LABEL, + type CompanySettings, + type CustomFieldDef, + type CustomFieldType, +} from './catalog'; +import { useCompanySettings } from './useCompanySettings'; + +export function SettingsView() { + const { settings, isLoading, save } = useCompanySettings(); + + // 저장 전 편집본(draft). 서버 반영은 저장 버튼에서만. + const [draft, setDraft] = useState({}); + const [saving, setSaving] = useState(false); + useEffect(() => { + if (!isLoading) setDraft(structuredClone(settings)); + // settings 객체는 쿼리 캐시 무효화 때만 바뀐다(참조 비교로 충분). + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isLoading, JSON.stringify(settings)]); + + const dirty = useMemo(() => JSON.stringify(draft) !== JSON.stringify(settings), [draft, settings]); + + const handleSave = async () => { + // 빈 문자열 라벨/브랜딩은 "기본값 사용"이므로 저장 전 제거해 문서를 깨끗하게 유지한다. + const labels = Object.fromEntries(Object.entries(draft.labels ?? {}).filter(([, v]) => v.trim())); + const branding = Object.fromEntries(Object.entries(draft.branding ?? {}).filter(([, v]) => (v ?? '').trim())); + const itemFields = (draft.item_fields ?? []).filter((f) => f.key.trim() && f.label.trim()); + const supplierFields = (draft.supplier_fields ?? []).filter((f) => f.key.trim() && f.label.trim()); + const sessionFields = (draft.session_fields ?? []).filter((f) => f.key.trim() && f.label.trim()); + const next: CompanySettings = { + ...draft, + labels, + branding, + item_fields: itemFields, + supplier_fields: supplierFields, + session_fields: sessionFields, + }; + setSaving(true); + try { + await save(next); + showToast('회사 설정이 저장되었습니다.', 'success'); + } catch (err) { + showToast(err instanceof Error ? err.message : '설정 저장 실패', 'error'); + } finally { + setSaving(false); + } + }; + + // 로고 파일 업로드 — 상품 이미지와 동일한 스토리지 엔드포인트(/v1/item/image, Azure Blob)를 재사용해 URL 을 받는다. + const handleUploadLogo = async (file: File): Promise => { + const res = await uploadItemImage({ file: file as unknown as string }); + if (res.result?.success === false) throw new Error(res.result.desc || '로고 업로드에 실패했습니다.'); + if (!res.image_url) throw new Error('업로드 응답에 URL 이 없습니다.'); + return res.image_url; + }; + + const setBranding = (key: keyof NonNullable, value: string) => + setDraft((d) => ({ ...d, branding: { ...d.branding, [key]: value } })); + const setLabel = (key: string, value: string) => + setDraft((d) => ({ ...d, labels: { ...d.labels, [key]: value } })); + + return ( +
+ +
+ + + 브랜딩(CI) + + + 용어(라벨) + + + 커스텀 필드 + + + + {/* 저장 바 — 변경이 있을 때만 활성화 */} +
+ {dirty && ( + + )} + +
+
+ + {/* ---- 브랜딩(CI) ---- */} + + +
+ + setBranding('service_name', e.target.value)} + placeholder="NegoData (기본값)" + /> + + +
+ setBranding('primary_color', e.target.value)} + /> + setBranding('primary_color', e.target.value)} + placeholder="#5E6AD2" + /> +
+
+ + setBranding('email_header', e.target.value)} + placeholder="NEGODATA (기본값)" + /> + +
+ + {/* 로고 이미지 — 업로드(드롭/선택) 또는 URL 직접 입력. 비우면 색상 마크+서비스명 텍스트. */} +
+ + setBranding('logo_url', url)} + onClear={() => setBranding('logo_url', '')} + onUpload={handleUploadLogo} + label="로고 이미지 업로드" + /> + +
+ + {/* 라이브 미리보기 */} +
+ 미리보기 — 사이드바 브랜드 +
+ {draft.branding?.logo_url ? ( + 로고 미리보기 + ) : ( + + )} + {draft.branding?.service_name || 'NegoData'} +
+
+
+
+ + {/* ---- 용어(라벨) ---- */} + + +
+
+ + + 기본 용어 + 우리 회사 용어 + 적용 위치 + + + + {LABEL_CATALOG.map((entry) => { + const value = draft.labels?.[entry.key] ?? ''; + return ( + + {entry.base} + + setLabel(entry.key, e.target.value)} + placeholder={`${entry.base} (기본값)`} + /> + + + {entry.where} + {value.trim() && ( + 변경됨 + )} + + + ); + })} + +
+
+ + + + {/* ---- 커스텀 필드 ---- */} + + setDraft((d) => ({ ...d, item_fields: fields }))} + /> + setDraft((d) => ({ ...d, supplier_fields: fields }))} + /> + setDraft((d) => ({ ...d, session_fields: fields }))} + /> + + +
+ ); +} + +function SectionCard({ title, desc, children }: { title: string; desc: string; children: React.ReactNode }) { + return ( +
+ {title} + {desc} + {children} +
+ ); +} + +function Field({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { + return ( +
+ {label} + {children} + {hint && {hint}} +
+ ); +} + +// 커스텀필드 정의 편집 — 표시명을 입력하면 key 를 자동 제안하되 직접 수정도 가능. +function CustomFieldsEditor({ + title, + desc, + fields, + onChange, +}: { + title: string; + desc: string; + fields: CustomFieldDef[]; + onChange: (fields: CustomFieldDef[]) => void; +}) { + const update = (i: number, patch: Partial) => + onChange(fields.map((f, idx) => (idx === i ? { ...f, ...patch } : f))); + + return ( + +
+ + + + 표시명 + 키 (영문) + 유형 + 삭제 + + + + {fields.length === 0 && ( + + + 추가된 커스텀 필드가 없습니다. + + + )} + {fields.map((f, i) => ( + + + update(i, { label: e.target.value })} + placeholder="예: 발주배수" + /> + + + update(i, { key: sanitizeKey(e.target.value) })} + placeholder="예: order_multiple" + /> + + + + value={f.type} + onValueChange={(v) => v && update(i, { type: v })} + > + + + + + {(Object.keys(CUSTOM_FIELD_TYPE_LABEL) as CustomFieldType[]).map((t) => ( + + {CUSTOM_FIELD_TYPE_LABEL[t]} + + ))} + + + + + + + + ))} + +
+
+
+ +
+
+ ); +} + +// key 입력 정리 — 영문/숫자/언더스코어만 허용(소문자화). +function sanitizeKey(raw: string): string { + return raw.toLowerCase().replace(/[^a-z0-9_]/g, ''); +} diff --git a/negodata/front/src/features/settings/catalog.ts b/negodata/front/src/features/settings/catalog.ts new file mode 100644 index 0000000..92190bc --- /dev/null +++ b/negodata/front/src/features/settings/catalog.ts @@ -0,0 +1,51 @@ +// 회사 커스터마이징 설정(companies.settings JSONB) 문서 타입 + 용어 라벨 카탈로그. +// 라벨 키는 여기 한 곳에만 추가한다 — 설정 화면(용어 탭)과 화면 배선(useLabel)이 같은 카탈로그를 읽는다. + +export type CustomFieldType = 'text' | 'number' | 'boolean'; + +export type CustomFieldDef = { + key: string; // custom JSONB 의 키 (영문 snake_case) + label: string; // 화면 표시명 + type: CustomFieldType; +}; + +export type CompanySettings = { + branding?: { + service_name?: string; // 사이드바/타이틀 서비스명 (기본 NegoData) + logo_url?: string; // 로고 이미지 URL. 없으면 색상 사각형+텍스트 + primary_color?: string; // 브랜드 색 (hex) + email_header?: string; // 초청 메일 헤더 문구 (기본 NEGODATA) + }; + labels?: Record; // 카탈로그 키 → 이 회사 용어 (없으면 기본값) + features?: Record; // 회사별 동작 플래그 (예: target_price_mode) + item_fields?: CustomFieldDef[]; // 상품 커스텀필드 정의 → items.custom + supplier_fields?: CustomFieldDef[]; // 협력사 커스텀필드 정의 → suppliers.custom + session_fields?: CustomFieldDef[]; // 협상완료 부가정보 정의 → sessions.custom (공급사가 타결 후 입력) +}; + +export type LabelCatalogEntry = { + key: string; + base: string; // 기본(우리 솔루션) 용어 + where: string; // 적용 위치 안내 (설정 화면 표시용) +}; + +// 용어 카탈로그. base 가 fallback 이므로 배선된 화면은 설정이 비어 있어도 기존과 동일하게 보인다. +export const LABEL_CATALOG: LabelCatalogEntry[] = [ + { key: 'target_margin', base: '목표 마진율', where: '견적 세팅, 목표가 산정내역, 견적 생성' }, + { key: 'item.price', base: '상품 단가', where: '상품 목록·등록, 엑셀 양식' }, + { key: 'category', base: '카테고리', where: '상품 목록·등록·필터, 통계' }, + { key: 'lead_time', base: '리드타임', where: '상품 등록, 엑셀 양식' }, + { key: 'delivery_type.1', base: '협력사배송', where: '배송유형 선택지 1' }, + { key: 'delivery_type.2', base: '지정택배배송', where: '배송유형 선택지 2' }, + { key: 'delivery_type.3', base: '픽업배송', where: '배송유형 선택지 3' }, +]; + +export const LABEL_DEFAULTS: Record = Object.fromEntries( + LABEL_CATALOG.map((e) => [e.key, e.base]), +); + +export const CUSTOM_FIELD_TYPE_LABEL: Record = { + text: '텍스트', + number: '숫자', + boolean: '예/아니오', +}; diff --git a/negodata/front/src/features/settings/useCompanySettings.ts b/negodata/front/src/features/settings/useCompanySettings.ts new file mode 100644 index 0000000..d9652e7 --- /dev/null +++ b/negodata/front/src/features/settings/useCompanySettings.ts @@ -0,0 +1,36 @@ +import { useQueryClient } from '@tanstack/react-query'; +import { useGetSettings, updateSettings, getGetSettingsQueryKey } from '@/api/generated/company-settings/company-settings'; +import { LABEL_DEFAULTS, type CompanySettings } from './catalog'; + +// 회사 커스터마이징 설정 조회 + 저장. +// 조회는 전 유저(브랜딩/라벨 렌더용), 저장은 백엔드가 OWNER 로 게이트한다. +export function useCompanySettings() { + const queryClient = useQueryClient(); + const query = useGetSettings({ query: { staleTime: 5 * 60 * 1000 } }); + const settings: CompanySettings = (query.data?.settings as CompanySettings) ?? {}; + + const save = async (next: CompanySettings) => { + const res = await updateSettings({ settings: next as Record }); + if (res.result?.success === false) throw new Error(res.result.desc || '설정 저장에 실패했습니다.'); + await queryClient.invalidateQueries({ queryKey: getGetSettingsQueryKey() }); + }; + + return { settings, isLoading: query.isLoading, save }; +} + +// 용어 라벨 헬퍼. label('item.price') → 회사 설정 용어, 없으면 카탈로그 기본값. +export function useLabels() { + const { settings } = useCompanySettings(); + const overrides = settings.labels ?? {}; + return (key: string): string => overrides[key] || LABEL_DEFAULTS[key] || key; +} + +// 브랜딩 헬퍼. 서비스명·로고 — 미설정 시 기본 브랜드(NegoData). +export function useBranding() { + const { settings } = useCompanySettings(); + return { + serviceName: settings.branding?.service_name || 'NegoData', + logoUrl: settings.branding?.logo_url || null, + primaryColor: settings.branding?.primary_color || null, + }; +} diff --git a/negodata/front/src/pages/partners.tsx b/negodata/front/src/pages/partners.tsx index a2b2b1b..3334358 100644 --- a/negodata/front/src/pages/partners.tsx +++ b/negodata/front/src/pages/partners.tsx @@ -1,10 +1,13 @@ -import { Plus, Upload, Download, FileSpreadsheet, ChevronDown } from 'lucide-react'; +import { useState } from 'react'; +import { Plus, Upload, Download, FileSpreadsheet, ChevronDown, Trash2 } from 'lucide-react'; import { useOverlayRouter } from '@/lib/useOverlayRouter'; import { showToast } from '@/lib/notify'; import { confirm } from '@/lib/confirm'; import { PageContainer } from '@/components/layout/PageContainer'; import { PageToolbar, SearchInput } from '@/components/layout/PageToolbar'; import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { useAuthStore } from '@/stores/auth'; import { DropdownMenu, DropdownMenuTrigger, DropdownMenuContent, DropdownMenuItem } from '@/components/ui/dropdown-menu'; import { useServerList } from '@/lib/useServerList'; import { usePartners } from '@/features/partners/hooks/usePartners'; @@ -36,6 +39,27 @@ export default function PartnersPage() { const formMode: 'create' | 'edit' = editId ? 'edit' : 'create'; const isFormOpen = overlay.has('new') || !!editing; + // 협력사 삭제는 최고관리자 전용(단건 삭제와 동일 규칙) — 일괄삭제 버튼도 최고관리자에게만 노출. + const isSuperAdmin = useAuthStore((st) => st.user?.role === '최고관리자'); + const [selectedIds, setSelectedIds] = useState([]); + + const handleBulkDelete = async () => { + if (selectedIds.length === 0) return; + if (!(await confirm({ title: '선택 협력사 일괄 삭제', description: `선택한 ${selectedIds.length}개 협력사를 명부에서 삭제하시겠습니까?`, confirmText: '삭제', destructive: true }))) return; + let ok = 0; + let fail = 0; + for (const id of selectedIds) { + try { + await deletePartner(id); + ok += 1; + } catch { + fail += 1; + } + } + setSelectedIds([]); + showToast(fail === 0 ? `${ok}개 협력사가 삭제되었습니다.` : `${ok}개 삭제 · ${fail}개 실패`, fail === 0 ? 'info' : 'error'); + }; + const openCreate = () => overlay.open('new'); const openEdit = (part: Partner) => overlay.open('detail', part.supplier_id); @@ -55,6 +79,19 @@ export default function PartnersPage() { + {isSuperAdmin && ( + + )} + }> @@ -92,6 +129,8 @@ export default function PartnersPage() { overlay.open('new'); const openEdit = (prod: Product) => overlay.open('detail', prod.item_id); + // 선택 일괄삭제(IMK #8) — 단건 삭제 API 루프(엑셀 일괄등록과 동일 패턴). 소유자 아닌 행은 서버가 거부 → 실패 집계. + const handleBulkDelete = async () => { + if (selectedIds.length === 0) return; + if (!(await confirm({ title: '선택 상품 일괄 삭제', description: `선택한 ${selectedIds.length}개 상품을 삭제하시겠습니까?`, confirmText: '삭제', destructive: true }))) return; + let ok = 0; + let fail = 0; + for (const id of selectedIds) { + try { + await deleteProduct(id); + ok += 1; + } catch { + fail += 1; + } + } + setSelectedIds([]); + showToast(fail === 0 ? `${ok}개 상품이 삭제되었습니다.` : `${ok}개 삭제 · ${fail}개 실패(권한 등)`, fail === 0 ? 'info' : 'error'); + }; + const handleDeleteProduct = async (id: string, prodName: string) => { if (await confirm({ title: '상품 삭제', description: `[${prodName}] 상품 정보를 완전 삭제하시겠습니까?`, confirmText: '삭제', destructive: true })) { try { @@ -73,6 +94,17 @@ export default function ProductsPage() { + +
+ ))} +
+ + +
+ ) +} diff --git a/frontend/src/features/chat/stores/useChatInitStore.ts b/frontend/src/features/chat/stores/useChatInitStore.ts index 6f23dc3..30a82af 100644 --- a/frontend/src/features/chat/stores/useChatInitStore.ts +++ b/frontend/src/features/chat/stores/useChatInitStore.ts @@ -29,6 +29,7 @@ const initialState: ChatInitData = { item_spec: '', quotation_memo: '', quotation_end_time: '', + custom: {}, } export const useChatInitStore = create((set) => ({ diff --git a/frontend/src/features/chat/types.ts b/frontend/src/features/chat/types.ts index 86ec723..691ae2f 100644 --- a/frontend/src/features/chat/types.ts +++ b/frontend/src/features/chat/types.ts @@ -81,4 +81,5 @@ export type ChatInitData = { item_spec: string quotation_memo: string quotation_end_time: string + custom: Record // 협상완료 부가정보 기존 입력값(프리필용) } diff --git a/frontend/src/features/list/components/ExtraInfoPopup.tsx b/frontend/src/features/list/components/ExtraInfoPopup.tsx new file mode 100644 index 0000000..1613531 --- /dev/null +++ b/frontend/src/features/list/components/ExtraInfoPopup.tsx @@ -0,0 +1,115 @@ +import { useState } from 'react' +import { X } from 'lucide-react' +import { Modal } from '@/components' +import { useMeQuery } from '@/apis' +import type { SessionField } from '@/apis/auth/auth.type' +import type { ListItem } from '../types' + +export interface ExtraInfoPopupProps { + target: ListItem + onClose: () => void + /** 부가정보 저장 (custom = {key: value}) */ + onSubmit: (custom: Record) => void +} + +// 협상완료 부가정보 입력 팝업. 필드 정의(session_fields)는 회사 설정(/me)에서 오고, 기존 입력값(target.custom)으로 프리필. +export function ExtraInfoPopup({ target, onClose, onSubmit }: ExtraInfoPopupProps) { + const { data: user } = useMeQuery() + const fields: SessionField[] = user?.sessionFields ?? [] + + const [values, setValues] = useState>(() => { + const init: Record = {} + for (const f of fields) init[f.key] = target.custom?.[f.key] ?? (f.type === 'boolean' ? false : '') + return init + }) + + const set = (key: string, value: unknown) => setValues((v) => ({ ...v, [key]: value })) + + const handleSubmit = () => { + // 빈 값은 제외하고 저장(부분 입력 허용) + const out: Record = {} + for (const f of fields) { + const v = values[f.key] + if (f.type === 'boolean') out[f.key] = !!v + else if (v !== '' && v != null) out[f.key] = f.type === 'number' ? Number(v) : v + } + onSubmit(out) + onClose() + } + + return ( + +
+
+
+

협상완료 부가정보

+

{target.qt_number} · {target.item_name}

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

입력할 부가정보 항목이 없습니다.

+ ) : ( + fields.map((f) => ( +
+ + {f.type === 'boolean' ? ( + + ) : ( + set(f.key, e.target.value)} + className="h-11 w-full rounded-xl border border-border bg-white px-3 text-sm outline-none focus:border-brand-600 focus:ring-1 focus:ring-brand-600" + placeholder={f.label} + /> + )} +
+ )) + )} +
+ +
+ + +
+
+
+ ) +} diff --git a/frontend/src/features/list/components/WorkspaceCards.tsx b/frontend/src/features/list/components/WorkspaceCards.tsx index 406dc2f..07e43de 100644 --- a/frontend/src/features/list/components/WorkspaceCards.tsx +++ b/frontend/src/features/list/components/WorkspaceCards.tsx @@ -9,10 +9,11 @@ interface WorkspaceCardsProps { busyId?: string | null onEnter: (item: ListItem) => void onReject: (item: ListItem) => void + onExtraInfo: (item: ListItem) => void } // 모바일(lg 미만) 협상 목록: 테이블 대신 카드 스택. -export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject }: WorkspaceCardsProps) { +export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject, onExtraInfo }: WorkspaceCardsProps) { if (isLoading) { return (
@@ -27,7 +28,7 @@ export function WorkspaceCards({ items, isLoading, busyId, onEnter, onReject }: return (
{items.map((item) => ( - + ))}
) @@ -38,25 +39,26 @@ function Card({ busy, onEnter, onReject, + onExtraInfo, }: { item: ListItem busy: boolean onEnter: (item: ListItem) => void onReject: (item: ListItem) => void + onExtraInfo: (item: ListItem) => void }) { const meta = statusMeta(item.session_status) const canEnter = !['미참여', '협상거부'].includes(item.session_status) const canReject = ['협상생성', '협상중'].includes(item.session_status) - const enterLabel = item.session_status === '협상완료' ? '결과 보기' : '협상 입장' + const isDone = item.session_status === '협상완료' + const enterLabel = isDone ? '결과 보기' : '협상 입장' + const hasExtra = item.custom && Object.keys(item.custom).length > 0 return (
- - {item.qt_type || '-'} - {item.qt_number || '-'}

{item.item_name || '-'}

@@ -76,8 +78,17 @@ function Card({ 마감 {formatKstDateTime(item.qt_end_time)}
- {(canEnter || canReject) && ( + {(canEnter || canReject || isDone) && (
+ {isDone && ( + + )} {canReject && (