) => {
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();