Skip to content
Closed
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
32 changes: 15 additions & 17 deletions src/features/dashboard/edit-layout/model/useDashboardLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// 화면에 배치된 위젯 = layout. 추가는 카탈로그 항목(LayoutItem)을 넣고, 삭제는 layout에서 뺀다.
// 초기 레이아웃은 서버(RSC)에서 조회해 initialLayout으로 주입받는다(마운트 후 재조회 없음).
// 저장(쓰기)만 서버액션으로 위임한다. 영속화 키는 (workspaceId, pageType). editMode는 저장하지 않는다.
import { useCallback, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { Layout, LayoutItem } from 'react-grid-layout';

import { saveDashboardLayout, type DashboardLayoutState } from '@/entities/dashboard-layout';
Expand All @@ -26,43 +26,41 @@ export function useDashboardLayout({
}: UseDashboardLayoutParams) {
const [layout, setLayout] = useState<Layout>(initialLayout.layout);
const [editMode, setEditMode] = useState(false);
const didMountRef = useRef(false);

// TODO: DB 연동 — 변경 저장 (드래그 중 잦은 호출은 debounce 예정)
const commit = useCallback(
(next: Layout) => {
void saveDashboardLayout(workspaceId, pageType, { layout: next });
},
[workspaceId, pageType],
);
useEffect(() => {
if (!didMountRef.current) {
didMountRef.current = true;
return;
}

void saveDashboardLayout(workspaceId, pageType, { layout });
}, [layout, workspaceId, pageType]);
Comment on lines +29 to +39

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

React 19 StrictMode useEffect double invocation ref guard first render

💡 Result:

In React 19, Strict Mode continues the behavior of double-invoking useEffect in development to ensure that components are resilient to being mounted and unmounted multiple times [1][2][3]. This is a diagnostic tool, not a bug [1][4]. Using a ref guard (e.g., a useRef boolean) to prevent an effect from running more than once is generally discouraged as a primary solution [2][5]. This pattern often hides underlying issues—such as missing cleanup functions or non-idempotent code—that can still cause bugs in production during navigation or fast component re-mounting [2][6][4]. The recommended approach is to ensure your effects are idempotent by implementing a proper cleanup function [2][3][4]: 1. Implement Cleanup: If your effect sets up a subscription, timer, or network request, your cleanup function should explicitly stop or undo that action (e.g., unsubscribe, clearTimeout, or use AbortController for fetches) [1][7]. 2. Design for Idempotency: Write your effect as if it could be called, cleaned up, and called again at any time [2][4]. If the effect works correctly after this sequence in development, it is considered production-ready [1][2]. 3. Analyze the Need: If you feel a ref guard is necessary, it often indicates the logic should not be inside a useEffect at all [7][5]. Consider moving the logic to event handlers (if it is a response to user action) or to router-level listeners (if it is a response to navigation) [7][4]. There is a known issue where, due to how Strict Mode handles simulated unmounts, ref values may persist in ways that cause ref-guarded logic to execute incorrectly [8]. Relying on ref guards for critical logic is therefore brittle and risks state leaks or duplicate executions [8][5]. In production, these effects will only run once per mount, as the double-invocation behavior is stripped from production builds [1][2][4].

Citations:


🏁 Script executed:

sed -n '1,220p' src/features/dashboard/edit-layout/model/useDashboardLayout.ts && printf '\n---\n' && rg -n "saveDashboardLayout|didMountRef|useEffect" src/features/dashboard -S

Repository: TeampleRun/syncly

Length of output: 3119


🏁 Script executed:

sed -n '1,220p' src/entities/dashboard-layout/index.ts && printf '\n---\n' && rg -n "export .*saveDashboardLayout|function saveDashboardLayout|const saveDashboardLayout" src/entities/dashboard-layout -S

Repository: TeampleRun/syncly

Length of output: 638


🏁 Script executed:

sed -n '1,220p' src/entities/dashboard-layout/api/save-dashboard-layout.ts && printf '\n---\n' && sed -n '1,220p' src/entities/dashboard-layout/api/get-dashboard-layout.ts && printf '\n---\n' && rg -n "initialLayout|useDashboardLayout\\(" src -S

Repository: TeampleRun/syncly

Length of output: 2336


🏁 Script executed:

sed -n '1,220p' src/views/dashboard/ui/DashboardView.tsx && printf '\n---\n' && sed -n '1,220p' src/app/workspaces/[workspaceId]/dashboard/page.tsx

Repository: TeampleRun/syncly

Length of output: 3422


🌐 Web query:

React StrictMode remount preserve useRef state effect second mount React 19

💡 Result:

In React Strict Mode (available in React 18 and 19), components are intentionally double-invoked in development to help identify side effects and impurity issues [1][2]. Regarding the behavior of useRef and effect lifecycles: 1. useRef and State Preservation: While useRef is designed to persist values across renders, Strict Mode's simulated unmount and remount cycle can create confusion [3][2]. During this development-only cycle, the component is unmounted and then remounted [3][2]. The useRef object itself is re-created during the second mount, and the previous reference is discarded [4]. If your application logic relies on useRef state persisting across this simulated cycle, it will break because the initial value (or the value set during the first mount) is lost [5][6]. 2. Effect Cleanup: Because the component remounts, the useEffect cleanup function from the first mount is called, and the effect setup code is run again for the second mount [3][2]. If your code does not properly clean up resources (such as event listeners, timers, or socket connections) in the first pass, you will encounter bugs—such as orphaned processes or memory leaks—because the second mount initiates a fresh, duplicate resource [5][2]. 3. Development vs. Production: This behavior is strictly for development [4][2]. In production, components mount once and the effects run accordingly, meaning useRef values will persist as expected for the component's lifetime [4][2]. To ensure your components are resilient: - Always provide a cleanup function in your useEffect to destroy or reset resources [7][2]. - Avoid reading or writing to useRef.current during the render phase [4][8]. - Do not rely on useRef to initialize state that must persist through simulated unmount/remount cycles; if data must survive, it should be managed using useState or useReducer, which React preserves across remounts [3]. React 19 further refines these checks, including updates to how ref callbacks are invoked, to ensure that components handle the mounting and unmounting lifecycle more predictably [9]. If you are experiencing issues where a cleanup function uses the "wrong" ref, ensure that your setup and cleanup are strictly coupled within the same effect and that you are not leaking variables outside the scope of that effect [6][2].

Citations:


🌐 Web query:

site:react.dev StrictMode useRef preserved remount effect double invoke development

💡 Result:

In React development mode, when StrictMode is enabled, React intentionally calls your component functions twice [1][2] and invokes Effect setup and cleanup functions twice [3][4]. This behavior is designed to stress-test your components, ensuring they are pure [2][5] and that your Effects correctly handle synchronization, mounting, and unmounting [3][4]. Regarding useRef and StrictMode: 1. Persistence of Refs: Refs are preserved between re-renders of the same component instance [6]. While StrictMode causes the component function to run twice, React maintains the ref object's persistence across these calls [6][7]. 2. Ref Callbacks: If you are using ref callbacks (passing a function to the ref attribute), these may be invoked twice in development due to the double-mounting behavior of StrictMode [7]. This is an intentional check to ensure that you are handling the cleanup of those refs (e.g., nulling them out) correctly [4][7]. 3. Development-only Behavior: These double-invocations are strictly for development to help you identify bugs, such as side effects during rendering or incomplete cleanup logic [8][2]. This behavior does not occur in production, nor does it impact the state or ref persistence your application relies on for its core logic [2]. If your application behavior is negatively affected by this double-invocation, it typically indicates that your components are performing side effects during the render phase or failing to properly clean up Effects, both of which are common issues that StrictMode is intended to reveal [3][4][2].

Citations:


didMountRef 가드는 초기 저장을 막지 못합니다.
StrictMode 개발 모드에서는 effect가 한 번 더 실행되므로, 첫 실행에서 didMountRef.current = true가 된 뒤 다시 saveDashboardLayout(...)이 호출될 수 있습니다. DB 저장이 붙으면 마운트 직후 불필요한 쓰기가 생기니, 초기 실행 여부를 별도 dirty 플래그나 이전 layout 비교로 분리하는 쪽이 안전합니다.

🤖 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/features/dashboard/edit-layout/model/useDashboardLayout.ts` around lines
29 - 39, The current didMountRef-based guard in useDashboardLayout does not
reliably block the initial save under StrictMode, so the first mount can still
trigger saveDashboardLayout. Update the effect logic in useDashboardLayout to
track whether the layout has actually changed since the initial render, using a
separate dirty flag or previous-layout comparison instead of relying on
didMountRef. Keep the saveDashboardLayout(workspaceId, pageType, { layout })
call behind that change detection so only real edits are persisted.


const handleLayoutChange = useCallback(
(next: Layout) => {
const positions = next.map(toPosition);
setLayout(positions);
commit(positions);
},
[commit],
[],
);

const addWidget = useCallback(
(item: LayoutItem) =>
setLayout((prev) => {
if (prev.some((entry) => entry.i === item.i)) return prev;
const next = [...prev, toPosition(item)];
commit(next);
return next;
return [...prev, toPosition(item)];
}),
[commit],
[],
);

const removeWidget = useCallback(
(id: string) =>
setLayout((prev) => {
const next = prev.filter((entry) => entry.i !== id);
commit(next);
return next;
return prev.filter((entry) => entry.i !== id);
}),
[commit],
[],
);

const toggleEdit = useCallback(() => setEditMode((prev) => !prev), []);
Expand Down
6 changes: 5 additions & 1 deletion src/shared/dashboard/model/widget.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@ import type { LayoutItem } from 'react-grid-layout';

import type { WidgetSize } from '../lib/widget-size';

export interface WidgetRenderContext {
workspaceId: string;
}

export interface WidgetDefinition {
/** 위젯을 추가할 때의 기본 배치 + 위젯 id(layout.i) */
layout: LayoutItem;
/** 위젯 추가 목록·라벨 표시명 */
title: string;
/** 현재 타일 크기(sm/md/lg)를 받아 밀도가 다른 변형을 렌더 */
render: (size: WidgetSize) => ReactNode;
render: (size: WidgetSize, context: WidgetRenderContext) => ReactNode;
}
2 changes: 1 addition & 1 deletion src/views/dashboard/config/template-widgets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export const TEMPLATE_WIDGETS: Record<WorkspacePurpose, WidgetId[]> = {
],
// TODO: 매장운영 템플릿에 들어가는 위젯 생성, 추가, 수정
// 매장운영 — 일정/업무/캘린더/회의록 (개발 지표 제외)
'store-operation': ['calendar', 'recent-notices', 'recent-resources'],
'store-operation': ['work-schedule', 'calendar', 'recent-notices', 'recent-resources'],
// TODO: 팀플 템플릿에 들어가는 위젯 생성, 추가, 수정
// 팀 프로젝트 — 진척·협업
'team-project': ['my-tasks', 'recent-notes', 'calendar', 'recent-notices', 'recent-resources'],
Expand Down
10 changes: 9 additions & 1 deletion src/views/dashboard/config/widget-catalog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { TodaySchedule } from '@/widgets/side-project/dashboard-today-schedule';
import { Velocity } from '@/widgets/side-project/dashboard-velocity';
import { RecentNotices } from '@/widgets/store-operation/dashboard-recent-notices';
import { RecentResources } from '@/widgets/store-operation/dashboard-recent-resources';
import { WorkScheduleSummary } from '@/widgets/store-operation/dashboard-work-schedule';

// layout의 x/y는 "추가될 때의 기본 위치"이며, 그리드가 충돌 시 자동 정렬한다.
// key는 layout.i(위젯 id)와 일치해야 한다.
Expand Down Expand Up @@ -50,7 +51,14 @@ export const WIDGET_CATALOG = {
'recent-resources': {
layout: { i: 'recent-resources', x: 0, y: 10, w: 6, h: 5, minW: 2, minH: 3 },
title: '최근 자료',
render: (size) => <RecentResources size={size} />,
render: (size, { workspaceId }) => <RecentResources workspaceId={workspaceId} size={size} />,
},
'work-schedule': {
layout: { i: 'work-schedule', x: 6, y: 10, w: 6, h: 5, minW: 3, minH: 3 },
title: '업무 스케줄',
render: (size, { workspaceId }) => (
<WorkScheduleSummary workspaceId={workspaceId} size={size} />
),
},
'today-schedule': {
layout: { i: 'today-schedule', x: 0, y: 10, w: 6, h: 4, minW: 2, minH: 3 },
Expand Down
5 changes: 4 additions & 1 deletion src/views/dashboard/ui/DashboardGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ const renderResizeHandle = (axis: ResizeHandleAxis, ref: Ref<HTMLElement>) => (
);

interface DashboardGridProps {
/** 위젯 데이터 조회 스코프 */
workspaceId: string;
/** 위젯 카탈로그 (id → 렌더러) */
widgets: WidgetDefinition[];
layout: Layout;
Expand All @@ -35,6 +37,7 @@ interface DashboardGridProps {
}

export default function DashboardGrid({
workspaceId,
widgets,
layout,
editMode,
Expand Down Expand Up @@ -104,7 +107,7 @@ export default function DashboardGrid({
</button>
</>
)}
{widget.render(size)}
{widget.render(size, { workspaceId })}
</div>
);
})}
Expand Down
1 change: 1 addition & 0 deletions src/views/dashboard/ui/DashboardView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export default function DashboardView({
<>
{editMode && <EditModeBanner />}
<DashboardGrid
workspaceId={workspaceId}
widgets={CATALOG_WIDGETS}
layout={layout}
editMode={editMode}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,6 @@ import type { WidgetSize } from '@/shared/dashboard/lib/widget-size';
import { WidgetCard, WidgetCardAction, WidgetCardHeader } from '@/shared/dashboard/ui/widget-card';
import { cn } from '@/shared/lib/utils';

// 작성일(내림차순) 정렬 — "최근" 자료를 위로. 원본 배열을 변형하지 않도록 복사 후 정렬한다.
const sortedResources = [...mockResources].sort((a, b) => b.createdAt.localeCompare(a.createdAt));

const header = (
<WidgetCardHeader title="최근 자료" action={<WidgetCardAction>자료실</WidgetCardAction>} />
);
Expand Down Expand Up @@ -41,7 +38,28 @@ function ResourceIcon({ resource, className }: { resource: ResourceItem; classNa
);
}

export default function RecentResources({ size = 'md' }: { size?: WidgetSize }) {
interface RecentResourcesProps {
workspaceId: string;
size?: WidgetSize;
}

export default function RecentResources({ workspaceId, size = 'md' }: RecentResourcesProps) {
// 작성일(내림차순) 정렬 — "최근" 자료를 위로. 원본 배열을 변형하지 않도록 복사 후 정렬한다.
const sortedResources = mockResources
.filter((resource) => resource.workspaceId === workspaceId)
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));

if (sortedResources.length === 0) {
return (
<WidgetCard>
{header}
<div className="text-brand-muted flex min-h-0 flex-1 items-center justify-center text-center text-sm">
등록된 자료가 없습니다.
</div>
</WidgetCard>
);
}

if (size === 'sm') {
const latest = sortedResources[0];
return (
Expand Down
2 changes: 2 additions & 0 deletions src/widgets/store-operation/dashboard-work-schedule/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// dashboard-work-schedule 위젯의 Public API
export { default as WorkScheduleSummary } from './ui/WorkScheduleSummary';
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
// 업무 스케줄 위젯 — 매장 운영 워크스페이스의 오늘 근무 현황과 요일별 요약을 보여준다.
// · sm: 오늘 근무 인원 + 대표 근무 유형
// · md: 오늘 근무 유형별 인원
// · lg: 오늘 근무 유형별 인원 + 다음 4일 근무 인원
import { ClipboardList } from 'lucide-react';

import {
countSchedulesByWeekday,
createInitialWorkSchedule,
mockWorkScheduleConfig,
weekdays,
type WeekdayKey,
type WorkShiftColor,
} from '@/entities/work-schedule';
import { mockWorkspaceMembers } from '@/entities/workspace-member';
import type { WidgetSize } from '@/shared/dashboard/lib/widget-size';
import { WidgetCard, WidgetCardAction, WidgetCardHeader } from '@/shared/dashboard/ui/widget-card';
import { cn } from '@/shared/lib/utils';

const header = (
<WidgetCardHeader title="업무 스케줄" action={<WidgetCardAction>스케줄</WidgetCardAction>} />
);

const shiftColorClassName: Record<WorkShiftColor, string> = {
sky: 'bg-sky-100 text-sky-700',
violet: 'bg-violet-100 text-violet-700',
amber: 'bg-amber-100 text-amber-700',
slate: 'bg-slate-100 text-slate-600',
emerald: 'bg-emerald-100 text-emerald-700',
rose: 'bg-rose-100 text-rose-700',
};

const weekdayByDateIndex: WeekdayKey[] = [
'sunday',
'monday',
'tuesday',
'wednesday',
'thursday',
'friday',
'saturday',
];

function getTodayWeekday(): WeekdayKey {
return weekdayByDateIndex[new Date().getDay()];
}

function getNextWeekdays(today: WeekdayKey, count: number) {
const todayIndex = weekdays.findIndex((weekday) => weekday.key === today);
return Array.from({ length: count }, (_, index) => {
const weekdayIndex = (todayIndex + index) % weekdays.length;
return weekdays[weekdayIndex];
});
}
Comment on lines +47 to +53

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

"다음 4일" 요약에 오늘이 중복 포함됨.

getNextWeekdays(today, count)index=0일 때 todayIndex + 0이므로 첫 항목이 오늘 자신입니다. 파일 상단 주석(Line 4)은 "오늘 근무 유형별 인원 + 다음 4일 근무 인원"이라고 명시하는데, 실제로는 오늘을 포함해 3일치만 "다음" 정보로 추가됩니다. 상단에서 이미 오늘 근무 인원을 보여주므로 그리드 첫 칸이 중복입니다.

🐛 제안 수정
 function getNextWeekdays(today: WeekdayKey, count: number) {
   const todayIndex = weekdays.findIndex((weekday) => weekday.key === today);
   return Array.from({ length: count }, (_, index) => {
-    const weekdayIndex = (todayIndex + index) % weekdays.length;
+    const weekdayIndex = (todayIndex + index + 1) % weekdays.length;
     return weekdays[weekdayIndex];
   });
 }

Also applies to: 122-122

🤖 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/widgets/store-operation/dashboard-work-schedule/ui/WorkScheduleSummary.tsx`
around lines 47 - 53, `getNextWeekdays` is including `today` as the first item,
which duplicates the already-shown current day in `WorkScheduleSummary`. Update
the helper so the sequence starts from the day after `today` (for example by
offsetting the index in `getNextWeekdays`), and keep the existing `weekdays`
lookup logic in `WorkScheduleSummary` aligned with the “오늘 근무 유형별 인원 + 다음 4일”
summary.


function shiftTimeLabel(startTime: string | null, endTime: string | null) {
if (!startTime || !endTime) return '휴무';
return `${startTime}-${endTime}`;
}

interface WorkScheduleSummaryProps {
workspaceId: string;
size?: WidgetSize;
}

export default function WorkScheduleSummary({
workspaceId,
size = 'md',
}: WorkScheduleSummaryProps) {
const members = mockWorkspaceMembers.filter((member) => member.workspaceId === workspaceId);
const schedule = createInitialWorkSchedule({
workspaceId,
members,
config: mockWorkScheduleConfig,
});
const today = getTodayWeekday();
const todayCounts = countSchedulesByWeekday({
schedule,
config: mockWorkScheduleConfig,
weekday: today,
});
const workingShifts = mockWorkScheduleConfig.shifts.filter((shift) => !shift.isOff);
const totalWorkingMembers = workingShifts.reduce(
(total, shift) => total + (todayCounts[shift.id] ?? 0),
0,
);
const primaryShift = workingShifts.find((shift) => (todayCounts[shift.id] ?? 0) > 0);

if (members.length === 0) {
return (
<WidgetCard>
{header}
<div className="text-brand-muted flex min-h-0 flex-1 items-center justify-center text-center text-sm">
등록된 근무자가 없습니다.
</div>
</WidgetCard>
);
}

if (size === 'sm') {
return (
<WidgetCard>
{header}
<div className="flex items-center gap-3">
<span className="bg-brand/10 text-brand flex size-10 shrink-0 items-center justify-center rounded-xl">
<ClipboardList className="size-5" aria-hidden="true" />
</span>
<div className="min-w-0">
<p className="text-brand-muted text-xs">오늘 근무</p>
<p className="text-brand-ink mt-0.5 text-lg font-bold">{totalWorkingMembers}명</p>
{primaryShift && (
<p className="text-brand-muted truncate text-xs">
{primaryShift.name} · {shiftTimeLabel(primaryShift.startTime, primaryShift.endTime)}
</p>
)}
</div>
</div>
</WidgetCard>
);
}

const visibleShifts = size === 'md' ? workingShifts : mockWorkScheduleConfig.shifts;
const nextDays = getNextWeekdays(today, 4);

return (
<WidgetCard>
{header}
<div className="mb-3 flex items-end justify-between gap-3">
<div>
<p className="text-brand-muted text-xs">오늘 근무 인원</p>
<p className="text-brand-ink mt-1 text-2xl font-bold">{totalWorkingMembers}명</p>
</div>
<span className="text-brand-muted rounded-full bg-slate-50 px-2.5 py-1 text-xs font-semibold">
전체 {members.length}명
</span>
</div>

<ul className="flex min-h-0 flex-1 flex-col gap-2 overflow-y-auto">
{visibleShifts.map((shift) => (
<li key={shift.id} className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<span
className={cn(
'inline-flex shrink-0 rounded-full px-2 py-1 text-xs font-semibold',
shiftColorClassName[shift.color],
)}
>
{shift.name}
</span>
<span className="text-brand-muted truncate text-xs">
{shiftTimeLabel(shift.startTime, shift.endTime)}
</span>
</div>
<span className="text-brand-ink text-sm font-bold">{todayCounts[shift.id] ?? 0}명</span>
</li>
))}
</ul>

{size === 'lg' && (
<div className="border-brand/10 mt-4 border-t pt-3">
<p className="text-brand-muted mb-2 text-xs font-semibold">요일별 근무 요약</p>
<div className="grid grid-cols-4 gap-2">
{nextDays.map((weekday) => {
const counts = countSchedulesByWeekday({
schedule,
config: mockWorkScheduleConfig,
weekday: weekday.key,
});
const workingCount = workingShifts.reduce(
(total, shift) => total + (counts[shift.id] ?? 0),
0,
);

return (
<div key={weekday.key} className="bg-brand-surface rounded-xl px-2 py-2 text-center">
<p className="text-brand-muted text-[11px]">{weekday.label}</p>
<p className="text-brand-ink mt-0.5 text-sm font-bold">{workingCount}명</p>
</div>
);
})}
</div>
</div>
)}
</WidgetCard>
);
}