[feat] negodata: 알림함 — 견적 마감 결과(낙찰/재생성/결렬) 자동 통지

- company.notifications 테이블 + NotificationType(SUCCESS/REGENERATED/FAILURE)
- 백엔드 알림 조회/읽음 API + close_and_decide 결과 분기마다 알림 생성
- 헤더 알림 벨(안읽음 배지) + 알림 페이지(읽음·딥링크)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Mina Choi 2026-06-30 17:24:59 +09:00
parent b42cc41b5e
commit 1d111a031e
30 changed files with 995 additions and 3 deletions

View File

@ -65,6 +65,19 @@ class users(MainTableMixin, MAIN_BASE):
role = Column(SmallInteger, nullable=False, default=UserRole.USER.value)
class notifications(MainTableMixin, MAIN_BASE):
__tablename__ = "notifications"
__table_args__ = {"schema": "company"}
notification_id = Column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
user_id = Column(UUID(as_uuid=True), nullable=False, index=True) # 수신자(users.user_id) = 견적 작성자
type = Column(SmallInteger, nullable=False) # NotificationType: 1=success(낙찰) 2=regenerated(재생성) 3=failure(결렬)
ref_qt_id = Column(UUID(as_uuid=True), nullable=True) # 관련 견적(quotations.qt_id)
ref_session_id = Column(UUID(as_uuid=True), nullable=True) # 관련 세션(sessions.session_id)
data = Column(JSONB, nullable=True) # 렌더 스냅샷(유형별)
read_at = Column(DateTime(timezone=True), nullable=True) # 읽은 시각(NULL=안읽음)
class items(MainTableMixin, MAIN_BASE):
__tablename__ = "items"
__table_args__ = {"schema": "partner"}

View File

@ -172,6 +172,15 @@ class CloseOutcome(Enum):
REGEN_FAILED = "regen_failed" # 재생성 시도했으나 실패 — 원본은 CLOSED 인데 다음 라운드가 없음(체인 끊김, 모니터링 필요)
class NotificationType(CodeEnum):
"""company.notifications.type 코드값. 견적 생애 이벤트를 작성자에게 통지. 마감 결과 3종(SUCCESS/REGENERATED/FAILURE)은 close_and_decide 와 1:1. 네이밍은 KTC."""
SUCCESS = 1 # 낙찰(단독 최저가) — KTC SUCCESS
REGENERATED = 2 # 다음 라운드 자동 생성(동가/미참여) — KTC 대응어 없어 negodata 유지
FAILURE = 3 # 결렬: 낙찰 없이 마감(거절/부분/한도) — KTC FAILURE
CREATED = 4 # 견적 생성됨(작성 직후) — 생성 알림
class ChatSender(CodeEnum):
"""negotiation.chats.sender 코드값. 채팅 발신 주체."""

View File

@ -0,0 +1,107 @@
from abc import ABC, abstractmethod
from typing import Tuple
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 notifications
from common.enums import ErrorType
from common.logger import LOG
# 알림 CRUD. 항상 user_id(수신자)로 스코프한다.
class INotificationCRUD(ABC):
@abstractmethod
async def list_for_user(self, cdb: AsyncSession, user_id, skip, limit) -> Tuple[ErrorType, list, int]:
pass
@abstractmethod
async def count_unread(self, cdb: AsyncSession, user_id) -> Tuple[ErrorType, int]:
pass
@abstractmethod
async def mark_read(self, cdb: AsyncSession, user_id, notification_id, ts) -> ErrorType:
pass
@abstractmethod
async def mark_all_read(self, cdb: AsyncSession, user_id, ts) -> ErrorType:
pass
class NotificationCRUD(INotificationCRUD):
async def list_for_user(self, cdb: AsyncSession, user_id, skip, limit) -> Tuple[ErrorType, list, int]:
try:
cnt_err, cnt_rows = await DB_SESSION_MNG.execute(
cdb,
select(func.count()).select_from(notifications).where(
notifications.user_id == user_id, notifications.deleted == False # noqa: E712
),
)
if cnt_err != ErrorType.SUCCESS:
return cnt_err, [], 0
total = int(cnt_rows[0] or 0) if cnt_rows else 0
list_err, rows = await DB_SESSION_MNG.execute(
cdb,
select(notifications)
.where(notifications.user_id == user_id, notifications.deleted == False) # noqa: E712
.order_by(notifications.created_at.desc())
.offset(skip)
.limit(limit),
)
if list_err != ErrorType.SUCCESS:
return list_err, [], 0
return ErrorType.SUCCESS, list(rows), total
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, [], 0
async def count_unread(self, cdb: AsyncSession, user_id) -> Tuple[ErrorType, int]:
try:
err_type, rows = await DB_SESSION_MNG.execute(
cdb,
select(func.count()).select_from(notifications).where(
notifications.user_id == user_id,
notifications.read_at.is_(None),
notifications.deleted == False, # noqa: E712
),
)
if err_type != ErrorType.SUCCESS:
return err_type, 0
return ErrorType.SUCCESS, (int(rows[0] or 0) if rows else 0)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED, 0
async def mark_read(self, cdb: AsyncSession, user_id, notification_id, ts) -> ErrorType:
try:
query = (
update(notifications)
.where(
notifications.notification_id == notification_id,
notifications.user_id == user_id, # 남의 알림 못 건드리게 수신자 스코프
notifications.read_at.is_(None),
)
.values(read_at=ts, updated_at=ts)
)
return await DB_SESSION_MNG.add(cdb, query)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED
async def mark_all_read(self, cdb: AsyncSession, user_id, ts) -> ErrorType:
try:
query = (
update(notifications)
.where(
notifications.user_id == user_id,
notifications.read_at.is_(None),
notifications.deleted == False, # noqa: E712
)
.values(read_at=ts, updated_at=ts)
)
return await DB_SESSION_MNG.add(cdb, query)
except Exception as ex:
LOG.e_no_callstack(ex)
return ErrorType.DB_RUN_FAILED

