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/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 ( = + | { 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/api/delete-meeting-note.ts b/src/entities/meeting-note/api/delete-meeting-note.ts new file mode 100644 index 0000000..956086a --- /dev/null +++ b/src/entities/meeting-note/api/delete-meeting-note.ts @@ -0,0 +1,46 @@ +'use server'; + +// 작성자 또는 워크스페이스 소유자만 회의록을 삭제할 수 있습니다. +import { revalidatePath } from 'next/cache'; +import { z } from 'zod'; + +import type { MeetingNoteActionResult } from './create-meeting-note'; +import { authorizeMeetingNoteMutation } from './shared'; + +const deleteMeetingNoteSchema = z.object({ + workspaceId: z.guid(), + meetingNoteId: z.guid(), +}); + +export async function deleteMeetingNote( + input: z.input, +): Promise> { + const parsed = deleteMeetingNoteSchema.safeParse(input); + + if (!parsed.success) { + return { ok: false, message: '입력값이 올바르지 않습니다.' }; + } + + const value = parsed.data; + const authorized = await authorizeMeetingNoteMutation(value); + + if (!authorized.ok) { + return authorized; + } + + const { error } = await authorized.context.supabase + .from('meeting_notes') + .delete() + .eq('id', value.meetingNoteId) + .eq('workspace_id', value.workspaceId); + + if (error) { + console.error('[meeting-note/deleteMeetingNote] 삭제 실패:', error); + return { ok: false, message: '회의록 삭제에 실패했습니다. 잠시 후 다시 시도해주세요.' }; + } + + revalidatePath(`/workspaces/${value.workspaceId}/meeting-notes`); + revalidatePath(`/workspaces/${value.workspaceId}/dashboard`); + + return { ok: true, data: undefined }; +} 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/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/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 6d29886..ab2d43e 100644 --- a/src/entities/meeting-note/index.ts +++ b/src/entities/meeting-note/index.ts @@ -1,7 +1,31 @@ -export { getMockMeetingNotesByWorkspaceId } from './model/mock-meeting-notes-by-workspace'; export type { MeetingNote, MeetingNoteFormValues, MeetingNoteParticipant, + MeetingNoteViewer, + MeetingNoteBoardData, } from './model/meeting-note.types'; +export type { MeetingNoteRow } from './model/meeting-note.db.types'; +export { + toMeetingNote, + toMeetingNoteInsert, + toMeetingNoteUpdate, + 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 { getMeetingNote } from './api/get-meeting-note'; +export { createMeetingNote, type MeetingNoteActionResult } from './api/create-meeting-note'; +export { updateMeetingNote } from './api/update-meeting-note'; +export { deleteMeetingNote } from './api/delete-meeting-note'; +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-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.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..66775c3 --- /dev/null +++ b/src/entities/meeting-note/model/meeting-note.mapper.ts @@ -0,0 +1,130 @@ +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'; + +// 조회 시 실제로 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 = [ + '#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: MeetingNoteQueryRow, + 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..a633730 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[]; @@ -21,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/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 accad64..0000000 --- a/src/entities/meeting-note/model/mock-meeting-notes-by-workspace.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { MeetingNote } from './meeting-note.types'; - -const mockMeetingNotesByWorkspaceId: Record = { - test: [ - { - id: 'meeting-note-1', - workspaceId: 'test', - 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', - 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/entities/meeting-note/ui/MeetingNoteCard.tsx b/src/entities/meeting-note/ui/MeetingNoteCard.tsx index 039a90a..470edbb 100644 --- a/src/entities/meeting-note/ui/MeetingNoteCard.tsx +++ b/src/entities/meeting-note/ui/MeetingNoteCard.tsx @@ -1,29 +1,53 @@ -import { ArrowRight, Check } from 'lucide-react'; -import type { KeyboardEvent } from 'react'; +import { ArrowRight, Check, MoreHorizontal } from 'lucide-react'; +import type { KeyboardEvent, MouseEvent } from 'react'; import type { MeetingNote } from '../model/meeting-note.types'; interface MeetingNoteCardProps { meetingNote: MeetingNote; isExpanded?: boolean; onClick?: () => void; + // 수정·삭제 메뉴 관련 (권한이 있을 때만 노출). 메뉴 열림 상태는 목록이 소유한다. + canManage?: boolean; + isMenuOpen?: boolean; + onToggleMenu?: () => void; + onEdit?: () => void; + onDelete?: () => void; + isDeleting?: boolean; } export function MeetingNoteCard({ meetingNote, isExpanded = false, onClick, + canManage = false, + isMenuOpen = false, + onToggleMenu, + onEdit, + onDelete, + isDeleting = false, }: MeetingNoteCardProps) { const handleKeyDown = (event: KeyboardEvent) => { if (!onClick) { return; } + // 중첩된 메뉴/수정/삭제 버튼에서 버블링된 키 이벤트로는 카드가 토글되지 않도록, + // 카드 자신이 포커스된 상태의 키 입력만 처리한다. + if (event.target !== event.currentTarget) { + return; + } + if (event.key === 'Enter' || event.key === ' ') { event.preventDefault(); onClick(); } }; + // 메뉴 관련 클릭은 카드 펼침(onClick)으로 전파되지 않도록 막는다. + const stopCardToggle = (event: MouseEvent) => { + event.stopPropagation(); + }; + return (
))} + + {canManage ? ( +
+ + + {isMenuOpen ? ( +
+ + +
+ ) : null} +
+ ) : null}
diff --git a/src/entities/workspace-member/api/use-workspace-members-by-id.ts b/src/entities/workspace-member/api/use-workspace-members-by-id.ts index 3fb1c96..75d55da 100644 --- a/src/entities/workspace-member/api/use-workspace-members-by-id.ts +++ b/src/entities/workspace-member/api/use-workspace-members-by-id.ts @@ -2,14 +2,21 @@ import { useQuery } from '@tanstack/react-query'; +import type { WorkspaceMember } from '../model/workspace-member.types'; import { getWorkspaceMembersByWorkspaceIdClient } from './get-workspace-members-by-id.client'; export const workspaceMembersByWorkspaceQueryKey = (workspaceId: string) => ['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-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] ?? [])], - }, - })), -})); diff --git a/src/features/manage-meeting-notes/ui/MeetingNoteForm.tsx b/src/features/manage-meeting-notes/ui/MeetingNoteForm.tsx index 35844ab..7cc9232 100644 --- a/src/features/manage-meeting-notes/ui/MeetingNoteForm.tsx +++ b/src/features/manage-meeting-notes/ui/MeetingNoteForm.tsx @@ -4,12 +4,16 @@ 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 { 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() { @@ -74,30 +78,42 @@ 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) { +export function MeetingNoteForm({ workspaceId, meetingNote }: MeetingNoteFormProps) { const router = useRouter(); - const workspaceMembers = getMockWorkspaceMembersByWorkspaceId(workspaceId); - const addMeetingNote = useMeetingNotesStore((state) => state.addMeetingNote); - const [formValues, setFormValues] = useState(() => { - const initialMeetingDate = getTodayIsoDate(); - - return { - title: '', - meetingDate: initialMeetingDate, - decisions: '', - followUpActions: '', - }; + const queryClient = useQueryClient(); + const isEditMode = Boolean(meetingNote); + const { data: workspaceMembers = [] } = useWorkspaceMembersByWorkspaceId(workspaceId); + 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); @@ -113,40 +129,34 @@ 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() || saveMutation.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, - 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 saveMutation.mutateAsync({ + 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(() => { @@ -282,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 ? '회의록 수정' : '새 회의록'} +

-
- {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, + ) + } + 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/features/manage-member-profile/ui/MemberProfileForm.tsx b/src/features/manage-member-profile/ui/MemberProfileForm.tsx index 528e05f..0028cb6 100644 --- a/src/features/manage-member-profile/ui/MemberProfileForm.tsx +++ b/src/features/manage-member-profile/ui/MemberProfileForm.tsx @@ -4,8 +4,8 @@ import { useState } from 'react'; import { useRouter } from 'next/navigation'; import { Loader2 } from 'lucide-react'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; import { toast } from 'sonner'; -import { leaveWorkspace, updateMyNickname } from '@/entities/workspace-member'; import { Dialog, DialogContent, @@ -13,6 +13,11 @@ import { DialogFooter, DialogTitle, } from '@/shared/ui/dialog'; +import { + updateMyNickname, + workspaceMembersByWorkspaceQueryKey, + leaveWorkspace, +} from '@/entities/workspace-member'; interface MemberProfileFormProps { workspaceId: string; @@ -26,40 +31,45 @@ export function MemberProfileForm({ isOwner, }: MemberProfileFormProps) { const router = useRouter(); + const queryClient = useQueryClient(); 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; - - 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() }); }; const handleLeave = async () => { @@ -105,7 +115,7 @@ export function MemberProfileForm({ 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/dashboard/config/widget-catalog.tsx b/src/views/dashboard/config/widget-catalog.tsx index 1256efe..ab164a3 100644 --- a/src/views/dashboard/config/widget-catalog.tsx +++ b/src/views/dashboard/config/widget-catalog.tsx @@ -50,7 +50,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/index.ts b/src/views/meeting-notes/index.ts index c45ed70..934462f 100644 --- a/src/views/meeting-notes/index.ts +++ b/src/views/meeting-notes/index.ts @@ -1,2 +1,3 @@ export { default as MeetingNotesPage } from './ui/MeetingNotesPage'; export { default as NewMeetingNotePage } from './ui/NewMeetingNotePage'; +export { default as EditMeetingNotePage } from './ui/EditMeetingNotePage'; diff --git a/src/views/meeting-notes/ui/EditMeetingNotePage.tsx b/src/views/meeting-notes/ui/EditMeetingNotePage.tsx new file mode 100644 index 0000000..1e12e84 --- /dev/null +++ b/src/views/meeting-notes/ui/EditMeetingNotePage.tsx @@ -0,0 +1,26 @@ +import { notFound } from 'next/navigation'; +import { getMeetingNote } from '@/entities/meeting-note'; +import { MeetingNoteForm } from '@/features/manage-meeting-notes'; +import { plusJakartaSans } from '@/shared/lib/fonts'; + +interface EditMeetingNotePageProps { + workspaceId: string; + meetingNoteId: string; +} + +export default async function EditMeetingNotePage({ + workspaceId, + meetingNoteId, +}: EditMeetingNotePageProps) { + const meetingNote = await getMeetingNote(workspaceId, meetingNoteId); + + if (!meetingNote) { + notFound(); + } + + return ( +
+ +
+ ); +} diff --git a/src/views/meeting-notes/ui/MeetingNotesPage.tsx b/src/views/meeting-notes/ui/MeetingNotesPage.tsx index c330d69..9ec00d0 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,12 +6,12 @@ interface MeetingNotesPageProps { workspaceId: string; } -export default function MeetingNotesPage({ workspaceId }: MeetingNotesPageProps) { - const meetingNotes = getMockMeetingNotesByWorkspaceId(workspaceId); +export default async function MeetingNotesPage({ workspaceId }: MeetingNotesPageProps) { + const { meetingNotes, viewer } = await getMeetingNotes(workspaceId); return (
- +
); } 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 (
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..0ba675e 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,57 @@ +'use client'; + // 최근 회의록 위젯 — 타일 크기에 따라 밀도가 다른 변형을 렌더 // · sm: 가장 최근 회의록 1건(제목만) // · 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'; -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 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 ( + + {header} +
+ {isPending + ? '회의록을 불러오는 중입니다.' + : isError + ? '회의록을 불러오지 못했습니다.' + : '작성된 회의록이 없습니다.'} +
+
+ ); + } + if (size === 'sm') { const latest = meetingNotes[0]; return ( 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();