diff --git a/src/app/workspaces/[workspaceId]/dashboard/page.tsx b/src/app/workspaces/[workspaceId]/dashboard/page.tsx index 3332107..3ec3d1f 100644 --- a/src/app/workspaces/[workspaceId]/dashboard/page.tsx +++ b/src/app/workspaces/[workspaceId]/dashboard/page.tsx @@ -1,9 +1,10 @@ // 워크스페이스 대시보드 라우트 — 레이아웃은 서버(RSC)에서 조회해 initialLayout으로 주입한다. -// (레이아웃 조회 키: user_id + workspace_id + page_type / 저장분 없으면 빈 대시보드로 시작) +// (레이아웃 조회 키: user_id + workspace_id / 저장분 없으면 빈 대시보드로 시작) // purpose는 추가 가능한 위젯을 템플릿별로 거르는 데만 쓰인다. -import { getDashboardLayout } from '@/entities/dashboard-layout'; +import { getDashboardLayout } from '@/entities/dashboard-layout/api/get-dashboard-layout'; import { DashboardView } from '@/views/dashboard'; -import { getMockWorkspaceById } from '@/entities/workspace'; +import { notFound } from 'next/navigation'; +import { getWorkspaceById } from '@/entities/workspace/api/get-workspace-by-id'; interface DashboardPageProps { params: Promise<{ workspaceId: string }>; @@ -12,7 +13,11 @@ interface DashboardPageProps { export default async function DashboardPage({ params }: DashboardPageProps) { const { workspaceId } = await params; - const workspace = getMockWorkspaceById(workspaceId)!; + const workspace = await getWorkspaceById(workspaceId); + + if (!workspace) { + notFound(); + } const initialLayout = await getDashboardLayout(workspaceId, 'dashboard'); return ( diff --git a/src/app/workspaces/[workspaceId]/layout.tsx b/src/app/workspaces/[workspaceId]/layout.tsx index bd7f5fb..01838a1 100644 --- a/src/app/workspaces/[workspaceId]/layout.tsx +++ b/src/app/workspaces/[workspaceId]/layout.tsx @@ -1,6 +1,6 @@ // 워크스페이스 공통 사이드바와 헤더를 적용하는 라우트 레이아웃입니다. import { notFound } from 'next/navigation'; -import { getMockWorkspaceById } from '@/entities/workspace'; +import { getWorkspaceById } from '@/entities/workspace/api/get-workspace-by-id'; import { WorkspaceShell } from '@/widgets/workspace-shell'; interface WorkspaceLayoutProps { @@ -12,7 +12,7 @@ interface WorkspaceLayoutProps { export default async function WorkspaceLayout({ children, params }: WorkspaceLayoutProps) { const { workspaceId } = await params; - const workspace = getMockWorkspaceById(workspaceId); + const workspace = await getWorkspaceById(workspaceId); if (!workspace) { notFound(); diff --git a/src/app/workspaces/[workspaceId]/page.tsx b/src/app/workspaces/[workspaceId]/page.tsx index 770de6c..a148163 100644 --- a/src/app/workspaces/[workspaceId]/page.tsx +++ b/src/app/workspaces/[workspaceId]/page.tsx @@ -1,5 +1,5 @@ import { notFound, redirect } from 'next/navigation'; -import { getMockWorkspaceById } from '@/entities/workspace'; +import { getWorkspaceById } from '@/entities/workspace/api/get-workspace-by-id'; interface WorkspaceHomePageProps { params: Promise<{ @@ -9,7 +9,7 @@ interface WorkspaceHomePageProps { export default async function WorkspaceHomePage({ params }: WorkspaceHomePageProps) { const { workspaceId } = await params; - const workspace = getMockWorkspaceById(workspaceId); + const workspace = await getWorkspaceById(workspaceId); if (!workspace) { notFound(); diff --git a/src/app/workspaces/[workspaceId]/settings/page.tsx b/src/app/workspaces/[workspaceId]/settings/page.tsx index 99658a4..f9afe8e 100644 --- a/src/app/workspaces/[workspaceId]/settings/page.tsx +++ b/src/app/workspaces/[workspaceId]/settings/page.tsx @@ -1,7 +1,7 @@ // 설정 페이지 라우트 — 활성 탭을 searchParam(?tab=)으로 읽고, 표시에 필요한 데이터를 RSC에서 조회해 주입한다. // 실 API 전환 시 아래 조회부만 async(Supabase)로 교체하면 되고, 하위 뷰/훅은 그대로 둔다. import { notFound } from 'next/navigation'; -import { getMockWorkspaceById } from '@/entities/workspace'; +import { getWorkspaceById } from '@/entities/workspace/api/get-workspace-by-id'; import { getMockWorkspaceMembersByWorkspaceId, mockCurrentWorkspaceMember, @@ -23,7 +23,7 @@ export default async function WorkspaceSettingsPage({ }: WorkspaceSettingsPageProps) { const { workspaceId } = await params; const { tab } = await searchParams; - const workspace = getMockWorkspaceById(workspaceId); + const workspace = await getWorkspaceById(workspaceId); if (!workspace) { notFound(); diff --git a/src/app/workspaces/[workspaceId]/work-schedule/page.tsx b/src/app/workspaces/[workspaceId]/work-schedule/page.tsx index d2710c6..ba87945 100644 --- a/src/app/workspaces/[workspaceId]/work-schedule/page.tsx +++ b/src/app/workspaces/[workspaceId]/work-schedule/page.tsx @@ -1,4 +1,9 @@ -// 워크스페이스 근무 일정 페이지의 라우트 진입점입니다. +// 현재 주의 멤버, 근무유형, 스케줄 데이터를 병렬 조회해 근무 스케줄 화면에 전달하는 서버 페이지입니다. +import { getWorkspaceMembersByWorkspaceId } from '@/entities/workspace-member/api/get-workspace-members-by-id'; +import { getWorkScheduleEntriesByWeek } from '@/entities/work-schedule/api/get-work-schedule-entries-by-week'; +import { getWorkShiftTypesByWorkspaceId } from '@/entities/work-schedule/api/get-work-shift-types-by-workspace-id'; +import { ensureWeeklyWorkScheduleEntries } from '@/entities/work-schedule/api/ensure-weekly-work-schedule-entries'; +import { getCurrentWeekRange } from '@/entities/work-schedule'; import { WorkScheduleView } from '@/views/store-operation/work-schedule'; interface WorkSchedulePageProps { @@ -9,6 +14,21 @@ interface WorkSchedulePageProps { export default async function WorkSchedulePage({ params }: WorkSchedulePageProps) { const { workspaceId } = await params; + const { startDate, endDate } = getCurrentWeekRange(); + await ensureWeeklyWorkScheduleEntries(workspaceId, startDate); + const [members, shifts, schedule] = await Promise.all([ + getWorkspaceMembersByWorkspaceId(workspaceId), + getWorkShiftTypesByWorkspaceId(workspaceId), + getWorkScheduleEntriesByWeek(workspaceId, startDate, endDate), + ]); - return ; + return ( + + ); } diff --git a/src/entities/dashboard-layout/api/get-dashboard-layout.ts b/src/entities/dashboard-layout/api/get-dashboard-layout.ts index d204502..5d15f85 100644 --- a/src/entities/dashboard-layout/api/get-dashboard-layout.ts +++ b/src/entities/dashboard-layout/api/get-dashboard-layout.ts @@ -1,18 +1,44 @@ -// 대시보드 레이아웃 조회 — DB 연동 자리. -// 저장분이 없으면(신규) 빈 레이아웃으로 시작한다 — 템플릿 기반 기본값/폴백은 두지 않는다. +// 현재 사용자의 워크스페이스별 대시보드 레이아웃을 조회하고, 저장값이 없으면 빈 레이아웃을 반환합니다. +import { getCurrentUserId } from '@/shared/api/supabase/current-user'; +import { createSupabaseServerClient } from '@/shared/api/supabase/server'; +import type { Layout, LayoutItem } from 'react-grid-layout'; import type { DashboardLayoutState } from '../model/dashboard-layout.types'; -// TODO: DB 연동 — WORKSPACE_LAYOUTS에서 (workspace_id, user_id(세션), page_type) 기준 select +function isLayoutItem(value: unknown): value is LayoutItem { + if (!value || typeof value !== 'object') return false; + + const item = value as Record; + return ( + typeof item.i === 'string' && + typeof item.x === 'number' && + typeof item.y === 'number' && + typeof item.w === 'number' && + typeof item.h === 'number' + ); +} + +function toDashboardLayout(value: unknown): Layout { + return Array.isArray(value) && value.every(isLayoutItem) ? value : []; +} + export async function getDashboardLayout( workspaceId: string, pageType: string, ): Promise { - void workspaceId; + const supabase = await createSupabaseServerClient(); + const userId = await getCurrentUserId(); + const { data, error } = await supabase + .from('user_dashboard_layouts') + .select('layout') + .eq('workspace_id', workspaceId) + .eq('user_id', userId) + .maybeSingle(); + + if (error) throw new Error(`대시보드 레이아웃 조회에 실패했습니다: ${error.message}`); + void pageType; - // 임시 목 저장분 — DB의 layout jsonb를 흉내낸다. 위치(i,x,y,w,h)만 담고, - // 제약(minW/minH)은 저장하지 않는다(렌더 시 카탈로그에서 머지됨). return { - layout: [], + layout: toDashboardLayout(data?.layout), }; } diff --git a/src/entities/dashboard-layout/api/save-dashboard-layout.ts b/src/entities/dashboard-layout/api/save-dashboard-layout.ts index e411796..bc268c8 100644 --- a/src/entities/dashboard-layout/api/save-dashboard-layout.ts +++ b/src/entities/dashboard-layout/api/save-dashboard-layout.ts @@ -1,16 +1,31 @@ -// 대시보드 레이아웃 저장 — DB 연동 자리(서버액션). -// layout jsonb 한 행 = DashboardLayoutState 통째. 드래그 중 잦은 호출은 debounce 필요. +// 현재 사용자의 워크스페이스별 대시보드 레이아웃을 JSONB 한 행으로 upsert하는 서버 액션입니다. 'use server'; +import { getCurrentUserId } from '@/shared/api/supabase/current-user'; +import { createSupabaseServerClient } from '@/shared/api/supabase/server'; +import type { LayoutItem } from 'react-grid-layout'; import type { DashboardLayoutState } from '../model/dashboard-layout.types'; -// TODO: DB 연동 — WORKSPACE_LAYOUTS upsert (workspace_id, user_id(세션), page_type, layout) +function toStoredLayout(layout: DashboardLayoutState['layout']) { + return layout.map(({ i, x, y, w, h }: LayoutItem) => ({ i, x, y, w, h })); +} + export async function saveDashboardLayout( workspaceId: string, pageType: string, state: DashboardLayoutState, ): Promise { - void workspaceId; + const supabase = await createSupabaseServerClient(); + const userId = await getCurrentUserId(); + const { error } = await supabase.from('user_dashboard_layouts').upsert( + { + user_id: userId, + workspace_id: workspaceId, + layout: toStoredLayout(state.layout), + }, + { onConflict: 'user_id,workspace_id' }, + ); + + if (error) throw new Error(`대시보드 레이아웃 저장에 실패했습니다: ${error.message}`); void pageType; - void state; } diff --git a/src/entities/dashboard-layout/index.ts b/src/entities/dashboard-layout/index.ts index cb26738..db00774 100644 --- a/src/entities/dashboard-layout/index.ts +++ b/src/entities/dashboard-layout/index.ts @@ -1,4 +1,2 @@ // dashboard-layout 엔티티의 Public API — 개인 대시보드 레이아웃 조회/저장. export type { DashboardLayoutState } from './model/dashboard-layout.types'; -export { getDashboardLayout } from './api/get-dashboard-layout'; -export { saveDashboardLayout } from './api/save-dashboard-layout'; 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 new file mode 100644 index 0000000..40cdfd8 --- /dev/null +++ b/src/entities/work-schedule/api/ensure-weekly-work-schedule-entries.ts @@ -0,0 +1,51 @@ +// 이번 주에 아직 배정되지 않은 멤버·요일 조합을 기본 근무유형으로만 생성해 화면과 DB 기준을 맞춥니다. +import { getCurrentUserId } from '@/shared/api/supabase/current-user'; +import { createSupabaseServerClient } from '@/shared/api/supabase/server'; +import { getDefaultWorkShiftOption } from '../lib/get-default-work-shift-option'; +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'; + +export async function ensureWeeklyWorkScheduleEntries( + workspaceId: string, + weekStartDate: string, +): Promise { + const [members, shifts] = await Promise.all([ + getWorkspaceMembersByWorkspaceId(workspaceId), + getWorkShiftTypesByWorkspaceId(workspaceId), + ]); + const defaultShift = getDefaultWorkShiftOption(shifts); + + if (!defaultShift || members.length === 0) return; + + 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, + user_id: member.userId, + work_date: getWorkDateByWeekday(weekStartDate, weekday.key), + shift_type_id: defaultShift.id, + created_by: createdBy, + })), + ); + + const { error } = await supabase.from('work_schedule_entries').upsert(entries, { + onConflict: 'workspace_id,user_id,work_date', + ignoreDuplicates: true, + }); + + if (error) throw new Error(`기본 근무 스케줄 생성에 실패했습니다: ${error.message}`); +} diff --git a/src/entities/work-schedule/api/get-dashboard-work-schedule.ts b/src/entities/work-schedule/api/get-dashboard-work-schedule.ts new file mode 100644 index 0000000..69a485d --- /dev/null +++ b/src/entities/work-schedule/api/get-dashboard-work-schedule.ts @@ -0,0 +1,20 @@ +'use server'; + +// 대시보드 근무 스케줄 위젯이 현재 주의 멤버, 근무유형, 일정 데이터를 한 번에 조회하는 서버 액션입니다. +import { getWorkspaceMembersByWorkspaceId } from '@/entities/workspace-member/api/get-workspace-members-by-id'; +import { getCurrentWeekRange } from '../lib/work-date'; +import { getWorkScheduleEntriesByWeek } from './get-work-schedule-entries-by-week'; +import { getWorkShiftTypesByWorkspaceId } from './get-work-shift-types-by-workspace-id'; +import { ensureWeeklyWorkScheduleEntries } from './ensure-weekly-work-schedule-entries'; + +export async function getDashboardWorkSchedule(workspaceId: string) { + const { startDate, endDate } = getCurrentWeekRange(); + await ensureWeeklyWorkScheduleEntries(workspaceId, startDate); + const [members, shifts, schedule] = await Promise.all([ + getWorkspaceMembersByWorkspaceId(workspaceId), + getWorkShiftTypesByWorkspaceId(workspaceId), + getWorkScheduleEntriesByWeek(workspaceId, startDate, endDate), + ]); + + return { members, shifts, schedule }; +} diff --git a/src/entities/work-schedule/api/get-work-schedule-entries-by-week.ts b/src/entities/work-schedule/api/get-work-schedule-entries-by-week.ts new file mode 100644 index 0000000..7331616 --- /dev/null +++ b/src/entities/work-schedule/api/get-work-schedule-entries-by-week.ts @@ -0,0 +1,29 @@ +// 워크스페이스의 지정된 한 주 스케줄을 조회하고, DB 날짜를 월~일 UI 키로 변환하는 서버 조회 함수입니다. +import { cache } from 'react'; +import { createSupabaseServerClient } from '@/shared/api/supabase/server'; +import { getWeekdayFromWorkDate } from '../lib/work-date'; +import type { WorkScheduleEntry } from '../model/work-schedule.types'; + +export const getWorkScheduleEntriesByWeek = cache( + async (workspaceId: string, startDate: string, endDate: string): Promise => { + const supabase = await createSupabaseServerClient(); + const { data, error } = await supabase + .from('work_schedule_entries') + .select('workspace_id, user_id, work_date, shift_type_id') + .eq('workspace_id', workspaceId) + .gte('work_date', startDate) + .lte('work_date', endDate); + + if (error) { + throw new Error(`근무 스케줄 조회에 실패했습니다: ${error.message}`); + } + + return (data ?? []).map((entry) => ({ + workspaceId: entry.workspace_id, + userId: entry.user_id, + workDate: entry.work_date, + weekday: getWeekdayFromWorkDate(entry.work_date), + shiftTypeId: entry.shift_type_id, + })); + }, +); diff --git a/src/entities/work-schedule/api/get-work-shift-types-by-workspace-id.ts b/src/entities/work-schedule/api/get-work-shift-types-by-workspace-id.ts new file mode 100644 index 0000000..d7ed731 --- /dev/null +++ b/src/entities/work-schedule/api/get-work-shift-types-by-workspace-id.ts @@ -0,0 +1,34 @@ +// 워크스페이스별 근무유형을 정렬 순서대로 조회해 화면용 타입으로 변환하는 서버 조회 함수입니다. +import { cache } from 'react'; +import { createSupabaseServerClient } from '@/shared/api/supabase/server'; +import type { WorkShiftColor, WorkShiftOption } from '../model/work-schedule.types'; + +function toTime(value: string | null): string | null { + return value ? value.slice(0, 5) : null; +} + +export const getWorkShiftTypesByWorkspaceId = cache( + async (workspaceId: string): Promise => { + const supabase = await createSupabaseServerClient(); + const { data, error } = await supabase + .from('work_shift_types') + .select('id, code, name, start_time, end_time, ends_next_day, color, is_off') + .eq('workspace_id', workspaceId) + .order('sort_order'); + + if (error) { + throw new Error(`근무 유형 조회에 실패했습니다: ${error.message}`); + } + + return (data ?? []).map((shift) => ({ + id: shift.id, + code: shift.code, + name: shift.name, + startTime: toTime(shift.start_time), + endTime: toTime(shift.end_time), + endsNextDay: shift.ends_next_day, + color: shift.color as WorkShiftColor, + isOff: shift.is_off, + })); + }, +); diff --git a/src/entities/work-schedule/api/work-schedule-actions.ts b/src/entities/work-schedule/api/work-schedule-actions.ts new file mode 100644 index 0000000..5d49d72 --- /dev/null +++ b/src/entities/work-schedule/api/work-schedule-actions.ts @@ -0,0 +1,215 @@ +'use server'; + +// 스케줄 셀과 근무유형 설정 변경을 검증한 뒤 Supabase에 저장하는 서버 액션 모음입니다. +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 type { WorkShiftColor, WorkShiftOption } from '../model/work-schedule.types'; + +const colorSchema = z.enum(['sky', 'violet', 'amber', 'slate', 'emerald', 'rose']); +// 개발 시드 UUID처럼 RFC 버전 비트가 0인 GUID도 허용한다. +const uuidSchema = z.guid(); +const timeSchema = z.string().regex(/^([01]\d|2[0-3]):[0-5]\d$/, { + message: '시간은 00:00부터 23:59 사이여야 합니다.', +}); + +const shiftTypeSchema = z + .object({ + id: uuidSchema, + workspaceId: uuidSchema, + name: z.string().trim().min(1).max(40), + startTime: timeSchema.nullable(), + endTime: timeSchema.nullable(), + endsNextDay: z.boolean(), + color: colorSchema, + isOff: z.boolean(), + }) + .superRefine((shift, context) => { + if (shift.isOff) { + if (shift.startTime || shift.endTime) { + context.addIssue({ + code: 'custom', + message: '휴무 유형에는 근무 시간을 지정할 수 없습니다.', + }); + } + return; + } + + if (!shift.startTime || !shift.endTime) { + context.addIssue({ code: 'custom', message: '근무 시작과 종료 시간을 입력해주세요.' }); + return; + } + + if (!shift.endsNextDay && shift.endTime <= shift.startTime) { + context.addIssue({ code: 'custom', message: '종료 시간은 시작 시간 이후여야 합니다.' }); + } + }); + +function revalidateWorkspace(workspaceId: string): void { + revalidatePath(`/workspaces/${workspaceId}/work-schedule`); +} + +export async function saveWorkScheduleEntry(input: { + workspaceId: string; + userId: string; + workDate: string; + shiftTypeId: string; +}): Promise { + const value = z + .object({ + workspaceId: uuidSchema, + userId: uuidSchema, + workDate: z.string().date(), + shiftTypeId: uuidSchema, + }) + .parse(input); + const supabase = await createSupabaseServerClient(); + const updateExistingEntry = () => + supabase + .from('work_schedule_entries') + .update({ shift_type_id: value.shiftTypeId }) + .eq('workspace_id', value.workspaceId) + .eq('user_id', value.userId) + .eq('work_date', value.workDate) + .select('id') + .maybeSingle(); + + const { data: updatedEntry, error: updateError } = await updateExistingEntry(); + + if (updateError) throw new Error(`근무 스케줄 저장에 실패했습니다: ${updateError.message}`); + + if (!updatedEntry) { + const { error: insertError } = await supabase.from('work_schedule_entries').insert({ + workspace_id: value.workspaceId, + user_id: value.userId, + work_date: value.workDate, + shift_type_id: value.shiftTypeId, + created_by: await getCurrentUserId(), + }); + + if (insertError?.code === '23505') { + const { data: retriedEntry, error: retryError } = await updateExistingEntry(); + if (retryError || !retriedEntry) { + throw new Error( + `근무 스케줄 저장에 실패했습니다: ${retryError?.message ?? insertError.message}`, + ); + } + } else if (insertError) { + throw new Error(`근무 스케줄 생성에 실패했습니다: ${insertError.message}`); + } + } + + revalidateWorkspace(value.workspaceId); +} + +export async function createWorkShiftType(workspaceId: string): Promise { + 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(); + + if (sortOrderError) + throw new Error(`근무 유형 순서 조회에 실패했습니다: ${sortOrderError.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(); + + 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, + }; +} + +export async function updateWorkShiftType(input: z.infer): Promise { + const value = shiftTypeSchema.parse(input); + const supabase = await createSupabaseServerClient(); + const { error } = await supabase + .from('work_shift_types') + .update({ + name: value.name, + start_time: value.startTime, + end_time: value.endTime, + ends_next_day: value.endsNextDay, + color: value.color, + is_off: value.isOff, + }) + .eq('id', value.id) + .eq('workspace_id', value.workspaceId); + + if (error) throw new Error(`근무 유형 저장에 실패했습니다: ${error.message}`); + revalidateWorkspace(value.workspaceId); +} + +export async function reorderWorkShiftTypes(input: { + workspaceId: string; + shiftTypeIds: string[]; +}): Promise { + const value = z + .object({ workspaceId: uuidSchema, shiftTypeIds: z.array(uuidSchema).min(1) }) + .parse(input); + const supabase = await createSupabaseServerClient(); + const results = await Promise.all( + value.shiftTypeIds.map((id, sortOrder) => + supabase + .from('work_shift_types') + .update({ sort_order: sortOrder }) + .eq('id', id) + .eq('workspace_id', value.workspaceId), + ), + ); + const failed = results.find(({ error }) => error); + if (failed?.error) throw new Error(`근무 유형 순서 저장에 실패했습니다: ${failed.error.message}`); + revalidateWorkspace(value.workspaceId); +} + +export async function replaceAndDeleteWorkShiftType(input: { + workspaceId: string; + deletedShiftTypeId: string; + replacementShiftTypeId: string; +}): Promise { + const value = z + .object({ + workspaceId: uuidSchema, + deletedShiftTypeId: uuidSchema, + replacementShiftTypeId: uuidSchema, + }) + .refine((data) => data.deletedShiftTypeId !== data.replacementShiftTypeId) + .parse(input); + const supabase = await createSupabaseServerClient(); + const { error } = await supabase.rpc('replace_and_delete_work_shift_type', { + p_workspace_id: value.workspaceId, + p_deleted_shift_type_id: value.deletedShiftTypeId, + p_replacement_shift_type_id: value.replacementShiftTypeId, + }); + + if (error) throw new Error(`근무 유형 삭제에 실패했습니다: ${error.message}`); + revalidateWorkspace(value.workspaceId); +} diff --git a/src/entities/work-schedule/index.ts b/src/entities/work-schedule/index.ts index 8eba50b..49cd265 100644 --- a/src/entities/work-schedule/index.ts +++ b/src/entities/work-schedule/index.ts @@ -1,4 +1,4 @@ -// 근무 일정 도메인 타입, 목업 설정, 헬퍼 함수의 공개 API입니다. +// 근무 스케줄 도메인에서 다른 레이어가 사용할 타입과 순수 헬퍼 함수를 모아 공개하는 진입점입니다. export type { WorkScheduleConfig, WorkScheduleEntry, @@ -12,4 +12,5 @@ export { createInitialWorkSchedule } from './lib/create-initial-work-schedule'; export { getDefaultWorkShiftOption } from './lib/get-default-work-shift-option'; export { getWorkMembersByWeekday } from './lib/get-work-members-by-weekday'; export { getNextWorkShiftOption } from './lib/get-next-work-shift-option'; +export { getCurrentWeekRange, getWorkDateByWeekday } from './lib/work-date'; export { mockWorkScheduleConfig } from './model/mock-work-schedule-config'; diff --git a/src/entities/work-schedule/lib/count-schedules-by-weekday.ts b/src/entities/work-schedule/lib/count-schedules-by-weekday.ts index 91633d8..0bb1e75 100644 --- a/src/entities/work-schedule/lib/count-schedules-by-weekday.ts +++ b/src/entities/work-schedule/lib/count-schedules-by-weekday.ts @@ -1,4 +1,4 @@ -// 특정 요일의 각 근무 옵션에 몇 명의 멤버가 배정되어 있는지 계산합니다. +// 특정 요일에 근무유형별로 몇 명이 배정됐는지 화면 하단 합계용으로 계산합니다. import type { WeekdayKey } from '../model/weekdays'; import type { WorkScheduleConfig, WorkScheduleEntry } from '../model/work-schedule.types'; @@ -24,7 +24,7 @@ export function countSchedulesByWeekday({ }); uniqueEntries.forEach((entry) => { - counts[entry.shiftOptionId] = (counts[entry.shiftOptionId] ?? 0) + 1; + counts[entry.shiftTypeId] = (counts[entry.shiftTypeId] ?? 0) + 1; }); return counts; diff --git a/src/entities/work-schedule/lib/create-initial-work-schedule.ts b/src/entities/work-schedule/lib/create-initial-work-schedule.ts index c4a3688..e756e32 100644 --- a/src/entities/work-schedule/lib/create-initial-work-schedule.ts +++ b/src/entities/work-schedule/lib/create-initial-work-schedule.ts @@ -1,4 +1,4 @@ -// 모든 워크스페이스 멤버에 대한 기본 요일별 근무 일정을 생성합니다. +// DB 데이터가 없는 목업 화면에서 멤버별 월요일부터 일요일까지 기본 근무 일정을 생성합니다. import type { WorkspaceMember } from '@/entities/workspace-member'; import { getDefaultWorkShiftOption } from './get-default-work-shift-option'; import { weekdays } from '../model/weekdays'; @@ -16,13 +16,30 @@ export function createInitialWorkSchedule({ config, }: CreateInitialWorkScheduleParams): WorkScheduleEntry[] { const defaultShift = getDefaultWorkShiftOption(config.shifts); + const today = new Date(); + const mondayOffset = (today.getDay() + 6) % 7; + const monday = new Date(today); + monday.setDate(today.getDate() - mondayOffset); + + const toDateString = (date: Date): string => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + }; return members.flatMap((member) => - weekdays.map((weekday) => ({ - workspaceId, - userId: member.userId, - weekday: weekday.key, - shiftOptionId: defaultShift.id, - })), + weekdays.map((weekday, index) => { + const workDate = new Date(monday); + workDate.setDate(monday.getDate() + index); + + return { + workspaceId, + userId: member.userId, + weekday: weekday.key, + workDate: toDateString(workDate), + shiftTypeId: defaultShift.id, + }; + }), ); } diff --git a/src/entities/work-schedule/lib/get-next-work-shift-option.ts b/src/entities/work-schedule/lib/get-next-work-shift-option.ts index 4662394..779c720 100644 --- a/src/entities/work-schedule/lib/get-next-work-shift-option.ts +++ b/src/entities/work-schedule/lib/get-next-work-shift-option.ts @@ -1,16 +1,16 @@ -// 설정된 배열 순서에 따라 다음 근무 옵션을 반환합니다. +// 셀을 클릭했을 때 현재 근무유형 다음에 배치된 유형을 순환하여 반환합니다. import type { WorkShiftOption } from '../model/work-schedule.types'; interface GetNextWorkShiftOptionParams { shifts: WorkShiftOption[]; - currentShiftOptionId: string; + currentShiftTypeId: string; } export function getNextWorkShiftOption({ shifts, - currentShiftOptionId, + currentShiftTypeId, }: GetNextWorkShiftOptionParams): WorkShiftOption { - const currentIndex = shifts.findIndex((shift) => shift.id === currentShiftOptionId); + const currentIndex = shifts.findIndex((shift) => shift.id === currentShiftTypeId); const nextIndex = currentIndex === -1 ? 0 : (currentIndex + 1) % shifts.length; return shifts[nextIndex]; diff --git a/src/entities/work-schedule/lib/get-work-members-by-weekday.ts b/src/entities/work-schedule/lib/get-work-members-by-weekday.ts index 50c40a6..265d6fa 100644 --- a/src/entities/work-schedule/lib/get-work-members-by-weekday.ts +++ b/src/entities/work-schedule/lib/get-work-members-by-weekday.ts @@ -1,4 +1,4 @@ -// 휴무 옵션을 제외하여 특정 요일에 근무 중인 멤버를 판별합니다. +// 휴무 유형을 제외하고 특정 요일에 실제 근무하는 멤버만 추려 요일별 근무자 목록에 사용합니다. import type { WorkspaceMember } from '@/entities/workspace-member'; import type { WeekdayKey } from '../model/weekdays'; import type { WorkScheduleConfig, WorkScheduleEntry } from '../model/work-schedule.types'; @@ -22,7 +22,7 @@ export function getWorkMembersByWeekday({ const workingUserIds = new Set( schedule - .filter((entry) => entry.weekday === weekday && !offShiftIds.has(entry.shiftOptionId)) + .filter((entry) => entry.weekday === weekday && !offShiftIds.has(entry.shiftTypeId)) .map((entry) => entry.userId), ); diff --git a/src/entities/work-schedule/lib/work-date.ts b/src/entities/work-schedule/lib/work-date.ts new file mode 100644 index 0000000..9b4de09 --- /dev/null +++ b/src/entities/work-schedule/lib/work-date.ts @@ -0,0 +1,71 @@ +// DB의 실제 work_date와 월~일 화면 열을 서로 변환하고, 현재 주의 조회 범위를 계산합니다. +import type { WeekdayKey } from '../model/weekdays'; + +const weekdayKeys: WeekdayKey[] = [ + 'sunday', + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', +]; + +export function getWeekdayFromWorkDate(workDate: string): WeekdayKey { + return weekdayKeys[new Date(`${workDate}T00:00:00Z`).getUTCDay()]; +} + +export function getCurrentWeekRange(now = new Date()): { startDate: string; endDate: string } { + const { year, month, day } = getKstDateParts(now); + const currentDate = new Date(Date.UTC(year, month - 1, day)); + const mondayOffset = (currentDate.getUTCDay() + 6) % 7; + const monday = new Date(currentDate); + monday.setUTCDate(currentDate.getUTCDate() - mondayOffset); + + const sunday = new Date(monday); + sunday.setUTCDate(monday.getUTCDate() + 6); + + return { + startDate: toDateString(monday), + endDate: toDateString(sunday), + }; +} + +export function getWorkDateByWeekday(startDate: string, weekday: WeekdayKey): string { + const weekdayIndex = [ + 'monday', + 'tuesday', + 'wednesday', + 'thursday', + 'friday', + 'saturday', + 'sunday', + ].indexOf(weekday); + const date = new Date(`${startDate}T00:00:00Z`); + date.setUTCDate(date.getUTCDate() + weekdayIndex); + + return toDateString(date); +} + +function toDateString(date: Date): string { + const year = date.getUTCFullYear(); + const month = String(date.getUTCMonth() + 1).padStart(2, '0'); + const day = String(date.getUTCDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +function getKstDateParts(date: Date): { year: number; month: number; day: number } { + const parts = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Seoul', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).formatToParts(date); + const byType = new Map(parts.map((part) => [part.type, part.value])); + + return { + year: Number(byType.get('year')), + month: Number(byType.get('month')), + day: Number(byType.get('day')), + }; +} diff --git a/src/entities/work-schedule/model/mock-work-schedule-config.ts b/src/entities/work-schedule/model/mock-work-schedule-config.ts index b25ee94..78c889c 100644 --- a/src/entities/work-schedule/model/mock-work-schedule-config.ts +++ b/src/entities/work-schedule/model/mock-work-schedule-config.ts @@ -1,37 +1,45 @@ -// 워크스페이스별 설정이 저장되기 전까지 사용하는 기본 목업 근무 설정입니다. +// 대시보드 등 아직 DB 조회로 전환되지 않은 화면에서 사용하는 기본 근무유형 목업 설정입니다. import type { WorkScheduleConfig } from './work-schedule.types'; export const mockWorkScheduleConfig: WorkScheduleConfig = { shifts: [ { id: 'shift-open', + code: 'open', name: '오픈', startTime: '09:00', endTime: '14:00', + endsNextDay: false, color: 'sky', isOff: false, }, { id: 'shift-middle', + code: 'middle', name: '미들', startTime: '14:00', endTime: '19:00', + endsNextDay: false, color: 'violet', isOff: false, }, { id: 'shift-close', + code: 'close', name: '마감', startTime: '19:00', - endTime: '24:00', + endTime: '00:00', + endsNextDay: true, color: 'amber', isOff: false, }, { id: 'shift-off', + code: 'off', name: '휴무', startTime: null, endTime: null, + endsNextDay: false, color: 'slate', isOff: true, }, diff --git a/src/entities/work-schedule/model/work-schedule.types.ts b/src/entities/work-schedule/model/work-schedule.types.ts index 58c2532..1c867ec 100644 --- a/src/entities/work-schedule/model/work-schedule.types.ts +++ b/src/entities/work-schedule/model/work-schedule.types.ts @@ -1,13 +1,15 @@ -// 목업 근무 일정 모듈의 설정과 항목에 대한 핵심 타입입니다. +// DB 컬럼을 화면에서 다루기 쉬운 camelCase 형태로 표현하는 근무유형과 일정 타입입니다. import type { WeekdayKey } from './weekdays'; export type WorkShiftColor = 'sky' | 'violet' | 'amber' | 'slate' | 'emerald' | 'rose'; export interface WorkShiftOption { id: string; + code: string; name: string; startTime: string | null; endTime: string | null; + endsNextDay: boolean; color: WorkShiftColor; isOff: boolean; } @@ -20,5 +22,6 @@ export interface WorkScheduleEntry { workspaceId: string; userId: string; weekday: WeekdayKey; - shiftOptionId: string; + workDate: string; + shiftTypeId: string; } diff --git a/src/entities/workspace-member/api/get-workspace-members-by-id.ts b/src/entities/workspace-member/api/get-workspace-members-by-id.ts new file mode 100644 index 0000000..428ea68 --- /dev/null +++ b/src/entities/workspace-member/api/get-workspace-members-by-id.ts @@ -0,0 +1,56 @@ +// 멤버 조회 함수를 생성합니다 + +import { cache } from 'react'; +import { createSupabaseServerClient } from '@/shared/api/supabase/server'; +import type { WorkspaceMember } from '@/entities/workspace-member/model/workspace-member.types'; + +export const getWorkspaceMembersByWorkspaceId = cache( + async (workspaceId: string): Promise => { + const supabase = await createSupabaseServerClient(); + + const { data: memberships, error: membershipError } = await supabase + .from('workspace_members') + .select('workspace_id, user_id, workspace_nickname, role') + .eq('workspace_id', workspaceId) + .order('joined_at'); + + if (membershipError) { + throw new Error(`워크스페이스 멤버 조회에 실패했습니다: ${membershipError.message}`); + } + + const userIds = (memberships ?? []).map((member) => member.user_id); + + if (userIds.length === 0) { + return []; + } + + const { data: profiles, error: profileError } = await supabase + .from('profiles') + .select('id,email,real_name') + .in('id', userIds); + + if (profileError) { + throw new Error(`멤버 프로필 조회에 실패했습니다: ${profileError.message}`); + } + + const profilesById = new Map((profiles ?? []).map((profile) => [profile.id, profile])); + + return (memberships ?? []).flatMap((member) => { + const profile = profilesById.get(member.user_id); + + if (!profile) { + return []; + } + + return { + workspaceId: member.workspace_id, + userId: member.user_id, + workspaceNickname: member.workspace_nickname, + avatarLabel: profile.real_name.slice(0, 1), + email: profile.email, + role: member.role, + status: 'joined', + }; + }); + }, +); diff --git a/src/entities/workspace/api/get-workspace-by-id.ts b/src/entities/workspace/api/get-workspace-by-id.ts new file mode 100644 index 0000000..dc1b68d --- /dev/null +++ b/src/entities/workspace/api/get-workspace-by-id.ts @@ -0,0 +1,29 @@ +// 워크스페이스 상세 조회 — RSC에서 현재 세션의 RLS를 적용해 접근 가능한 워크스페이스만 반환한다. +import { cache } from 'react'; +import { createSupabaseServerClient } from '@/shared/api/supabase/server'; +import { toUiPurpose } from '../model/purpose.mapper'; +import type { Workspace } from '../model/workspace.types'; + +export const getWorkspaceById = cache(async (workspaceId: string): Promise => { + const supabase = await createSupabaseServerClient(); + const { data, error } = await supabase + .from('workspaces') + .select('id, name, description, purpose') + .eq('id', workspaceId) + .maybeSingle(); + + if (error) { + throw new Error(`워크스페이스 조회에 실패했습니다: ${error.message}`); + } + + if (!data) { + return null; + } + + return { + id: data.id, + name: data.name, + description: data.description ?? undefined, + purpose: toUiPurpose(data.purpose), + }; +}); diff --git a/src/features/dashboard/edit-layout/model/useDashboardLayout.ts b/src/features/dashboard/edit-layout/model/useDashboardLayout.ts index 40d65dd..89e720f 100644 --- a/src/features/dashboard/edit-layout/model/useDashboardLayout.ts +++ b/src/features/dashboard/edit-layout/model/useDashboardLayout.ts @@ -1,18 +1,20 @@ // 대시보드 레이아웃 편집 상태 훅 — 배치(layout) + 편집 모드를 관리하고 변경을 영속화한다. // 화면에 배치된 위젯 = layout. 추가는 카탈로그 항목(LayoutItem)을 넣고, 삭제는 layout에서 뺀다. // 초기 레이아웃은 서버(RSC)에서 조회해 initialLayout으로 주입받는다(마운트 후 재조회 없음). -// 저장(쓰기)만 서버액션으로 위임한다. 영속화 키는 (workspaceId, pageType). editMode는 저장하지 않는다. +// 저장(쓰기)만 서버액션으로 위임한다. 현재 DB 영속화 키는 (userId, workspaceId)이며 editMode는 저장하지 않는다. import { useCallback, useEffect, useRef, useState } from 'react'; +import { toast } from 'sonner'; import type { Layout, LayoutItem } from 'react-grid-layout'; -import { saveDashboardLayout, type DashboardLayoutState } from '@/entities/dashboard-layout'; +import { saveDashboardLayout } from '@/entities/dashboard-layout/api/save-dashboard-layout'; +import type { DashboardLayoutState } from '@/entities/dashboard-layout/model/dashboard-layout.types'; // 저장·상태로 남기는 값은 위치(i,x,y,w,h)만 — minW/minH 등 위젯 제약은 카탈로그가 소유하며 // 렌더 시점에 머지한다(DB에 위젯 설정이 중복 저장되지 않도록). const toPosition = ({ i, x, y, w, h }: LayoutItem): LayoutItem => ({ i, x, y, w, h }); interface UseDashboardLayoutParams { - /** 영속화 키 — 어떤 워크스페이스의 어떤 페이지 레이아웃인지 */ + /** 향후 페이지별 레이아웃 확장을 위한 구분값 — 현재 DB에는 저장하지 않는다 */ workspaceId: string; pageType: string; /** 서버(RSC)에서 조회한 초기 레이아웃 */ @@ -27,15 +29,25 @@ export function useDashboardLayout({ const [layout, setLayout] = useState(initialLayout.layout); const [editMode, setEditMode] = useState(false); const didMountRef = useRef(false); + const saveQueueRef = useRef(Promise.resolve()); - // TODO: DB 연동 — 변경 저장 (드래그 중 잦은 호출은 debounce 예정) useEffect(() => { if (!didMountRef.current) { didMountRef.current = true; return; } - void saveDashboardLayout(workspaceId, pageType, { layout }); + const timeoutId = window.setTimeout(() => { + saveQueueRef.current = saveQueueRef.current + .catch(() => undefined) + .then(() => saveDashboardLayout(workspaceId, pageType, { layout })) + .catch((error: unknown) => { + console.error(error); + toast.error('대시보드 레이아웃 저장에 실패했습니다.'); + }); + }, 400); + + return () => window.clearTimeout(timeoutId); }, [layout, workspaceId, pageType]); const handleLayoutChange = useCallback((next: Layout) => { 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 52fec70..1ea5e56 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 @@ -1,10 +1,11 @@ 'use client'; -// 목업 UI에서 일정 셀의 로컬 상태와 근무 옵션 교체 동작을 관리합니다. +// 서버에서 받은 일정의 화면 상태를 관리하고, 셀 클릭 시 다음 근무유형으로 낙관적으로 변경합니다. import { useState } from 'react'; import { getDefaultWorkShiftOption, getNextWorkShiftOption, + getWorkDateByWeekday, weekdays, type WeekdayKey, type WorkScheduleConfig, @@ -16,109 +17,76 @@ interface UseWorkScheduleStateParams { initialSchedule: WorkScheduleEntry[]; members: WorkspaceMember[]; config: WorkScheduleConfig; + weekStartDate: string; } function completeScheduleEntries({ - schedule, + initialSchedule, members, config, -}: { - schedule: WorkScheduleEntry[]; - members: WorkspaceMember[]; - config: WorkScheduleConfig; -}): WorkScheduleEntry[] { + weekStartDate, +}: UseWorkScheduleStateParams): WorkScheduleEntry[] { const defaultShift = getDefaultWorkShiftOption(config.shifts); - const existingEntryIds = new Set( - schedule.map((entry) => `${entry.workspaceId}:${entry.userId}:${entry.weekday}`), + const existingEntries = new Set( + initialSchedule.map((entry) => `${entry.userId}:${entry.weekday}`), ); - const missingEntries = members.flatMap((member) => - weekdays.flatMap((weekday) => { - const entryId = `${member.workspaceId}:${member.userId}:${weekday.key}`; - - if (existingEntryIds.has(entryId)) { - return []; - } + return [ + ...initialSchedule, + ...members.flatMap((member) => + weekdays.flatMap((weekday) => { + if (existingEntries.has(`${member.userId}:${weekday.key}`) || !defaultShift) return []; - return { - workspaceId: member.workspaceId, - userId: member.userId, - weekday: weekday.key, - shiftOptionId: defaultShift.id, - }; - }), - ); + return { + workspaceId: member.workspaceId, + userId: member.userId, + weekday: weekday.key, + workDate: getWorkDateByWeekday(weekStartDate, weekday.key), + shiftTypeId: defaultShift.id, + }; + }), + ), + ]; +} - if (missingEntries.length === 0) { - return schedule; - } +export function useWorkScheduleState(params: UseWorkScheduleStateParams) { + const { config } = params; + const [schedule, setSchedule] = useState(() => completeScheduleEntries(params)); - return [...schedule, ...missingEntries]; -} + const cycleCell = (userId: string, weekday: WeekdayKey): WorkScheduleEntry | null => { + const currentEntry = schedule.find( + (entry) => entry.userId === userId && entry.weekday === weekday, + ); + if (!currentEntry) return null; -export function useWorkScheduleState({ - initialSchedule, - members, - config, -}: UseWorkScheduleStateParams) { - const [schedule, setSchedule] = useState(() => - completeScheduleEntries({ - schedule: initialSchedule, - members, - config, - }), - ); + const nextShift = getNextWorkShiftOption({ + shifts: config.shifts, + currentShiftTypeId: currentEntry.shiftTypeId, + }); + const nextEntry = { ...currentEntry, shiftTypeId: nextShift.id }; - const cycleCell = (userId: string, weekday: WeekdayKey): void => { setSchedule((current) => { - const completedSchedule = completeScheduleEntries({ - schedule: current, - members, - config, - }); - - return completedSchedule.map((entry) => { + return current.map((entry) => { if (entry.userId !== userId || entry.weekday !== weekday) { return entry; } - - const nextShift = getNextWorkShiftOption({ - shifts: config.shifts, - currentShiftOptionId: entry.shiftOptionId, - }); - - return { - ...entry, - shiftOptionId: nextShift.id, - }; + return nextEntry; }); }); + + return nextEntry; }; - const replaceShiftOption = (fromShiftOptionId: string, toShiftOptionId: string): void => { + const replaceShiftOption = (fromShiftTypeId: string, toShiftTypeId: string): void => { setSchedule((current) => { - const completedSchedule = completeScheduleEntries({ - schedule: current, - members, - config, - }); - - return completedSchedule.map((entry) => - entry.shiftOptionId === fromShiftOptionId - ? { ...entry, shiftOptionId: toShiftOptionId } - : entry, + return current.map((entry) => + entry.shiftTypeId === fromShiftTypeId ? { ...entry, shiftTypeId: toShiftTypeId } : entry, ); }); }; - const completedSchedule = completeScheduleEntries({ - schedule, - members, - config, - }); - return { - schedule: completedSchedule, + schedule, cycleCell, replaceShiftOption, }; diff --git a/src/features/manage-work-schedule/ui/WorkScheduleBoard.tsx b/src/features/manage-work-schedule/ui/WorkScheduleBoard.tsx index 87301f6..cc2e958 100644 --- a/src/features/manage-work-schedule/ui/WorkScheduleBoard.tsx +++ b/src/features/manage-work-schedule/ui/WorkScheduleBoard.tsx @@ -1,7 +1,8 @@ 'use client'; -// 수정 가능한 요일별 근무 일정표, 근무 설정, 요일별 요약을 렌더링합니다. +// 근무표, 근무유형 설정, 삭제 대체 선택 UI를 렌더링하고 서버 액션 저장을 연결하는 핵심 화면입니다. import { useState } from 'react'; +import { toast } from 'sonner'; import { countSchedulesByWeekday, getWorkMembersByWeekday, @@ -9,41 +10,61 @@ import { type WorkScheduleConfig, type WorkScheduleEntry, } from '@/entities/work-schedule'; +import { + createWorkShiftType, + reorderWorkShiftTypes, + replaceAndDeleteWorkShiftType, + saveWorkScheduleEntry, + updateWorkShiftType, +} from '@/entities/work-schedule/api/work-schedule-actions'; import type { WorkspaceMember } from '@/entities/workspace-member'; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from '@/shared/ui/dialog'; import { useWorkScheduleState } from '../model/use-work-schedule-state'; import { WorkScheduleCell } from './WorkScheduleCell'; import { WorkShiftSettingsPanel } from './WorkShiftSettingsPanel'; import { WorkShiftLegend } from './WorkShiftLegend'; interface WorkScheduleBoardProps { + workspaceId: string; members: WorkspaceMember[]; config: WorkScheduleConfig; initialSchedule: WorkScheduleEntry[]; + weekStartDate: string; } -export function WorkScheduleBoard({ members, config, initialSchedule }: WorkScheduleBoardProps) { +export function WorkScheduleBoard({ + workspaceId, + members, + config, + initialSchedule, + weekStartDate, +}: WorkScheduleBoardProps) { const [scheduleConfig, setScheduleConfig] = useState(config); const [isSettingsOpen, setIsSettingsOpen] = useState(false); + const [shiftToDeleteId, setShiftToDeleteId] = useState(null); + const [replacementShiftId, setReplacementShiftId] = useState(''); const { schedule, cycleCell, replaceShiftOption } = useWorkScheduleState({ initialSchedule, members, config: scheduleConfig, + weekStartDate, }); - const handleAddShift = (): void => { - setScheduleConfig((current) => ({ - shifts: [ - ...current.shifts, - { - id: `shift-${crypto.randomUUID()}`, - name: '새 근무', - startTime: '09:00', - endTime: '18:00', - color: 'emerald', - isOff: false, - }, - ], - })); + const handleAddShift = async (): Promise => { + try { + const newShift = await createWorkShiftType(workspaceId); + setScheduleConfig((current) => ({ shifts: [...current.shifts, newShift] })); + } catch (error) { + console.error(error); + toast.error('근무 유형을 추가하지 못했습니다.'); + } }; const handleUpdateShift = ( @@ -55,17 +76,47 @@ export function WorkScheduleBoard({ members, config, initialSchedule }: WorkSche })); }; + const handleCommitShift = async (shiftId: string): Promise => { + const shift = scheduleConfig.shifts.find((item) => item.id === shiftId); + if (!shift) return; + + try { + await updateWorkShiftType({ workspaceId, ...shift }); + } catch (error) { + console.error(error); + toast.error('근무 유형 저장에 실패했습니다. 입력 값을 확인해주세요.'); + } + }; + const handleDeleteShift = (shiftId: string): void => { if (scheduleConfig.shifts.length <= 1) return; + const replacement = scheduleConfig.shifts.find((shift) => shift.id !== shiftId); + setReplacementShiftId(replacement?.id ?? ''); + setShiftToDeleteId(shiftId); + }; - const nextShifts = scheduleConfig.shifts.filter((shift) => shift.id !== shiftId); - const fallbackShift = nextShifts.find((shift) => !shift.isOff) ?? nextShifts[0]; + const confirmDeleteShift = async (): Promise => { + if (!shiftToDeleteId || !replacementShiftId) return; - replaceShiftOption(shiftId, fallbackShift.id); - setScheduleConfig({ shifts: nextShifts }); + try { + await replaceAndDeleteWorkShiftType({ + workspaceId, + deletedShiftTypeId: shiftToDeleteId, + replacementShiftTypeId: replacementShiftId, + }); + replaceShiftOption(shiftToDeleteId, replacementShiftId); + setScheduleConfig((current) => ({ + shifts: current.shifts.filter((shift) => shift.id !== shiftToDeleteId), + })); + setShiftToDeleteId(null); + } catch (error) { + console.error(error); + toast.error('근무 유형을 삭제하지 못했습니다.'); + } }; - const handleMoveShift = (shiftId: string, direction: 'up' | 'down'): void => { + const handleMoveShift = async (shiftId: string, direction: 'up' | 'down'): Promise => { + let nextShiftIds: string[] | null = null; setScheduleConfig((current) => { const currentIndex = current.shifts.findIndex((shift) => shift.id === shiftId); const targetIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1; @@ -78,13 +129,44 @@ export function WorkScheduleBoard({ members, config, initialSchedule }: WorkSche const currentShift = nextShifts[currentIndex]; nextShifts[currentIndex] = nextShifts[targetIndex]; nextShifts[targetIndex] = currentShift; + nextShiftIds = nextShifts.map((shift) => shift.id); return { shifts: nextShifts, }; }); + + if (!nextShiftIds) return; + try { + await reorderWorkShiftTypes({ workspaceId, shiftTypeIds: nextShiftIds }); + } catch (error) { + console.error(error); + toast.error('근무 유형 순서 저장에 실패했습니다.'); + } }; + const handleCycleCell = async ( + userId: string, + weekday: (typeof weekdays)[number]['key'], + ): Promise => { + const nextEntry = cycleCell(userId, weekday); + if (!nextEntry) return; + + try { + await saveWorkScheduleEntry({ + workspaceId, + userId, + workDate: nextEntry.workDate, + shiftTypeId: nextEntry.shiftTypeId, + }); + } catch (error) { + console.error(error); + toast.error('근무 스케줄 저장에 실패했습니다.'); + } + }; + + const shiftToDelete = scheduleConfig.shifts.find((shift) => shift.id === shiftToDeleteId); + return (
@@ -105,6 +187,7 @@ export function WorkScheduleBoard({ members, config, initialSchedule }: WorkSche onDeleteShift={handleDeleteShift} onMoveShift={handleMoveShift} onUpdateShift={handleUpdateShift} + onCommitShift={handleCommitShift} /> ) : null} @@ -137,9 +220,7 @@ export function WorkScheduleBoard({ members, config, initialSchedule }: WorkSche const entry = schedule.find( (item) => item.userId === member.userId && item.weekday === weekday.key, ); - const shift = scheduleConfig.shifts.find( - (item) => item.id === entry?.shiftOptionId, - ); + const shift = scheduleConfig.shifts.find((item) => item.id === entry?.shiftTypeId); if (!entry || !shift) return null; @@ -147,7 +228,7 @@ export function WorkScheduleBoard({ members, config, initialSchedule }: WorkSche cycleCell(member.userId, weekday.key)} + onCycle={() => void handleCycleCell(member.userId, weekday.key)} /> ); })} @@ -204,6 +285,52 @@ export function WorkScheduleBoard({ members, config, initialSchedule }: WorkSche ); })}
+ + !open && setShiftToDeleteId(null)} + > + + + {shiftToDelete?.name} 근무 유형을 삭제할까요? + + 이 유형이 배정된 일정은 아래에서 선택한 대체 근무 유형으로 일괄 변경됩니다. + + + + + + + + +
); } diff --git a/src/features/manage-work-schedule/ui/WorkShiftSettingsPanel.tsx b/src/features/manage-work-schedule/ui/WorkShiftSettingsPanel.tsx index 5b90b4c..55395e1 100644 --- a/src/features/manage-work-schedule/ui/WorkShiftSettingsPanel.tsx +++ b/src/features/manage-work-schedule/ui/WorkShiftSettingsPanel.tsx @@ -1,4 +1,4 @@ -// 근무 옵션을 추가, 제거, 정렬하고 시간을 설정할 수 있는 인라인 편집기입니다. +// 매장별 근무유형의 이름, 시간, 색상, 휴무 여부와 표시 순서를 편집하는 설정 패널입니다. import { ChevronDown, ChevronUp } from 'lucide-react'; import type { WorkScheduleConfig, WorkShiftColor, WorkShiftOption } from '@/entities/work-schedule'; @@ -10,6 +10,7 @@ interface WorkShiftSettingsPanelProps { onDeleteShift: (shiftId: string) => void; onMoveShift: (shiftId: string, direction: 'up' | 'down') => void; onUpdateShift: (shiftId: string, nextShift: WorkShiftOption) => void; + onCommitShift: (shiftId: string) => void; } export function WorkShiftSettingsPanel({ @@ -18,6 +19,7 @@ export function WorkShiftSettingsPanel({ onDeleteShift, onMoveShift, onUpdateShift, + onCommitShift, }: WorkShiftSettingsPanelProps) { return (
@@ -42,7 +44,7 @@ export function WorkShiftSettingsPanel({ {config.shifts.map((shift, index) => (
@@ -70,6 +73,7 @@ export function WorkShiftSettingsPanel({ startTime: event.target.value || null, }) } + onBlur={() => onCommitShift(shift.id)} className="h-9 w-full rounded-lg border border-slate-200 bg-white px-3 text-sm text-slate-900 outline-none focus:border-indigo-400 disabled:bg-slate-100 disabled:text-slate-400" /> @@ -86,6 +90,7 @@ export function WorkShiftSettingsPanel({ endTime: event.target.value || null, }) } + onBlur={() => onCommitShift(shift.id)} className="h-9 w-full rounded-lg border border-slate-200 bg-white px-3 text-sm text-slate-900 outline-none focus:border-indigo-400 disabled:bg-slate-100 disabled:text-slate-400" /> @@ -100,6 +105,7 @@ export function WorkShiftSettingsPanel({ color: event.target.value as WorkShiftColor, }) } + onBlur={() => onCommitShift(shift.id)} className="h-9 w-full rounded-lg border border-slate-200 bg-white px-3 text-sm text-slate-900 outline-none focus:border-indigo-400" > {shiftColors.map((color) => ( @@ -110,6 +116,23 @@ export function WorkShiftSettingsPanel({ + +