[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:
parent
b42cc41b5e
commit
1d111a031e
@ -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"}
|
||||
|
||||
@ -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 코드값. 채팅 발신 주체."""
|
||||
|
||||
|
||||
107
negodata/backend/crud/notification_crud.py
Normal file
107
negodata/backend/crud/notification_crud.py
Normal 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
|
||||
@ -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)
|
||||
|
||||
32
negodata/backend/router/v1/notification/notification.py
Normal file
32
negodata/backend/router/v1/notification/notification.py
Normal 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)))
|
||||
33
negodata/backend/router/v1/notification/protocol.py
Normal file
33
negodata/backend/router/v1/notification/protocol.py
Normal 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
|
||||
88
negodata/backend/services/notification.py
Normal file
88
negodata/backend/services/notification.py
Normal 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
|
||||
@ -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]:
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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;
|
||||
};
|
||||
22
negodata/front/src/api/generated/model/notificationData.ts
Normal file
22
negodata/front/src/api/generated/model/notificationData.ts
Normal 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;
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
20
negodata/front/src/api/generated/model/notificationType.ts
Normal file
20
negodata/front/src/api/generated/model/notificationType.ts
Normal 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;
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
257
negodata/front/src/api/generated/notification/notification.ts
Normal file
257
negodata/front/src/api/generated/notification/notification.ts
Normal 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);
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -11,6 +11,7 @@ const PAGE_TO_PATH: Record<PageType, string> = {
|
||||
QUOTATION: '/quotation',
|
||||
CARDS: '/cards',
|
||||
MEMBERS: '/members',
|
||||
NOTIFICATIONS: '/notifications',
|
||||
};
|
||||
|
||||
export default function AuthenticatedLayout() {
|
||||
|
||||
@ -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>
|
||||
|
||||
<HeaderMeta user={user} today={today} />
|
||||
<div className="flex items-center gap-3 md:gap-4">
|
||||
<NotificationBell />
|
||||
<HeaderMeta user={user} today={today} />
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content Body Area */}
|
||||
|
||||
28
negodata/front/src/components/layout/NotificationBell.tsx
Normal file
28
negodata/front/src/components/layout/NotificationBell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
188
negodata/front/src/pages/notifications.tsx
Normal file
188
negodata/front/src/pages/notifications.tsx
Normal 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 };
|
||||
}
|
||||
}
|
||||
@ -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';
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user