View File

@ -18,6 +18,7 @@ import router.v1.card.card
import router.v1.quotation.quotation
import router.v1.quotation_setting.quotation_setting
import router.v1.dashboard.dashboard
import router.v1.notification.notification
API_SERVER_START_TIME = GTime.UTCStr()
@ -71,3 +72,4 @@ app.include_router(router.v1.card.card.router)
app.include_router(router.v1.quotation.quotation.router)
app.include_router(router.v1.quotation_setting.quotation_setting.router)
app.include_router(router.v1.dashboard.dashboard.router)
app.include_router(router.v1.notification.notification.router)

View File

@ -0,0 +1,32 @@
from uuid import UUID
from fastapi import APIRouter, Depends
from common.models.gmodel import PageParams, UserInfo
from router.v1.validator.dependencies import IsValidAccessToken, RemoveNoneResponse
from services.notification import NotificationService
from .protocol import Res_NotificationList, Res_NotificationRead
# 알림(인박스) 라우터. 항상 로그인 유저(user_info.user_id) 기준으로 조회/처리한다.
router = APIRouter(prefix="/v1/notification", tags=["Notification"], responses={404: {"description": "Not found"}})
@router.get(path="/list", response_model=Res_NotificationList, summary="알림 목록(+안읽음 수)")
async def list_notifications(
service: NotificationService = Depends(),
user_info: UserInfo = Depends(IsValidAccessToken),
pg: PageParams = Depends(),
):
return RemoveNoneResponse(await service.list_notifications(user_info.user_id, pg))
@router.post(path="/read-all", response_model=Res_NotificationRead, summary="전체 읽음 처리")
async def read_all(service: NotificationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)):
return RemoveNoneResponse(await service.mark_all_read(user_info.user_id))
@router.post(path="/{notification_id}/read", response_model=Res_NotificationRead, summary="알림 읽음 처리")
async def read_one(
notification_id: UUID, service: NotificationService = Depends(), user_info: UserInfo = Depends(IsValidAccessToken)
):
return RemoveNoneResponse(await service.mark_read(user_info.user_id, str(notification_id)))

View File

@ -0,0 +1,33 @@
import uuid
from datetime import datetime
from typing import Any, Optional
from pydantic import ConfigDict
from common.enums import NotificationType
from common.models.gmodel import Res_PageProtocol, Res_WebPacketProtocol, WebPacketProtocol
class NotificationProtocol(WebPacketProtocol):
pass
class NotificationData(WebPacketProtocol):
model_config = ConfigDict(from_attributes=True)
notification_id: uuid.UUID
type: NotificationType
ref_qt_id: Optional[uuid.UUID] = None
ref_session_id: Optional[uuid.UUID] = None
data: Optional[Any] = None # 렌더 스냅샷(유형별 필드)
read_at: Optional[datetime] = None
created_at: Optional[datetime] = None
class Res_NotificationList(Res_PageProtocol):
notifications: list[NotificationData] = []
unread: int = 0 # 안읽음 총수(헤더 빨콩이)
class Res_NotificationRead(Res_WebPacketProtocol):
pass

View File

