-
Notifications
You must be signed in to change notification settings - Fork 3
feat:매장 운영 업무 스케줄 위젯 추가(#28) #28
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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<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 }); | ||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win
🛡️ 제안: catch로 에러 처리 추가- void saveDashboardLayout(workspaceId, pageType, { layout });
+ saveDashboardLayout(workspaceId, pageType, { layout }).catch((error) => {
+ console.error('Failed to save dashboard layout', error);
+ });📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||
| }, [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), []); | ||||||||||
|
|
||||||||||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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=tsRepository: 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" srcRepository: 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.tsxRepository: TeampleRun/syncly Length of output: 9812 src/widgets/store-operation/dashboard-work-schedule/ui/WorkScheduleSummary.tsx:69-86, 158-182 — 요일별 근무 요약은 목업 초기 스케줄만 집계합니다. 🤖 Prompt for AI Agents |
||
|
|
||
| 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> | ||
| ); | ||
| } | ||
There was a problem hiding this comment.
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