+ 아직 정산 요청이 없어요. 오너가 활동 내역을 정리해 제출해야
+ 해요.
+
+
+ );
+ }
+
+ return (
+
{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'),