@ -0,0 +1,88 @@
import uuid
from fastapi import Depends
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import notifications
from common.enums import DBWRType, ErrorType, NotificationType
from common.logger import LOG
from common.models.gmodel import PageParams
from common.utils.gtime import GTime
from crud.notification_crud import INotificationCRUD, NotificationCRUD
from router.v1.notification.protocol import NotificationData, Res_NotificationList, Res_NotificationRead
def _as_uuid(v):
return uuid.UUID(v) if isinstance(v, str) else v
async def create_notification(
user_id,
ntype: NotificationType,
data: dict,
ref_qt_id=None,
ref_session_id=None,
) -> None:
"""알림 1건 기록(인박스). 수신자=user_id(견적 작성자), data=렌더 스냅샷(유형별 필드).
부가 효과라 실패해도 흐름을 막지 않는다(raise , 로그만)."""
notif = notifications(
user_id=_as_uuid(user_id),
type=ntype.value,
ref_qt_id=_as_uuid(ref_qt_id) if ref_qt_id else None,
ref_session_id=_as_uuid(ref_session_id) if ref_session_id else None,
data=data,
)
err = await DB_SESSION_MNG.execute_lambda_run(
[notifications.DBType()],
[lambda s: DB_SESSION_MNG.insert(s, notif, raise_error=False)],
)
if err != ErrorType.SUCCESS:
LOG.e_no_callstack(f"[notify] 알림 기록 실패 user={user_id} type={ntype.name}")
class NotificationService:
"""알림 조회/읽음 처리(헤더 벨·알림 페이지). 항상 로그인 유저(user_id)로 스코프."""
def __init__(self, crud: INotificationCRUD = Depends(NotificationCRUD)):
self.crud = crud
async def list_notifications(self, user_id: str, pg: PageParams) -> Res_NotificationList:
res = Res_NotificationList(page=pg.page, size=pg.size)
uid = uuid.UUID(user_id)
err_type, rows, total = await DB_SESSION_MNG.execute_lambda(
notifications.DBType(),
DBWRType.DB_READ.value,
lambda s: self.crud.list_for_user(s, uid, pg.skip, pg.size),
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
res.notifications = [NotificationData.model_validate(r) for r in rows]
res.total = total
_e, unread = await DB_SESSION_MNG.execute_lambda(
notifications.DBType(),
DBWRType.DB_READ.value,
lambda s: self.crud.count_unread(s, uid),
)
res.unread = unread if _e == ErrorType.SUCCESS else 0
return res
async def mark_read(self, user_id: str, notification_id: str) -> Res_NotificationRead:
res = Res_NotificationRead()
err_type = await DB_SESSION_MNG.execute_lambda_run(
[notifications.DBType()],
[lambda s: self.crud.mark_read(s, uuid.UUID(user_id), uuid.UUID(notification_id), GTime.UTC())],
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res
async def mark_all_read(self, user_id: str) -> Res_NotificationRead:
res = Res_NotificationRead()
err_type = await DB_SESSION_MNG.execute_lambda_run(
[notifications.DBType()],
[lambda s: self.crud.mark_all_read(s, uuid.UUID(user_id), GTime.UTC())],
)
if err_type != ErrorType.SUCCESS:
res.result.SetResult(err_type)
return res

View File

@ -7,7 +7,7 @@ from fastapi import Depends
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import quotations, sessions, chats, versions, version_nego_cards, version_wild_cards
from common.enums import CloseOutcome, DBWRType, ErrorType, QuotationStatus, QuotationType, SessionStatus
from common.enums import CloseOutcome, DBWRType, ErrorType, NotificationType, QuotationStatus, QuotationType, SessionStatus
from common.logger import LOG
from common.models.gmodel import PageParams
from common.utils.gtime import GTime
@ -34,6 +34,7 @@ from router.v1.quotation.protocol import (
TargetCandidate,
)
from services.email import EmailUnavailable, build_invite_email, send_email
from services.notification import create_notification
class QuotationService:
@ -620,6 +621,13 @@ class QuotationService:
# 1) 단독 낙찰 → 확정
if winner is not None:
await self._award_and_close(qt_uuid, winner)
winner_price = min((int(bp) for _, bp, _ in done if bp is not None), default=None)
await create_notification(
original.user_id, NotificationType.SUCCESS,
{"qt_name": original.name, "qt_number": original.number,
"winner_name": winner["name"], "winner_price": winner_price},
ref_qt_id=qt_uuid,
)
return CloseOutcome.AWARDED
# 동가/미참여 재생성은 사유별 한도(각 1번, 순서 무관) 확인 후
@ -637,10 +645,21 @@ class QuotationService:
f"code={regen.result.code}({regen.result.desc})"
)
return CloseOutcome.REGEN_FAILED
await create_notification(
original.user_id, NotificationType.REGENERATED,
{"qt_name": original.name, "qt_number": original.number, "reason": "equal",
"next_round": original.round + 1, "tied_price": equal["price"], "tied_count": len(equal["suppliers"])},
ref_qt_id=regen.qt_id,
)
return CloseOutcome.REGENERATED
# 3) 협상거부 있음 → 마감만 (재생성 안 함)
if has_rejected:
await self._just_close(qt_uuid)
await create_notification(
original.user_id, NotificationType.FAILURE,
{"qt_name": original.name, "qt_number": original.number, "reason": "rejected"},
ref_qt_id=qt_uuid,
)
return CloseOutcome.CLOSED
# 4) 전원 미참여 → 공급사 전체로 다음 라운드 (체인에 미참여 재생성 이력 없을 때만)
if not done and rows and no_part_used < self.MAX_REGEN_PER_CAUSE:
@ -654,9 +673,19 @@ class QuotationService:
f"code={regen.result.code}({regen.result.desc})"
)
return CloseOutcome.REGEN_FAILED
await create_notification(
original.user_id, NotificationType.REGENERATED,
{"qt_name": original.name, "qt_number": original.number, "reason": "no_show", "next_round": original.round + 1},
ref_qt_id=regen.qt_id,
)
return CloseOutcome.REGENERATED
# 5) 그 외 / 한도 도달 → 마감만
await self._just_close(qt_uuid)
await create_notification(
original.user_id, NotificationType.FAILURE,
{"qt_name": original.name, "qt_number": original.number, "reason": "closed"},
ref_qt_id=qt_uuid,
)
return CloseOutcome.CLOSED
async def _chain_regen_counts(self, number: str, current_round: int) -> tuple[int, int]:

