diff --git a/src/features/dashboard/edit-layout/model/useDashboardLayout.ts b/src/features/dashboard/edit-layout/model/useDashboardLayout.ts index e7971b4..a124a3d 100644 --- a/src/features/dashboard/edit-layout/model/useDashboardLayout.ts +++ b/src/features/dashboard/edit-layout/model/useDashboardLayout.ts @@ -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'; @@ -26,43 +26,41 @@ export function useDashboardLayout({ }: UseDashboardLayoutParams) { const [layout, setLayout] = useState(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]); 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), []); diff --git a/src/shared/dashboard/model/widget.types.ts b/src/shared/dashboard/model/widget.types.ts index 3a858d5..41871a3 100644 --- a/src/shared/dashboard/model/widget.types.ts +++ b/src/shared/dashboard/model/widget.types.ts @@ -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; } diff --git a/src/views/dashboard/config/template-widgets.ts b/src/views/dashboard/config/template-widgets.ts index d4c2dcf..b44c28d 100644 --- a/src/views/dashboard/config/template-widgets.ts +++ b/src/views/dashboard/config/template-widgets.ts @@ -19,7 +19,7 @@ export const TEMPLATE_WIDGETS: Record = { ], // 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'], diff --git a/src/views/dashboard/config/widget-catalog.tsx b/src/views/dashboard/config/widget-catalog.tsx index c25cb34..61690b7 100644 --- a/src/views/dashboard/config/widget-catalog.tsx +++ b/src/views/dashboard/config/widget-catalog.tsx @@ -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)와 일치해야 한다. @@ -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) => , + render: (size, { workspaceId }) => , + }, + 'work-schedule': { + layout: { i: 'work-schedule', x: 6, y: 10, w: 6, h: 5, minW: 3, minH: 3 }, + title: '업무 스케줄', + render: (size, { workspaceId }) => ( + + ), }, 'today-schedule': { layout: { i: 'today-schedule', x: 0, y: 10, w: 6, h: 4, minW: 2, minH: 3 }, diff --git a/src/views/dashboard/ui/DashboardGrid.tsx b/src/views/dashboard/ui/DashboardGrid.tsx index 8227882..270fa43 100644 --- a/src/views/dashboard/ui/DashboardGrid.tsx +++ b/src/views/dashboard/ui/DashboardGrid.tsx @@ -26,6 +26,8 @@ const renderResizeHandle = (axis: ResizeHandleAxis, ref: Ref) => ( ); interface DashboardGridProps { + /** 위젯 데이터 조회 스코프 */ + workspaceId: string; /** 위젯 카탈로그 (id → 렌더러) */ widgets: WidgetDefinition[]; layout: Layout; @@ -35,6 +37,7 @@ interface DashboardGridProps { } export default function DashboardGrid({ + workspaceId, widgets, layout, editMode, @@ -104,7 +107,7 @@ export default function DashboardGrid({ )} - {widget.render(size)} + {widget.render(size, { workspaceId })} ); })} diff --git a/src/views/dashboard/ui/DashboardView.tsx b/src/views/dashboard/ui/DashboardView.tsx index 330b07e..63f915c 100644 --- a/src/views/dashboard/ui/DashboardView.tsx +++ b/src/views/dashboard/ui/DashboardView.tsx @@ -57,6 +57,7 @@ export default function DashboardView({ <> {editMode && } b.createdAt.localeCompare(a.createdAt)); - const header = ( 자료실} /> ); @@ -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 ( + + {header} +
+ 등록된 자료가 없습니다. +
+
+ ); + } + if (size === 'sm') { const latest = sortedResources[0]; return ( diff --git a/src/widgets/store-operation/dashboard-work-schedule/index.ts b/src/widgets/store-operation/dashboard-work-schedule/index.ts new file mode 100644 index 0000000..8e2cde6 --- /dev/null +++ b/src/widgets/store-operation/dashboard-work-schedule/index.ts @@ -0,0 +1,2 @@ +// dashboard-work-schedule 위젯의 Public API +export { default as WorkScheduleSummary } from './ui/WorkScheduleSummary'; diff --git a/src/widgets/store-operation/dashboard-work-schedule/ui/WorkScheduleSummary.tsx b/src/widgets/store-operation/dashboard-work-schedule/ui/WorkScheduleSummary.tsx new file mode 100644 index 0000000..a184f49 --- /dev/null +++ b/src/widgets/store-operation/dashboard-work-schedule/ui/WorkScheduleSummary.tsx @@ -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 = ( + 스케줄} /> +); + +const shiftColorClassName: Record = { + 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); + + if (members.length === 0) { + return ( + + {header} +
+ 등록된 근무자가 없습니다. +
+
+ ); + } + + if (size === 'sm') { + return ( + + {header} +
+ + +
+

오늘 근무

+

{totalWorkingMembers}명

+ {primaryShift && ( +

+ {primaryShift.name} · {shiftTimeLabel(primaryShift.startTime, primaryShift.endTime)} +

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

오늘 근무 인원

+

{totalWorkingMembers}명

+
+ + 전체 {members.length}명 + +
+ +
    + {visibleShifts.map((shift) => ( +
  • +
    + + {shift.name} + + + {shiftTimeLabel(shift.startTime, shift.endTime)} + +
    + {todayCounts[shift.id] ?? 0}명 +
  • + ))} +
+ + {size === 'lg' && ( +
+

요일별 근무 요약

+
+ {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 ( +
+

{weekday.label}

+

{workingCount}명

+
+ ); + })} +
+
+ )} +
+ ); +}