From 1deab3d00356f3b8c649a19cb935d14d1722e0ca Mon Sep 17 00:00:00 2001 From: seoJing Date: Sat, 6 Jun 2026 13:05:52 +0900 Subject: [PATCH] feat: wire feed promotion roles settlement --- src/app/(detail)/feed/[id]/page.tsx | 79 +++++++++- src/entities/spot/types.ts | 8 +- .../chat/client/MainChatPageClient.tsx | 42 +++++ src/features/chat/model/spot-action-items.ts | 37 ++++- src/features/chat/model/types.ts | 19 ++- .../chat/model/use-main-chat-store.ts | 19 ++- src/features/chat/ui/ChatCreationPanel.tsx | 147 ++++++++++++++++++ .../chat/ui/lifecycle/ClosedPhasePanel.tsx | 85 +++++++++- .../chat/ui/lifecycle/MatchedPhasePanel.tsx | 22 ++- src/features/feed/api/feed-api.ts | 5 + src/features/feed/model/types.ts | 8 +- .../ui/detail/FeedParticipationActions.tsx | 25 ++- src/features/spot/api/spot-api.ts | 102 ++++++++++-- .../client/detail/SettlementSubmitSheet.tsx | 3 + .../client/detail/SpotSettlementActions.tsx | 4 + src/lib/endpoint.ts | 4 + 16 files changed, 574 insertions(+), 35 deletions(-) diff --git a/src/app/(detail)/feed/[id]/page.tsx b/src/app/(detail)/feed/[id]/page.tsx index 46e957c..017c835 100644 --- a/src/app/(detail)/feed/[id]/page.tsx +++ b/src/app/(detail)/feed/[id]/page.tsx @@ -22,6 +22,7 @@ import { VenueSection } from '@/features/feed/ui/detail/VenueSection'; import { DetailHeader, DetailPageShell } from '@/shared/ui'; import type { FeedApplication, + FeedAuthorProfile, FeedItem, FeedManagementFlow, SupporterApplication, @@ -63,6 +64,76 @@ function formatPrice(price: number): string { return price.toLocaleString('ko-KR') + '원'; } +type FeedDisplayRole = FeedAuthorProfile['role']; + +function getFeedRoleLabel(role?: FeedDisplayRole): string { + if (role === 'OWNER') return '오너'; + if (role === 'SUPPORTER') return '서포터'; + return '파트너'; +} + +function SpotPromotionProgress({ item }: { item: FeedItem }) { + const progressPercent = Math.min(item.progressPercent ?? 0, 100); + const fundingGoal = item.fundingGoal ?? item.price; + const fundedAmount = item.fundedAmount ?? 0; + const remainingAmount = Math.max( + item.remainingAmount ?? fundingGoal - fundedAmount, + 0, + ); + const remainingParticipantCount = Math.max( + item.remainingParticipantCount ?? 0, + 0, + ); + const converted = Boolean(item.spotId) || item.status === 'MATCHED'; + + return ( +
+
+
+

+ 스팟 전환 현황 +

+

+ {converted + ? '스팟으로 전환됐어요' + : remainingParticipantCount > 0 + ? `${remainingParticipantCount}명만 더 모이면 스팟이 열려요` + : remainingAmount > 0 + ? `${formatPrice(remainingAmount)} 더 필요해요` + : '스팟 전환 조건을 채웠어요'} +

+
+ + {progressPercent}% + +
+
+
+
+
+ + 현재 {formatPrice(fundedAmount)} / 목표{' '} + {formatPrice(fundingGoal)} + + {!converted && remainingAmount > 0 && ( + 남은 금액 {formatPrice(remainingAmount)} + )} +
+
+ ); +} + +function RoleBadge({ role }: { role?: FeedDisplayRole }) { + return ( + + {getFeedRoleLabel(role)} + + ); +} + function AuthorSection({ item }: { item: FeedItem }) { const profile = item.authorProfile; const isSupporter = profile?.role === 'SUPPORTER'; @@ -88,11 +159,7 @@ function AuthorSection({ item }: { item: FeedItem }) {

{item.authorNickname}

- {profile && ( - - {isSupporter ? '서포터' : '파트너'} - - )} + {profile && }
{item.location} @@ -162,6 +229,7 @@ function OfferDetailContent({ item }: { item: FeedItem }) { )}
+ {/* 본문 */} @@ -249,6 +317,7 @@ function RequestDetailContent({ 서포터와 매칭된 후 채팅을 통해 금액과 일정을 함께 조율합니다.

+ {/* 서포터 지원 현황 */} diff --git a/src/entities/spot/types.ts b/src/entities/spot/types.ts index 8dad818..5db65b2 100644 --- a/src/entities/spot/types.ts +++ b/src/entities/spot/types.ts @@ -33,6 +33,7 @@ export type PagedResponse = ApiResponse; export type SpotType = 'OFFER' | 'REQUEST'; export type SpotStatus = 'OPEN' | 'MATCHED' | 'CLOSED' | 'CANCELLED'; +export type SpotMemberRole = 'OWNER' | 'SUPPORTER' | 'PARTNER'; export type SpotForfeitPool = { toPool: number; @@ -73,6 +74,9 @@ export type TimelineEvent = { export type SpotDetail = Spot & { timeline: TimelineEvent[]; + settlement?: SpotSettlementApproval | null; + participantCount?: number; + isOwner?: boolean; }; // ─── 목록 필터 ──────────────────────────────────────────────────────────────── @@ -83,7 +87,7 @@ export type SpotDashboardTab = 'OFFER' | 'REQUEST' | 'RENT'; export type SpotParticipant = { userId: string; nickname: string; - role: 'AUTHOR' | 'PARTICIPANT'; + role: SpotMemberRole | 'AUTHOR' | 'PARTICIPANT'; joinedAt: string; }; @@ -191,6 +195,8 @@ export type SpotSettlementLineItem = { }; export type SpotSettlementApproval = { + id?: string; + spotId?: string; status: WorkflowApprovalStatus; requestedAmount: number; approvedAmount: number; diff --git a/src/features/chat/client/MainChatPageClient.tsx b/src/features/chat/client/MainChatPageClient.tsx index f59b9ab..7b2e44a 100644 --- a/src/features/chat/client/MainChatPageClient.tsx +++ b/src/features/chat/client/MainChatPageClient.tsx @@ -11,6 +11,7 @@ import { IconFileText, IconHeartHandshake, IconMap, + IconReceipt, } from '@tabler/icons-react'; import { usePathname, useRouter, useSearchParams } from 'next/navigation'; import { cn } from '@/shared/lib/cn'; @@ -307,6 +308,11 @@ function SpotItemList({ ): item is Extract => item.kind === 'reverse-offer', ) ?? null; + const settlementItem = + actionItems.find( + (item): item is Extract => + item.kind === 'settlement', + ) ?? null; const voteItems = actionItems.filter( (item): item is Extract => item.kind === 'vote', @@ -316,6 +322,7 @@ function SpotItemList({ const hasFiles = room.spot.files.length > 0; const hasItems = Boolean(reverseOfferItem) || + Boolean(settlementItem) || voteItems.length > 0 || hasSchedule || hasFiles; @@ -399,6 +406,41 @@ function SpotItemList({ ))} + {settlementItem && ( + + )} + {hasSchedule && room.spot.schedule && ( + + ); +} + function ReverseOfferActionPanel({ item, onClose, @@ -1792,6 +1932,13 @@ export function ChatCreationPanel({ onClose }: ChatCreationPanelProps) { return ( ); + if (activeActionItem.kind === 'settlement') + return ( + + ); if (activeActionItem.kind === 'reverse-offer') return ( +

정산

+

+ 아직 정산 요청이 없어요. 오너가 활동 내역을 정리해 제출해야 + 해요. +

+ + ); + } + + return ( +
+
+

정산

+ + {settlement.status === 'APPROVED' + ? '승인 완료' + : '승인 대기'} + +
+

+ {settlement.summary || '정산 내역 확인이 필요해요.'} +

+
+ {settlement.lineItems.map((item) => ( +
+ + {item.label} + + + {formatPoints(item.amount)} + +
+ ))} +
+
+
+ 요청 금액 + + {formatPoints(settlement.requestedAmount)} + +
+
+
+ ); +} + export function ClosedPhasePanel({ room }: Props) { const spot = room.spot; + const loadRoom = useMainChatStore((state) => state.loadRoom); const reviews = spot.reviews; const closedEvent = spot.timeline.find( (e) => e.kind === 'COMPLETED' || e.kind === 'CANCELLED', ); + const workflow = { + spotId: spot.id, + progressLabel: '활동 종료 · 정산 단계', + settlementApproval: spot.settlement ?? undefined, + }; return (
@@ -38,6 +111,16 @@ export function ClosedPhasePanel({ room }: Props) { )} + + void loadRoom(room.id)} + />
diff --git a/src/features/chat/ui/lifecycle/MatchedPhasePanel.tsx b/src/features/chat/ui/lifecycle/MatchedPhasePanel.tsx index 7541cc7..8901279 100644 --- a/src/features/chat/ui/lifecycle/MatchedPhasePanel.tsx +++ b/src/features/chat/ui/lifecycle/MatchedPhasePanel.tsx @@ -36,6 +36,12 @@ function formatFileSize(sizeBytes: number): string { return `${sizeBytes}B`; } +function getSpotMemberRoleLabel(role: SpotParticipant['role']): string { + if (role === 'OWNER' || role === 'AUTHOR') return '오너'; + if (role === 'SUPPORTER') return '서포터'; + return '파트너'; +} + export function MatchedPhasePanel({ room }: Props) { const spot = room.spot; @@ -170,12 +176,16 @@ function ParticipantsTile({ >
{participants.slice(0, MAX).map((p) => ( - +
+ + + {getSpotMemberRoleLabel(p.role)} + +
))} {participants.length > MAX && (
diff --git a/src/features/feed/api/feed-api.ts b/src/features/feed/api/feed-api.ts index 4add45f..014f855 100644 --- a/src/features/feed/api/feed-api.ts +++ b/src/features/feed/api/feed-api.ts @@ -82,6 +82,8 @@ export type BackendFeedApplication = Omit< applicantProfile?: FeedApplication['applicantProfile']; appliedRole?: FeedApplicationRole; deposit?: number; + spotConverted?: boolean; + spotId?: string | number | null; }; export function toFeedApplication( @@ -106,6 +108,9 @@ export function toFeedApplication( : undefined), appliedRole: application.appliedRole ?? fallback?.role ?? 'PARTNER', deposit: application.deposit ?? fallback?.deposit ?? 0, + spotConverted: application.spotConverted, + spotId: + application.spotId == null ? undefined : String(application.spotId), }; } diff --git a/src/features/feed/model/types.ts b/src/features/feed/model/types.ts index 5b31d49..068991a 100644 --- a/src/features/feed/model/types.ts +++ b/src/features/feed/model/types.ts @@ -11,7 +11,7 @@ export type FeedItemType = 'OFFER' | 'REQUEST' | 'RENT'; export type FeedItemStatus = 'OPEN' | 'MATCHED' | 'CLOSED'; export type FeedTabType = 'HOME' | 'EXPLORE'; export type FeedCategory = '음악' | '요리' | '운동' | '공예' | '언어' | '기타'; -export type FeedAuthorRole = 'SUPPORTER' | 'PARTNER'; +export type FeedAuthorRole = 'OWNER' | 'SUPPORTER' | 'PARTNER'; // 지원자 본인의 신청 생명주기 (SupporterApplicationStatus는 호스트 측 triage 라벨과 별개) export type FeedApplicationStatus = @@ -69,6 +69,8 @@ export interface FeedApplication { applicantProfile?: FeedApplicationApplicantProfile | null; nickname?: string; avatarUrl?: string | null; + spotConverted?: boolean; + spotId?: string; } export interface FeedAuthorProfile { @@ -91,6 +93,10 @@ export interface FeedItem { type: FeedItemType; status: FeedItemStatus; progressPercent?: number; // OFFER 타입일 때 펀딩 진행률 (0~100) + fundingGoal?: number; + fundedAmount?: number; + remainingAmount?: number; + remainingParticipantCount?: number; imageUrl?: string; views: number; likes: number; diff --git a/src/features/feed/ui/detail/FeedParticipationActions.tsx b/src/features/feed/ui/detail/FeedParticipationActions.tsx index 885a18c..c1c5722 100644 --- a/src/features/feed/ui/detail/FeedParticipationActions.tsx +++ b/src/features/feed/ui/detail/FeedParticipationActions.tsx @@ -367,11 +367,32 @@ export function FeedParticipationActions({ try { if (decision === 'accept') { - await acceptFeedApplication.mutateAsync({ + const response = await acceptFeedApplication.mutateAsync({ feedId: item.id, applicationId, }); - showBottomNavMessage('신청자를 수락했어요.', ''); + const convertedSpotId = response.data.spotId ?? item.spotId; + + if (response.data.spotConverted && convertedSpotId) { + showBottomNavMessage( + '스팟이 생성됐어요. 팀 채팅으로 이동할게요.', + '', + ); + router.push( + `/chat?tab=team&spotId=${encodeURIComponent(convertedSpotId)}`, + ); + return; + } + + const remainCopy = + item.remainingParticipantCount != null && + item.remainingParticipantCount > 0 + ? ` 앞으로 ${item.remainingParticipantCount}명 더 필요해요.` + : item.remainingAmount != null && + item.remainingAmount > 0 + ? ` 앞으로 ${item.remainingAmount.toLocaleString('ko-KR')}P 더 필요해요.` + : ''; + showBottomNavMessage(`신청자를 수락했어요.${remainCopy}`, ''); } else { await rejectFeedApplication.mutateAsync({ feedId: item.id, diff --git a/src/features/spot/api/spot-api.ts b/src/features/spot/api/spot-api.ts index d7475f4..7038493 100644 --- a/src/features/spot/api/spot-api.ts +++ b/src/features/spot/api/spot-api.ts @@ -21,12 +21,7 @@ import type { SpotReview, SubmitSettlementPayload, } from '@/entities/spot/types'; -import { - approveMockSpotSettlement, - getMockSpotReviews, - submitMockSpotReview, - submitMockSpotSettlement, -} from '../model/mock'; +import { getMockSpotReviews, submitMockSpotReview } from '../model/mock'; export type SpotListParams = { type?: SpotType; @@ -94,9 +89,34 @@ type BackendPaged = { meta?: PagedResponse['meta']; }; -type BackendSpot = Omit & { +type BackendSettlementLineItem = { + label?: string; + amount?: number; +}; + +type BackendSettlement = { + id?: string | number; + spotId?: string | number; + status?: SpotSettlementApproval['status']; + summary?: string; + totalAmount?: number; + requestedAmount?: number; + approvedAmount?: number; + lineItems?: BackendSettlementLineItem[]; + requesterId?: string; + createdAt?: string; + approvedBy?: string; + approvedAt?: string | null; +}; + +type BackendSpot = Omit & { + id: string | number; type: Spot['type'] | 'RENT'; status: Spot['status']; + settlement?: BackendSettlement | null; + participantCount?: number; + isOwner?: boolean; + timeline?: SpotDetail['timeline']; }; type BackendSpotMapItem = Omit & { @@ -107,7 +127,7 @@ type BackendSpotMapItem = Omit & { type BackendParticipant = { userId: string; nickname?: string; - role: SpotParticipant['role']; + role?: SpotParticipant['role']; joinedAt: string; }; @@ -172,10 +192,41 @@ type BackendNote = { function toSpot(spot: BackendSpot): Spot { return { ...spot, + id: String(spot.id), type: spot.type === 'RENT' ? 'OFFER' : spot.type, }; } +function toSettlement( + settlement?: BackendSettlement | null, +): SpotSettlementApproval | null { + if (!settlement) return null; + + const requestedAmount = + settlement.requestedAmount ?? settlement.totalAmount ?? 0; + const approvedAmount = + settlement.approvedAmount ?? + (settlement.status === 'APPROVED' ? requestedAmount : 0); + + return { + id: settlement.id == null ? undefined : String(settlement.id), + spotId: + settlement.spotId == null ? undefined : String(settlement.spotId), + status: settlement.status ?? 'PENDING', + requestedAmount, + approvedAmount, + summary: settlement.summary ?? '', + lineItems: (settlement.lineItems ?? []).map((item) => ({ + label: item.label ?? '정산 항목', + amount: item.amount ?? 0, + })), + submittedBy: settlement.requesterId, + submittedAt: settlement.createdAt, + approvedBy: settlement.approvedBy, + approvedAt: settlement.approvedAt ?? undefined, + }; +} + function toSpotMapItem(item: BackendSpotMapItem): SpotMapItem { return { ...item, @@ -187,7 +238,10 @@ function toSpotMapItem(item: BackendSpotMapItem): SpotMapItem { function toSpotDetail(spot: BackendSpot): SpotDetail { return { ...toSpot(spot), - timeline: [], + participantCount: spot.participantCount, + isOwner: spot.isOwner, + settlement: toSettlement(spot.settlement), + timeline: spot.timeline ?? [], }; } @@ -202,7 +256,7 @@ function toParticipant(participant: BackendParticipant): SpotParticipant { return { userId: participant.userId, nickname: participant.nickname ?? participant.userId, - role: participant.role, + role: (participant.role ?? 'PARTNER') as SpotParticipant['role'], joinedAt: participant.joinedAt, }; } @@ -680,10 +734,34 @@ export const spotsApi = { id: string, payload: SubmitSettlementPayload, ): Promise<{ data: SpotSettlementApproval }> => - submitMockSpotSettlement(id, payload), + clientApiFetch(endpoints.spots.settlement(id), { + method: 'POST', + body: JSON.stringify(payload), + }).then((data) => ({ + data: toSettlement(data) ?? { + status: 'PENDING', + requestedAmount: 0, + approvedAmount: 0, + summary: '', + lineItems: [], + }, + })), approveSettlement: async ( id: string, ): Promise<{ data: SpotSettlementApproval }> => - approveMockSpotSettlement(id), + clientApiFetch( + endpoints.spots.settlementApprove(id), + { + method: 'POST', + }, + ).then((data) => ({ + data: toSettlement(data) ?? { + status: 'APPROVED', + requestedAmount: 0, + approvedAmount: 0, + summary: '', + lineItems: [], + }, + })), }; diff --git a/src/features/spot/client/detail/SettlementSubmitSheet.tsx b/src/features/spot/client/detail/SettlementSubmitSheet.tsx index 2780043..3f0012f 100644 --- a/src/features/spot/client/detail/SettlementSubmitSheet.tsx +++ b/src/features/spot/client/detail/SettlementSubmitSheet.tsx @@ -25,6 +25,7 @@ type Props = { open: boolean; onClose: () => void; forfeitPool?: SpotForfeitPool; + onSubmitted?: () => void; }; export function SettlementSubmitSheet({ @@ -32,6 +33,7 @@ export function SettlementSubmitSheet({ open, onClose, forfeitPool, + onSubmitted, }: Props) { const [items, setItems] = useState([ emptyLineItem(), @@ -103,6 +105,7 @@ export function SettlementSubmitSheet({ setItems([emptyLineItem(), emptyLineItem()]); setSummary(''); onClose(); + onSubmitted?.(); }; return ( diff --git a/src/features/spot/client/detail/SpotSettlementActions.tsx b/src/features/spot/client/detail/SpotSettlementActions.tsx index 50e3b0e..a55aad5 100644 --- a/src/features/spot/client/detail/SpotSettlementActions.tsx +++ b/src/features/spot/client/detail/SpotSettlementActions.tsx @@ -17,6 +17,7 @@ type Props = { currentUserId: string; authorId: string; forfeitPool?: SpotForfeitPool; + onSettled?: () => void; }; export function SpotSettlementActions({ @@ -26,6 +27,7 @@ export function SpotSettlementActions({ currentUserId, authorId, forfeitPool, + onSettled, }: Props) { const [sheetOpen, setSheetOpen] = useState(false); const [approveOpen, setApproveOpen] = useState(false); @@ -47,6 +49,7 @@ export function SpotSettlementActions({ const handleApprove = async () => { await approve.mutateAsync(spotId); setApproveOpen(false); + onSettled?.(); }; return ( @@ -79,6 +82,7 @@ export function SpotSettlementActions({ open={sheetOpen} onClose={() => setSheetOpen(false)} forfeitPool={forfeitPool} + onSubmitted={onSettled} /> backendEndpoint(`/spots/${spotId}/cancel`), complete: (spotId: string) => backendEndpoint(`/spots/${spotId}/complete`), + settlement: (spotId: string) => + backendEndpoint(`/spots/${spotId}/settlement`), + settlementApprove: (spotId: string) => + backendEndpoint(`/spots/${spotId}/settlement/approve`), }, feeds: { root: backendEndpoint('/feeds'),