View File

@ -70,9 +70,17 @@ export * from './itemDataUpdatedAt';
export * from './itemDataVatYn';
export * from './listCardsParams';
export * from './listItemsParams';
export * from './listNotificationsParams';
export * from './listQuotationsParams';
export * from './listSuppliersParams';
export * from './listUsersParams';
export * from './notificationData';
export * from './notificationDataCreatedAt';
export * from './notificationDataData';
export * from './notificationDataReadAt';
export * from './notificationDataRefQtId';
export * from './notificationDataRefSessionId';
export * from './notificationType';
export * from './quotationCardData';
export * from './quotationCardDataCondition';
export * from './quotationCardDataEditScript';
@ -261,6 +269,10 @@ export * from './resMeContactNumber';
export * from './resMeEmail';
export * from './resMeMsg';
export * from './resMeName';
export * from './resNotificationList';
export * from './resNotificationListMsg';
export * from './resNotificationRead';
export * from './resNotificationReadMsg';
export * from './resNotifySessions';
export * from './resNotifySessionsMsg';
export * from './resQuotation';

View File

@ -0,0 +1,18 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
export type ListNotificationsParams = {
/**
* @minimum 1
*/
page?: number;
/**
* @minimum 1
* @maximum 100
*/
size?: number;
};

View File

@ -0,0 +1,22 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
import type { NotificationType } from './notificationType';
import type { NotificationDataRefQtId } from './notificationDataRefQtId';
import type { NotificationDataRefSessionId } from './notificationDataRefSessionId';
import type { NotificationDataData } from './notificationDataData';
import type { NotificationDataReadAt } from './notificationDataReadAt';
import type { NotificationDataCreatedAt } from './notificationDataCreatedAt';
export interface NotificationData {
notification_id: string;
type: NotificationType;
ref_qt_id?: NotificationDataRefQtId;
ref_session_id?: NotificationDataRefSessionId;
data?: NotificationDataData;
read_at?: NotificationDataReadAt;
created_at?: NotificationDataCreatedAt;
}

View File

@ -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 NotificationDataCreatedAt = string | null;

View File

@ -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 NotificationDataData = unknown | null;

View File

@ -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 NotificationDataReadAt = string | null;

View File

@ -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 NotificationDataRefQtId = string | null;

View File

@ -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 NotificationDataRefSessionId = string | null;

View File

@ -0,0 +1,20 @@
/**
* Generated by orval v7.21.0 🍺
* Do not edit manually.
* Negodata Api Server
* OpenAPI spec version: 0.1.0
*/
/**
* company.notifications.type . . 3(SUCCESS/REGENERATED/FAILURE) close_and_decide 1:1. KTC.
*/
export type NotificationType = typeof NotificationType[keyof typeof NotificationType];
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const NotificationType = {
SUCCESS: 1,
REGENERATED: 2,
FAILURE: 3,
CREATED: 4,
} as const;

View File

