Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 74 additions & 5 deletions src/app/(detail)/feed/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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 (
<div className="mt-4 rounded-2xl border border-accent-border bg-accent-muted/60 px-4 py-3">
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-xs font-semibold tracking-[0.12em] text-accent uppercase">
스팟 전환 현황
</p>
<p className="mt-1 text-sm font-semibold text-gray-900">
{converted
? '스팟으로 전환됐어요'
: remainingParticipantCount > 0
? `${remainingParticipantCount}명만 더 모이면 스팟이 열려요`
: remainingAmount > 0
? `${formatPrice(remainingAmount)} 더 필요해요`
: '스팟 전환 조건을 채웠어요'}
</p>
</div>
<span className="shrink-0 rounded-full bg-white px-2.5 py-1 text-xs font-semibold text-accent">
{progressPercent}%
</span>
</div>
<div className="mt-3 h-2 overflow-hidden rounded-full bg-white">
<div
className="h-full rounded-full bg-accent transition-all duration-500"
style={{ width: `${progressPercent}%` }}
/>
</div>
<div className="mt-2 flex flex-wrap items-center justify-between gap-x-4 gap-y-1 text-xs text-gray-500">
<span>
현재 {formatPrice(fundedAmount)} / 목표{' '}
{formatPrice(fundingGoal)}
</span>
{!converted && remainingAmount > 0 && (
<span>남은 금액 {formatPrice(remainingAmount)}</span>
)}
</div>
</div>
);
}

function RoleBadge({ role }: { role?: FeedDisplayRole }) {
return (
<span className="rounded-full bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-500">
{getFeedRoleLabel(role)}
</span>
);
}

