diff --git a/src/app/workspaces/[workspaceId]/layout.tsx b/src/app/workspaces/[workspaceId]/layout.tsx index 01838a1..536b42b 100644 --- a/src/app/workspaces/[workspaceId]/layout.tsx +++ b/src/app/workspaces/[workspaceId]/layout.tsx @@ -1,6 +1,7 @@ // 워크스페이스 공통 사이드바와 헤더를 적용하는 라우트 레이아웃입니다. import { notFound } from 'next/navigation'; import { getWorkspaceById } from '@/entities/workspace/api/get-workspace-by-id'; +import { getCurrentWorkspaceMember } from '@/entities/workspace-member/api/get-current-workspace-member'; import { WorkspaceShell } from '@/widgets/workspace-shell'; interface WorkspaceLayoutProps { @@ -12,14 +13,17 @@ interface WorkspaceLayoutProps { export default async function WorkspaceLayout({ children, params }: WorkspaceLayoutProps) { const { workspaceId } = await params; - const workspace = await getWorkspaceById(workspaceId); + const [workspace, currentMember] = await Promise.all([ + getWorkspaceById(workspaceId), + getCurrentWorkspaceMember(workspaceId), + ]); - if (!workspace) { + if (!workspace || !currentMember) { notFound(); } return ( - + {children} ); diff --git a/src/app/workspaces/[workspaceId]/notices/page.tsx b/src/app/workspaces/[workspaceId]/notices/page.tsx index faea680..8e069b9 100644 --- a/src/app/workspaces/[workspaceId]/notices/page.tsx +++ b/src/app/workspaces/[workspaceId]/notices/page.tsx @@ -1,4 +1,5 @@ // 워크스페이스 공지 페이지의 라우트 진입점입니다. +import { getNoticeBoard } from '@/entities/notice/api/get-notice-board'; import { NoticesView } from '@/views/store-operation/notices'; interface NoticesPageProps { @@ -9,6 +10,7 @@ interface NoticesPageProps { export default async function NoticesPage({ params }: NoticesPageProps) { const { workspaceId } = await params; + const initialData = await getNoticeBoard(workspaceId); - return ; + return ; } diff --git a/src/app/workspaces/[workspaceId]/page.tsx b/src/app/workspaces/[workspaceId]/page.tsx index 2be2246..641912e 100644 --- a/src/app/workspaces/[workspaceId]/page.tsx +++ b/src/app/workspaces/[workspaceId]/page.tsx @@ -16,10 +16,10 @@ export default async function WorkspaceHomePage({ params }: WorkspaceHomePagePro } if (workspace.purpose === 'store-operation') { - redirect(`/workspaces/${workspaceId}/work-schedule`); + redirect(`/workspaces/${workspaceId}/dashboard`); } if (workspace.purpose === 'side-project') { - redirect(`/workspaces/${workspaceId}/sprint-board`); + redirect(`/workspaces/${workspaceId}/dashboard`); } redirect(`/workspaces/${workspaceId}/project-management`); diff --git a/src/entities/notice/api/get-notice-board.ts b/src/entities/notice/api/get-notice-board.ts new file mode 100644 index 0000000..633d386 --- /dev/null +++ b/src/entities/notice/api/get-notice-board.ts @@ -0,0 +1,84 @@ +'use server'; + +// 워크스페이스 공지와 현재 사용자의 역할을 함께 조회해 공지 화면·위젯의 데이터 기준을 통일합니다. +import { z } from 'zod'; +import { getCurrentUserId } from '@/shared/api/supabase/current-user'; +import { createSupabaseServerClient } from '@/shared/api/supabase/server'; +import type { Notice, NoticeBoardData } from '../model/notice.types'; + +const workspaceIdSchema = z.guid(); + +function toCreatedAtLabel(value: string): string { + return value.slice(0, 10); +} + +export async function getNoticeBoard(workspaceId: string): Promise { + const parsedWorkspaceId = workspaceIdSchema.parse(workspaceId); + const supabase = await createSupabaseServerClient(); + const currentUserId = await getCurrentUserId(); + + const [ + { data: announcements, error: announcementError }, + { data: membership, error: memberError }, + ] = await Promise.all([ + supabase + .from('announcements') + .select('id, workspace_id, author_id, title, content, is_pinned, created_at') + .eq('workspace_id', parsedWorkspaceId) + .order('is_pinned', { ascending: false }) + .order('created_at', { ascending: false }), + supabase + .from('workspace_members') + .select('user_id, role') + .eq('workspace_id', parsedWorkspaceId) + .eq('user_id', currentUserId) + .maybeSingle(), + ]); + + if (announcementError) { + throw new Error(`공지 조회에 실패했습니다: ${announcementError.message}`); + } + + if (memberError) { + throw new Error(`현재 멤버 조회에 실패했습니다: ${memberError.message}`); + } + + const authorIds = [ + ...new Set( + (announcements ?? []).flatMap((notice) => (notice.author_id ? [notice.author_id] : [])), + ), + ]; + const { data: profiles, error: profileError } = authorIds.length + ? await supabase.from('profiles').select('id, real_name').in('id', authorIds) + : { data: [], error: null }; + + if (profileError) { + throw new Error(`공지 작성자 조회에 실패했습니다: ${profileError.message}`); + } + + const profileNameById = new Map( + (profiles ?? []).map((profile) => [profile.id, profile.real_name]), + ); + const notices: Notice[] = (announcements ?? []).map((notice) => ({ + id: notice.id, + workspaceId: notice.workspace_id, + authorId: notice.author_id, + title: notice.title, + content: notice.content, + authorName: notice.author_id + ? (profileNameById.get(notice.author_id) ?? '알 수 없음') + : '탈퇴한 사용자', + createdAt: toCreatedAtLabel(notice.created_at), + isPinned: notice.is_pinned, + })); + + return { + notices, + viewer: membership + ? { + userId: membership.user_id, + role: membership.role, + } + : null, + }; +} diff --git a/src/entities/notice/api/notice-actions.ts b/src/entities/notice/api/notice-actions.ts new file mode 100644 index 0000000..80ab390 --- /dev/null +++ b/src/entities/notice/api/notice-actions.ts @@ -0,0 +1,208 @@ +'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'; + +const uuidSchema = z.guid(); +const noticeContentSchema = z.object({ + title: z.string().trim().min(1, '공지 제목을 입력해주세요.').max(120), + content: z.string().trim().min(1, '공지 내용을 입력해주세요.').max(10_000), +}); + +type WorkspaceMember = { user_id: string; role: 'owner' | 'member' }; + +export type NoticeActionResult = { ok: true; data: T } | { ok: false; message: string }; + +class NoticeActionError extends Error {} + +function throwNoticeActionError(message: string): never { + throw new NoticeActionError(message); +} + +function toActionFailure(error: unknown, fallbackMessage: string): NoticeActionResult { + if (error instanceof NoticeActionError) { + return { ok: false, message: error.message }; + } + + console.error('[notice action] 예상하지 못한 오류:', error); + return { ok: false, message: fallbackMessage }; +} + +function revalidateNoticePages(workspaceId: string): void { + revalidatePath(`/workspaces/${workspaceId}/notices`); + revalidatePath(`/workspaces/${workspaceId}/dashboard`); +} + +async function getCurrentWorkspaceMember(workspaceId: string): Promise<{ + supabase: Awaited>; + member: WorkspaceMember; +}> { + const supabase = await createSupabaseServerClient(); + const currentUserId = await getCurrentUserId(); + const { data, error } = await supabase + .from('workspace_members') + .select('user_id, role') + .eq('workspace_id', workspaceId) + .eq('user_id', currentUserId) + .maybeSingle(); + + if (error) { + console.error('[notice action] 워크스페이스 멤버 확인 실패:', error); + throwNoticeActionError('워크스페이스 멤버 정보를 확인하지 못했습니다.'); + } + + if (!data) { + throwNoticeActionError('워크스페이스 멤버만 공지를 관리할 수 있습니다.'); + } + + return { supabase, member: data }; +} + +async function getEditableAnnouncement(input: { workspaceId: string; noticeId: string }) { + const { supabase, member } = await getCurrentWorkspaceMember(input.workspaceId); + const { data: notice, error } = await supabase + .from('announcements') + .select('id, author_id') + .eq('id', input.noticeId) + .eq('workspace_id', input.workspaceId) + .maybeSingle(); + + if (error) { + console.error('[notice action] 공지 조회 실패:', error); + throwNoticeActionError('공지 정보를 확인하지 못했습니다.'); + } + + if (!notice) { + throwNoticeActionError('공지를 찾을 수 없습니다.'); + } + + if (member.role !== 'owner' && notice.author_id !== member.user_id) { + throwNoticeActionError( + '작성자 또는 워크스페이스 소유자만 공지를 수정하거나 삭제할 수 있습니다.', + ); + } + + return { supabase, member, notice }; +} + +export async function createNotice(input: { + workspaceId: string; + title: string; + content: string; +}): Promise> { + try { + const value = z.object({ workspaceId: uuidSchema }).merge(noticeContentSchema).parse(input); + const { supabase, member } = await getCurrentWorkspaceMember(value.workspaceId); + const { data, error } = await supabase + .from('announcements') + .insert({ + workspace_id: value.workspaceId, + author_id: member.user_id, + title: value.title, + content: value.content, + }) + .select('id') + .single(); + + if (error) { + console.error('[notice action] 공지 등록 실패:', error); + throwNoticeActionError('공지 등록에 실패했습니다. 잠시 후 다시 시도해주세요.'); + } + + revalidateNoticePages(value.workspaceId); + return { ok: true, data: { id: data.id } }; + } catch (error) { + return toActionFailure(error, '공지 등록에 실패했습니다. 입력값을 확인해주세요.'); + } +} + +export async function updateNotice(input: { + workspaceId: string; + noticeId: string; + title: string; + content: string; +}): Promise> { + try { + const value = z + .object({ workspaceId: uuidSchema, noticeId: uuidSchema }) + .merge(noticeContentSchema) + .parse(input); + const { supabase } = await getEditableAnnouncement(value); + const { error } = await supabase + .from('announcements') + .update({ title: value.title, content: value.content }) + .eq('id', value.noticeId) + .eq('workspace_id', value.workspaceId); + + if (error) { + console.error('[notice action] 공지 수정 실패:', error); + throwNoticeActionError('공지 수정에 실패했습니다. 잠시 후 다시 시도해주세요.'); + } + + revalidateNoticePages(value.workspaceId); + return { ok: true, data: undefined }; + } catch (error) { + return toActionFailure(error, '공지 수정에 실패했습니다. 입력값을 확인해주세요.'); + } +} + +export async function deleteNotice(input: { + workspaceId: string; + noticeId: string; +}): Promise> { + try { + const value = z.object({ workspaceId: uuidSchema, noticeId: uuidSchema }).parse(input); + const { supabase } = await getEditableAnnouncement(value); + const { error } = await supabase + .from('announcements') + .delete() + .eq('id', value.noticeId) + .eq('workspace_id', value.workspaceId); + + if (error) { + console.error('[notice action] 공지 삭제 실패:', error); + throwNoticeActionError('공지 삭제에 실패했습니다. 잠시 후 다시 시도해주세요.'); + } + + revalidateNoticePages(value.workspaceId); + return { ok: true, data: undefined }; + } catch (error) { + return toActionFailure(error, '공지 삭제에 실패했습니다. 잠시 후 다시 시도해주세요.'); + } +} + +export async function setNoticePinned(input: { + workspaceId: string; + noticeId: string; + isPinned: boolean; +}): Promise> { + try { + const value = z + .object({ workspaceId: uuidSchema, noticeId: uuidSchema, isPinned: z.boolean() }) + .parse(input); + const { supabase, member } = await getCurrentWorkspaceMember(value.workspaceId); + + if (member.role !== 'owner') { + throwNoticeActionError('워크스페이스 소유자만 공지를 고정할 수 있습니다.'); + } + + const { error } = await supabase + .from('announcements') + .update({ is_pinned: value.isPinned }) + .eq('id', value.noticeId) + .eq('workspace_id', value.workspaceId); + + if (error) { + console.error('[notice action] 공지 고정 상태 변경 실패:', error); + throwNoticeActionError('공지 고정 상태 변경에 실패했습니다. 잠시 후 다시 시도해주세요.'); + } + + revalidateNoticePages(value.workspaceId); + return { ok: true, data: undefined }; + } catch (error) { + return toActionFailure(error, '공지 고정 상태 변경에 실패했습니다. 잠시 후 다시 시도해주세요.'); + } +} diff --git a/src/entities/notice/index.ts b/src/entities/notice/index.ts index 45dbc7d..2f8aab9 100644 --- a/src/entities/notice/index.ts +++ b/src/entities/notice/index.ts @@ -1,3 +1,2 @@ -// 공지 도메인의 타입과 목업 데이터 공개 API입니다. -export type { Notice, NoticeFormValues } from './model/notice.types'; -export { mockNotices } from './model/mock-notices'; +// 공지 도메인이 외부 레이어에 제공하는 타입 공개 API입니다. +export type { Notice, NoticeBoardData, NoticeFormValues, NoticeViewer } from './model/notice.types'; diff --git a/src/entities/notice/model/mock-notices.ts b/src/entities/notice/model/mock-notices.ts deleted file mode 100644 index 94c5466..0000000 --- a/src/entities/notice/model/mock-notices.ts +++ /dev/null @@ -1,44 +0,0 @@ -import type { Notice } from './notice.types'; - -export const mockNotices: Notice[] = [ - { - id: 'notice-1', - workspaceId: 'test', - title: '7월 신메뉴 출시 안내', - authorName: '김민서', - createdAt: '2025-06-28', - isPinned: true, - content: - "7월 1일부터 여름 한정 '망고 라떼'와 '피치 에이드'가 출시됩니다. 레시피 숙지 부탁드립니다.", - }, - { - id: 'notice-2', - workspaceId: 'test', - title: '주간 청소 구역 배정', - authorName: '이준혁', - createdAt: '2025-06-26', - isPinned: false, - content: - '이번 주 청소 구역 배정표를 확인해주세요. 마감 담당자는 냉장고 하단과 픽업대 주변을 추가로 점검해 주세요.', - }, - { - id: 'notice-3', - workspaceId: 'test', - title: '유니폼 교체 안내', - authorName: '김민서', - createdAt: '2025-06-24', - isPinned: false, - content: - '신규 유니폼이 입고되었습니다. 이번 주 출근 시 기존 유니폼을 반납하고 새 유니폼을 수령해 주세요.', - }, - { - id: 'notice-4', - workspaceId: 'test', - title: '카드 단말기 교체 완료', - authorName: '이준혁', - createdAt: '2025-06-22', - isPinned: false, - content: - '카드 단말기 교체가 완료되었습니다. 결제 오류가 반복되면 매니저에게 바로 공유해 주세요.', - }, -]; diff --git a/src/entities/notice/model/notice-query.ts b/src/entities/notice/model/notice-query.ts new file mode 100644 index 0000000..8cd54b3 --- /dev/null +++ b/src/entities/notice/model/notice-query.ts @@ -0,0 +1,2 @@ +// 공지 화면과 대시보드 위젯이 동일한 서버 데이터를 공유하기 위한 TanStack Query 키입니다. +export const noticeBoardQueryKey = (workspaceId: string) => ['notice-board', workspaceId] as const; diff --git a/src/entities/notice/model/notice.types.ts b/src/entities/notice/model/notice.types.ts index 8fd25b8..8847355 100644 --- a/src/entities/notice/model/notice.types.ts +++ b/src/entities/notice/model/notice.types.ts @@ -1,7 +1,8 @@ -// Supabase 공지 테이블 연결 전까지 화면 상태와 목업 데이터에서 공유하는 공지 형태입니다. +// 공지 화면과 대시보드 위젯이 공통으로 사용하는 공지 조회 형태입니다. export interface Notice { id: string; workspaceId: string; + authorId: string | null; title: string; content: string; authorName: string; @@ -13,3 +14,14 @@ export interface NoticeFormValues { title: string; content: string; } + +// 현재 사용자의 공지 권한을 UI에서 판단하기 위해 서버가 함께 내려주는 최소 멤버 정보입니다. +export interface NoticeViewer { + userId: string; + role: 'owner' | 'member'; +} + +export interface NoticeBoardData { + notices: Notice[]; + viewer: NoticeViewer | null; +} diff --git a/src/entities/work-schedule/api/ensure-weekly-work-schedule-entries.ts b/src/entities/work-schedule/api/ensure-weekly-work-schedule-entries.ts index 40cdfd8..1c680dc 100644 --- a/src/entities/work-schedule/api/ensure-weekly-work-schedule-entries.ts +++ b/src/entities/work-schedule/api/ensure-weekly-work-schedule-entries.ts @@ -6,32 +6,23 @@ import { getWorkDateByWeekday } from '../lib/work-date'; import { weekdays } from '../model/weekdays'; import { getWorkShiftTypesByWorkspaceId } from './get-work-shift-types-by-workspace-id'; import { getWorkspaceMembersByWorkspaceId } from '@/entities/workspace-member/api/get-workspace-members-by-id'; +import type { WorkShiftOption } from '../model/work-schedule.types'; export async function ensureWeeklyWorkScheduleEntries( workspaceId: string, weekStartDate: string, -): Promise { +): Promise { const [members, shifts] = await Promise.all([ getWorkspaceMembersByWorkspaceId(workspaceId), getWorkShiftTypesByWorkspaceId(workspaceId), ]); const defaultShift = getDefaultWorkShiftOption(shifts); - if (!defaultShift || members.length === 0) return; + if (!defaultShift || members.length === 0) return defaultShift ?? null; const supabase = await createSupabaseServerClient(); - const weekEndDate = getWorkDateByWeekday(weekStartDate, 'sunday'); - const { count, error: countError } = await supabase - .from('work_schedule_entries') - .select('id', { count: 'exact', head: true }) - .eq('workspace_id', workspaceId) - .gte('work_date', weekStartDate) - .lte('work_date', weekEndDate); - - if (countError) throw new Error(`근무 스케줄 수 조회에 실패했습니다: ${countError.message}`); - if (count === members.length * weekdays.length) return; - const createdBy = await getCurrentUserId(); + // 현재 멤버와 이번 주의 모든 조합을 멱등적으로 넣어 탈퇴 멤버의 기존 행 때문에 누락을 놓치지 않는다. const entries = members.flatMap((member) => weekdays.map((weekday) => ({ workspace_id: workspaceId, @@ -48,4 +39,6 @@ export async function ensureWeeklyWorkScheduleEntries( }); if (error) throw new Error(`기본 근무 스케줄 생성에 실패했습니다: ${error.message}`); + + return defaultShift; } diff --git a/src/entities/work-schedule/api/work-schedule-actions.ts b/src/entities/work-schedule/api/work-schedule-actions.ts index 5d49d72..5daf519 100644 --- a/src/entities/work-schedule/api/work-schedule-actions.ts +++ b/src/entities/work-schedule/api/work-schedule-actions.ts @@ -5,6 +5,7 @@ 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 { getCurrentWeekRange } from '../lib/work-date'; import type { WorkShiftColor, WorkShiftOption } from '../model/work-schedule.types'; const colorSchema = z.enum(['sky', 'violet', 'amber', 'slate', 'emerald', 'rose']); @@ -103,48 +104,38 @@ export async function saveWorkScheduleEntry(input: { revalidateWorkspace(value.workspaceId); } -export async function createWorkShiftType(workspaceId: string): Promise { +export async function createWorkShiftType(workspaceId: string): Promise<{ + shift: WorkShiftOption; + defaultShiftTypeId: string; +}> { const parsedWorkspaceId = uuidSchema.parse(workspaceId); const supabase = await createSupabaseServerClient(); - const { data: lastShift, error: sortOrderError } = await supabase - .from('work_shift_types') - .select('sort_order') - .eq('workspace_id', parsedWorkspaceId) - .order('sort_order', { ascending: false }) - .limit(1) - .maybeSingle(); + const { data, error } = await supabase.rpc('create_work_shift_type_and_ensure_weekly_entries', { + p_workspace_id: parsedWorkspaceId, + p_week_start_date: getCurrentWeekRange().startDate, + }); - if (sortOrderError) - throw new Error(`근무 유형 순서 조회에 실패했습니다: ${sortOrderError.message}`); + if (error) throw new Error(`근무 유형 추가에 실패했습니다: ${error.message}`); - const { data, error } = await supabase - .from('work_shift_types') - .insert({ - workspace_id: parsedWorkspaceId, - code: `custom-${crypto.randomUUID()}`, - name: '새 근무', - start_time: '09:00', - end_time: '18:00', - ends_next_day: false, - color: 'emerald', - is_off: false, - sort_order: (lastShift?.sort_order ?? -1) + 1, - }) - .select('id, code, name, start_time, end_time, ends_next_day, color, is_off') - .single(); + const createdShift = data?.[0]; + if (!createdShift) { + throw new Error('근무 유형 추가 결과를 확인하지 못했습니다.'); + } - if (error) throw new Error(`근무 유형 추가에 실패했습니다: ${error.message}`); revalidateWorkspace(parsedWorkspaceId); return { - id: data.id, - code: data.code, - name: data.name, - startTime: data.start_time?.slice(0, 5) ?? null, - endTime: data.end_time?.slice(0, 5) ?? null, - endsNextDay: data.ends_next_day, - color: data.color as WorkShiftColor, - isOff: data.is_off, + shift: { + id: createdShift.id, + code: createdShift.code, + name: createdShift.name, + startTime: createdShift.start_time?.slice(0, 5) ?? null, + endTime: createdShift.end_time?.slice(0, 5) ?? null, + endsNextDay: createdShift.ends_next_day, + color: createdShift.color as WorkShiftColor, + isOff: createdShift.is_off, + }, + defaultShiftTypeId: createdShift.default_shift_type_id, }; } diff --git a/src/entities/workspace-member/api/get-current-workspace-member.ts b/src/entities/workspace-member/api/get-current-workspace-member.ts new file mode 100644 index 0000000..fef811d --- /dev/null +++ b/src/entities/workspace-member/api/get-current-workspace-member.ts @@ -0,0 +1,60 @@ +// 현재 로그인한 사용자의 프로필과 워크스페이스 내 역할을 Shell 표시용으로 조회한다. +import { cache } from 'react'; +import { createSupabaseServerClient } from '@/shared/api/supabase/server'; +import type { WorkspaceMember } from '../model/workspace-member.types'; + +export const getCurrentWorkspaceMember = cache( + async (workspaceId: string): Promise => { + const supabase = await createSupabaseServerClient(); + const { + data: { user }, + error: userError, + } = await supabase.auth.getUser(); + + if (userError) { + throw new Error(`로그인 사용자 조회에 실패했습니다: ${userError.message}`); + } + + if (!user) { + return null; + } + + const { data: membership, error: membershipError } = await supabase + .from('workspace_members') + .select('workspace_id, user_id, workspace_nickname, role') + .eq('workspace_id', workspaceId) + .eq('user_id', user.id) + .maybeSingle(); + + if (membershipError) { + throw new Error(`현재 워크스페이스 멤버 조회에 실패했습니다: ${membershipError.message}`); + } + + if (!membership) { + return null; + } + + const { data: profile, error: profileError } = await supabase + .from('profiles') + .select('email, real_name') + .eq('id', user.id) + .maybeSingle(); + + if (profileError) { + throw new Error(`현재 사용자 프로필 조회에 실패했습니다: ${profileError.message}`); + } + + const displayName = + profile?.real_name || membership.workspace_nickname || user.email || '사용자'; + + return { + workspaceId: membership.workspace_id, + userId: membership.user_id, + workspaceNickname: membership.workspace_nickname || displayName, + avatarLabel: displayName.slice(0, 1), + email: profile?.email || user.email || '', + role: membership.role, + status: 'joined', + }; + }, +); diff --git a/src/entities/workspace/api/create-workspace.ts b/src/entities/workspace/api/create-workspace.ts index 8034b3f..ad86597 100644 --- a/src/entities/workspace/api/create-workspace.ts +++ b/src/entities/workspace/api/create-workspace.ts @@ -3,7 +3,6 @@ // 워크스페이스 생성 서버액션 — create_workspace RPC // (RPC가 workspaces + owner 멤버십 + purpose별 기본 모듈 + invite_code 생성을 한 트랜잭션으로 처리) import { createSupabaseServerClient } from '@/shared/api/supabase/server'; -import { DEV_USER_ID } from '@/shared/config/dev-user'; import { createWorkspaceInputSchema, type CreateWorkspaceInput, @@ -19,7 +18,6 @@ export async function createWorkspace(input: CreateWorkspaceInput): Promise<{ id const supabase = await createSupabaseServerClient(); const { data, error } = await supabase.rpc('create_workspace', { - p_user_id: DEV_USER_ID, p_name: parsed.data.name, p_purpose: toDbPurpose(parsed.data.purpose), ...(parsed.data.description ? { p_description: parsed.data.description } : {}), diff --git a/src/entities/workspace/api/get-my-workspaces.ts b/src/entities/workspace/api/get-my-workspaces.ts index 9b7b802..c8c429b 100644 --- a/src/entities/workspace/api/get-my-workspaces.ts +++ b/src/entities/workspace/api/get-my-workspaces.ts @@ -1,12 +1,24 @@ // 내 워크스페이스 목록 조회 — get_my_workspaces RPC (집계 포함 단일 쿼리, N+1 없음) import { getSupabaseBrowserClient } from '@/shared/api/supabase/client'; -import { DEV_USER_ID } from '@/shared/config/dev-user'; import { toUiPurpose } from '../model/purpose.mapper'; import type { WorkspaceSummary } from '../model/workspace.types'; export async function getMyWorkspaces(): Promise { const supabase = getSupabaseBrowserClient(); - const { data, error } = await supabase.rpc('get_my_workspaces', { p_user_id: DEV_USER_ID }); + const { + data: { user }, + error: userError, + } = await supabase.auth.getUser(); + + if (userError) { + throw new Error(`로그인 사용자 조회에 실패했습니다: ${userError.message}`); + } + + if (!user) { + return []; + } + + const { data, error } = await supabase.rpc('get_my_workspaces', { p_user_id: user.id }); if (error) { throw new Error(`워크스페이스 목록 조회에 실패했습니다: ${error.message}`); diff --git a/src/entities/workspace/api/use-my-workspaces.ts b/src/entities/workspace/api/use-my-workspaces.ts index 4ddba28..b8f505f 100644 --- a/src/entities/workspace/api/use-my-workspaces.ts +++ b/src/entities/workspace/api/use-my-workspaces.ts @@ -2,11 +2,10 @@ // 내 워크스페이스 목록 쿼리 훅 — GET은 tanstack-query 컨벤션 import { useQuery } from '@tanstack/react-query'; -import { DEV_USER_ID } from '@/shared/config/dev-user'; import { getMyWorkspaces } from './get-my-workspaces'; // 생성/수정 후 invalidateQueries({ queryKey: ['workspaces'] })로 무효화한다 -export const myWorkspacesQueryKey = ['workspaces', 'my', DEV_USER_ID] as const; +export const myWorkspacesQueryKey = ['workspaces', 'my'] as const; export function useMyWorkspaces() { return useQuery({ diff --git a/src/features/create-workspace/ui/CreateWorkspaceDialog.tsx b/src/features/create-workspace/ui/CreateWorkspaceDialog.tsx index 04ca715..ae1c14d 100644 --- a/src/features/create-workspace/ui/CreateWorkspaceDialog.tsx +++ b/src/features/create-workspace/ui/CreateWorkspaceDialog.tsx @@ -28,6 +28,12 @@ interface CreateWorkspaceDialogProps { const INPUT_CLASS = 'bg-brand-secondary text-brand-ink placeholder:text-brand-ink/50 h-11 w-full rounded-[18px] border-2 border-transparent px-4.5 text-sm transition-colors focus-visible:border-brand focus-visible:ring-0'; +const WORKSPACE_NAME_PLACEHOLDERS: Record = { + 'team-project': '예: 캡스톤 디자인 팀', + 'side-project': '예: Syncly 프로젝트 팀', + 'store-operation': '예: 카페 Syncly 운영', +}; + export default function CreateWorkspaceDialog({ purpose, open, @@ -124,7 +130,7 @@ export default function CreateWorkspaceDialog({ { - if (first.isPinned !== second.isPinned) { - return first.isPinned ? -1 : 1; - } - - return second.createdAt.localeCompare(first.createdAt); +export function useNoticeBoardState({ initialData, workspaceId }: UseNoticeBoardStateParams) { + const notifiedQueryError = useRef(null); + const { + data = initialData, + isPending, + isError, + isRefetchError, + error, + refetch, + } = useQuery({ + queryKey: noticeBoardQueryKey(workspaceId), + queryFn: () => getNoticeBoard(workspaceId), + initialData, }); -} + const [selectedNoticeId, setSelectedNoticeId] = useState( + () => initialData.notices[0]?.id ?? null, + ); + const [editingNoticeId, setEditingNoticeId] = useState(null); + const [isComposerOpen, setIsComposerOpen] = useState(false); -function createNoticeId() { - if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') { - return `notice-${crypto.randomUUID()}`; - } + const selectedNotice = + data.notices.find((notice) => notice.id === selectedNoticeId) ?? data.notices[0] ?? null; + const editingNotice = data.notices.find((notice) => notice.id === editingNoticeId) ?? null; - return `notice-${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; -} + useEffect(() => { + if (!error || (!isError && !isRefetchError)) { + notifiedQueryError.current = null; + return; + } -function createTodayLabel() { - return new Date().toISOString().slice(0, 10); -} + if (notifiedQueryError.current === error) return; -export function useNoticeBoardState({ - initialNotices, - workspaceId, - authorName, -}: UseNoticeBoardStateParams) { - const [notices, setNotices] = useState(() => sortNotices(initialNotices)); - const [selectedNoticeId, setSelectedNoticeId] = useState( - () => sortNotices(initialNotices)[0]?.id ?? null, - ); - const [editingNoticeId, setEditingNoticeId] = useState(null); - const [isComposerOpen, setIsComposerOpen] = useState(false); + notifiedQueryError.current = error; + toast.error('공지 목록을 새로고침하지 못했습니다. 잠시 후 다시 시도해주세요.'); + }, [error, isError, isRefetchError]); + + const refreshNoticeBoard = async () => { + await refetch(); + }; - const selectedNotice = notices.find((notice) => notice.id === selectedNoticeId) ?? null; - const editingNotice = notices.find((notice) => notice.id === editingNoticeId) ?? null; + const createMutation = useMutation({ mutationFn: createNotice }); + const updateMutation = useMutation({ mutationFn: updateNotice }); + const deleteMutation = useMutation({ mutationFn: deleteNotice }); + const pinMutation = useMutation({ mutationFn: setNoticePinned }); const openCreateComposer = () => { setEditingNoticeId(null); @@ -63,7 +80,7 @@ export function useNoticeBoardState({ setIsComposerOpen(false); }; - const submitNotice = (values: NoticeFormValues) => { + const submitNotice = async (values: NoticeFormValues) => { const trimmedTitle = values.title.trim(); const trimmedContent = values.content.trim(); @@ -71,61 +88,85 @@ export function useNoticeBoardState({ return; } - if (editingNoticeId) { - setNotices((currentNotices) => - currentNotices.map((notice) => - notice.id === editingNoticeId - ? { ...notice, title: trimmedTitle, content: trimmedContent } - : notice, - ), - ); - setSelectedNoticeId(editingNoticeId); + try { + if (editingNoticeId) { + const result = await updateMutation.mutateAsync({ + workspaceId, + noticeId: editingNoticeId, + title: trimmedTitle, + content: trimmedContent, + }); + if (!result.ok) { + toast.error(result.message); + return; + } + setSelectedNoticeId(editingNoticeId); + } else { + const result = await createMutation.mutateAsync({ + workspaceId, + title: trimmedTitle, + content: trimmedContent, + }); + if (!result.ok) { + toast.error(result.message); + return; + } + setSelectedNoticeId(result.data.id); + } + + await refreshNoticeBoard(); closeComposer(); - return; + } catch (error) { + toast.error(error instanceof Error ? error.message : '공지 저장에 실패했습니다.'); } - - const nextNotice: Notice = { - id: createNoticeId(), - workspaceId, - title: trimmedTitle, - content: trimmedContent, - authorName, - createdAt: createTodayLabel(), - isPinned: false, - }; - - setNotices((currentNotices) => sortNotices([nextNotice, ...currentNotices])); - setSelectedNoticeId(nextNotice.id); - closeComposer(); }; - const deleteNotice = (noticeId: string) => { - const nextNotices = sortNotices(notices.filter((notice) => notice.id !== noticeId)); - - setNotices(nextNotices); - - if (selectedNoticeId === noticeId) { - setSelectedNoticeId(nextNotices[0]?.id ?? null); - } - - if (editingNoticeId === noticeId) { - closeComposer(); + const removeNotice = async (noticeId: string) => { + try { + const result = await deleteMutation.mutateAsync({ workspaceId, noticeId }); + if (!result.ok) { + toast.error(result.message); + return; + } + await refreshNoticeBoard(); + + if (selectedNoticeId === noticeId) { + setSelectedNoticeId(null); + } + + if (editingNoticeId === noticeId) { + closeComposer(); + } + } catch (error) { + toast.error(error instanceof Error ? error.message : '공지 삭제에 실패했습니다.'); } }; - const togglePinned = (noticeId: string) => { - setNotices((currentNotices) => - sortNotices( - currentNotices.map((notice) => - notice.id === noticeId ? { ...notice, isPinned: !notice.isPinned } : notice, - ), - ), - ); - setSelectedNoticeId(noticeId); + const togglePinned = async (noticeId: string) => { + const notice = data.notices.find((item) => item.id === noticeId); + + if (!notice) return; + + try { + const result = await pinMutation.mutateAsync({ + workspaceId, + noticeId, + isPinned: !notice.isPinned, + }); + if (!result.ok) { + toast.error(result.message); + return; + } + await refreshNoticeBoard(); + setSelectedNoticeId(noticeId); + } catch (error) { + toast.error(error instanceof Error ? error.message : '공지 고정 상태 변경에 실패했습니다.'); + } }; return { - notices, + notices: data.notices, + viewer: data.viewer, selectedNotice, editingNotice, isComposerOpen, @@ -134,7 +175,13 @@ export function useNoticeBoardState({ closeComposer, selectNotice: setSelectedNoticeId, submitNotice, - deleteNotice, + deleteNotice: removeNotice, togglePinned, + isPending, + isSaving: + createMutation.isPending || + updateMutation.isPending || + deleteMutation.isPending || + pinMutation.isPending, }; } diff --git a/src/features/manage-notices/ui/NoticeComposer.tsx b/src/features/manage-notices/ui/NoticeComposer.tsx index ad19f1d..0cc0e30 100644 --- a/src/features/manage-notices/ui/NoticeComposer.tsx +++ b/src/features/manage-notices/ui/NoticeComposer.tsx @@ -5,22 +5,25 @@ import type { Notice, NoticeFormValues } from '@/entities/notice'; interface NoticeComposerProps { editingNotice: Notice | null; - onSubmit: (values: NoticeFormValues) => void; + onSubmit: (values: NoticeFormValues) => Promise; onCancel: () => void; } export function NoticeComposer({ editingNotice, onSubmit, onCancel }: NoticeComposerProps) { const [title, setTitle] = useState(editingNotice?.title ?? ''); const [content, setContent] = useState(editingNotice?.content ?? ''); + const [isSubmitting, setIsSubmitting] = useState(false); const canSubmit = title.trim().length > 0 && content.trim().length > 0; return (
{ + onSubmit={async (event) => { event.preventDefault(); - onSubmit({ title, content }); + setIsSubmitting(true); + await onSubmit({ title, content }); + setIsSubmitting(false); }} >

@@ -53,14 +56,15 @@ export function NoticeComposer({ editingNotice, onSubmit, onCancel }: NoticeComp
+ {canManage ? ( +
+ - {isMenuOpen ? ( -
- - - -
- ) : null} -
+ {isMenuOpen ? ( +
+ {canEdit ? ( + + ) : null} + {canPin ? ( + + ) : null} + {canEdit ? ( + + ) : null} +
+ ) : null} +
+ ) : null} ); })} diff --git a/src/features/manage-work-schedule/model/use-work-schedule-state.ts b/src/features/manage-work-schedule/model/use-work-schedule-state.ts index 1ea5e56..958e142 100644 --- a/src/features/manage-work-schedule/model/use-work-schedule-state.ts +++ b/src/features/manage-work-schedule/model/use-work-schedule-state.ts @@ -50,7 +50,7 @@ function completeScheduleEntries({ } export function useWorkScheduleState(params: UseWorkScheduleStateParams) { - const { config } = params; + const { config, members, weekStartDate } = params; const [schedule, setSchedule] = useState(() => completeScheduleEntries(params)); const cycleCell = (userId: string, weekday: WeekdayKey): WorkScheduleEntry | null => { @@ -77,6 +77,29 @@ export function useWorkScheduleState(params: UseWorkScheduleStateParams) { return nextEntry; }; + const completeMissingEntries = (shiftTypeId: string): void => { + setSchedule((current) => { + const existingEntries = new Set(current.map((entry) => `${entry.userId}:${entry.weekday}`)); + + return [ + ...current, + ...members.flatMap((member) => + weekdays.flatMap((weekday) => { + if (existingEntries.has(`${member.userId}:${weekday.key}`)) return []; + + return { + workspaceId: member.workspaceId, + userId: member.userId, + weekday: weekday.key, + workDate: getWorkDateByWeekday(weekStartDate, weekday.key), + shiftTypeId, + }; + }), + ), + ]; + }); + }; + const replaceShiftOption = (fromShiftTypeId: string, toShiftTypeId: string): void => { setSchedule((current) => { return current.map((entry) => @@ -88,6 +111,7 @@ export function useWorkScheduleState(params: UseWorkScheduleStateParams) { return { schedule, cycleCell, + completeMissingEntries, replaceShiftOption, }; } diff --git a/src/features/manage-work-schedule/ui/WorkScheduleBoard.tsx b/src/features/manage-work-schedule/ui/WorkScheduleBoard.tsx index cc2e958..b218a00 100644 --- a/src/features/manage-work-schedule/ui/WorkScheduleBoard.tsx +++ b/src/features/manage-work-schedule/ui/WorkScheduleBoard.tsx @@ -9,6 +9,7 @@ import { weekdays, type WorkScheduleConfig, type WorkScheduleEntry, + type WorkShiftOption, } from '@/entities/work-schedule'; import { createWorkShiftType, @@ -39,6 +40,14 @@ interface WorkScheduleBoardProps { weekStartDate: string; } +function canPersistShift(shift: WorkShiftOption): boolean { + if (shift.isOff) return true; + + if (!shift.startTime || !shift.endTime) return false; + + return shift.endsNextDay || shift.endTime > shift.startTime; +} + export function WorkScheduleBoard({ workspaceId, members, @@ -50,7 +59,7 @@ export function WorkScheduleBoard({ const [isSettingsOpen, setIsSettingsOpen] = useState(false); const [shiftToDeleteId, setShiftToDeleteId] = useState(null); const [replacementShiftId, setReplacementShiftId] = useState(''); - const { schedule, cycleCell, replaceShiftOption } = useWorkScheduleState({ + const { schedule, cycleCell, completeMissingEntries, replaceShiftOption } = useWorkScheduleState({ initialSchedule, members, config: scheduleConfig, @@ -59,8 +68,9 @@ export function WorkScheduleBoard({ const handleAddShift = async (): Promise => { try { - const newShift = await createWorkShiftType(workspaceId); - setScheduleConfig((current) => ({ shifts: [...current.shifts, newShift] })); + const { shift, defaultShiftTypeId } = await createWorkShiftType(workspaceId); + setScheduleConfig((current) => ({ shifts: [...current.shifts, shift] })); + completeMissingEntries(defaultShiftTypeId); } catch (error) { console.error(error); toast.error('근무 유형을 추가하지 못했습니다.'); @@ -78,7 +88,8 @@ export function WorkScheduleBoard({ const handleCommitShift = async (shiftId: string): Promise => { const shift = scheduleConfig.shifts.find((item) => item.id === shiftId); - if (!shift) return; + // 시작·종료 시간을 순서대로 고치는 동안의 임시 시간값은 저장하지 않는다. + if (!shift || !canPersistShift(shift)) return; try { await updateWorkShiftType({ workspaceId, ...shift }); diff --git a/src/shared/model/database.types.ts b/src/shared/model/database.types.ts index 8acb007..cad65e8 100644 --- a/src/shared/model/database.types.ts +++ b/src/shared/model/database.types.ts @@ -776,15 +776,36 @@ export type Database = { [_ in never]: never } Functions: { + create_work_shift_type_and_ensure_weekly_entries: { + Args: { p_week_start_date: string; p_workspace_id: string } + Returns: { + code: string + color: string + default_shift_type_id: string + end_time: string + ends_next_day: boolean + id: string + is_off: boolean + name: string + start_time: string + }[] + } create_workspace: { Args: { p_description?: string p_name: string p_purpose: Database["public"]["Enums"]["workspace_purpose"] - p_user_id: string } Returns: string } + get_invite_preview: { + Args: { p_code: string } + Returns: { + member_count: number + name: string + workspace_id: string + }[] + } get_my_workspaces: { Args: { p_user_id: string } Returns: { @@ -811,6 +832,10 @@ export type Database = { workspace_id: string }[] } + join_workspace_by_invite_code: { + Args: { p_code: string; p_user_id: string } + Returns: string + } replace_and_delete_work_shift_type: { Args: { p_deleted_shift_type_id: string diff --git a/src/views/dashboard/config/widget-catalog.tsx b/src/views/dashboard/config/widget-catalog.tsx index 61690b7..2efb5a9 100644 --- a/src/views/dashboard/config/widget-catalog.tsx +++ b/src/views/dashboard/config/widget-catalog.tsx @@ -46,7 +46,7 @@ export const WIDGET_CATALOG = { 'recent-notices': { layout: { i: 'recent-notices', x: 6, y: 5, w: 6, h: 5, minW: 2, minH: 3 }, title: '최근 공지', - render: (size) => , + render: (size, { workspaceId }) => , }, 'recent-resources': { layout: { i: 'recent-resources', x: 0, y: 10, w: 6, h: 5, minW: 2, minH: 3 }, diff --git a/src/views/store-operation/notices/ui/NoticesView.tsx b/src/views/store-operation/notices/ui/NoticesView.tsx index d2b12b9..8698f4c 100644 --- a/src/views/store-operation/notices/ui/NoticesView.tsx +++ b/src/views/store-operation/notices/ui/NoticesView.tsx @@ -1,8 +1,7 @@ 'use client'; import { Plus } from 'lucide-react'; -import { mockNotices } from '@/entities/notice'; -import { mockCurrentWorkspaceMember } from '@/entities/workspace-member'; +import type { NoticeBoardData } from '@/entities/notice'; import { NoticeComposer, NoticeDetailPanel, @@ -12,9 +11,10 @@ import { interface NoticesViewProps { workspaceId: string; + initialData: NoticeBoardData; } -export function NoticesView({ workspaceId }: NoticesViewProps) { +export function NoticesView({ workspaceId, initialData }: NoticesViewProps) { const { notices, selectedNotice, @@ -27,10 +27,11 @@ export function NoticesView({ workspaceId }: NoticesViewProps) { submitNotice, deleteNotice, togglePinned, + viewer, + isSaving, } = useNoticeBoardState({ - initialNotices: mockNotices, + initialData, workspaceId, - authorName: mockCurrentWorkspaceMember.workspaceNickname, }); return ( @@ -65,6 +66,8 @@ export function NoticesView({ workspaceId }: NoticesViewProps) { onEditNotice={openEditComposer} onDeleteNotice={deleteNotice} onTogglePinned={togglePinned} + viewer={viewer} + isSaving={isSaving} /> diff --git a/src/widgets/store-operation/dashboard-recent-notices/ui/RecentNotices.tsx b/src/widgets/store-operation/dashboard-recent-notices/ui/RecentNotices.tsx index 0924b69..e411a2b 100644 --- a/src/widgets/store-operation/dashboard-recent-notices/ui/RecentNotices.tsx +++ b/src/widgets/store-operation/dashboard-recent-notices/ui/RecentNotices.tsx @@ -3,18 +3,15 @@ // · md: 리스트(제목 + 작성자·작성일) // · lg: 총 개수 + 리스트(제목 + 본문 미리보기 + 작성자·작성일) import { Bell, Pin } from 'lucide-react'; +import { useQuery } from '@tanstack/react-query'; -import { mockNotices, type Notice } from '@/entities/notice'; +import { getNoticeBoard } from '@/entities/notice/api/get-notice-board'; +import { noticeBoardQueryKey } from '@/entities/notice/model/notice-query'; +import type { Notice } from '@/entities/notice'; import type { WidgetSize } from '@/shared/dashboard/lib/widget-size'; import { WidgetCard, WidgetCardAction, WidgetCardHeader } from '@/shared/dashboard/ui/widget-card'; import { cn } from '@/shared/lib/utils'; -// 고정 공지 우선 → 작성일(내림차순) 정렬. 원본 배열을 변형하지 않도록 복사 후 정렬한다. -const sortedNotices = [...mockNotices].sort((a, b) => { - if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1; - return b.createdAt.localeCompare(a.createdAt); -}); - const header = ( 전체 보기} /> ); @@ -36,9 +33,54 @@ function noticeMeta(notice: Notice) { return `${notice.authorName} · ${notice.createdAt}`; } -export default function RecentNotices({ size = 'md' }: { size?: WidgetSize }) { +interface RecentNoticesProps { + workspaceId: string; + size?: WidgetSize; +} + +export default function RecentNotices({ workspaceId, size = 'md' }: RecentNoticesProps) { + const { data, isError, isPending } = useQuery({ + queryKey: noticeBoardQueryKey(workspaceId), + queryFn: () => getNoticeBoard(workspaceId), + }); + + if (isError) { + return ( + + {header} +
+ 최근 공지를 불러오지 못했습니다. +
+
+ ); + } + + if (isPending || !data) { + return ( + + {header} +
+ 최근 공지를 불러오는 중입니다. +
+
+ ); + } + + const notices = data.notices; + + if (notices.length === 0) { + return ( + + {header} +
+ 등록된 공지가 없습니다. +
+
+ ); + } + if (size === 'sm') { - const latest = sortedNotices[0]; + const latest = notices[0]; return ( {header} @@ -54,9 +96,9 @@ export default function RecentNotices({ size = 'md' }: { size?: WidgetSize }) { return ( {header} -

총 {sortedNotices.length}개의 공지

+

총 {notices.length}개의 공지

    - {sortedNotices.map((notice) => ( + {notices.map((notice) => (
  • @@ -76,7 +118,7 @@ export default function RecentNotices({ size = 'md' }: { size?: WidgetSize }) { {header}
      - {sortedNotices.map((notice) => ( + {notices.map((notice) => (
    • diff --git a/src/widgets/workspace-shell/ui/WorkspaceHeader.tsx b/src/widgets/workspace-shell/ui/WorkspaceHeader.tsx index 8903d3b..dbb17b6 100644 --- a/src/widgets/workspace-shell/ui/WorkspaceHeader.tsx +++ b/src/widgets/workspace-shell/ui/WorkspaceHeader.tsx @@ -3,11 +3,12 @@ // 하나의 워크스페이스 내부 페이지에서 공통으로 사용하는 상단 헤더입니다. import { Bell, Search, UserRoundPlus } from 'lucide-react'; import { usePathname } from 'next/navigation'; -import { mockCurrentWorkspaceMember } from '@/entities/workspace-member'; +import type { WorkspaceMember } from '@/entities/workspace-member'; import type { WorkspaceNavigationItem } from '../model/workspace-navigation'; interface WorkspaceHeaderProps { navigationItems: WorkspaceNavigationItem[]; + currentMember: WorkspaceMember; } function getCurrentPageTitle(pathname: string, navigationItems: WorkspaceNavigationItem[]): string { @@ -16,7 +17,7 @@ function getCurrentPageTitle(pathname: string, navigationItems: WorkspaceNavigat return currentNavigationItem?.label ?? '대시보드'; } -export function WorkspaceHeader({ navigationItems }: WorkspaceHeaderProps) { +export function WorkspaceHeader({ navigationItems, currentMember }: WorkspaceHeaderProps) { const pathname = usePathname(); const title = getCurrentPageTitle(pathname, navigationItems); @@ -52,7 +53,7 @@ export function WorkspaceHeader({ navigationItems }: WorkspaceHeaderProps) {
      - {mockCurrentWorkspaceMember.avatarLabel} + {currentMember.avatarLabel}
      diff --git a/src/widgets/workspace-shell/ui/WorkspaceShell.tsx b/src/widgets/workspace-shell/ui/WorkspaceShell.tsx index 5ae01ff..d5e4f1b 100644 --- a/src/widgets/workspace-shell/ui/WorkspaceShell.tsx +++ b/src/widgets/workspace-shell/ui/WorkspaceShell.tsx @@ -3,6 +3,7 @@ // 공통 워크스페이스 사이드바, 헤더, 페이지 콘텐츠 프레임을 조합합니다. import { useState } from 'react'; import type { Workspace } from '@/entities/workspace'; +import type { WorkspaceMember } from '@/entities/workspace-member'; import { WorkspaceHeader } from './WorkspaceHeader'; import { WorkspaceSidebar } from './WorkspaceSidebar'; import { getWorkspaceNavigation } from '@/widgets/workspace-shell/lib/get-workspace-navigation'; @@ -10,10 +11,16 @@ import { getWorkspaceNavigation } from '@/widgets/workspace-shell/lib/get-worksp interface WorkspaceShellProps { workspace: Workspace; workspaceId: string; + currentMember: WorkspaceMember; children: React.ReactNode; } -export function WorkspaceShell({ workspace, workspaceId, children }: WorkspaceShellProps) { +export function WorkspaceShell({ + workspace, + workspaceId, + currentMember, + children, +}: WorkspaceShellProps) { const [isSidebarCollapsed, setIsSidebarCollapsed] = useState(false); const navigationItems = getWorkspaceNavigation(workspace.purpose); @@ -25,10 +32,11 @@ export function WorkspaceShell({ workspace, workspaceId, children }: WorkspaceSh isCollapsed={isSidebarCollapsed} navigationItems={navigationItems} onToggleCollapsed={() => setIsSidebarCollapsed((current) => !current)} + currentMember={currentMember} />
      - +
      {children}
    diff --git a/src/widgets/workspace-shell/ui/WorkspaceSidebar.tsx b/src/widgets/workspace-shell/ui/WorkspaceSidebar.tsx index 626031b..0653944 100644 --- a/src/widgets/workspace-shell/ui/WorkspaceSidebar.tsx +++ b/src/widgets/workspace-shell/ui/WorkspaceSidebar.tsx @@ -3,16 +3,17 @@ // 워크스페이스 페이지에서 공통으로 사용하는 좌측 사이드바입니다. import Image from 'next/image'; import Link from 'next/link'; -import { ChevronRight, LogOut, Menu, Store } from 'lucide-react'; +import { ChevronRight, LogOut, Menu } from 'lucide-react'; import { usePathname } from 'next/navigation'; -import type { Workspace } from '@/entities/workspace'; -import { mockCurrentWorkspaceMember } from '@/entities/workspace-member'; +import { WORKSPACE_PURPOSE_META, type Workspace } from '@/entities/workspace'; +import type { WorkspaceMember } from '@/entities/workspace-member'; import { cn } from '@/shared/lib/utils'; import type { WorkspaceNavigationItem } from '../model/workspace-navigation'; interface WorkspaceSidebarProps { workspace: Workspace; workspaceId: string; + currentMember: WorkspaceMember; isCollapsed: boolean; navigationItems: WorkspaceNavigationItem[]; onToggleCollapsed: () => void; @@ -21,11 +22,14 @@ interface WorkspaceSidebarProps { export function WorkspaceSidebar({ workspace, workspaceId, + currentMember, isCollapsed, navigationItems, onToggleCollapsed, }: WorkspaceSidebarProps) { const pathname = usePathname(); + const purposeMeta = WORKSPACE_PURPOSE_META[workspace.purpose]; + const PurposeIcon = purposeMeta.icon; return (