From e6900e7b283917c0af0ad412766cbb4948bc36e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=EC=84=B1=EC=A7=84?= Date: Mon, 13 Jul 2026 10:11:53 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat:=EC=9B=8C=ED=81=AC=EC=8A=A4=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20DB=20=EC=A1=B0=ED=9A=8C=20=EC=A0=84?= =?UTF-8?q?=ED=99=98(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/workspaces/[workspaceId]/layout.tsx | 4 +- src/app/workspaces/[workspaceId]/page.tsx | 4 +- .../[workspaceId]/settings/page.tsx | 4 +- .../api/get-workspace-members-by-id.ts | 56 +++++++++++++++++++ .../workspace/api/get-workspace-by-id.ts | 29 ++++++++++ src/shared/api/supabase/current-user.ts | 12 ++++ 6 files changed, 103 insertions(+), 6 deletions(-) create mode 100644 src/entities/workspace-member/api/get-workspace-members-by-id.ts create mode 100644 src/entities/workspace/api/get-workspace-by-id.ts create mode 100644 src/shared/api/supabase/current-user.ts 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/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/shared/api/supabase/current-user.ts b/src/shared/api/supabase/current-user.ts new file mode 100644 index 0000000..adcbff1 --- /dev/null +++ b/src/shared/api/supabase/current-user.ts @@ -0,0 +1,12 @@ +// 서버에서 현재 인증 사용자를 조회하고, 인증 연동 전에는 개발 테스트 사용자로 대체합니다. +import { DEV_USER_ID } from '@/shared/config/dev-user'; +import { createSupabaseServerClient } from './server'; + +export async function getCurrentUserId(): Promise { + const supabase = await createSupabaseServerClient(); + const { + data: { user }, + } = await supabase.auth.getUser(); + + return user?.id ?? DEV_USER_ID; +} From cc58d0195b7b314781981453d4fb05ce19d36426 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=95=88=EC=84=B1=EC=A7=84?= Date: Mon, 13 Jul 2026 10:12:04 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=EA=B7=BC=EB=AC=B4=20=EC=8A=A4?= =?UTF-8?q?=EC=BC=80=EC=A4=84=20DB=20=EC=97=B0=EB=8F=99(#42)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../[workspaceId]/work-schedule/page.tsx | 24 ++- .../ensure-weekly-work-schedule-entries.ts | 40 ++++ .../api/get-work-schedule-entries-by-week.ts | 29 +++ .../get-work-shift-types-by-workspace-id.ts | 34 +++ .../api/work-schedule-actions.ts | 194 ++++++++++++++++++ src/entities/work-schedule/index.ts | 3 +- .../lib/count-schedules-by-weekday.ts | 4 +- .../lib/create-initial-work-schedule.ts | 31 ++- .../lib/get-next-work-shift-option.ts | 8 +- .../lib/get-work-members-by-weekday.ts | 4 +- src/entities/work-schedule/lib/work-date.ts | 54 +++++ .../model/mock-work-schedule-config.ts | 12 +- .../model/work-schedule.types.ts | 7 +- .../model/use-work-schedule-state.ts | 122 ++++------- .../ui/WorkScheduleBoard.tsx | 177 +++++++++++++--- .../ui/WorkShiftSettingsPanel.tsx | 28 ++- src/shared/model/database.types.ts | 74 ++++++- .../work-schedule/ui/WorkScheduleView.tsx | 31 +-- .../20260713000000_add_work_shift_types.sql | 168 +++++++++++++++ 19 files changed, 902 insertions(+), 142 deletions(-) create mode 100644 src/entities/work-schedule/api/ensure-weekly-work-schedule-entries.ts create mode 100644 src/entities/work-schedule/api/get-work-schedule-entries-by-week.ts create mode 100644 src/entities/work-schedule/api/get-work-shift-types-by-workspace-id.ts create mode 100644 src/entities/work-schedule/api/work-schedule-actions.ts create mode 100644 src/entities/work-schedule/lib/work-date.ts create mode 100644 supabase/migrations/20260713000000_add_work_shift_types.sql 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/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..19a1d1f --- /dev/null +++ b/src/entities/work-schedule/api/ensure-weekly-work-schedule-entries.ts @@ -0,0 +1,40 @@ +// 이번 주에 아직 배정되지 않은 멤버·요일 조합을 기본 근무유형으로만 생성해 화면과 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 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-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..9bd0abf --- /dev/null +++ b/src/entities/work-schedule/api/work-schedule-actions.ts @@ -0,0 +1,194 @@ +'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']); +// PostgreSQL이 허용하는 UUID 형식 전체를 받는다. 현재 개발 시드 ID는 RFC 버전 비트가 0이다. +const uuidSchema = z + .string() + .regex(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i, { + message: '유효한 UUID 형식이 아닙니다.', + }); +const timeSchema = z.string().regex(/^\d{2}:\d{2}$/); + +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 { error } = await supabase.from('work_schedule_entries').upsert( + { + workspace_id: value.workspaceId, + user_id: value.userId, + work_date: value.workDate, + shift_type_id: value.shiftTypeId, + created_by: await getCurrentUserId(), + }, + { onConflict: 'workspace_id,user_id,work_date' }, + ); + + if (error) throw new Error(`근무 스케줄 저장에 실패했습니다: ${error.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..bb9d9d6 --- /dev/null +++ b/src/entities/work-schedule/lib/work-date.ts @@ -0,0 +1,54 @@ +// 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:00`).getDay()]; +} + +export function getCurrentWeekRange(now = new Date()): { startDate: string; endDate: string } { + const mondayOffset = (now.getDay() + 6) % 7; + const monday = new Date(now); + monday.setDate(now.getDate() - mondayOffset); + monday.setHours(0, 0, 0, 0); + + const sunday = new Date(monday); + sunday.setDate(monday.getDate() + 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:00`); + date.setDate(date.getDate() + weekdayIndex); + + return toDateString(date); +} + +function 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}`; +} 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/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({ + +