function AuthorSection({ item }: { item: FeedItem }) {
const profile = item.authorProfile;
const isSupporter = profile?.role === 'SUPPORTER';
Expand All @@ -88,11 +159,7 @@ function AuthorSection({ item }: { item: FeedItem }) {
<p className="font-semibold text-gray-900">
{item.authorNickname}
</p>
{profile && (
<span className="rounded-full bg-gray-100 px-1.5 py-0.5 text-[10px] font-medium text-gray-500">
{isSupporter ? '서포터' : '파트너'}
</span>
)}
{profile && <RoleBadge role={profile.role} />}
</div>
<div className="mt-0.5 flex items-center gap-2 text-xs text-gray-400">
<span>{item.location}</span>
Expand Down Expand Up @@ -162,6 +229,7 @@ function OfferDetailContent({ item }: { item: FeedItem }) {
</span>
)}
</div>
<SpotPromotionProgress item={item} />
</div>

{/* 본문 */}
Expand Down Expand Up @@ -249,6 +317,7 @@ function RequestDetailContent({
서포터와 매칭된 후 채팅을 통해 금액과 일정을 함께
조율합니다.
</p>
<SpotPromotionProgress item={item} />
</div>

{/* 서포터 지원 현황 */}
Expand Down
8 changes: 7 additions & 1 deletion src/entities/spot/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export type PagedResponse<T> = ApiResponse<T[]>;

export type SpotType = 'OFFER' | 'REQUEST';
export type SpotStatus = 'OPEN' | 'MATCHED' | 'CLOSED' | 'CANCELLED';
export type SpotMemberRole = 'OWNER' | 'SUPPORTER' | 'PARTNER';

export type SpotForfeitPool = {
toPool: number;
Expand Down Expand Up @@ -73,6 +74,9 @@ export type TimelineEvent = {

export type SpotDetail = Spot & {
timeline: TimelineEvent[];
settlement?: SpotSettlementApproval | null;
participantCount?: number;
isOwner?: boolean;
};

// ─── 목록 필터 ────────────────────────────────────────────────────────────────
Expand All @@ -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;
};

Expand Down Expand Up @@ -191,6 +195,8 @@ export type SpotSettlementLineItem = {
};

export type SpotSettlementApproval = {
id?: string;
spotId?: string;
status: WorkflowApprovalStatus;
requestedAmount: number;
approvedAmount: number;
Expand Down
42 changes: 42 additions & 0 deletions src/features/chat/client/MainChatPageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -307,6 +308,11 @@ function SpotItemList({
): item is Extract<SpotActionItem, { kind: 'reverse-offer' }> =>
item.kind === 'reverse-offer',
) ?? null;
const settlementItem =
actionItems.find(
(item): item is Extract<SpotActionItem, { kind: 'settlement' }> =>
item.kind === 'settlement',
) ?? null;
const voteItems = actionItems.filter(
(item): item is Extract<SpotActionItem, { kind: 'vote' }> =>
item.kind === 'vote',
Expand All @@ -316,6 +322,7 @@ function SpotItemList({
const hasFiles = room.spot.files.length > 0;
const hasItems =
Boolean(reverseOfferItem) ||
Boolean(settlementItem) ||
voteItems.length > 0 ||
hasSchedule ||
hasFiles;
Expand Down Expand Up @@ -399,6 +406,41 @@ function SpotItemList({
</button>
))}

{settlementItem && (
<button
type="button"
onClick={() => onActionItem(settlementItem)}
className="flex items-center gap-3 rounded-2xl px-3 py-2.5 text-left transition-colors active:bg-zinc-100"
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-zinc-100 text-zinc-600">
<IconReceipt size={16} stroke={1.75} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-baseline justify-between gap-2">
<span className="truncate text-[13.5px] font-semibold text-zinc-900">
정산
</span>
<span
suppressHydrationWarning
className="shrink-0 text-[10.5px] font-medium text-zinc-400 tabular-nums"
>
{formatListTime(settlementItem.updatedAt)}
</span>
</div>
<p className="mt-0.5 truncate text-[12.5px] leading-snug text-zinc-500">
{settlementItem.settlement
? settlementItem.settlement.status ===
'APPROVED'
? `승인 완료 · ${settlementItem.settlement.approvedAmount.toLocaleString('ko-KR')}P`
: `승인 대기 · ${settlementItem.settlement.requestedAmount.toLocaleString('ko-KR')}P`
: settlementItem.isAuthor
? '아직 정산 요청 전 · 제출 필요'
: '오너 정산 요청 대기 중'}
</p>
</div>
</button>
)}

{hasSchedule && room.spot.schedule && (
<button
type="button"
Expand Down
37 changes: 35 additions & 2 deletions src/features/chat/model/spot-action-items.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { SharedFile, SpotSchedule, SpotVote } from '@/entities/spot/types';
import type {
SharedFile,
SpotSchedule,
SpotSettlementApproval,
SpotVote,
} from '@/entities/spot/types';
import type {
ChatScheduleDraft,
ChatActionTarget,
Expand Down Expand Up @@ -110,17 +115,44 @@ function createReverseOfferActionItem(
};
}

function createSettlementActionItem(
room: SpotChatRoom,
settlement: SpotSettlementApproval | null,
): SpotActionItem {
return {
kind: 'settlement',
id: getSpotSettlementActionId(room.id),
roomId: room.id,
roomTitle: room.title,
settlement,
spotStatus: room.spot.status,
isAuthor: room.spot.authorId === room.currentUserId,
updatedAt:
settlement?.approvedAt ?? settlement?.submittedAt ?? room.updatedAt,
};
}

export function getSpotScheduleActionId(roomId: string): string {
return `schedule-${roomId}`;
}

export function getSpotSettlementActionId(roomId: string): string {
return `settlement-${roomId}`;
}

export function getSpotActionItems(room: SpotChatRoom): SpotActionItem[] {
const items: SpotActionItem[] = [];

if (room.reverseOffer) {
items.push(createReverseOfferActionItem(room, room.reverseOffer));
}

if (room.spot.status === 'CLOSED') {
items.push(
createSettlementActionItem(room, room.spot.settlement ?? null),
);
}

items.push(
...room.spot.votes.map((vote) => createVoteActionItem(room, vote)),
);
Expand Down Expand Up @@ -180,6 +212,7 @@ export function isSupportedChatActionKind(
value === 'vote' ||
value === 'schedule' ||
value === 'file' ||
value === 'reverse-offer'
value === 'reverse-offer' ||
value === 'settlement'
);
}
19 changes: 18 additions & 1 deletion src/features/chat/model/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,12 @@ export type MainChatTopTab = 'personal' | 'team';
export type PersonalCounterpartRole = 'SUPPORTER' | 'PARTNER';
export type MainChatPersonalFilter = 'all' | 'unread' | 'SUPPORTER' | 'PARTNER';
export type MainChatTeamFilter = 'all' | 'vote' | 'schedule' | 'file';
export type ChatActionKind = 'vote' | 'schedule' | 'file' | 'reverse-offer';
export type ChatActionKind =
| 'vote'
| 'schedule'
| 'file'
| 'reverse-offer'
| 'settlement';

export type ChatReverseOfferStatus =
| 'PARTNER_REVIEW'
Expand Down Expand Up @@ -242,4 +247,16 @@ export type SpotActionItem =
roomTitle: string;
reverseOffer: ChatReverseOfferSummary;
updatedAt: string;
}
| {
kind: Extract<ChatActionKind, 'settlement'>;
id: string;
roomId: string;
roomTitle: string;
settlement:
| import('@/entities/spot/types').SpotSettlementApproval
| null;
spotStatus: import('@/entities/spot/types').SpotStatus;
isAuthor: boolean;
updatedAt: string;
};
19 changes: 15 additions & 4 deletions src/features/chat/model/use-main-chat-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -269,7 +269,11 @@ async function enrichSpotRoomWithBackend(
room: SpotChatRoom,
): Promise<SpotChatRoom> {
const spotId = room.spot.id;
const [participants, schedule, votes, files] = await Promise.all([
const [detail, participants, schedule, votes, files] = await Promise.all([
spotsApi
.get(spotId)
.then((response) => response.data)
.catch(() => room.spot),
spotsApi
.getParticipants(spotId)
.then((response) => response.data)
Expand All @@ -288,15 +292,22 @@ async function enrichSpotRoomWithBackend(
.catch(() => room.spot.files),
]);
const owner = participants.find(
(participant) => participant.role === 'AUTHOR',
(participant) =>
participant.role === 'OWNER' || participant.role === 'AUTHOR',
);

return {
...room,
spot: {
...room.spot,
authorId: room.spot.authorId || owner?.userId || '',
authorNickname: room.spot.authorNickname || owner?.nickname || '',
...detail,
authorId:
detail.authorId || room.spot.authorId || owner?.userId || '',
authorNickname:
detail.authorNickname ||
room.spot.authorNickname ||
owner?.nickname ||
'',
participants,
schedule: schedule ?? undefined,
votes,
Expand Down
Loading
Loading