@ -0,0 +1,19 @@
/**
* 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 { ResNotificationListMsg } from './resNotificationListMsg';
import type { NotificationData } from './notificationData';
export interface ResNotificationList {
result?: ErrorInfo;
msg?: ResNotificationListMsg;
total?: number;
page?: number;
size?: number;
notifications?: NotificationData[];
unread?: number;
}

View File

@ -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 ResNotificationListMsg = string | null;

View File

@ -0,0 +1,13 @@
/**
* 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 { ResNotificationReadMsg } from './resNotificationReadMsg';
export interface ResNotificationRead {
result?: ErrorInfo;
msg?: ResNotificationReadMsg;
}

View File

@ -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 ResNotificationReadMsg = string | null;

View File

@ -0,0 +1,257 @@
/**
* 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,
ListNotificationsParams,
ResNotificationList,
ResNotificationRead
} from '.././model';
import { customFetch } from '../../mutator/custom-fetch';
type SecondParameter<T extends (...args: never) => unknown> = Parameters<T>[1];
/**
* @summary (+ )
*/
export const listNotifications = (
params?: ListNotificationsParams,
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
) => {
return customFetch<ResNotificationList>(
{url: `/v1/notification/list`, method: 'GET',
params, signal
},
options);
}
export const getListNotificationsQueryKey = (params?: ListNotificationsParams,) => {
return [
`/v1/notification/list`, ...(params ? [params]: [])
] as const;
}
export const getListNotificationsQueryOptions = <TData = Awaited<ReturnType<typeof listNotifications>>, TError = void | HTTPValidationError>(params?: ListNotificationsParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listNotifications>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
) => {
const {query: queryOptions, request: requestOptions} = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListNotificationsQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof listNotifications>>> = ({ signal }) => listNotifications(params, requestOptions, signal);
return { queryKey, queryFn, ...queryOptions} as UseQueryOptions<Awaited<ReturnType<typeof listNotifications>>, TError, TData> & { queryKey: DataTag<QueryKey, TData, TError> }
}
export type ListNotificationsQueryResult = NonNullable<Awaited<ReturnType<typeof listNotifications>>>
export type ListNotificationsQueryError = void | HTTPValidationError
export function useListNotifications<TData = Awaited<ReturnType<typeof listNotifications>>, TError = void | HTTPValidationError>(
params: undefined | ListNotificationsParams, options: { query:Partial<UseQueryOptions<Awaited<ReturnType<typeof listNotifications>>, TError, TData>> & Pick<
DefinedInitialDataOptions<
Awaited<ReturnType<typeof listNotifications>>,
TError,
Awaited<ReturnType<typeof listNotifications>>
> , 'initialData'
>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): DefinedUseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useListNotifications<TData = Awaited<ReturnType<typeof listNotifications>>, TError = void | HTTPValidationError>(
params?: ListNotificationsParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listNotifications>>, TError, TData>> & Pick<
UndefinedInitialDataOptions<
Awaited<ReturnType<typeof listNotifications>>,
TError,
Awaited<ReturnType<typeof listNotifications>>
> , 'initialData'
>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
export function useListNotifications<TData = Awaited<ReturnType<typeof listNotifications>>, TError = void | HTTPValidationError>(
params?: ListNotificationsParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listNotifications>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> }
/**
* @summary (+ )
*/
export function useListNotifications<TData = Awaited<ReturnType<typeof listNotifications>>, TError = void | HTTPValidationError>(
params?: ListNotificationsParams, options?: { query?:Partial<UseQueryOptions<Awaited<ReturnType<typeof listNotifications>>, TError, TData>>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient
): UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> } {
const queryOptions = getListNotificationsQueryOptions(params,options)
const query = useQuery(queryOptions, queryClient) as UseQueryResult<TData, TError> & { queryKey: DataTag<QueryKey, TData, TError> };
query.queryKey = queryOptions.queryKey ;
return query;
}
/**
* @summary
*/
export const readAll = (
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
) => {
return customFetch<ResNotificationRead>(
{url: `/v1/notification/read-all`, method: 'POST', signal
},
options);
}
export const getReadAllMutationOptions = <TError = void,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof readAll>>, TError,void, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof readAll>>, TError,void, TContext> => {
const mutationKey = ['readAll'];
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<Awaited<ReturnType<typeof readAll>>, void> = () => {
return readAll(requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type ReadAllMutationResult = NonNullable<Awaited<ReturnType<typeof readAll>>>
export type ReadAllMutationError = void
/**
* @summary
*/
export const useReadAll = <TError = void,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof readAll>>, TError,void, TContext>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof readAll>>,
TError,
void,
TContext
> => {
const mutationOptions = getReadAllMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}
/**
* @summary
*/
export const readOne = (
notificationId: string,
options?: SecondParameter<typeof customFetch>,signal?: AbortSignal
) => {
return customFetch<ResNotificationRead>(
{url: `/v1/notification/${notificationId}/read`, method: 'POST', signal
},
options);
}
export const getReadOneMutationOptions = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof readOne>>, TError,{notificationId: string}, TContext>, request?: SecondParameter<typeof customFetch>}
): UseMutationOptions<Awaited<ReturnType<typeof readOne>>, TError,{notificationId: string}, TContext> => {
const mutationKey = ['readOne'];
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<Awaited<ReturnType<typeof readOne>>, {notificationId: string}> = (props) => {
const {notificationId} = props ?? {};
return readOne(notificationId,requestOptions)
}
return { mutationFn, ...mutationOptions }}
export type ReadOneMutationResult = NonNullable<Awaited<ReturnType<typeof readOne>>>
export type ReadOneMutationError = void | HTTPValidationError
/**
* @summary
*/
export const useReadOne = <TError = void | HTTPValidationError,
TContext = unknown>(options?: { mutation?:UseMutationOptions<Awaited<ReturnType<typeof readOne>>, TError,{notificationId: string}, TContext>, request?: SecondParameter<typeof customFetch>}
, queryClient?: QueryClient): UseMutationResult<
Awaited<ReturnType<typeof readOne>>,
TError,
{notificationId: string},
TContext
> => {
const mutationOptions = getReadOneMutationOptions(options);
return useMutation(mutationOptions, queryClient);
}

View File

@ -11,6 +11,7 @@ import PartnersPage from '../pages/partners';
import QuotationPage from '../pages/quotation';
import CardsPage from '../pages/cards';
import MembersPage from '../pages/members';
import NotificationsPage from '../pages/notifications';
export const router = createBrowserRouter([
// dev 전용: import.meta.env.DEV가 false인 프로덕션 빌드에선 이 배열 항목과
@ -59,6 +60,7 @@ export const router = createBrowserRouter([
{path: 'partners', Component: PartnersPage},
{path: 'quotation', Component: QuotationPage},
{path: 'cards', Component: CardsPage},
{path: 'notifications', Component: NotificationsPage},
{
// 최고관리자 전용. 부모 loader 가 initAuth 를 마친 뒤 실행되므로 유저 상태가 복원돼 있다.
path: 'members',

View File

@ -11,6 +11,7 @@ const PAGE_TO_PATH: Record<PageType, string> = {
QUOTATION: '/quotation',
CARDS: '/cards',
MEMBERS: '/members',
NOTIFICATIONS: '/notifications',
};
export default function AuthenticatedLayout() {

View File

@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Typography } from '@/components/ui/typography';
import { cn } from '@/lib/utils';
import { NotificationBell } from './NotificationBell';
import {
LayoutDashboard,
Briefcase,
@ -49,6 +50,7 @@ const pageLabelMap: Record<PageType, string> = {
QUOTATION: '견적관리',
CARDS: '협상카드관리',
MEMBERS: '회원관리',
NOTIFICATIONS: '알림',
};
export default function Layout({ children, currentPage, setPage, onLogout }: LayoutProps) {
@ -215,7 +217,10 @@ export default function Layout({ children, currentPage, setPage, onLogout }: Lay
</Typography>
</div>
<div className="flex items-center gap-3 md:gap-4">
<NotificationBell />
<HeaderMeta user={user} today={today} />
</div>
</header>
{/* Content Body Area */}

View File

@ -0,0 +1,28 @@
import { Bell } from 'lucide-react';
import { useNavigate } from 'react-router';
import { useListNotifications } from '@/api/generated/notification/notification';
// 헤더 알림벨. 안읽음 수를 빨간 배지(빨콩이)로 표시하고, 클릭 시 알림 페이지로 이동.
// 30초마다 안읽음 수 재조회(가벼운 폴링).
export function NotificationBell() {
const navigate = useNavigate();
const { data } = useListNotifications({ size: 1 }, { query: { refetchInterval: 30000 } });
const unread = data?.unread ?? 0;
return (
<button
type="button"
onClick={() => navigate('/notifications')}
title="알림"
aria-label={unread > 0 ? `알림 ${unread}건 안읽음` : '알림'}
className="relative p-1.5 rounded text-muted-foreground hover:text-foreground hover:bg-muted/80 transition-colors cursor-pointer"
>
<Bell size={18} />
{unread > 0 && (
<span className="absolute -top-0.5 -right-0.5 min-w-4 h-4 px-1 flex items-center justify-center rounded-full bg-destructive text-destructive-foreground text-[10px] font-bold leading-none">
{unread > 99 ? '99+' : unread}
</span>
)}
</button>
);
}

View File

@ -0,0 +1,188 @@
import { useEffect, useRef, type ReactNode } from 'react';
import { useNavigate } from 'react-router';
import { useInfiniteQuery, useQueryClient } from '@tanstack/react-query';
import { Trophy, RefreshCw, XCircle, Bell, CheckCheck, FilePlus2 } from 'lucide-react';
import { PageContainer } from '@/components/layout/PageContainer';
import { Typography } from '@/components/ui/typography';
import { cn } from '@/lib/utils';
import {
listNotifications,
useReadAll,
useReadOne,
} from '@/api/generated/notification/notification';
import { NotificationType } from '@/api/generated/model/notificationType';
import type { NotificationData } from '@/api/generated/model/notificationData';
const PAGE_SIZE = 20;
export default function NotificationsPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
// offset 페이징(page/size)을 그대로 쓰는 무한 스크롤: page 를 1→2→3 누적.
// 다음 페이지 여부는 응답의 total/page/size 로 판정(page*size < total).
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery({
queryKey: ['/v1/notification/list', 'infinite'],
queryFn: ({ pageParam }) => listNotifications({ page: pageParam, size: PAGE_SIZE }),
initialPageParam: 1,
getNextPageParam: (last) => {
const page = last.page ?? 1;
const size = last.size ?? PAGE_SIZE;
const total = last.total ?? 0;
return page * size < total ? page + 1 : undefined;
},
});
const readAll = useReadAll();
const readOne = useReadOne();
const items = data?.pages.flatMap((p) => p.notifications ?? []) ?? [];
const unread = data?.pages[0]?.unread ?? 0;
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['/v1/notification/list'] });
const openOne = (n: NotificationData) => {
if (!n.read_at) readOne.mutate({ notificationId: n.notification_id }, { onSuccess: invalidate });
if (n.ref_qt_id) navigate(`/quotation?detail=${n.ref_qt_id}`);
};
// 리스트 끝 sentinel 이 뷰포트에 들어오면 다음 페이지 로드(무한 스크롤).
const sentinelRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const el = sentinelRef.current;
if (!el) return;
const io = new IntersectionObserver(
(entries) => {
if (entries[0]?.isIntersecting && hasNextPage && !isFetchingNextPage) fetchNextPage();
},
{ rootMargin: '120px' },
);
io.observe(el);
return () => io.disconnect();
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
return (
<PageContainer>
<div className="flex items-center justify-between p-4 rounded-lg border border-border bg-card">
<div className="flex items-center gap-2">
<Bell size={18} className="text-foreground" />
<Typography as="span" variant="small" className="font-bold"></Typography>
{unread > 0 && (
<Typography as="span" variant="small" className="text-xs font-bold text-destructive">{unread} </Typography>
)}
</div>
<button
type="button"
onClick={() => readAll.mutate(undefined, { onSuccess: invalidate })}
disabled={unread === 0}
className="flex items-center gap-1.5 px-3 py-2 bg-muted text-foreground border border-border text-xs font-semibold rounded hover:bg-muted-foreground/10 disabled:opacity-40 disabled:cursor-not-allowed cursor-pointer transition-colors"
>
<CheckCheck size={14} />
<Typography as="span" variant="small" className="text-xs text-inherit"> </Typography>
</button>
</div>
<div className="rounded-lg border border-border bg-card divide-y divide-border overflow-hidden">
{items.length === 0 ? (
<div className="p-10 text-center">
<Typography as="p" variant="small" className="text-muted-foreground"> .</Typography>
</div>
) : (
items.map((n) => {
const r = render(n);
const isUnread = !n.read_at;
return (
<button
key={n.notification_id}
type="button"
onClick={() => openOne(n)}
className={cn(
'w-full flex items-start gap-3 px-4 py-3 text-left hover:bg-muted/60 transition-colors cursor-pointer',
isUnread && 'bg-primary/5'
)}
>
<span className={cn('shrink-0 mt-0.5', r.tone)}>{r.icon}</span>
<div className="flex-1 min-w-0">
{/* 1줄: 이벤트(낙찰/재생성/결렬/생성) — 제목 */}
<Typography as="div" variant="small" className={cn('text-xs font-bold', r.tone)}>
{r.event}
</Typography>
{/* 2줄: 무슨 견적인지(건명) + 결과 */}
<Typography as="div" variant="small" className={cn('text-sm truncate', isUnread ? 'font-bold text-foreground' : 'text-foreground/80')}>
{r.line}
</Typography>
{/* 3줄: 견적번호 · 날짜 — quotation 상세 헤더와 같은 mono 보조 표기 */}
<Typography as="div" variant="small" className="text-[11px] font-mono text-muted-foreground mt-0.5">
{r.number ? `${r.number} · ` : ''}{fmtKst(n.created_at)}
</Typography>
</div>
{isUnread && <span className="shrink-0 mt-1.5 h-2 w-2 rounded-full bg-destructive" />}
</button>
);
})
)}
</div>
{hasNextPage && (
<div ref={sentinelRef} className="py-3 text-center">
{isFetchingNextPage && (
<Typography as="span" variant="small" className="text-muted-foreground"> </Typography>
)}
</div>
)}
</PageContainer>
);
}
// 서버 시각(타임존 표식 없는 UTC) → 한국시간 'YYYY-MM-DD HH:mm'. 실패 시 '-'.
function fmtKst(s?: string | null): string {
if (!s) return '-';
const iso = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(s) ? s : s + 'Z';
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return '-';
const p = new Intl.DateTimeFormat('ko-KR', {
timeZone: 'Asia/Seoul', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
}).formatToParts(d).reduce((o, x) => ((o[x.type] = x.value), o), {} as Record<string, string>);
return `${p.year}-${p.month}-${p.day} ${p.hour}:${p.minute}`;
}
// 견적건명(name, 필수 입력). 비면 번호, 둘 다 없으면 '견적'.
function qtName(d: Record<string, unknown>): string {
const name = (d.qt_name as string) || '';
const num = (d.qt_number as string) || '';
return name || num || '견적';
}
// 알림 1건 → 카드 3단 표기값.
// event : 이벤트 제목(낙찰/재생성/결렬/생성) — 한 줄에 쭉 늘어놓지 않고 분리
// line : 무슨 견적인지(건명) + 결과
// number: 견적번호(메타줄 보조 표기). icon/tone 은 유형별 색.
function render(n: NotificationData): { icon: ReactNode; tone: string; event: string; line: string; number: string } {
const d = (n.data ?? {}) as Record<string, unknown>;
const name = qtName(d);
const number = (d.qt_number as string) || '';
switch (n.type) {
case NotificationType.CREATED:
return { icon: <FilePlus2 size={18} />, tone: 'text-sky-600', event: '견적 생성', line: name, number };
case NotificationType.SUCCESS:
return {
icon: <Trophy size={18} />, tone: 'text-emerald-600', event: '견적 낙찰',
line: `${name}${d.winner_name ?? '-'} ${Number(d.winner_price ?? 0).toLocaleString()}`,
number,
};
case NotificationType.REGENERATED:
return {
icon: <RefreshCw size={18} />, tone: 'text-amber-600',
event: `견적 재생성 · ${d.reason === 'equal' ? '동가' : '전원 미참여'}`,
line: `${name}${d.next_round ?? ''}차로 다시 생성`,
number,
};
case NotificationType.FAILURE:
return {
icon: <XCircle size={18} />, tone: 'text-rose-600', event: '견적 결렬',
line: `${name} — 낙찰 없이 마감`,
number,
};
default:
return { icon: <Bell size={18} />, tone: 'text-muted-foreground', event: '견적 알림', line: name, number };
}
}

View File

@ -33,4 +33,4 @@ export interface NegotiationCard {
memo?: string;
}
export type PageType = 'DASHBOARD' | 'PRODUCTS' | 'PARTNERS' | 'QUOTATION' | 'CARDS' | 'MEMBERS';
export type PageType = 'DASHBOARD' | 'PRODUCTS' | 'PARTNERS' | 'QUOTATION' | 'CARDS' | 'MEMBERS' | 'NOTIFICATIONS';

View File

@ -333,11 +333,30 @@ CREATE TABLE IF NOT EXISTS negotiation.results (
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
);
-- ============================================================
-- company : 알림(인박스) — 협상 이벤트를 견적 작성자(유저)에게 통지
-- ============================================================
CREATE TABLE IF NOT EXISTS company.notifications (
notification_id uuid PRIMARY KEY DEFAULT gen_random_uuid(), -- 알림 식별자(PK)
user_id uuid NOT NULL, -- 수신자(company.users.user_id) = 견적 작성자
type SMALLINT NOT NULL, -- 알림 유형(NotificationType): 1=success(낙찰), 2=regenerated(재생성), 3=failure(결렬), 4=created(견적생성)
ref_qt_id uuid NULL, -- 관련 견적(quotation.quotations.qt_id), 클릭 시 이동
ref_session_id uuid NULL, -- 관련 세션(negotiation.sessions.session_id), 공급사 알림만
data JSONB NULL, -- 렌더 스냅샷(qt_name/qt_number + 유형별 필드). 발생 시점 값 보관(텍스트 불변).
read_at TIMESTAMPTZ NULL, -- 읽은 시각(NULL=안읽음). 행동 필요(검토) 판정 겸용
created_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 생성 시각(UTC)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), -- 수정 시각(UTC, 앱에서 갱신)
deleted BOOLEAN NOT NULL DEFAULT FALSE -- 소프트 삭제 여부
);
-- ============================================================
-- 인덱스 (FK 를 걸지 않으므로 조인 컬럼 인덱스를 명시적으로 생성)
-- ============================================================
CREATE INDEX IF NOT EXISTS idx_users_company_id ON company.users (company_id);
CREATE INDEX IF NOT EXISTS idx_user_tokens_user_id ON company.user_tokens (user_id);
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON company.notifications (user_id);
CREATE INDEX IF NOT EXISTS idx_notifications_user_unread ON company.notifications (user_id, created_at) WHERE deleted = FALSE AND read_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_notifications_ref_qt_id ON company.notifications (ref_qt_id);
CREATE INDEX IF NOT EXISTS idx_supplier_users_supplier_id ON supplier.supplier_users (supplier_id);
CREATE INDEX IF NOT EXISTS idx_sut_su_id ON supplier.supplier_user_tokens (su_id);
CREATE INDEX IF NOT EXISTS idx_suppliers_company_id ON partner.suppliers (company_id);

View File

@ -32,3 +32,22 @@ ALTER TABLE card.wild_cards
-- ───────────────────────────────────────────────────────────
ALTER TABLE negotiation.sessions
ADD COLUMN IF NOT EXISTS email_sent_at TIMESTAMPTZ;
-- ───────────────────────────────────────────────────────────
-- [2026-06-30] 알림(인박스): 협상 이벤트를 견적 작성자에게 통지
-- ───────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS company.notifications (
notification_id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid NOT NULL, -- 수신자(company.users.user_id) = 견적 작성자
type SMALLINT NOT NULL, -- 알림 유형(NotificationType): 1=success(낙찰), 2=regenerated(재생성), 3=failure(결렬)
ref_qt_id uuid NULL, -- 관련 견적(quotation.quotations.qt_id)
ref_session_id uuid NULL, -- 관련 세션(negotiation.sessions.session_id)
data JSONB NULL, -- 렌더 스냅샷(유형별)
read_at TIMESTAMPTZ NULL, -- 읽은 시각(NULL=안읽음)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT FALSE
);
CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON company.notifications (user_id);
CREATE INDEX IF NOT EXISTS idx_notifications_user_unread ON company.notifications (user_id, created_at) WHERE deleted = FALSE AND read_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_notifications_ref_qt_id ON company.notifications (ref_qt_id);