-
Notifications
You must be signed in to change notification settings - Fork 3
feat:매장 운영 대시보드 위젯 추가(#25) #26
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 |
|---|---|---|
| @@ -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
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 "다음 4일" 요약에 오늘이 중복 포함됨.
🐛 제안 수정 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 |
||
|
|
||
| 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> | ||
| ); | ||
| } | ||
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.
🎯 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:
Repository: TeampleRun/syncly
Length of output: 3119
🏁 Script executed:
Repository: TeampleRun/syncly
Length of output: 638
🏁 Script executed:
Repository: TeampleRun/syncly
Length of output: 2336
🏁 Script executed:
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
useRefand effect lifecycles: 1. useRef and State Preservation: WhileuseRefis 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]. TheuseRefobject itself is re-created during the second mount, and the previous reference is discarded [4]. If your application logic relies onuseRefstate 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, theuseEffectcleanup 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, meaninguseRefvalues will persist as expected for the component's lifetime [4][2]. To ensure your components are resilient: - Always provide a cleanup function in youruseEffectto destroy or reset resources [7][2]. - Avoid reading or writing touseRef.currentduring the render phase [4][8]. - Do not rely onuseRefto initialize state that must persist through simulated unmount/remount cycles; if data must survive, it should be managed usinguseStateoruseReducer, 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