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
59 changes: 0 additions & 59 deletions src/features/retrospectives/components/AiGradientIcon.tsx

This file was deleted.

5 changes: 2 additions & 3 deletions src/features/retrospectives/components/AiLoadingOverlay.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { createPortal } from 'react-dom'

import aiGradientIcon from '@/shared/assets/icon/ai-gradient.svg'
import { Button } from '@/shared/ui'

import AiGradientIcon from './AiGradientIcon'

type AiLoadingOverlayProps = {
isOpen: boolean
message?: string
Expand All @@ -21,7 +20,7 @@ export default function AiLoadingOverlay({
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30">
<div className="flex w-lg flex-col items-center justify-center gap-small rounded-small bg-grey-100 p-medium shadow-drop">
<div className="flex animate-pulse flex-col items-center justify-center gap-tiny">
<AiGradientIcon />
<img src={aiGradientIcon} alt="요약중 아이콘" className="size-6" />
<p className="text-blue-200 typo-subtitle2">{message}</p>
</div>
{onCancel && (
Expand Down
13 changes: 13 additions & 0 deletions src/features/retrospectives/components/SummaryInfoBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
type SummaryInfoBannerProps = {
message?: string
}

export default function SummaryInfoBanner({
message = 'AI 요약이 완료되었어요. 확인 후 자유롭게 수정해 보세요.',
}: SummaryInfoBannerProps) {
return (
<div className="w-full rounded-small bg-primary-100 border border-primary-200 px-base py-small">
<p className="typo-caption1 text-primary-400">{message}</p>
</div>
)
}
199 changes: 199 additions & 0 deletions src/features/retrospectives/components/TopicSummaryCard.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,199 @@
import { Plus, X } from 'lucide-react'

import { Card, Textarea } from '@/shared/ui'

import type { KeyPoint, SummaryTopic } from '../retrospectives.types'

/** 편집 모드에서 사용하는 상세 항목 타입 (stable key용 id 포함) */
export type EditableDetail = { id: string; value: string }

/** 편집 모드에서 사용하는 KeyPoint 타입 (stable key용 id 포함) */
export type EditableKeyPoint = Omit<KeyPoint, 'details'> & { id: string; details: EditableDetail[] }

type TopicSummaryCardProps = {
topic: SummaryTopic
isEditing: boolean
editedSummary?: string
editedKeyPoints?: EditableKeyPoint[]
onSummaryChange?: (value: string) => void
onKeyPointsChange?: (keyPoints: EditableKeyPoint[]) => void
}

export default function TopicSummaryCard({
topic,
isEditing,
editedSummary,
editedKeyPoints,
onSummaryChange,
onKeyPointsChange,
}: TopicSummaryCardProps) {
const { topicTitle, topicDescription, summary, keyPoints } = topic
const displaySummary = isEditing ? (editedSummary ?? summary) : summary
const editingKeyPoints = editedKeyPoints ?? []

// ─── keyPoints 수정 핸들러 ───

const handleTitleChange = (kpIndex: number, value: string) => {
onKeyPointsChange?.(
editingKeyPoints.map((kp, i) => (i === kpIndex ? { ...kp, title: value } : kp))
)
}

const handleDetailChange = (kpIndex: number, detailId: string, value: string) => {
onKeyPointsChange?.(
editingKeyPoints.map((kp, i) =>
i === kpIndex
? { ...kp, details: kp.details.map((d) => (d.id === detailId ? { ...d, value } : d)) }
: kp
)
)
}

const handleAddKeyPoint = () => {
onKeyPointsChange?.([
...editingKeyPoints,
{ title: '', details: [{ id: crypto.randomUUID(), value: '' }], id: crypto.randomUUID() },
])
}

const handleRemoveKeyPoint = (kpIndex: number) => {
onKeyPointsChange?.(editingKeyPoints.filter((_, i) => i !== kpIndex))
}

const handleAddDetail = (kpIndex: number) => {
onKeyPointsChange?.(
editingKeyPoints.map((kp, i) =>
i === kpIndex
? { ...kp, details: [...kp.details, { id: crypto.randomUUID(), value: '' }] }
: kp
)
)
}

const handleRemoveDetail = (kpIndex: number, detailId: string) => {
onKeyPointsChange?.(
editingKeyPoints.map((kp, i) =>
i === kpIndex ? { ...kp, details: kp.details.filter((d) => d.id !== detailId) } : kp
)
)
}

return (
<Card className="border-0 p-large shadow-[0_2px_16px_rgba(0,0,0,0.06)]">
<div className="flex flex-col gap-base">
{/* 토픽 헤더 */}
<div className="flex flex-col gap-xxtiny">
<h3 className="typo-heading3 text-black">{topicTitle}</h3>
<p className="typo-body4 text-grey-700">{topicDescription}</p>
</div>

{/* 핵심요약 + 주요포인트 */}
<div className="flex flex-col gap-medium">
{/* 핵심요약 */}
<div className="flex flex-col gap-small">
<h5 className="typo-subtitle2 text-black">핵심요약</h5>
{isEditing ? (
<Textarea
value={displaySummary}
onChange={(e) => onSummaryChange?.(e.target.value)}
height={80}
/>
) : (
<p className="typo-body3 text-grey-800 whitespace-pre-wrap">{displaySummary}</p>
)}
</div>

{/* 주요포인트 */}
<div className="flex flex-col gap-small">
<h5 className="typo-subtitle2 text-black">주요포인트</h5>
{isEditing ? (
<div className="rounded-small border border-grey-400 px-medium py-base transition-colors focus-within:border-primary-200">
{editingKeyPoints.map((kp, kpIndex) => (
<div key={kp.id} className={kpIndex > 0 ? 'mt-base' : ''}>
{/* 포인트 제목 */}
<div className="flex items-center gap-xsmall">
<span className="shrink-0 typo-subtitle2 text-black">{kpIndex + 1}.</span>
<input
type="text"
value={kp.title}
onChange={(e) => handleTitleChange(kpIndex, e.target.value)}
placeholder="포인트 제목"
className="min-w-0 flex-1 bg-transparent leading-5.5 typo-subtitle2 text-black outline-none placeholder:text-grey-500"
/>
<button
type="button"
onClick={() => handleRemoveKeyPoint(kpIndex)}
className="shrink-0 p-xtiny text-grey-500 hover:text-accent-300"
>
<X size={14} />
</button>
</div>

{/* 상세 내용 */}
{kp.details.map((detail) => (
<div
key={detail.id}
className="mt-xtiny flex items-center gap-xsmall pl-base"
>
<span className="shrink-0 typo-body1 text-black">•</span>
<input
type="text"
value={detail.value}
onChange={(e) => handleDetailChange(kpIndex, detail.id, e.target.value)}
placeholder="상세 내용"
className="min-w-0 flex-1 bg-transparent leading-[24px] typo-body1 text-black outline-none placeholder:text-grey-500"
/>
<button
type="button"
onClick={() => handleRemoveDetail(kpIndex, detail.id)}
className="shrink-0 p-xtiny text-grey-500 hover:text-accent-300"
>
<X size={14} />
</button>
</div>
))}

{/* 상세 추가 */}
<button
type="button"
onClick={() => handleAddDetail(kpIndex)}
className="mt-xtiny flex items-center gap-xtiny pl-base typo-body4 text-grey-500 hover:text-primary-400"
>
<Plus size={12} />
상세 추가
</button>
</div>
))}

{/* 포인트 추가 */}
<button
type="button"
onClick={handleAddKeyPoint}
className={`flex items-center gap-xtiny typo-body4 text-grey-500 hover:text-primary-400 ${editingKeyPoints.length > 0 ? 'mt-small' : ''}`}
>
<Plus size={12} />
포인트 추가
</button>
</div>
) : (
keyPoints.map((kp, kpIndex) => (
<div key={kpIndex} className="flex flex-col gap-xtiny">
<p className="typo-subtitle5 text-black">
{kpIndex + 1}. {kp.title}
</p>
<ul className="list-disc pl-base">
{kp.details.map((detail, i) => (
<li key={i} className="typo-body3 text-grey-700">
{detail}
</li>
))}
</ul>
</div>
))
)}
</div>
</div>
</div>
</Card>
)
}
6 changes: 6 additions & 0 deletions src/features/retrospectives/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,9 @@ export { default as AiLoadingOverlay } from './AiLoadingOverlay'
export { default as AiSummaryToast } from './AiSummaryToast'
export { default as RetrospectiveCardButtons } from './RetrospectiveCardButtons'
export { default as RetrospectiveSummarySkeleton } from './RetrospectiveSummarySkeleton'
export { default as SummaryInfoBanner } from './SummaryInfoBanner'
export {
type EditableDetail,
type EditableKeyPoint,
default as TopicSummaryCard,
} from './TopicSummaryCard'
50 changes: 49 additions & 1 deletion src/features/retrospectives/retrospectives.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@ import { api } from '@/api/client'
import { PAGE_SIZES } from '@/shared/constants'

import { RETROSPECTIVES_ENDPOINTS } from './retrospectives.endpoints'
import { getMockCollectedAnswers } from './retrospectives.mock'
import {
getMockCollectedAnswers,
getMockSummary,
mockPublishSummary,
mockUpdateSummary,
} from './retrospectives.mock'
import type {
CreateSttJobParams,
GetCollectedAnswersParams,
Expand Down Expand Up @@ -73,6 +78,34 @@ export const createSttJob = async (
params: CreateSttJobParams,
signal?: AbortSignal
): Promise<SttJobResponse> => {
if (USE_MOCK) {
await new Promise<void>((resolve, reject) => {
if (signal?.aborted) {
reject(new Error('canceled'))
return
}
const onAbort = () => {
clearTimeout(timer)
reject(new Error('canceled'))
}
const timer = setTimeout(() => {
signal?.removeEventListener('abort', onAbort)
resolve()
}, 2000)
signal?.addEventListener('abort', onAbort, { once: true })
})
return {
jobId: 1,
meetingId: params.meetingId,
userId: 1,
status: 'DONE',
summary: null,
highlights: null,
errorMessage: null,
createdAt: new Date().toISOString(),
}
}

const { gatheringId, meetingId, file } = params

const formData = new FormData()
Expand All @@ -97,6 +130,11 @@ export const createSttJob = async (
* @param meetingId - 약속 식별자
*/
export const getSummary = async (meetingId: number): Promise<RetrospectiveSummaryResponse> => {
if (USE_MOCK) {
await new Promise((resolve) => setTimeout(resolve, 300))
return getMockSummary(meetingId)
}

return api.get<RetrospectiveSummaryResponse>(RETROSPECTIVES_ENDPOINTS.SUMMARY(meetingId))
}

Expand All @@ -108,6 +146,11 @@ export const getSummary = async (meetingId: number): Promise<RetrospectiveSummar
export const updateSummary = async (
params: UpdateSummaryParams
): Promise<RetrospectiveSummaryResponse> => {
if (USE_MOCK) {
await new Promise((resolve) => setTimeout(resolve, 300))
return mockUpdateSummary(params.meetingId, params.data)
}

const { meetingId, data } = params
return api.patch<RetrospectiveSummaryResponse>(RETROSPECTIVES_ENDPOINTS.SUMMARY(meetingId), data)
}
Expand All @@ -120,5 +163,10 @@ export const updateSummary = async (
export const publishSummary = async (
params: PublishSummaryParams
): Promise<RetrospectiveSummaryResponse> => {
if (USE_MOCK) {
await new Promise((resolve) => setTimeout(resolve, 300))
return mockPublishSummary(params.meetingId)
}

return api.post<RetrospectiveSummaryResponse>(RETROSPECTIVES_ENDPOINTS.PUBLISH(params.meetingId))
}
Loading
Loading