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 예정)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

TODO: 드래그 중 저장 호출 debounce 필요

주석에 명시된 대로, layout이 변경될 때마다(드래그/리사이즈 stop 등) saveDashboardLayout이 호출되어 동일 세션에서 반복 호출이 발생할 수 있습니다. 실제 DB 연동 시 debounce나 트랜지션 배칭을 적용하는 것을 권장합니다. 원하시면 debounce 유틸리티 추가를 도와드릴까요?

🤖 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` at line 31,
The layout-save path in useDashboardLayout currently calls saveDashboardLayout
on every layout change, which can trigger repeated saves during drag/resize.
Update the effect or change handler in useDashboardLayout to debounce or batch
these calls so saveDashboardLayout is invoked only after changes settle, using
the existing layout state and saveDashboardLayout symbol to keep the behavior
centralized.

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 });

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

saveDashboardLayout 프로미스 에러 처리 누락

void saveDashboardLayout(...)는 프로미스를 버릴 뿐 실패를 처리하지 않습니다. 현재는 no-op 스텁이라 문제가 없지만, TODO에 명시된 실제 DB 연동이 구현되면 예외 발생 시 unhandled rejection으로 조용히 실패합니다.

🛡️ 제안: catch로 에러 처리 추가
-    void saveDashboardLayout(workspaceId, pageType, { layout });
+    saveDashboardLayout(workspaceId, pageType, { layout }).catch((error) => {
+      console.error('Failed to save dashboard layout', error);
+    });
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
void saveDashboardLayout(workspaceId, pageType, { layout });
saveDashboardLayout(workspaceId, pageType, { layout }).catch((error) => {
console.error('Failed to save dashboard layout', error);
});
🤖 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` at line 38,
The `useDashboardLayout` save path is discarding the `saveDashboardLayout`
promise without handling failures, so update the call site to explicitly catch
errors instead of relying on `void`. Use the `saveDashboardLayout` invocation in
`useDashboardLayout` to attach a rejection handler that logs or otherwise
handles the error, keeping the current fire-and-forget behavior while preventing
unhandled rejections when the real DB implementation is added.

}, [layout, workspaceId, pageType]);

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];
});
}

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);
Comment on lines +69 to +86

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

🏁 Script executed:

#!/bin/bash
# create-initial-work-schedule.ts의 실제 용도(placeholder 여부) 및 다른 소비처 확인
rg -n "createInitialWorkSchedule" -A5 -B5 --type=ts

Repository: TeampleRun/syncly

Length of output: 155


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== files =="
git ls-files | rg 'WorkScheduleSummary\.tsx|work-schedule|createInitialWorkSchedule|countSchedulesByWeekday|mockWorkScheduleConfig|mockWorkspaceMembers'

echo
echo "== usages =="
rg -n "createInitialWorkSchedule|countSchedulesByWeekday|mockWorkScheduleConfig|mockWorkspaceMembers|getTodayWeekday" src

echo
echo "== likely file outline =="
fd -a "WorkScheduleSummary.tsx|work-schedule.*ts" src

Repository: TeampleRun/syncly

Length of output: 5914


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== create-initial-work-schedule =="
cat -n src/entities/work-schedule/lib/create-initial-work-schedule.ts

echo
echo "== count-schedules-by-weekday =="
cat -n src/entities/work-schedule/lib/count-schedules-by-weekday.ts

echo
echo "== WorkScheduleSummary (relevant range) =="
sed -n '1,240p' src/widgets/store-operation/dashboard-work-schedule/ui/WorkScheduleSummary.tsx

echo
echo "== WorkScheduleView =="
cat -n src/views/store-operation/work-schedule/ui/WorkScheduleView.tsx

Repository: TeampleRun/syncly

Length of output: 9812


src/widgets/store-operation/dashboard-work-schedule/ui/WorkScheduleSummary.tsx:69-86, 158-182 — 요일별 근무 요약은 목업 초기 스케줄만 집계합니다. createInitialWorkSchedule가 모든 멤버에게 모든 요일의 같은 defaultShift.id를 넣어서, 이 그리드는 실제 요일별 차이 대신 항상 비슷한 값만 보여줍니다. 실제 주간 비교가 필요하면 스케줄 상태를 주입하거나, 목업이면 이 섹션을 다른 표현으로 바꾸는 편이 낫습니다.

🤖 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 69 - 86, The weekday summary in WorkScheduleSummary is aggregating
only the mock initial schedule, so it never reflects real day-to-day variation.
Update the logic around createInitialWorkSchedule, countSchedulesByWeekday, and
the workingShifts/primaryShift calculations to use actual schedule state or
injected data instead of the fixed mock defaultShift pattern; if this view is
meant to stay mock-only, replace this section with a simpler non-comparative
display that does not imply real weekday differences.


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>
);
}