Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 9 additions & 4 deletions src/app/workspaces/[workspaceId]/dashboard/page.tsx
Original file line number Diff line number Diff line change
@@ -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 }>;
Expand All @@ -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 (
Expand Down
4 changes: 2 additions & 2 deletions src/app/workspaces/[workspaceId]/layout.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions src/app/workspaces/[workspaceId]/page.tsx
Original file line number Diff line number Diff line change
@@ -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<{
Expand All @@ -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();
Expand Down
4 changes: 2 additions & 2 deletions src/app/workspaces/[workspaceId]/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.

if (!workspace) {
notFound();
Expand Down
24 changes: 22 additions & 2 deletions src/app/workspaces/[workspaceId]/work-schedule/page.tsx
Original file line number Diff line number Diff line change
@@ -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';
Comment on lines +2 to +5

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🟠 Major | ⚡ Quick win

getDashboardWorkSchedule와 로직이 중복됩니다.

getCurrentWeekRangeensureWeeklyWorkScheduleEntriesPromise.all(멤버/근무유형/스케줄) 흐름이 src/entities/work-schedule/api/get-dashboard-work-schedule.tsgetDashboardWorkSchedule와 동일합니다. 기존 함수를 재사용해 두 곳이 어긋나지 않도록 하는 것을 권장합니다.

♻️ 제안 리팩터
-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 { getDashboardWorkSchedule } from '`@/entities/work-schedule/api/get-dashboard-work-schedule`';
 import { getCurrentWeekRange } from '`@/entities/work-schedule`';
 import { WorkScheduleView } from '`@/views/store-operation/work-schedule`';
 ...
   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),
-  ]);
+  const { startDate } = getCurrentWeekRange();
+  const { members, shifts, schedule } = await getDashboardWorkSchedule(workspaceId);

Also applies to: 17-23

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/app/workspaces/`[workspaceId]/work-schedule/page.tsx around lines 2 - 5,
Update the work-schedule page’s data-loading flow to reuse
getDashboardWorkSchedule instead of duplicating getCurrentWeekRange,
ensureWeeklyWorkScheduleEntries, and the parallel member/work-type/schedule
requests. Remove the now-unused direct API imports and preserve the page’s
existing returned data shape and behavior.

import { getCurrentWeekRange } from '@/entities/work-schedule';
import { WorkScheduleView } from '@/views/store-operation/work-schedule';

interface WorkSchedulePageProps {
Expand All @@ -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 <WorkScheduleView workspaceId={workspaceId} />;
return (
<WorkScheduleView
workspaceId={workspaceId}
members={members}
shifts={shifts}
schedule={schedule}
weekStartDate={startDate}
/>
);
}
40 changes: 33 additions & 7 deletions src/entities/dashboard-layout/api/get-dashboard-layout.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
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<DashboardLayoutState> {
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),
};
}
25 changes: 20 additions & 5 deletions src/entities/dashboard-layout/api/save-dashboard-layout.ts
Original file line number Diff line number Diff line change
@@ -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> {
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;
}
2 changes: 0 additions & 2 deletions src/entities/dashboard-layout/index.ts
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -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<void> {
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}`);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
20 changes: 20 additions & 0 deletions src/entities/work-schedule/api/get-dashboard-work-schedule.ts
Original file line number Diff line number Diff line change
@@ -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 };
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Original file line number Diff line number Diff line change
@@ -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<WorkScheduleEntry[]> => {
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,
}));
},
);
Original file line number Diff line number Diff line change
@@ -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<WorkShiftOption[]> => {
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,
}));
},
);
Loading