From 47df30280a9921fd3eb82b0589eba9d89088a661 Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Wed, 15 Jul 2026 10:35:38 +0900 Subject: [PATCH 01/11] =?UTF-8?q?refactor:=20=EB=AF=B8=ED=8C=85=EB=85=B8?= =?UTF-8?q?=ED=8A=B8=20DB=20=EC=97=B0=EB=8F=99=EC=9A=A9=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=C2=B7=EB=A7=A4=ED=8D=BC=C2=B7=EC=8A=A4=ED=82=A4?= =?UTF-8?q?=EB=A7=88=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/meeting-note/index.ts | 15 +++ .../model/meeting-note.db.types.ts | 3 + .../meeting-note/model/meeting-note.mapper.ts | 114 ++++++++++++++++++ .../meeting-note/model/meeting-note.schema.ts | 25 ++++ .../meeting-note/model/meeting-note.types.ts | 3 + .../model/mock-meeting-notes-by-workspace.ts | 2 + .../ui/MeetingNoteForm.tsx | 1 + 7 files changed, 163 insertions(+) create mode 100644 src/entities/meeting-note/model/meeting-note.db.types.ts create mode 100644 src/entities/meeting-note/model/meeting-note.mapper.ts create mode 100644 src/entities/meeting-note/model/meeting-note.schema.ts diff --git a/src/entities/meeting-note/index.ts b/src/entities/meeting-note/index.ts index 6d29886..f101970 100644 --- a/src/entities/meeting-note/index.ts +++ b/src/entities/meeting-note/index.ts @@ -4,4 +4,19 @@ export type { MeetingNoteFormValues, MeetingNoteParticipant, } from './model/meeting-note.types'; +export type { MeetingNoteRow } from './model/meeting-note.db.types'; +export { + toMeetingNote, + toMeetingNoteInsert, + toMeetingNoteUpdate, + toMeetingDate, + toMeetingAt, + getParticipantColor, +} from './model/meeting-note.mapper'; +export { + meetingNoteContentSchema, + meetingNoteTitleSchema, + meetingDateSchema, + type MeetingNoteContentInput, +} from './model/meeting-note.schema'; export { MeetingNoteCard } from './ui/MeetingNoteCard'; diff --git a/src/entities/meeting-note/model/meeting-note.db.types.ts b/src/entities/meeting-note/model/meeting-note.db.types.ts new file mode 100644 index 0000000..1eefa8f --- /dev/null +++ b/src/entities/meeting-note/model/meeting-note.db.types.ts @@ -0,0 +1,3 @@ +import type { GenericTables } from '@/shared/model/supabase.types'; + +export type MeetingNoteRow = GenericTables<'meeting_notes'>; diff --git a/src/entities/meeting-note/model/meeting-note.mapper.ts b/src/entities/meeting-note/model/meeting-note.mapper.ts new file mode 100644 index 0000000..a2a671d --- /dev/null +++ b/src/entities/meeting-note/model/meeting-note.mapper.ts @@ -0,0 +1,114 @@ +import type { GenericTablesInsert, GenericTablesUpdate } from '@/shared/model/supabase.types'; + +import type { MeetingNoteRow } from './meeting-note.db.types'; +import type { MeetingNote, MeetingNoteParticipant } from './meeting-note.types'; + +// 참석자 아바타 색상은 DB에 저장하지 않고 userId 해시로 결정론적으로 재생성한다. +// 같은 참석자는 어느 카드에서든 항상 같은 색으로 보인다. +const PARTICIPANT_PALETTE = [ + '#FE9A00', + '#00C950', + '#615FFF', + '#2B7FFF', + '#00B8DB', + '#FF6B6B', +] as const; + +const KST_TIME_ZONE = 'Asia/Seoul'; + +export function getParticipantColor(seed: string): string { + let hash = 0; + for (let index = 0; index < seed.length; index += 1) { + hash = (hash * 31 + seed.charCodeAt(index)) >>> 0; + } + + return PARTICIPANT_PALETTE[hash % PARTICIPANT_PALETTE.length]; +} + +// DB의 meeting_at(timestamptz) → UI 표기용 KST 날짜(YYYY-MM-DD) +export function toMeetingDate(meetingAt: string): string { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: KST_TIME_ZONE, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(new Date(meetingAt)); + + const year = parts.find((part) => part.type === 'year')?.value; + const month = parts.find((part) => part.type === 'month')?.value; + const day = parts.find((part) => part.type === 'day')?.value; + + if (!year || !month || !day) { + return ''; + } + + return `${year}-${month}-${day}`; +} + +// UI 날짜(YYYY-MM-DD) → DB 저장용 KST 자정 ISO 문자열 +export function toMeetingAt(meetingDate: string): string { + return `${meetingDate}T00:00:00+09:00`; +} + +function toParticipant(userId: string, profileNameById: Map): MeetingNoteParticipant { + const name = profileNameById.get(userId) ?? '알 수 없음'; + + return { + id: userId, + name, + initial: name.slice(0, 1), + color: getParticipantColor(userId), + }; +} + +export function toMeetingNote( + row: MeetingNoteRow, + profileNameById: Map, +): MeetingNote { + return { + id: row.id, + workspaceId: row.workspace_id, + authorId: row.author_id, + title: row.title, + meetingDate: toMeetingDate(row.meeting_at), + participants: row.participants.map((userId) => toParticipant(userId, profileNameById)), + decisions: row.decisions, + followUpActions: row.follow_up_actions, + }; +} + +export function toMeetingNoteInsert(params: { + workspaceId: string; + authorId: string; + title: string; + meetingDate: string; + participantIds: string[]; + decisions: string[]; + followUpActions: string[]; +}): GenericTablesInsert<'meeting_notes'> { + return { + workspace_id: params.workspaceId, + author_id: params.authorId, + title: params.title, + meeting_at: toMeetingAt(params.meetingDate), + participants: params.participantIds, + decisions: params.decisions, + follow_up_actions: params.followUpActions, + }; +} + +export function toMeetingNoteUpdate(params: { + title: string; + meetingDate: string; + participantIds: string[]; + decisions: string[]; + followUpActions: string[]; +}): GenericTablesUpdate<'meeting_notes'> { + return { + title: params.title, + meeting_at: toMeetingAt(params.meetingDate), + participants: params.participantIds, + decisions: params.decisions, + follow_up_actions: params.followUpActions, + }; +} diff --git a/src/entities/meeting-note/model/meeting-note.schema.ts b/src/entities/meeting-note/model/meeting-note.schema.ts new file mode 100644 index 0000000..a19f74cf --- /dev/null +++ b/src/entities/meeting-note/model/meeting-note.schema.ts @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +export const meetingNoteTitleSchema = z + .string() + .trim() + .min(1, '회의 제목을 입력해주세요.') + .max(120, '회의 제목은 120자 이내로 입력해주세요.'); + +export const meetingDateSchema = z + .string() + .regex(/^\d{4}-\d{2}-\d{2}$/, '회의 날짜 형식이 올바르지 않습니다.'); + +// 결정사항·후속 업무는 빈 줄을 제거한 문자열 배열로 정규화한다. +const contentLinesSchema = z.array(z.string().trim().min(1)).default([]); + +// 생성·수정 공통 입력. workspaceId만 생성 액션에서 추가로 검증한다. +export const meetingNoteContentSchema = z.object({ + title: meetingNoteTitleSchema, + meetingDate: meetingDateSchema, + participantIds: z.array(z.guid()).default([]), + decisions: contentLinesSchema, + followUpActions: contentLinesSchema, +}); + +export type MeetingNoteContentInput = z.infer; diff --git a/src/entities/meeting-note/model/meeting-note.types.ts b/src/entities/meeting-note/model/meeting-note.types.ts index bce9bcc..f8b62c8 100644 --- a/src/entities/meeting-note/model/meeting-note.types.ts +++ b/src/entities/meeting-note/model/meeting-note.types.ts @@ -8,7 +8,10 @@ export interface MeetingNoteParticipant { export interface MeetingNote { id: string; workspaceId: string; + // 작성자 프로필이 삭제되면 author_id가 null이 될 수 있어(on delete set null) 옵셔널로 둔다. + authorId: string | null; title: string; + // UI 표기에 사용하는 KST 기준 날짜(YYYY-MM-DD). DB의 meeting_at(timestamptz)에서 변환한다. meetingDate: string; participants: MeetingNoteParticipant[]; decisions: string[]; diff --git a/src/entities/meeting-note/model/mock-meeting-notes-by-workspace.ts b/src/entities/meeting-note/model/mock-meeting-notes-by-workspace.ts index accad64..fc1e8e8 100644 --- a/src/entities/meeting-note/model/mock-meeting-notes-by-workspace.ts +++ b/src/entities/meeting-note/model/mock-meeting-notes-by-workspace.ts @@ -5,6 +5,7 @@ const mockMeetingNotesByWorkspaceId: Record = { { id: 'meeting-note-1', workspaceId: 'test', + authorId: null, title: '스프린트 1 킥오프', meetingDate: '2025-06-25', participants: [ @@ -19,6 +20,7 @@ const mockMeetingNotesByWorkspaceId: Record = { { id: 'meeting-note-2', workspaceId: 'test', + authorId: null, title: '디자인 시스템 논의', meetingDate: '2025-06-20', participants: [ diff --git a/src/features/manage-meeting-notes/ui/MeetingNoteForm.tsx b/src/features/manage-meeting-notes/ui/MeetingNoteForm.tsx index 35844ab..9675731 100644 --- a/src/features/manage-meeting-notes/ui/MeetingNoteForm.tsx +++ b/src/features/manage-meeting-notes/ui/MeetingNoteForm.tsx @@ -133,6 +133,7 @@ export function MeetingNoteForm({ workspaceId }: MeetingNoteFormProps) { addMeetingNote(workspaceId, { id: createMeetingNoteId(), workspaceId, + authorId: null, title: formValues.title.trim(), meetingDate: formValues.meetingDate, participants, From 79452f33707907d0a916560273fc3615fdae80ca Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Wed, 15 Jul 2026 10:46:03 +0900 Subject: [PATCH 02/11] =?UTF-8?q?=20feat:=20=ED=9A=8C=EC=9D=98=EB=A1=9D=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=C2=B7=EC=9C=84=EC=A0=AF=20Supabase=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../meeting-note/api/get-meeting-notes.ts | 60 +++++++++++++++++++ src/entities/meeting-note/index.ts | 6 ++ .../meeting-note/model/meeting-note-query.ts | 3 + .../meeting-note/model/meeting-note.mapper.ts | 18 +++++- .../meeting-note/model/meeting-note.types.ts | 11 ++++ .../ui/MeetingNotesList.tsx | 51 ++++++++-------- src/views/dashboard/config/widget-catalog.tsx | 2 +- .../meeting-notes/ui/MeetingNotesPage.tsx | 6 +- .../dashboard-recent-notes/ui/RecentNotes.tsx | 31 ++++++++-- 9 files changed, 152 insertions(+), 36 deletions(-) create mode 100644 src/entities/meeting-note/api/get-meeting-notes.ts create mode 100644 src/entities/meeting-note/model/meeting-note-query.ts diff --git a/src/entities/meeting-note/api/get-meeting-notes.ts b/src/entities/meeting-note/api/get-meeting-notes.ts new file mode 100644 index 0000000..1aecffb --- /dev/null +++ b/src/entities/meeting-note/api/get-meeting-notes.ts @@ -0,0 +1,60 @@ +'use server'; + +// 워크스페이스 범위의 회의록과 참석자·작성자 이름을 함께 조회합니다. +import { z } from 'zod'; +import { getCurrentUserId } from '@/shared/api/supabase/current-user'; +import { createSupabaseServerClient } from '@/shared/api/supabase/server'; + +import { + MEETING_NOTE_SELECT_QUERY, + toMeetingNote, + type MeetingNoteQueryRow, +} from '../model/meeting-note.mapper'; +import type { MeetingNoteBoardData } from '../model/meeting-note.types'; + +const workspaceIdSchema = z.guid(); + +export async function getMeetingNotes(workspaceId: string): Promise { + const parsedWorkspaceId = workspaceIdSchema.parse(workspaceId); + const supabase = await createSupabaseServerClient(); + const currentUserId = await getCurrentUserId(); + + const [{ data: notes, error: notesError }, { data: membership, error: memberError }] = + await Promise.all([ + supabase + .from('meeting_notes') + .select(MEETING_NOTE_SELECT_QUERY) + .eq('workspace_id', parsedWorkspaceId) + .order('meeting_at', { ascending: false }), + supabase + .from('workspace_members') + .select('user_id, role') + .eq('workspace_id', parsedWorkspaceId) + .eq('user_id', currentUserId) + .maybeSingle(), + ]); + + if (notesError) throw new Error(`회의록 조회에 실패했습니다: ${notesError.message}`); + if (memberError) throw new Error(`현재 멤버 조회에 실패했습니다: ${memberError.message}`); + + const rows = (notes ?? []) as MeetingNoteQueryRow[]; + + // 참석자 + 작성자 이름을 한 번의 profiles 조회로 복원한다. + const profileIds = [ + ...new Set( + rows.flatMap((row) => [...row.participants, ...(row.author_id ? [row.author_id] : [])]), + ), + ]; + const { data: profiles, error: profileError } = profileIds.length + ? await supabase.from('profiles').select('id, real_name').in('id', profileIds) + : { data: [], error: null }; + + if (profileError) throw new Error(`참석자 정보를 불러오지 못했습니다: ${profileError.message}`); + + const profileNameById = new Map((profiles ?? []).map((profile) => [profile.id, profile.real_name])); + + return { + meetingNotes: rows.map((row) => toMeetingNote(row, profileNameById)), + viewer: membership ? { userId: membership.user_id, role: membership.role } : null, + }; +} diff --git a/src/entities/meeting-note/index.ts b/src/entities/meeting-note/index.ts index f101970..17ada9e 100644 --- a/src/entities/meeting-note/index.ts +++ b/src/entities/meeting-note/index.ts @@ -3,6 +3,8 @@ export type { MeetingNote, MeetingNoteFormValues, MeetingNoteParticipant, + MeetingNoteViewer, + MeetingNoteBoardData, } from './model/meeting-note.types'; export type { MeetingNoteRow } from './model/meeting-note.db.types'; export { @@ -12,7 +14,11 @@ export { toMeetingDate, toMeetingAt, getParticipantColor, + MEETING_NOTE_SELECT_QUERY, + type MeetingNoteQueryRow, } from './model/meeting-note.mapper'; +export { meetingNotesQueryKey } from './model/meeting-note-query'; +export { getMeetingNotes } from './api/get-meeting-notes'; export { meetingNoteContentSchema, meetingNoteTitleSchema, diff --git a/src/entities/meeting-note/model/meeting-note-query.ts b/src/entities/meeting-note/model/meeting-note-query.ts new file mode 100644 index 0000000..497261d --- /dev/null +++ b/src/entities/meeting-note/model/meeting-note-query.ts @@ -0,0 +1,3 @@ +// 회의록 목록 페이지와 최근 회의록 위젯이 동일한 워크스페이스 조회 결과를 공유하기 위한 Query 키입니다. +export const meetingNotesQueryKey = (workspaceId: string) => + ['meeting-notes', workspaceId] as const; diff --git a/src/entities/meeting-note/model/meeting-note.mapper.ts b/src/entities/meeting-note/model/meeting-note.mapper.ts index a2a671d..66775c3 100644 --- a/src/entities/meeting-note/model/meeting-note.mapper.ts +++ b/src/entities/meeting-note/model/meeting-note.mapper.ts @@ -3,6 +3,22 @@ import type { GenericTablesInsert, GenericTablesUpdate } from '@/shared/model/su import type { MeetingNoteRow } from './meeting-note.db.types'; import type { MeetingNote, MeetingNoteParticipant } from './meeting-note.types'; +// 조회 시 실제로 select 하는 컬럼만 담는 서브셋 타입 (task 도메인 TaskQueryRow와 동일 패턴) +export type MeetingNoteQueryRow = Pick< + MeetingNoteRow, + | 'id' + | 'workspace_id' + | 'author_id' + | 'title' + | 'meeting_at' + | 'participants' + | 'decisions' + | 'follow_up_actions' +>; + +export const MEETING_NOTE_SELECT_QUERY = + 'id, workspace_id, author_id, title, meeting_at, participants, decisions, follow_up_actions'; + // 참석자 아바타 색상은 DB에 저장하지 않고 userId 해시로 결정론적으로 재생성한다. // 같은 참석자는 어느 카드에서든 항상 같은 색으로 보인다. const PARTICIPANT_PALETTE = [ @@ -62,7 +78,7 @@ function toParticipant(userId: string, profileNameById: Map): Me } export function toMeetingNote( - row: MeetingNoteRow, + row: MeetingNoteQueryRow, profileNameById: Map, ): MeetingNote { return { diff --git a/src/entities/meeting-note/model/meeting-note.types.ts b/src/entities/meeting-note/model/meeting-note.types.ts index f8b62c8..a633730 100644 --- a/src/entities/meeting-note/model/meeting-note.types.ts +++ b/src/entities/meeting-note/model/meeting-note.types.ts @@ -24,3 +24,14 @@ export interface MeetingNoteFormValues { decisions: string; followUpActions: string; } + +// 회의록 수정·삭제 메뉴를 현재 로그인한 사용자의 권한에 맞춰 노출하기 위한 정보다. +export interface MeetingNoteViewer { + userId: string; + role: 'owner' | 'member'; +} + +export interface MeetingNoteBoardData { + meetingNotes: MeetingNote[]; + viewer: MeetingNoteViewer | null; +} diff --git a/src/features/manage-meeting-notes/ui/MeetingNotesList.tsx b/src/features/manage-meeting-notes/ui/MeetingNotesList.tsx index 65f8c2e..163cd3b 100644 --- a/src/features/manage-meeting-notes/ui/MeetingNotesList.tsx +++ b/src/features/manage-meeting-notes/ui/MeetingNotesList.tsx @@ -1,11 +1,10 @@ 'use client'; -import { useEffect, useState } from 'react'; +import { useState } from 'react'; import Link from 'next/link'; import { Plus } from 'lucide-react'; import { MeetingNoteCard } from '@/entities/meeting-note'; import type { MeetingNote } from '@/entities/meeting-note'; -import { useMeetingNotesStore } from '../model/use-meeting-notes-store'; interface MeetingNotesListProps { workspaceId: string; @@ -13,17 +12,6 @@ interface MeetingNotesListProps { } export function MeetingNotesList({ workspaceId, meetingNotes }: MeetingNotesListProps) { - const storedMeetingNotes = useMeetingNotesStore( - (state) => state.meetingNotesByWorkspaceId[workspaceId], - ); - const initializeWorkspace = useMeetingNotesStore((state) => state.initializeWorkspace); - - useEffect(() => { - // 서버 연동 전 단계라, 라우트 진입 시 워크스페이스별 초기 mock 데이터를 store에 주입합니다. - initializeWorkspace(workspaceId, meetingNotes); - }, [initializeWorkspace, meetingNotes, workspaceId]); - - const displayedMeetingNotes = storedMeetingNotes ?? meetingNotes; const [expandedMeetingNoteId, setExpandedMeetingNoteId] = useState(null); return ( @@ -39,20 +27,29 @@ export function MeetingNotesList({ workspaceId, meetingNotes }: MeetingNotesList -
- {displayedMeetingNotes.map((meetingNote) => ( - - setExpandedMeetingNoteId((current) => - current === meetingNote.id ? null : meetingNote.id, - ) - } - /> - ))} -
+ {meetingNotes.length === 0 ? ( +
+

아직 작성된 회의록이 없습니다.

+

+ 첫 회의록을 작성해 팀의 결정사항을 기록해보세요. +

+
+ ) : ( +
+ {meetingNotes.map((meetingNote) => ( + + setExpandedMeetingNoteId((current) => + current === meetingNote.id ? null : meetingNote.id, + ) + } + /> + ))} +
+ )} ); } diff --git a/src/views/dashboard/config/widget-catalog.tsx b/src/views/dashboard/config/widget-catalog.tsx index fb46c76..0bdc603 100644 --- a/src/views/dashboard/config/widget-catalog.tsx +++ b/src/views/dashboard/config/widget-catalog.tsx @@ -43,7 +43,7 @@ export const WIDGET_CATALOG = { 'recent-notes': { layout: { i: 'recent-notes', x: 0, y: 14, w: 6, h: 5, minW: 2, minH: 3 }, title: '최근 회의록', - render: (size) => , + render: (size, { workspaceId }) => , }, 'recent-notices': { layout: { i: 'recent-notices', x: 0, y: 5, w: 6, h: 5, minW: 2, minH: 3 }, diff --git a/src/views/meeting-notes/ui/MeetingNotesPage.tsx b/src/views/meeting-notes/ui/MeetingNotesPage.tsx index c330d69..86c2381 100644 --- a/src/views/meeting-notes/ui/MeetingNotesPage.tsx +++ b/src/views/meeting-notes/ui/MeetingNotesPage.tsx @@ -1,4 +1,4 @@ -import { getMockMeetingNotesByWorkspaceId } from '@/entities/meeting-note'; +import { getMeetingNotes } from '@/entities/meeting-note'; import { MeetingNotesList } from '@/features/manage-meeting-notes'; import { plusJakartaSans } from '@/shared/lib/fonts'; @@ -6,8 +6,8 @@ interface MeetingNotesPageProps { workspaceId: string; } -export default function MeetingNotesPage({ workspaceId }: MeetingNotesPageProps) { - const meetingNotes = getMockMeetingNotesByWorkspaceId(workspaceId); +export default async function MeetingNotesPage({ workspaceId }: MeetingNotesPageProps) { + const { meetingNotes } = await getMeetingNotes(workspaceId); return (
diff --git a/src/widgets/side-project/dashboard-recent-notes/ui/RecentNotes.tsx b/src/widgets/side-project/dashboard-recent-notes/ui/RecentNotes.tsx index 523af92..30200b6 100644 --- a/src/widgets/side-project/dashboard-recent-notes/ui/RecentNotes.tsx +++ b/src/widgets/side-project/dashboard-recent-notes/ui/RecentNotes.tsx @@ -1,23 +1,46 @@ +'use client'; + // 최근 회의록 위젯 — 타일 크기에 따라 밀도가 다른 변형을 렌더 // · sm: 가장 최근 회의록 1건(제목만) // · md: 리스트(제목 + 작성일) // · lg: 총 개수 + 리스트(제목 + 본문 미리보기 + 작성일) import { FileText } from 'lucide-react'; +import { useQuery } from '@tanstack/react-query'; +import { getMeetingNotes, meetingNotesQueryKey } from '@/entities/meeting-note'; import type { WidgetSize } from '@/shared/dashboard/lib/widget-size'; import { WidgetCard, WidgetCardAction, WidgetCardHeader } from '@/shared/dashboard/ui/widget-card'; -import { getMockMeetingNotesByWorkspaceId } from '@/entities/meeting-note'; const header = ( 전체 보기} /> ); interface RecentNotesProps { - workspaceId?: string; + workspaceId: string; size?: WidgetSize; } -export default function RecentNotes({ workspaceId = 'test', size = 'md' }: RecentNotesProps) { - const meetingNotes = getMockMeetingNotesByWorkspaceId(workspaceId); +export default function RecentNotes({ workspaceId, size = 'md' }: RecentNotesProps) { + const { data, isError, isPending } = useQuery({ + queryKey: meetingNotesQueryKey(workspaceId), + queryFn: () => getMeetingNotes(workspaceId), + }); + const meetingNotes = data?.meetingNotes ?? []; + + if (isPending || isError || meetingNotes.length === 0) { + return ( + + {header} +
+ {isPending + ? '회의록을 불러오는 중입니다.' + : isError + ? '회의록을 불러오지 못했습니다.' + : '작성된 회의록이 없습니다.'} +
+
+ ); + } + if (size === 'sm') { const latest = meetingNotes[0]; return ( From ac8dbfe1b626739de279772fd3dd710a47841717 Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Wed, 15 Jul 2026 10:57:19 +0900 Subject: [PATCH 03/11] =?UTF-8?q?meeting=20note=20=EC=9E=91=EC=84=B1=20db?= =?UTF-8?q?=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../meeting-note/api/create-meeting-note.ts | 74 +++++++++++++++++ src/entities/meeting-note/index.ts | 1 + .../ui/MeetingNoteForm.tsx | 79 +++++++++---------- 3 files changed, 113 insertions(+), 41 deletions(-) create mode 100644 src/entities/meeting-note/api/create-meeting-note.ts diff --git a/src/entities/meeting-note/api/create-meeting-note.ts b/src/entities/meeting-note/api/create-meeting-note.ts new file mode 100644 index 0000000..16ac39d --- /dev/null +++ b/src/entities/meeting-note/api/create-meeting-note.ts @@ -0,0 +1,74 @@ +'use server'; + +// 워크스페이스 멤버 권한으로 회의록을 저장하고, 작성자는 현재 로그인 사용자로 고정합니다. +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; +import { getCurrentUserId } from '@/shared/api/supabase/current-user'; +import { createSupabaseServerClient } from '@/shared/api/supabase/server'; + +import { toMeetingNoteInsert } from '../model/meeting-note.mapper'; +import { meetingNoteContentSchema } from '../model/meeting-note.schema'; + +export type MeetingNoteActionResult = + | { ok: true; data: T } + | { ok: false; message: string }; + +const createMeetingNoteSchema = meetingNoteContentSchema.extend({ + workspaceId: z.guid(), +}); + +export async function createMeetingNote( + input: z.input, +): Promise> { + const parsed = createMeetingNoteSchema.safeParse(input); + + if (!parsed.success) { + return { ok: false, message: parsed.error.issues[0]?.message ?? '입력값이 올바르지 않습니다.' }; + } + + const value = parsed.data; + const supabase = await createSupabaseServerClient(); + const currentUserId = await getCurrentUserId(); + + const { data: membership, error: memberError } = await supabase + .from('workspace_members') + .select('user_id') + .eq('workspace_id', value.workspaceId) + .eq('user_id', currentUserId) + .maybeSingle(); + + if (memberError) { + console.error('[meeting-note/createMeetingNote] 멤버 확인 실패:', memberError); + return { ok: false, message: '워크스페이스 멤버 정보를 확인하지 못했습니다.' }; + } + + if (!membership) { + return { ok: false, message: '워크스페이스 멤버만 회의록을 작성할 수 있습니다.' }; + } + + const { data, error } = await supabase + .from('meeting_notes') + .insert( + toMeetingNoteInsert({ + workspaceId: value.workspaceId, + authorId: currentUserId, + title: value.title, + meetingDate: value.meetingDate, + participantIds: value.participantIds, + decisions: value.decisions, + followUpActions: value.followUpActions, + }), + ) + .select('id') + .single(); + + if (error) { + console.error('[meeting-note/createMeetingNote] 저장 실패:', error); + return { ok: false, message: '회의록 저장에 실패했습니다. 잠시 후 다시 시도해주세요.' }; + } + + revalidatePath(`/workspaces/${value.workspaceId}/meeting-notes`); + revalidatePath(`/workspaces/${value.workspaceId}/dashboard`); + + return { ok: true, data: { id: data.id } }; +} diff --git a/src/entities/meeting-note/index.ts b/src/entities/meeting-note/index.ts index 17ada9e..ec5e1a1 100644 --- a/src/entities/meeting-note/index.ts +++ b/src/entities/meeting-note/index.ts @@ -19,6 +19,7 @@ export { } from './model/meeting-note.mapper'; export { meetingNotesQueryKey } from './model/meeting-note-query'; export { getMeetingNotes } from './api/get-meeting-notes'; +export { createMeetingNote, type MeetingNoteActionResult } from './api/create-meeting-note'; export { meetingNoteContentSchema, meetingNoteTitleSchema, diff --git a/src/features/manage-meeting-notes/ui/MeetingNoteForm.tsx b/src/features/manage-meeting-notes/ui/MeetingNoteForm.tsx index 9675731..51ec4e2 100644 --- a/src/features/manage-meeting-notes/ui/MeetingNoteForm.tsx +++ b/src/features/manage-meeting-notes/ui/MeetingNoteForm.tsx @@ -4,9 +4,11 @@ import { useEffect, useRef, useState } from 'react'; import { CalendarDays, Check, ChevronDown } from 'lucide-react'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; -import type { MeetingNoteFormValues, MeetingNoteParticipant } from '@/entities/meeting-note'; -import { getMockWorkspaceMembersByWorkspaceId } from '@/entities/workspace-member'; -import { useMeetingNotesStore } from '../model/use-meeting-notes-store'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; +import type { MeetingNoteFormValues } from '@/entities/meeting-note'; +import { createMeetingNote, meetingNotesQueryKey } from '@/entities/meeting-note'; +import { useWorkspaceMembersByWorkspaceId } from '@/entities/workspace-member'; interface MeetingNoteFormProps { workspaceId: string; @@ -74,18 +76,18 @@ function getIsoDateFromParts(yearText: string, monthText: string, dayText: strin return `${yearText}-${monthText.padStart(2, '0')}-${dayText.padStart(2, '0')}`; } -function createMeetingNoteId() { - if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { - return `meeting-note-${crypto.randomUUID()}`; - } - - return `meeting-note-${Math.random().toString(36).slice(2, 10)}`; +function splitLines(value: string): string[] { + return value + .split('\n') + .map((item) => item.trim()) + .filter(Boolean); } export function MeetingNoteForm({ workspaceId }: MeetingNoteFormProps) { const router = useRouter(); - const workspaceMembers = getMockWorkspaceMembersByWorkspaceId(workspaceId); - const addMeetingNote = useMeetingNotesStore((state) => state.addMeetingNote); + const queryClient = useQueryClient(); + const { data: workspaceMembers = [] } = useWorkspaceMembersByWorkspaceId(workspaceId); + const createMutation = useMutation({ mutationFn: createMeetingNote }); const [formValues, setFormValues] = useState(() => { const initialMeetingDate = getTodayIsoDate(); @@ -113,41 +115,35 @@ export function MeetingNoteForm({ workspaceId }: MeetingNoteFormProps) { })); }; - const handleSubmit = (event: React.FormEvent) => { + const handleSubmit = async (event: React.FormEvent) => { event.preventDefault(); setHasSubmitted(true); - if (!formValues.title.trim()) { + if (!formValues.title.trim() || createMutation.isPending) { return; } - const participantPalette = ['#FE9A00', '#00C950', '#615FFF', '#2B7FFF', '#00B8DB', '#FF6B6B']; - // 체크한 멤버를 목록 카드에서 바로 렌더링할 수 있는 아바타 데이터로 변환합니다. - const participants: MeetingNoteParticipant[] = selectedParticipants.map((member, index) => ({ - id: member.userId, - name: member.workspaceNickname, - initial: member.avatarLabel, - color: participantPalette[index % participantPalette.length], - })); - - addMeetingNote(workspaceId, { - id: createMeetingNoteId(), - workspaceId, - authorId: null, - title: formValues.title.trim(), - meetingDate: formValues.meetingDate, - participants, - decisions: formValues.decisions - .split('\n') - .map((item) => item.trim()) - .filter(Boolean), - followUpActions: formValues.followUpActions - .split('\n') - .map((item) => item.trim()) - .filter(Boolean), - }); + try { + const result = await createMutation.mutateAsync({ + workspaceId, + title: formValues.title.trim(), + meetingDate: formValues.meetingDate, + participantIds: selectedParticipantIds, + decisions: splitLines(formValues.decisions), + followUpActions: splitLines(formValues.followUpActions), + }); + + if (!result.ok) { + toast.error(result.message); + return; + } - router.push(`/workspaces/${workspaceId}/meeting-notes`); + // 목록 페이지·대시보드 위젯이 새 회의록을 바로 반영하도록 캐시를 무효화한다. + await queryClient.invalidateQueries({ queryKey: meetingNotesQueryKey(workspaceId) }); + router.push(`/workspaces/${workspaceId}/meeting-notes`); + } catch { + toast.error('회의록 저장에 실패했습니다. 잠시 후 다시 시도해주세요.'); + } }; useEffect(() => { @@ -466,13 +462,14 @@ export function MeetingNoteForm({ workspaceId }: MeetingNoteFormProps) { From b327d8bb37cac04e79f7b43f06d5201692903db5 Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Wed, 15 Jul 2026 11:04:44 +0900 Subject: [PATCH 04/11] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9D=98=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20Supabase=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../meeting-notes/[noteId]/edit/page.tsx | 16 +++++ .../meeting-note/api/get-meeting-note.ts | 47 +++++++++++++++ src/entities/meeting-note/api/shared.ts | 59 +++++++++++++++++++ .../meeting-note/api/update-meeting-note.ts | 59 +++++++++++++++++++ src/entities/meeting-note/index.ts | 2 + .../ui/MeetingNoteForm.tsx | 59 ++++++++++++------- src/views/meeting-notes/index.ts | 1 + .../meeting-notes/ui/EditMeetingNotePage.tsx | 26 ++++++++ 8 files changed, 247 insertions(+), 22 deletions(-) create mode 100644 src/app/workspaces/[workspaceId]/meeting-notes/[noteId]/edit/page.tsx create mode 100644 src/entities/meeting-note/api/get-meeting-note.ts create mode 100644 src/entities/meeting-note/api/shared.ts create mode 100644 src/entities/meeting-note/api/update-meeting-note.ts create mode 100644 src/views/meeting-notes/ui/EditMeetingNotePage.tsx diff --git a/src/app/workspaces/[workspaceId]/meeting-notes/[noteId]/edit/page.tsx b/src/app/workspaces/[workspaceId]/meeting-notes/[noteId]/edit/page.tsx new file mode 100644 index 0000000..84471bc --- /dev/null +++ b/src/app/workspaces/[workspaceId]/meeting-notes/[noteId]/edit/page.tsx @@ -0,0 +1,16 @@ +import { EditMeetingNotePage } from '@/views/meeting-notes'; + +interface WorkspaceEditMeetingNotePageProps { + params: Promise<{ + workspaceId: string; + noteId: string; + }>; +} + +export default async function WorkspaceEditMeetingNotePage({ + params, +}: WorkspaceEditMeetingNotePageProps) { + const { workspaceId, noteId } = await params; + + return ; +} diff --git a/src/entities/meeting-note/api/get-meeting-note.ts b/src/entities/meeting-note/api/get-meeting-note.ts new file mode 100644 index 0000000..6ba020f --- /dev/null +++ b/src/entities/meeting-note/api/get-meeting-note.ts @@ -0,0 +1,47 @@ +'use server'; + +// 수정 페이지에서 단건 회의록과 참석자·작성자 이름을 함께 조회합니다. +import { z } from 'zod'; +import { createSupabaseServerClient } from '@/shared/api/supabase/server'; + +import { + MEETING_NOTE_SELECT_QUERY, + toMeetingNote, + type MeetingNoteQueryRow, +} from '../model/meeting-note.mapper'; +import type { MeetingNote } from '../model/meeting-note.types'; + +const idSchema = z.guid(); + +export async function getMeetingNote( + workspaceId: string, + meetingNoteId: string, +): Promise { + const parsedWorkspaceId = idSchema.parse(workspaceId); + const parsedNoteId = idSchema.parse(meetingNoteId); + const supabase = await createSupabaseServerClient(); + + const { data, error } = await supabase + .from('meeting_notes') + .select(MEETING_NOTE_SELECT_QUERY) + .eq('id', parsedNoteId) + .eq('workspace_id', parsedWorkspaceId) + .maybeSingle(); + + if (error) throw new Error(`회의록 조회에 실패했습니다: ${error.message}`); + if (!data) return null; + + const row = data as MeetingNoteQueryRow; + const profileIds = [ + ...new Set([...row.participants, ...(row.author_id ? [row.author_id] : [])]), + ]; + const { data: profiles, error: profileError } = profileIds.length + ? await supabase.from('profiles').select('id, real_name').in('id', profileIds) + : { data: [], error: null }; + + if (profileError) throw new Error(`참석자 정보를 불러오지 못했습니다: ${profileError.message}`); + + const profileNameById = new Map((profiles ?? []).map((profile) => [profile.id, profile.real_name])); + + return toMeetingNote(row, profileNameById); +} diff --git a/src/entities/meeting-note/api/shared.ts b/src/entities/meeting-note/api/shared.ts new file mode 100644 index 0000000..0120f0b --- /dev/null +++ b/src/entities/meeting-note/api/shared.ts @@ -0,0 +1,59 @@ +// 회의록 수정·삭제 액션이 공유하는 권한 확인 헬퍼입니다. +// 'use server'가 아닌 일반 서버 모듈로, 서버 액션에서만 import 합니다. +import { getCurrentUserId } from '@/shared/api/supabase/current-user'; +import { createSupabaseServerClient } from '@/shared/api/supabase/server'; + +type SupabaseServerClient = Awaited>; + +interface AuthorizedContext { + supabase: SupabaseServerClient; + currentUserId: string; +} + +// 대상 회의록이 존재하고, 현재 사용자가 작성자이거나 워크스페이스 소유자인지 확인한다. +// RLS로도 막히지만, 명확한 메시지와 supabase 컨텍스트 재사용을 위해 액션에서 먼저 검증한다. +export async function authorizeMeetingNoteMutation(input: { + workspaceId: string; + meetingNoteId: string; +}): Promise<{ ok: true; context: AuthorizedContext } | { ok: false; message: string }> { + const supabase = await createSupabaseServerClient(); + const currentUserId = await getCurrentUserId(); + + const [{ data: note, error: noteError }, { data: membership, error: memberError }] = + await Promise.all([ + supabase + .from('meeting_notes') + .select('id, author_id') + .eq('id', input.meetingNoteId) + .eq('workspace_id', input.workspaceId) + .maybeSingle(), + supabase + .from('workspace_members') + .select('user_id, role') + .eq('workspace_id', input.workspaceId) + .eq('user_id', currentUserId) + .maybeSingle(), + ]); + + if (noteError || memberError) { + console.error('[meeting-note] 권한 확인 실패:', noteError ?? memberError); + return { ok: false, message: '회의록 정보를 확인하지 못했습니다.' }; + } + + if (!membership) { + return { ok: false, message: '워크스페이스 멤버만 회의록을 관리할 수 있습니다.' }; + } + + if (!note) { + return { ok: false, message: '회의록을 찾을 수 없습니다.' }; + } + + if (membership.role !== 'owner' && note.author_id !== currentUserId) { + return { + ok: false, + message: '작성자 또는 워크스페이스 소유자만 회의록을 수정하거나 삭제할 수 있습니다.', + }; + } + + return { ok: true, context: { supabase, currentUserId } }; +} diff --git a/src/entities/meeting-note/api/update-meeting-note.ts b/src/entities/meeting-note/api/update-meeting-note.ts new file mode 100644 index 0000000..25c4fd9 --- /dev/null +++ b/src/entities/meeting-note/api/update-meeting-note.ts @@ -0,0 +1,59 @@ +'use server'; + +// 작성자 또는 워크스페이스 소유자만 회의록을 수정할 수 있습니다. +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; + +import { toMeetingNoteUpdate } from '../model/meeting-note.mapper'; +import { meetingNoteContentSchema } from '../model/meeting-note.schema'; +import type { MeetingNoteActionResult } from './create-meeting-note'; +import { authorizeMeetingNoteMutation } from './shared'; + +const updateMeetingNoteSchema = meetingNoteContentSchema.extend({ + workspaceId: z.guid(), + meetingNoteId: z.guid(), +}); + +export async function updateMeetingNote( + input: z.input, +): Promise> { + const parsed = updateMeetingNoteSchema.safeParse(input); + + if (!parsed.success) { + return { ok: false, message: parsed.error.issues[0]?.message ?? '입력값이 올바르지 않습니다.' }; + } + + const value = parsed.data; + const authorized = await authorizeMeetingNoteMutation({ + workspaceId: value.workspaceId, + meetingNoteId: value.meetingNoteId, + }); + + if (!authorized.ok) { + return authorized; + } + + const { error } = await authorized.context.supabase + .from('meeting_notes') + .update( + toMeetingNoteUpdate({ + title: value.title, + meetingDate: value.meetingDate, + participantIds: value.participantIds, + decisions: value.decisions, + followUpActions: value.followUpActions, + }), + ) + .eq('id', value.meetingNoteId) + .eq('workspace_id', value.workspaceId); + + if (error) { + console.error('[meeting-note/updateMeetingNote] 수정 실패:', error); + return { ok: false, message: '회의록 수정에 실패했습니다. 잠시 후 다시 시도해주세요.' }; + } + + revalidatePath(`/workspaces/${value.workspaceId}/meeting-notes`); + revalidatePath(`/workspaces/${value.workspaceId}/dashboard`); + + return { ok: true, data: { id: value.meetingNoteId } }; +} diff --git a/src/entities/meeting-note/index.ts b/src/entities/meeting-note/index.ts index ec5e1a1..93b7f5d 100644 --- a/src/entities/meeting-note/index.ts +++ b/src/entities/meeting-note/index.ts @@ -19,7 +19,9 @@ export { } from './model/meeting-note.mapper'; export { meetingNotesQueryKey } from './model/meeting-note-query'; export { getMeetingNotes } from './api/get-meeting-notes'; +export { getMeetingNote } from './api/get-meeting-note'; export { createMeetingNote, type MeetingNoteActionResult } from './api/create-meeting-note'; +export { updateMeetingNote } from './api/update-meeting-note'; export { meetingNoteContentSchema, meetingNoteTitleSchema, diff --git a/src/features/manage-meeting-notes/ui/MeetingNoteForm.tsx b/src/features/manage-meeting-notes/ui/MeetingNoteForm.tsx index 51ec4e2..7cc9232 100644 --- a/src/features/manage-meeting-notes/ui/MeetingNoteForm.tsx +++ b/src/features/manage-meeting-notes/ui/MeetingNoteForm.tsx @@ -6,12 +6,14 @@ import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; -import type { MeetingNoteFormValues } from '@/entities/meeting-note'; -import { createMeetingNote, meetingNotesQueryKey } from '@/entities/meeting-note'; +import type { MeetingNote, MeetingNoteFormValues } from '@/entities/meeting-note'; +import { createMeetingNote, meetingNotesQueryKey, updateMeetingNote } from '@/entities/meeting-note'; import { useWorkspaceMembersByWorkspaceId } from '@/entities/workspace-member'; interface MeetingNoteFormProps { workspaceId: string; + // 있으면 수정 모드, 없으면 생성 모드로 동작한다. + meetingNote?: MeetingNote; } function getTodayIsoDate() { @@ -83,23 +85,35 @@ function splitLines(value: string): string[] { .filter(Boolean); } -export function MeetingNoteForm({ workspaceId }: MeetingNoteFormProps) { +export function MeetingNoteForm({ workspaceId, meetingNote }: MeetingNoteFormProps) { const router = useRouter(); const queryClient = useQueryClient(); + const isEditMode = Boolean(meetingNote); const { data: workspaceMembers = [] } = useWorkspaceMembersByWorkspaceId(workspaceId); - const createMutation = useMutation({ mutationFn: createMeetingNote }); - const [formValues, setFormValues] = useState(() => { - const initialMeetingDate = getTodayIsoDate(); - - return { - title: '', - meetingDate: initialMeetingDate, - decisions: '', - followUpActions: '', - }; + const saveMutation = useMutation({ + mutationFn: (input: { + title: string; + meetingDate: string; + participantIds: string[]; + decisions: string[]; + followUpActions: string[]; + }) => + meetingNote + ? updateMeetingNote({ ...input, workspaceId, meetingNoteId: meetingNote.id }) + : createMeetingNote({ ...input, workspaceId }), }); - const [dateParts, setDateParts] = useState(() => getDatePartsFromIso(getTodayIsoDate())); - const [selectedParticipantIds, setSelectedParticipantIds] = useState([]); + const [formValues, setFormValues] = useState(() => ({ + title: meetingNote?.title ?? '', + meetingDate: meetingNote?.meetingDate ?? getTodayIsoDate(), + decisions: meetingNote?.decisions.join('\n') ?? '', + followUpActions: meetingNote?.followUpActions.join('\n') ?? '', + })); + const [dateParts, setDateParts] = useState(() => + getDatePartsFromIso(meetingNote?.meetingDate ?? getTodayIsoDate()), + ); + const [selectedParticipantIds, setSelectedParticipantIds] = useState( + () => meetingNote?.participants.map((participant) => participant.id) ?? [], + ); const [isParticipantListOpen, setIsParticipantListOpen] = useState(false); const [hasSubmitted, setHasSubmitted] = useState(false); const dateInputRef = useRef(null); @@ -119,13 +133,12 @@ export function MeetingNoteForm({ workspaceId }: MeetingNoteFormProps) { event.preventDefault(); setHasSubmitted(true); - if (!formValues.title.trim() || createMutation.isPending) { + if (!formValues.title.trim() || saveMutation.isPending) { return; } try { - const result = await createMutation.mutateAsync({ - workspaceId, + const result = await saveMutation.mutateAsync({ title: formValues.title.trim(), meetingDate: formValues.meetingDate, participantIds: selectedParticipantIds, @@ -138,7 +151,7 @@ export function MeetingNoteForm({ workspaceId }: MeetingNoteFormProps) { return; } - // 목록 페이지·대시보드 위젯이 새 회의록을 바로 반영하도록 캐시를 무효화한다. + // 목록 페이지·대시보드 위젯이 변경 사항을 바로 반영하도록 캐시를 무효화한다. await queryClient.invalidateQueries({ queryKey: meetingNotesQueryKey(workspaceId) }); router.push(`/workspaces/${workspaceId}/meeting-notes`); } catch { @@ -279,7 +292,9 @@ export function MeetingNoteForm({ workspaceId }: MeetingNoteFormProps) { onSubmit={handleSubmit} className="mt-[21px] rounded-[32px] border border-[#eceffa] bg-white px-[26.5px] pt-[26.5px] pb-[28px] shadow-[0_20px_48px_rgba(91,78,232,0.08)]" > -

새 회의록

+

+ {isEditMode ? '회의록 수정' : '새 회의록'} +

diff --git a/src/features/manage-meeting-notes/ui/MeetingNotesList.tsx b/src/features/manage-meeting-notes/ui/MeetingNotesList.tsx index 163cd3b..91ebba2 100644 --- a/src/features/manage-meeting-notes/ui/MeetingNotesList.tsx +++ b/src/features/manage-meeting-notes/ui/MeetingNotesList.tsx @@ -2,17 +2,55 @@ import { useState } from 'react'; import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import { toast } from 'sonner'; import { Plus } from 'lucide-react'; -import { MeetingNoteCard } from '@/entities/meeting-note'; -import type { MeetingNote } from '@/entities/meeting-note'; +import { MeetingNoteCard, deleteMeetingNote, meetingNotesQueryKey } from '@/entities/meeting-note'; +import type { MeetingNote, MeetingNoteViewer } from '@/entities/meeting-note'; interface MeetingNotesListProps { workspaceId: string; meetingNotes: MeetingNote[]; + viewer: MeetingNoteViewer | null; } -export function MeetingNotesList({ workspaceId, meetingNotes }: MeetingNotesListProps) { +export function MeetingNotesList({ workspaceId, meetingNotes, viewer }: MeetingNotesListProps) { + const router = useRouter(); + const queryClient = useQueryClient(); const [expandedMeetingNoteId, setExpandedMeetingNoteId] = useState(null); + const [openMenuMeetingNoteId, setOpenMenuMeetingNoteId] = useState(null); + const deleteMutation = useMutation({ mutationFn: deleteMeetingNote }); + + const canManage = (meetingNote: MeetingNote) => + viewer?.role === 'owner' || (!!viewer && meetingNote.authorId === viewer.userId); + + const handleDelete = async (meetingNoteId: string) => { + setOpenMenuMeetingNoteId(null); + + if (deleteMutation.isPending) { + return; + } + + if (!window.confirm('이 회의록을 삭제하시겠습니까?')) { + return; + } + + try { + const result = await deleteMutation.mutateAsync({ workspaceId, meetingNoteId }); + + if (!result.ok) { + toast.error(result.message); + return; + } + + await queryClient.invalidateQueries({ queryKey: meetingNotesQueryKey(workspaceId) }); + // 목록 페이지는 서버 컴포넌트라, 삭제된 항목이 사라지도록 서버 데이터를 다시 불러온다. + router.refresh(); + } catch { + toast.error('회의록 삭제에 실패했습니다. 잠시 후 다시 시도해주세요.'); + } + }; return (
@@ -46,6 +84,19 @@ export function MeetingNotesList({ workspaceId, meetingNotes }: MeetingNotesList current === meetingNote.id ? null : meetingNote.id, ) } + canManage={canManage(meetingNote)} + isMenuOpen={openMenuMeetingNoteId === meetingNote.id} + onToggleMenu={() => + setOpenMenuMeetingNoteId((current) => + current === meetingNote.id ? null : meetingNote.id, + ) + } + onEdit={() => { + setOpenMenuMeetingNoteId(null); + router.push(`/workspaces/${workspaceId}/meeting-notes/${meetingNote.id}/edit`); + }} + onDelete={() => void handleDelete(meetingNote.id)} + isDeleting={deleteMutation.isPending} /> ))}
diff --git a/src/views/meeting-notes/ui/MeetingNotesPage.tsx b/src/views/meeting-notes/ui/MeetingNotesPage.tsx index 86c2381..9ec00d0 100644 --- a/src/views/meeting-notes/ui/MeetingNotesPage.tsx +++ b/src/views/meeting-notes/ui/MeetingNotesPage.tsx @@ -7,11 +7,11 @@ interface MeetingNotesPageProps { } export default async function MeetingNotesPage({ workspaceId }: MeetingNotesPageProps) { - const { meetingNotes } = await getMeetingNotes(workspaceId); + const { meetingNotes, viewer } = await getMeetingNotes(workspaceId); return (
- +
); } From 9527a943ac3f729972f2735262c0e93ffb812ea4 Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Wed, 15 Jul 2026 11:13:00 +0900 Subject: [PATCH 06/11] =?UTF-8?q?chore:=20=EB=AF=B8=EC=82=AC=EC=9A=A9=20mo?= =?UTF-8?q?ck=C2=B7zustand=20=EC=8A=A4=ED=86=A0=EC=96=B4=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/meeting-note/index.ts | 1 - .../model/mock-meeting-notes-by-workspace.ts | 45 ------------------- src/features/manage-meeting-notes/index.ts | 1 - .../model/use-meeting-notes-store.ts | 36 --------------- 4 files changed, 83 deletions(-) delete mode 100644 src/entities/meeting-note/model/mock-meeting-notes-by-workspace.ts delete mode 100644 src/features/manage-meeting-notes/model/use-meeting-notes-store.ts diff --git a/src/entities/meeting-note/index.ts b/src/entities/meeting-note/index.ts index acc5632..ab2d43e 100644 --- a/src/entities/meeting-note/index.ts +++ b/src/entities/meeting-note/index.ts @@ -1,4 +1,3 @@ -export { getMockMeetingNotesByWorkspaceId } from './model/mock-meeting-notes-by-workspace'; export type { MeetingNote, MeetingNoteFormValues, diff --git a/src/entities/meeting-note/model/mock-meeting-notes-by-workspace.ts b/src/entities/meeting-note/model/mock-meeting-notes-by-workspace.ts deleted file mode 100644 index fc1e8e8..0000000 --- a/src/entities/meeting-note/model/mock-meeting-notes-by-workspace.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { MeetingNote } from './meeting-note.types'; - -const mockMeetingNotesByWorkspaceId: Record = { - test: [ - { - id: 'meeting-note-1', - workspaceId: 'test', - authorId: null, - title: '스프린트 1 킥오프', - meetingDate: '2025-06-25', - participants: [ - { id: 'participant-1', name: '김지은', initial: '김', color: '#FE9A00' }, - { id: 'participant-2', name: '박서준', initial: '박', color: '#00C950' }, - { id: 'participant-3', name: '이하은', initial: '이', color: '#615FFF' }, - { id: 'participant-4', name: '최민준', initial: '최', color: '#2B7FFF' }, - ], - decisions: ['칸반 보드 도입 확정', '주 2회 데일리 스탠드업 진행'], - followUpActions: ['칸반 보드 초기 세팅 (담당: 김지은)'], - }, - { - id: 'meeting-note-2', - workspaceId: 'test', - authorId: null, - title: '디자인 시스템 논의', - meetingDate: '2025-06-20', - participants: [ - { id: 'participant-1', name: '김지은', initial: '김', color: '#FE9A00' }, - { id: 'participant-2', name: '박서준', initial: '박', color: '#00C950' }, - ], - decisions: ['공통 버튼 규격 확정', '입력 필드와 카드 radius 통일'], - followUpActions: ['공통 컴포넌트 분리', '디자인 토큰 정리'], - }, - ], -}; - -export function getMockMeetingNotesByWorkspaceId(workspaceId: string): MeetingNote[] { - return ( - mockMeetingNotesByWorkspaceId[workspaceId]?.map((meetingNote) => ({ - ...meetingNote, - participants: meetingNote.participants.map((participant) => ({ ...participant })), - decisions: [...meetingNote.decisions], - followUpActions: [...meetingNote.followUpActions], - })) ?? [] - ); -} diff --git a/src/features/manage-meeting-notes/index.ts b/src/features/manage-meeting-notes/index.ts index 73be1a2..919061c 100644 --- a/src/features/manage-meeting-notes/index.ts +++ b/src/features/manage-meeting-notes/index.ts @@ -1,3 +1,2 @@ export { MeetingNoteForm } from './ui/MeetingNoteForm'; export { MeetingNotesList } from './ui/MeetingNotesList'; -export { useMeetingNotesStore } from './model/use-meeting-notes-store'; diff --git a/src/features/manage-meeting-notes/model/use-meeting-notes-store.ts b/src/features/manage-meeting-notes/model/use-meeting-notes-store.ts deleted file mode 100644 index 1716cd1..0000000 --- a/src/features/manage-meeting-notes/model/use-meeting-notes-store.ts +++ /dev/null @@ -1,36 +0,0 @@ -'use client'; - -import { create } from 'zustand'; -import type { MeetingNote } from '@/entities/meeting-note'; - -interface MeetingNotesStore { - meetingNotesByWorkspaceId: Record; - initializeWorkspace: (workspaceId: string, meetingNotes: MeetingNote[]) => void; - addMeetingNote: (workspaceId: string, meetingNote: MeetingNote) => void; -} - -export const useMeetingNotesStore = create((set) => ({ - meetingNotesByWorkspaceId: {}, - initializeWorkspace: (workspaceId, meetingNotes) => - set((state) => { - // 최초 진입 시에만 mock 데이터를 넣고, 이후 작성한 항목은 유지합니다. - if (state.meetingNotesByWorkspaceId[workspaceId]) { - return state; - } - - return { - meetingNotesByWorkspaceId: { - ...state.meetingNotesByWorkspaceId, - [workspaceId]: meetingNotes, - }, - }; - }), - addMeetingNote: (workspaceId, meetingNote) => - set((state) => ({ - meetingNotesByWorkspaceId: { - ...state.meetingNotesByWorkspaceId, - // 방금 작성한 회의록이 목록에서 바로 보이도록 맨 앞에 추가합니다. - [workspaceId]: [meetingNote, ...(state.meetingNotesByWorkspaceId[workspaceId] ?? [])], - }, - })), -})); From 0d6346ca4d92e1f34595aa47d7fe66ad64bf4488 Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Wed, 15 Jul 2026 11:28:37 +0900 Subject: [PATCH 07/11] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9D=98=EB=A1=9D=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20rls=20=EA=B0=95=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...000000_restrict_meeting_note_mutations.sql | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 supabase/migrations/20260715000000_restrict_meeting_note_mutations.sql diff --git a/supabase/migrations/20260715000000_restrict_meeting_note_mutations.sql b/supabase/migrations/20260715000000_restrict_meeting_note_mutations.sql new file mode 100644 index 0000000..e273071 --- /dev/null +++ b/supabase/migrations/20260715000000_restrict_meeting_note_mutations.sql @@ -0,0 +1,83 @@ +-- 회의록은 워크스페이스 멤버가 조회하고, 작성자 또는 소유자만 수정·삭제할 수 있도록 제한한다. +-- 작성 시 author_id는 반드시 본인이어야 한다. (기존의 "멤버면 모든 CRUD" 정책을 대체) + +drop policy if exists meeting_notes_member_all on public.meeting_notes; +drop policy if exists meeting_notes_select_member on public.meeting_notes; +drop policy if exists meeting_notes_insert_member on public.meeting_notes; +drop policy if exists meeting_notes_update_author_or_owner on public.meeting_notes; +drop policy if exists meeting_notes_delete_author_or_owner on public.meeting_notes; + +create policy meeting_notes_select_member +on public.meeting_notes +for select +to authenticated +using (private.is_workspace_member(workspace_id)); + +create policy meeting_notes_insert_member +on public.meeting_notes +for insert +to authenticated +with check ( + private.is_workspace_member(workspace_id) + and author_id = auth.uid() +); + +create policy meeting_notes_update_author_or_owner +on public.meeting_notes +for update +to authenticated +using ( + private.is_workspace_member(workspace_id) + and ( + private.is_workspace_owner(workspace_id) + or author_id = auth.uid() + ) +) +with check ( + private.is_workspace_member(workspace_id) + and ( + private.is_workspace_owner(workspace_id) + or author_id = auth.uid() + ) +); + +create policy meeting_notes_delete_author_or_owner +on public.meeting_notes +for delete +to authenticated +using ( + private.is_workspace_member(workspace_id) + and ( + private.is_workspace_owner(workspace_id) + or author_id = auth.uid() + ) +); + +-- 작성자·워크스페이스·생성일은 수정 API로 변경할 수 없는 감사 메타데이터다. +create or replace function private.prevent_meeting_note_immutable_fields() +returns trigger +language plpgsql +set search_path = public, pg_temp +as $$ +begin + if new.workspace_id is distinct from old.workspace_id then + raise exception '회의록의 워크스페이스는 변경할 수 없습니다.'; + end if; + + if new.author_id is distinct from old.author_id then + raise exception '회의록의 작성자는 변경할 수 없습니다.'; + end if; + + if new.created_at is distinct from old.created_at then + raise exception '회의록의 생성일은 변경할 수 없습니다.'; + end if; + + return new; +end; +$$; + +drop trigger if exists prevent_meeting_note_immutable_fields on public.meeting_notes; +create trigger prevent_meeting_note_immutable_fields +before update on public.meeting_notes +for each row +execute function private.prevent_meeting_note_immutable_fields(); From 34d5660cd03fa5aba3515b226f9e5a28e95b168f Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Wed, 15 Jul 2026 12:33:29 +0900 Subject: [PATCH 08/11] =?UTF-8?q?fix:=20=EB=8B=89=EB=84=A4=EC=9E=84=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD=EC=9D=B4=20=EB=8B=A4=EB=A5=B8=20=ED=99=94?= =?UTF-8?q?=EB=A9=B4=EC=97=90=20=EC=A6=89=EC=8B=9C=20=EB=B0=98=EC=98=81?= =?UTF-8?q?=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[workspaceId]/sprint-board/page.tsx | 2 +- .../[workspaceId]/work-schedule/page.tsx | 2 +- .../api/use-workspace-members-by-id.ts | 9 +++- .../ui/MemberProfileForm.tsx | 50 +++++++++++-------- .../model/use-member-management.ts | 12 ++++- .../sprint-board/ui/SprintBoardView.tsx | 21 ++++++-- .../work-schedule/ui/WorkScheduleView.tsx | 15 ++++-- 7 files changed, 76 insertions(+), 35 deletions(-) diff --git a/src/app/workspaces/[workspaceId]/sprint-board/page.tsx b/src/app/workspaces/[workspaceId]/sprint-board/page.tsx index 0b4157f..7115077 100644 --- a/src/app/workspaces/[workspaceId]/sprint-board/page.tsx +++ b/src/app/workspaces/[workspaceId]/sprint-board/page.tsx @@ -19,7 +19,7 @@ export default async function SprintBoardPage({ params, searchParams }: SprintBo ); } diff --git a/src/app/workspaces/[workspaceId]/work-schedule/page.tsx b/src/app/workspaces/[workspaceId]/work-schedule/page.tsx index ba87945..22d4b40 100644 --- a/src/app/workspaces/[workspaceId]/work-schedule/page.tsx +++ b/src/app/workspaces/[workspaceId]/work-schedule/page.tsx @@ -25,7 +25,7 @@ export default async function WorkSchedulePage({ params }: WorkSchedulePageProps return ( ['workspace-members', 'workspace', workspaceId] as const; -export function useWorkspaceMembersByWorkspaceId(workspaceId: string) { +// initialData를 넘기면 RSC에서 조회한 멤버로 첫 렌더를 채우고(SSR 유지), 이후에는 공유 캐시가 소유한다. +// 미지정 시 기존 동작(클라 조회)과 동일하다. +export function useWorkspaceMembersByWorkspaceId( + workspaceId: string, + initialData?: WorkspaceMember[], +) { return useQuery({ queryKey: workspaceMembersByWorkspaceQueryKey(workspaceId), queryFn: () => getWorkspaceMembersByWorkspaceIdClient(workspaceId), + initialData, }); } diff --git a/src/features/manage-member-profile/ui/MemberProfileForm.tsx b/src/features/manage-member-profile/ui/MemberProfileForm.tsx index 3eaa808..7f856d5 100644 --- a/src/features/manage-member-profile/ui/MemberProfileForm.tsx +++ b/src/features/manage-member-profile/ui/MemberProfileForm.tsx @@ -3,8 +3,9 @@ // 프로필 탭 — 현재 사용자의 워크스페이스 닉네임을 수정한다. // 초기 닉네임은 서버(RSC)에서 주입받고, 저장은 updateMyNickname 서버액션을 호출한다. import { useState } from 'react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; -import { updateMyNickname } from '@/entities/workspace-member'; +import { updateMyNickname, workspaceMembersByWorkspaceQueryKey } from '@/entities/workspace-member'; interface MemberProfileFormProps { workspaceId: string; @@ -12,38 +13,43 @@ interface MemberProfileFormProps { } export function MemberProfileForm({ workspaceId, initialNickname }: MemberProfileFormProps) { + const queryClient = useQueryClient(); const [committedNickname, setCommittedNickname] = useState(initialNickname); const [nickname, setNickname] = useState(initialNickname); const [isSaved, setIsSaved] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); - const isDirty = nickname !== committedNickname; - const canSubmit = nickname.trim().length > 0 && isDirty && !isSubmitting; - - const handleSubmit = async (event: React.FormEvent) => { - event.preventDefault(); - if (!canSubmit) { - return; - } - - const nextNickname = nickname.trim(); - - setIsSubmitting(true); - try { - await updateMyNickname({ workspaceId, nickname: nextNickname }); - setNickname(nextNickname); - setCommittedNickname(nextNickname); + // updateMyNickname은 실패 시 throw 하므로 onSuccess/onError로 깔끔하게 분기할 수 있다. + const updateMutation = useMutation({ + mutationFn: updateMyNickname, + onSuccess: async (_data, { nickname: savedNickname }) => { + // 멤버 목록·스프린트·워크스케줄 등 공유 캐시를 쓰는 화면이 변경된 닉네임을 반영하도록 무효화한다. + await queryClient.invalidateQueries({ + queryKey: workspaceMembersByWorkspaceQueryKey(workspaceId), + }); + setNickname(savedNickname); + setCommittedNickname(savedNickname); setIsSaved(true); - } catch (error) { + }, + onError: (error) => { console.error(error); toast.error( error instanceof Error ? error.message : '닉네임 저장에 실패했습니다. 잠시 후 다시 시도해주세요.', ); - } finally { - setIsSubmitting(false); + }, + }); + + const isDirty = nickname !== committedNickname; + const canSubmit = nickname.trim().length > 0 && isDirty && !updateMutation.isPending; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + if (!canSubmit) { + return; } + + updateMutation.mutate({ workspaceId, nickname: nickname.trim() }); }; return ( @@ -73,7 +79,7 @@ export function MemberProfileForm({ workspaceId, initialNickname }: MemberProfil disabled={!canSubmit} className="h-10 rounded-2xl bg-[var(--color-brand)] px-5 text-sm font-bold text-white hover:bg-indigo-500 disabled:cursor-not-allowed disabled:opacity-50" > - {isSubmitting ? '저장 중…' : '저장'} + {updateMutation.isPending ? '저장 중…' : '저장'} {isSaved && !isDirty ? ( 저장되었습니다. diff --git a/src/features/manage-workspace-members/model/use-member-management.ts b/src/features/manage-workspace-members/model/use-member-management.ts index 866f196..a0353d5 100644 --- a/src/features/manage-workspace-members/model/use-member-management.ts +++ b/src/features/manage-workspace-members/model/use-member-management.ts @@ -7,7 +7,10 @@ import { useMemo, useState, useSyncExternalStore } from 'react'; import { toast } from 'sonner'; import { sendInviteEmail, setWorkspaceInviteEnabled } from '@/entities/workspace'; -import type { WorkspaceMember } from '@/entities/workspace-member'; +import { + useWorkspaceMembersByWorkspaceId, + type WorkspaceMember, +} from '@/entities/workspace-member'; export type InviteMode = 'email' | 'link'; @@ -32,7 +35,12 @@ export function useMemberManagement({ inviteCode, inviteEnabled, }: UseMemberManagementParams) { - const [members] = useState(initialMembers); + // 멤버 목록은 RSC 값으로 첫 렌더를 채우되, 공유 캐시가 소유한다. + // 닉네임 변경 등으로 캐시가 무효화되면 목록·초대 중복 검사가 함께 최신화된다. + const { data: members = initialMembers } = useWorkspaceMembersByWorkspaceId( + workspaceId, + initialMembers, + ); const [inviteMode, setInviteMode] = useState('email'); const [email, setEmail] = useState(''); const [isSendingInvite, setIsSendingInvite] = useState(false); diff --git a/src/views/side-project/sprint-board/ui/SprintBoardView.tsx b/src/views/side-project/sprint-board/ui/SprintBoardView.tsx index 3445408..0a544af 100644 --- a/src/views/side-project/sprint-board/ui/SprintBoardView.tsx +++ b/src/views/side-project/sprint-board/ui/SprintBoardView.tsx @@ -3,12 +3,15 @@ // 스프린트 보드 페이지 뷰 — useQuery로 스프린트/업무/백로그를 조회해 렌더한다(GET 컨벤션 §5). // 선택 스프린트는 URL(?sprint=id)에서 온 selectedSprintId로 판정하고, 없으면 현재 스프린트로 폴백한다. // 로딩/에러/빈 상태를 여기서 분기하고, 상호작용 보드/백로그는 feature에 위임한다. -// members(담당자 표시명 해석용)는 서버(RSC)에서 조회해 prop으로 주입받는다. +// members(담당자 표시명 해석용)는 RSC 값(initialMembers)으로 첫 렌더를 채우고 공유 캐시가 소유한다. import { Plus_Jakarta_Sans } from 'next/font/google'; import { resolveCurrentSprint, useSprints } from '@/entities/side-project/sprint'; import { useBacklogTasks, useSprintTasks } from '@/entities/side-project/task'; -import type { WorkspaceMember } from '@/entities/workspace-member'; +import { + useWorkspaceMembersByWorkspaceId, + type WorkspaceMember, +} from '@/entities/workspace-member'; import { SprintBoard } from '@/features/manage-sprint-tasks'; import { SprintToolbar } from '@/features/manage-sprints'; @@ -32,11 +35,19 @@ function CenteredMessage({ children }: { children: React.ReactNode }) { interface SprintBoardViewProps { workspaceId: string; selectedSprintId?: string; - /** 담당자 표시명 해석용 워크스페이스 멤버(RSC에서 주입) */ - members: WorkspaceMember[]; + /** 담당자 표시명 해석용 워크스페이스 멤버(RSC에서 주입, 공유 캐시의 초기값) */ + initialMembers: WorkspaceMember[]; } -export function SprintBoardView({ workspaceId, selectedSprintId, members }: SprintBoardViewProps) { +export function SprintBoardView({ + workspaceId, + selectedSprintId, + initialMembers, +}: SprintBoardViewProps) { + const { data: members = initialMembers } = useWorkspaceMembersByWorkspaceId( + workspaceId, + initialMembers, + ); const sprintsQuery = useSprints(workspaceId); // 선택값이 없거나 유효하지 않으면 데이터에서 현재 스프린트를 판정(진행 중 우선 → 없으면 최신) const sprint = sprintsQuery.data diff --git a/src/views/store-operation/work-schedule/ui/WorkScheduleView.tsx b/src/views/store-operation/work-schedule/ui/WorkScheduleView.tsx index 0ceb0cf..a80dd2a 100644 --- a/src/views/store-operation/work-schedule/ui/WorkScheduleView.tsx +++ b/src/views/store-operation/work-schedule/ui/WorkScheduleView.tsx @@ -1,13 +1,17 @@ 'use client'; // 서버에서 조회한 매장 운영 워크스페이스의 근무유형과 일정을 화면 구성 요소에 전달합니다. +// members는 RSC 값(initialMembers)으로 첫 렌더를 채우고 공유 캐시가 소유한다(닉네임 변경 즉시 반영). import type { WorkScheduleEntry, WorkShiftOption } from '@/entities/work-schedule'; -import type { WorkspaceMember } from '@/entities/workspace-member'; +import { + useWorkspaceMembersByWorkspaceId, + type WorkspaceMember, +} from '@/entities/workspace-member'; import { WorkScheduleBoard } from '@/features/manage-work-schedule'; interface WorkScheduleViewProps { workspaceId: string; - members: WorkspaceMember[]; + initialMembers: WorkspaceMember[]; shifts: WorkShiftOption[]; schedule: WorkScheduleEntry[]; weekStartDate: string; @@ -15,11 +19,16 @@ interface WorkScheduleViewProps { export function WorkScheduleView({ workspaceId, - members, + initialMembers, shifts, schedule, weekStartDate, }: WorkScheduleViewProps) { + const { data: members = initialMembers } = useWorkspaceMembersByWorkspaceId( + workspaceId, + initialMembers, + ); + return (
From 3bb98067831284b9ef5db73eb92c9e2f24697226 Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Thu, 16 Jul 2026 11:33:14 +0900 Subject: [PATCH 09/11] =?UTF-8?q?feat:=20=ED=9A=8C=EC=9D=98=EB=A1=9D=20?= =?UTF-8?q?=EC=9C=84=EC=A0=AF=20router=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dashboard-recent-notes/ui/RecentNotes.tsx | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/src/widgets/side-project/dashboard-recent-notes/ui/RecentNotes.tsx b/src/widgets/side-project/dashboard-recent-notes/ui/RecentNotes.tsx index 30200b6..0ba675e 100644 --- a/src/widgets/side-project/dashboard-recent-notes/ui/RecentNotes.tsx +++ b/src/widgets/side-project/dashboard-recent-notes/ui/RecentNotes.tsx @@ -5,27 +5,38 @@ // · md: 리스트(제목 + 작성일) // · lg: 총 개수 + 리스트(제목 + 본문 미리보기 + 작성일) import { FileText } from 'lucide-react'; +import { useRouter } from 'next/navigation'; import { useQuery } from '@tanstack/react-query'; import { getMeetingNotes, meetingNotesQueryKey } from '@/entities/meeting-note'; import type { WidgetSize } from '@/shared/dashboard/lib/widget-size'; import { WidgetCard, WidgetCardAction, WidgetCardHeader } from '@/shared/dashboard/ui/widget-card'; -const header = ( - 전체 보기} /> -); interface RecentNotesProps { workspaceId: string; size?: WidgetSize; } export default function RecentNotes({ workspaceId, size = 'md' }: RecentNotesProps) { + const router = useRouter(); const { data, isError, isPending } = useQuery({ queryKey: meetingNotesQueryKey(workspaceId), queryFn: () => getMeetingNotes(workspaceId), }); const meetingNotes = data?.meetingNotes ?? []; + // workspaceId가 필요해 컴포넌트 내부에서 헤더를 구성한다. + const header = ( + router.push(`/workspaces/${workspaceId}/meeting-notes`)}> + 전체 보기 + + } + /> + ); + if (isPending || isError || meetingNotes.length === 0) { return ( From d10e95e913673429c09a7eefadbb4395a52011b6 Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Thu, 16 Jul 2026 11:36:30 +0900 Subject: [PATCH 10/11] =?UTF-8?q?fix:=20=ED=9A=8C=EC=9D=98=EB=A1=9D=20?= =?UTF-8?q?=EC=B9=B4=EB=93=9C=20=EB=A9=94=EB=89=B4=20=ED=82=A4=EB=B3=B4?= =?UTF-8?q?=EB=93=9C=20=EC=A1=B0=EC=9E=91=20=EC=8B=9C=20=EC=B9=B4=EB=93=9C?= =?UTF-8?q?=20=ED=8E=BC=EC=B9=A8=EC=9D=B4=20=ED=95=A8=EA=BB=98=20=ED=8A=B8?= =?UTF-8?q?=EB=A6=AC=EA=B1=B0=EB=90=98=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/entities/meeting-note/ui/MeetingNoteCard.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/entities/meeting-note/ui/MeetingNoteCard.tsx b/src/entities/meeting-note/ui/MeetingNoteCard.tsx index d640261..470edbb 100644 --- a/src/entities/meeting-note/ui/MeetingNoteCard.tsx +++ b/src/entities/meeting-note/ui/MeetingNoteCard.tsx @@ -31,6 +31,12 @@ export function MeetingNoteCard({ return; } + // 중첩된 메뉴/수정/삭제 버튼에서 버블링된 키 이벤트로는 카드가 토글되지 않도록, + // 카드 자신이 포커스된 상태의 키 입력만 처리한다. + if (event.target !== event.currentTarget) { + return; + } + if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); onClick(); From b43bcfcde3ceb0858d51b6d38024146eaff6dfcf Mon Sep 17 00:00:00 2001 From: Kwon812 Date: Thu, 16 Jul 2026 12:00:49 +0900 Subject: [PATCH 11/11] =?UTF-8?q?refactor:=20isSubmitting=20->=20ispending?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../manage-member-profile/ui/MemberProfileForm.tsx | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/features/manage-member-profile/ui/MemberProfileForm.tsx b/src/features/manage-member-profile/ui/MemberProfileForm.tsx index e3a5fbc..0028cb6 100644 --- a/src/features/manage-member-profile/ui/MemberProfileForm.tsx +++ b/src/features/manage-member-profile/ui/MemberProfileForm.tsx @@ -35,13 +35,9 @@ export function MemberProfileForm({ const [committedNickname, setCommittedNickname] = useState(initialNickname); const [nickname, setNickname] = useState(initialNickname); const [isSaved, setIsSaved] = useState(false); - const [isSubmitting, setIsSubmitting] = useState(false); const [isLeaving, setIsLeaving] = useState(false); const [showLeaveConfirm, setShowLeaveConfirm] = useState(false); - const isDirty = nickname !== committedNickname; - const canSubmit = nickname.trim().length > 0 && isDirty && !isSubmitting; - // updateMyNickname은 실패 시 throw 하므로 onSuccess/onError로 깔끔하게 분기할 수 있다. const updateMutation = useMutation({ mutationFn: updateMyNickname, @@ -64,6 +60,9 @@ export function MemberProfileForm({ }, }); + const isDirty = nickname !== committedNickname; + const canSubmit = nickname.trim().length > 0 && isDirty && !updateMutation.isPending; + const handleSubmit = (event: React.FormEvent) => { event.preventDefault(); if (!canSubmit) {