fix(WEB): React/Next.js 보안 업데이트 및 캘린더/주문관리 개선
- 보안: next 15.5.7 → 15.5.9 (CVE-2025-55184, CVE-2025-55183, CVE-2025-67779) - 보안: react/react-dom 19.2.1 → 19.2.3 - 캘린더: MonthView, ScheduleBar 개선 - 주문관리: 리스트/액션/타입 수정 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
34
package-lock.json
generated
34
package-lock.json
generated
@@ -41,11 +41,11 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.552.0",
|
||||
"next": "^15.5.7",
|
||||
"next": "^15.5.9",
|
||||
"next-intl": "^4.4.0",
|
||||
"react": "^19.2.1",
|
||||
"react": "^19.2.3",
|
||||
"react-day-picker": "^9.11.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-hook-form": "^7.66.0",
|
||||
"recharts": "^3.4.1",
|
||||
"sonner": "^2.0.7",
|
||||
@@ -944,9 +944,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "15.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.7.tgz",
|
||||
"integrity": "sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==",
|
||||
"version": "15.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.9.tgz",
|
||||
"integrity": "sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
@@ -8011,12 +8011,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "15.5.7",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-15.5.7.tgz",
|
||||
"integrity": "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==",
|
||||
"version": "15.5.9",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-15.5.9.tgz",
|
||||
"integrity": "sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "15.5.7",
|
||||
"@next/env": "15.5.9",
|
||||
"@swc/helpers": "0.5.15",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
"postcss": "8.4.31",
|
||||
@@ -8702,9 +8702,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
|
||||
"integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==",
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -8732,15 +8732,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz",
|
||||
"integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==",
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.1"
|
||||
"react": "^19.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hook-form": {
|
||||
|
||||
@@ -45,11 +45,11 @@
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.552.0",
|
||||
"next": "^15.5.7",
|
||||
"next": "^15.5.9",
|
||||
"next-intl": "^4.4.0",
|
||||
"react": "^19.2.1",
|
||||
"react": "^19.2.3",
|
||||
"react-day-picker": "^9.11.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-hook-form": "^7.66.0",
|
||||
"recharts": "^3.4.1",
|
||||
"sonner": "^2.0.7",
|
||||
|
||||
@@ -36,7 +36,6 @@ import {
|
||||
ORDER_SORT_OPTIONS,
|
||||
ORDER_STATUS_STYLES,
|
||||
ORDER_STATUS_LABELS,
|
||||
ORDER_STATUS_CALENDAR_COLORS,
|
||||
ORDER_TYPE_OPTIONS,
|
||||
ORDER_TYPE_LABELS,
|
||||
MOCK_PARTNERS,
|
||||
@@ -45,6 +44,7 @@ import {
|
||||
MOCK_ORDER_MANAGERS,
|
||||
MOCK_ORDER_COMPANIES,
|
||||
MOCK_WORK_TEAM_LEADERS,
|
||||
getScheduleColorByManager,
|
||||
} from './types';
|
||||
import {
|
||||
getOrderList,
|
||||
@@ -155,6 +155,7 @@ export default function OrderManagementListClient({
|
||||
const workTeamOptions: MultiSelectOption[] = useMemo(() => MOCK_WORK_TEAM_LEADERS, []);
|
||||
|
||||
// 달력용 이벤트 데이터 변환 (필터 적용)
|
||||
// 색상: 담당자(orderManager)별 고정 색상
|
||||
const calendarEvents: ScheduleEvent[] = useMemo(() => {
|
||||
return orders
|
||||
.filter((order) => {
|
||||
@@ -183,7 +184,7 @@ export default function OrderManagementListClient({
|
||||
title: `${order.orderManager} - ${order.siteName} / ${order.orderNumber}`,
|
||||
startDate: order.periodStart,
|
||||
endDate: order.periodEnd,
|
||||
color: ORDER_STATUS_CALENDAR_COLORS[order.status],
|
||||
color: getScheduleColorByManager(order.orderManager), // 담당자별 고정 색상
|
||||
status: order.status,
|
||||
data: order,
|
||||
}));
|
||||
|
||||
@@ -5,8 +5,9 @@ import { MOCK_ORDER_DETAIL } from './types';
|
||||
import { format, addDays, subDays, subMonths } from 'date-fns';
|
||||
|
||||
/**
|
||||
* 목업 발주 데이터 생성
|
||||
* 목업 발주 데이터 생성 (고정 데이터)
|
||||
* - types.ts의 MOCK 옵션들과 정확히 일치해야 필터가 동작함
|
||||
* - Math.random() 제거 → index 기반 deterministic 데이터
|
||||
*/
|
||||
function generateMockOrders(): Order[] {
|
||||
// types.ts MOCK_PARTNERS와 일치
|
||||
@@ -61,20 +62,26 @@ function generateMockOrders(): Order[] {
|
||||
const statuses: Order['status'][] = ['waiting', 'order_complete', 'delivery_scheduled', 'delivery_complete'];
|
||||
|
||||
const orders: Order[] = [];
|
||||
const today = new Date();
|
||||
// 고정 기준일 (2026-01-06)
|
||||
const baseDate = new Date(2026, 0, 6);
|
||||
|
||||
for (let i = 0; i < 50; i++) {
|
||||
const partner = partners[Math.floor(Math.random() * partners.length)];
|
||||
const site = sites[Math.floor(Math.random() * sites.length)];
|
||||
const status = statuses[Math.floor(Math.random() * statuses.length)];
|
||||
const orderType = orderTypes[Math.floor(Math.random() * orderTypes.length)];
|
||||
const periodStart = subMonths(today, Math.floor(Math.random() * 3));
|
||||
const periodEnd = addDays(periodStart, Math.floor(Math.random() * 30) + 10);
|
||||
const orderDate = subDays(periodStart, Math.floor(Math.random() * 5));
|
||||
const constructionStartDate = addDays(periodStart, Math.floor(Math.random() * 5));
|
||||
const plannedDelivery = addDays(orderDate, Math.floor(Math.random() * 14) + 3);
|
||||
// index 기반 deterministic 선택 (랜덤 제거)
|
||||
const partner = partners[i % partners.length];
|
||||
const site = sites[i % sites.length];
|
||||
const status = statuses[i % statuses.length];
|
||||
const orderType = orderTypes[i % orderTypes.length];
|
||||
|
||||
// 날짜도 index 기반으로 고정
|
||||
const monthOffset = i % 3; // 0, 1, 2개월 전
|
||||
const dayOffset = (i * 3) % 30; // 0~29일 분산
|
||||
const periodStart = subMonths(addDays(baseDate, -dayOffset), monthOffset);
|
||||
const periodEnd = addDays(periodStart, 10 + (i % 20)); // 10~29일 기간
|
||||
const orderDate = subDays(periodStart, i % 5);
|
||||
const constructionStartDate = addDays(periodStart, i % 5);
|
||||
const plannedDelivery = addDays(orderDate, 3 + (i % 14));
|
||||
const actualDelivery = status === 'delivery_complete'
|
||||
? format(addDays(plannedDelivery, Math.floor(Math.random() * 5) - 2), 'yyyy-MM-dd')
|
||||
? format(addDays(plannedDelivery, (i % 5) - 2), 'yyyy-MM-dd')
|
||||
: null;
|
||||
|
||||
orders.push({
|
||||
@@ -83,24 +90,24 @@ function generateMockOrders(): Order[] {
|
||||
partnerId: partner.id,
|
||||
partnerName: partner.name,
|
||||
siteName: site,
|
||||
name: names[Math.floor(Math.random() * names.length)],
|
||||
constructionPM: constructionPMs[Math.floor(Math.random() * constructionPMs.length)],
|
||||
orderManager: orderManagers[Math.floor(Math.random() * orderManagers.length)],
|
||||
name: names[i % names.length],
|
||||
constructionPM: constructionPMs[i % constructionPMs.length],
|
||||
orderManager: orderManagers[i % orderManagers.length],
|
||||
orderNumber: `ORD-${2026}-${String(i + 1).padStart(4, '0')}`,
|
||||
orderCompany: orderCompanies[Math.floor(Math.random() * orderCompanies.length)],
|
||||
workTeamLeader: workTeamLeaders[Math.floor(Math.random() * workTeamLeaders.length)],
|
||||
orderCompany: orderCompanies[i % orderCompanies.length],
|
||||
workTeamLeader: workTeamLeaders[i % workTeamLeaders.length],
|
||||
constructionStartDate: format(constructionStartDate, 'yyyy-MM-dd'),
|
||||
orderType,
|
||||
item: items[Math.floor(Math.random() * items.length)],
|
||||
quantity: Math.floor(Math.random() * 100) + 1,
|
||||
item: items[i % items.length],
|
||||
quantity: 10 + (i * 7) % 90, // 10~99 고정 패턴
|
||||
orderDate: format(orderDate, 'yyyy-MM-dd'),
|
||||
plannedDeliveryDate: format(plannedDelivery, 'yyyy-MM-dd'),
|
||||
actualDeliveryDate: actualDelivery,
|
||||
status,
|
||||
periodStart: format(periodStart, 'yyyy-MM-dd'),
|
||||
periodEnd: format(periodEnd, 'yyyy-MM-dd'),
|
||||
createdAt: format(subDays(periodStart, Math.floor(Math.random() * 10)), 'yyyy-MM-dd\'T\'HH:mm:ss'),
|
||||
updatedAt: format(today, 'yyyy-MM-dd\'T\'HH:mm:ss'),
|
||||
createdAt: format(subDays(periodStart, i % 10), 'yyyy-MM-dd\'T\'HH:mm:ss'),
|
||||
updatedAt: format(baseDate, 'yyyy-MM-dd\'T\'HH:mm:ss'),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -112,7 +112,7 @@ export const ORDER_STATUS_STYLES: Record<OrderStatus, string> = {
|
||||
};
|
||||
|
||||
/**
|
||||
* 발주 상태별 달력 색상
|
||||
* 발주 상태별 달력 색상 (레거시 - 상태 기반)
|
||||
*/
|
||||
export const ORDER_STATUS_CALENDAR_COLORS: Record<OrderStatus, string> = {
|
||||
waiting: 'yellow',
|
||||
@@ -121,6 +121,67 @@ export const ORDER_STATUS_CALENDAR_COLORS: Record<OrderStatus, string> = {
|
||||
delivery_complete: 'green',
|
||||
};
|
||||
|
||||
/**
|
||||
* 스케줄 색상 팔레트 (10가지 고정 색상)
|
||||
* - 인덱스 기반 순환: 11번째 이벤트는 다시 1번 색상
|
||||
* - API 연동 시에도 동일하게 사용
|
||||
*/
|
||||
export const SCHEDULE_COLOR_PALETTE = [
|
||||
{ name: 'blue', bg: 'bg-blue-500', text: 'text-white', hex: '#3b82f6' },
|
||||
{ name: 'green', bg: 'bg-green-500', text: 'text-white', hex: '#22c55e' },
|
||||
{ name: 'yellow', bg: 'bg-yellow-500', text: 'text-white', hex: '#eab308' },
|
||||
{ name: 'red', bg: 'bg-red-500', text: 'text-white', hex: '#ef4444' },
|
||||
{ name: 'purple', bg: 'bg-purple-500', text: 'text-white', hex: '#a855f7' },
|
||||
{ name: 'pink', bg: 'bg-pink-500', text: 'text-white', hex: '#ec4899' },
|
||||
{ name: 'orange', bg: 'bg-orange-500', text: 'text-white', hex: '#f97316' },
|
||||
{ name: 'teal', bg: 'bg-teal-500', text: 'text-white', hex: '#14b8a6' },
|
||||
{ name: 'indigo', bg: 'bg-indigo-500', text: 'text-white', hex: '#6366f1' },
|
||||
{ name: 'cyan', bg: 'bg-cyan-500', text: 'text-white', hex: '#06b6d4' },
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* 인덱스 기반 스케줄 색상 반환 (순환)
|
||||
* @param index - 이벤트 인덱스 (0부터 시작)
|
||||
* @returns 색상 이름 (ScheduleCalendar에서 사용)
|
||||
*/
|
||||
export function getScheduleColor(index: number): string {
|
||||
return SCHEDULE_COLOR_PALETTE[index % SCHEDULE_COLOR_PALETTE.length].name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 담당자별 색상 매핑
|
||||
* - 같은 담당자는 항상 같은 색상
|
||||
* - MOCK_ORDER_MANAGERS 순서에 따라 색상 할당
|
||||
*/
|
||||
const ORDER_MANAGER_COLOR_MAP: Record<string, string> = {
|
||||
'김담당': 'blue',
|
||||
'이담당': 'green',
|
||||
'박담당': 'pink',
|
||||
'최담당': 'purple',
|
||||
};
|
||||
|
||||
/**
|
||||
* 담당자 이름 기반 스케줄 색상 반환
|
||||
* - 매핑된 담당자: 고정 색상
|
||||
* - 매핑되지 않은 담당자: 이름 해시 기반 색상 (일관성 유지)
|
||||
* @param managerName - 발주담당자 이름
|
||||
* @returns 색상 이름 (ScheduleCalendar에서 사용)
|
||||
*/
|
||||
export function getScheduleColorByManager(managerName: string): string {
|
||||
// 매핑된 담당자면 고정 색상 반환
|
||||
if (ORDER_MANAGER_COLOR_MAP[managerName]) {
|
||||
return ORDER_MANAGER_COLOR_MAP[managerName];
|
||||
}
|
||||
|
||||
// 매핑되지 않은 담당자: 이름 해시 기반으로 일관된 색상 할당
|
||||
let hash = 0;
|
||||
for (let i = 0; i < managerName.length; i++) {
|
||||
hash = managerName.charCodeAt(i) + ((hash << 5) - hash);
|
||||
}
|
||||
const index = Math.abs(hash) % SCHEDULE_COLOR_PALETTE.length;
|
||||
return SCHEDULE_COLOR_PALETTE[index].name;
|
||||
}
|
||||
|
||||
/**
|
||||
* 발주 구분 (타입) 옵션
|
||||
*/
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
isSameDate,
|
||||
splitIntoWeeks,
|
||||
getEventSegmentsForWeek,
|
||||
assignEventRows,
|
||||
assignGlobalEventRows,
|
||||
getEventsForDate,
|
||||
getBadgeForDate,
|
||||
} from './utils';
|
||||
@@ -53,6 +53,12 @@ export function MonthView({
|
||||
// 주 단위로 분할
|
||||
const weeks = useMemo(() => splitIntoWeeks(monthDays), [monthDays]);
|
||||
|
||||
// 전역 row 할당 (먼저 시작한 이벤트가 종료까지 같은 행 유지)
|
||||
const globalRowAssignments = useMemo(
|
||||
() => assignGlobalEventRows(events),
|
||||
[events]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{/* 요일 헤더 */}
|
||||
@@ -89,6 +95,7 @@ export function MonthView({
|
||||
selectedDate={selectedDate}
|
||||
maxEventsPerDay={maxEventsPerDay}
|
||||
weekStartsOn={weekStartsOn}
|
||||
globalRowAssignments={globalRowAssignments}
|
||||
onDateClick={onDateClick}
|
||||
onEventClick={onEventClick}
|
||||
/>
|
||||
@@ -106,6 +113,7 @@ interface WeekRowProps {
|
||||
selectedDate: Date | null;
|
||||
maxEventsPerDay: number;
|
||||
weekStartsOn: 0 | 1;
|
||||
globalRowAssignments: Map<string, number>;
|
||||
onDateClick: (date: Date) => void;
|
||||
onEventClick: (event: import('./types').ScheduleEvent) => void;
|
||||
}
|
||||
@@ -118,6 +126,7 @@ function WeekRow({
|
||||
selectedDate,
|
||||
maxEventsPerDay,
|
||||
weekStartsOn,
|
||||
globalRowAssignments,
|
||||
onDateClick,
|
||||
onEventClick,
|
||||
}: WeekRowProps) {
|
||||
@@ -127,12 +136,6 @@ function WeekRow({
|
||||
[events, weekDays, weekStartsOn]
|
||||
);
|
||||
|
||||
// 이벤트 행 배치
|
||||
const rowAssignments = useMemo(
|
||||
() => assignEventRows(eventSegments),
|
||||
[eventSegments]
|
||||
);
|
||||
|
||||
// 표시할 이벤트 수 계산 (maxEventsPerDay 초과 시 +N 표시)
|
||||
const visibleRows = maxEventsPerDay;
|
||||
|
||||
@@ -152,7 +155,8 @@ function WeekRow({
|
||||
}, [weekDays, events, visibleRows]);
|
||||
|
||||
// 셀 최소 높이 계산 (이벤트 행 수에 따라) - 더 넉넉하게 확보
|
||||
const maxRowIndex = Math.max(0, ...Array.from(rowAssignments.values()));
|
||||
const segmentRowIndices = eventSegments.map(s => globalRowAssignments.get(s.event.id) || 0);
|
||||
const maxRowIndex = Math.max(0, ...segmentRowIndices);
|
||||
const rowHeight = Math.max(120, 40 + Math.min(maxRowIndex + 1, visibleRows) * 28 + 24);
|
||||
|
||||
return (
|
||||
@@ -212,11 +216,11 @@ function WeekRow({
|
||||
{/* 이벤트 바들 (절대 위치) */}
|
||||
{eventSegments
|
||||
.filter((segment) => {
|
||||
const rowIndex = rowAssignments.get(segment.event.id) || 0;
|
||||
const rowIndex = globalRowAssignments.get(segment.event.id) || 0;
|
||||
return rowIndex < visibleRows;
|
||||
})
|
||||
.map((segment) => {
|
||||
const rowIndex = rowAssignments.get(segment.event.id) || 0;
|
||||
const rowIndex = globalRowAssignments.get(segment.event.id) || 0;
|
||||
|
||||
return (
|
||||
<ScheduleBar
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
import { cn } from '@/components/ui/utils';
|
||||
import type { ScheduleEvent } from './types';
|
||||
import { EVENT_COLORS } from './types';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
|
||||
interface ScheduleBarProps {
|
||||
event: ScheduleEvent;
|
||||
@@ -25,6 +31,7 @@ interface ScheduleBarProps {
|
||||
* - 여러 날에 걸치는 바 표시
|
||||
* - 색상 구분 (상태별)
|
||||
* - 호버/클릭 이벤트
|
||||
* - 툴팁으로 담당자/기간 정보 표시
|
||||
*/
|
||||
export function ScheduleBar({
|
||||
event,
|
||||
@@ -42,32 +49,48 @@ export function ScheduleBar({
|
||||
const widthPercent = (colSpan / 7) * 100;
|
||||
const leftPercent = (startCol / 7) * 100;
|
||||
|
||||
// 툴팁 내용 생성
|
||||
const tooltipContent = `${event.title}\n기간: ${event.startDate} ~ ${event.endDate}`;
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick(event);
|
||||
}}
|
||||
className={cn(
|
||||
'absolute h-5 px-2 text-xs font-medium truncate',
|
||||
'transition-all hover:opacity-80 hover:shadow-sm',
|
||||
'flex items-center cursor-pointer',
|
||||
colorClass,
|
||||
// 라운드 처리
|
||||
isStart && isEnd && 'rounded-md',
|
||||
isStart && !isEnd && 'rounded-l-md',
|
||||
!isStart && isEnd && 'rounded-r-md',
|
||||
!isStart && !isEnd && 'rounded-none'
|
||||
)}
|
||||
style={{
|
||||
width: `calc(${widthPercent}% - 4px)`,
|
||||
left: `calc(${leftPercent}% + 2px)`,
|
||||
top: `${rowIndex * 24 + 32}px`, // 날짜 영역(32px) 아래부터 시작
|
||||
}}
|
||||
title={event.title}
|
||||
>
|
||||
{isStart && <span className="truncate">{event.title}</span>}
|
||||
</button>
|
||||
<TooltipProvider delayDuration={200}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onClick(event);
|
||||
}}
|
||||
className={cn(
|
||||
'absolute h-5 px-2 text-xs font-medium truncate',
|
||||
'transition-all hover:opacity-80 hover:shadow-sm',
|
||||
'flex items-center cursor-pointer',
|
||||
colorClass,
|
||||
// 라운드 처리
|
||||
isStart && isEnd && 'rounded-md',
|
||||
isStart && !isEnd && 'rounded-l-md',
|
||||
!isStart && isEnd && 'rounded-r-md',
|
||||
!isStart && !isEnd && 'rounded-none'
|
||||
)}
|
||||
style={{
|
||||
width: `calc(${widthPercent}% - 4px)`,
|
||||
left: `calc(${leftPercent}% + 2px)`,
|
||||
top: `${rowIndex * 24 + 32}px`, // 날짜 영역(32px) 아래부터 시작
|
||||
}}
|
||||
>
|
||||
{isStart && <span className="truncate">{event.title}</span>}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
<div className="text-sm">
|
||||
<p className="font-medium">{event.title}</p>
|
||||
<p className="text-muted-foreground text-xs mt-1">
|
||||
기간: {event.startDate} ~ {event.endDate}
|
||||
</p>
|
||||
</div>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
@@ -161,14 +161,21 @@ export interface WeekViewProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트 색상 매핑
|
||||
* 이벤트 색상 매핑 (10가지 색상 + gray)
|
||||
* - 순환 색상: blue, green, yellow, red, purple, pink, orange, teal, indigo, cyan
|
||||
*/
|
||||
export const EVENT_COLORS: Record<string, string> = {
|
||||
gray: 'bg-gray-400 text-white',
|
||||
blue: 'bg-blue-500 text-white',
|
||||
yellow: 'bg-yellow-500 text-white',
|
||||
green: 'bg-green-500 text-white',
|
||||
yellow: 'bg-yellow-500 text-white',
|
||||
red: 'bg-red-500 text-white',
|
||||
purple: 'bg-purple-500 text-white',
|
||||
pink: 'bg-pink-500 text-white',
|
||||
orange: 'bg-orange-500 text-white',
|
||||
teal: 'bg-teal-500 text-white',
|
||||
indigo: 'bg-indigo-500 text-white',
|
||||
cyan: 'bg-cyan-500 text-white',
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@@ -211,6 +211,7 @@ export function getEventSegmentsForWeek(
|
||||
* 이벤트 행 배치 계산 (다른 색상은 다른 행에 배치)
|
||||
* - 같은 색상(작업반장)끼리만 같은 행 공유 가능
|
||||
* - 다른 색상은 무조건 다른 행에 배치
|
||||
* - 시작일이 빠른 색상 그룹이 위에 배치
|
||||
*/
|
||||
export function assignEventRows(segments: WeekEventSegment[]): Map<string, number> {
|
||||
const rowMap = new Map<string, number>();
|
||||
@@ -225,10 +226,18 @@ export function assignEventRows(segments: WeekEventSegment[]): Map<string, numbe
|
||||
colorGroups.get(color)!.push(segment);
|
||||
});
|
||||
|
||||
// 색상 그룹을 가장 이른 시작일 기준으로 정렬
|
||||
const sortedColorGroups = Array.from(colorGroups.entries()).sort((a, b) => {
|
||||
// 각 그룹에서 가장 이른 시작일 찾기
|
||||
const aMinStart = Math.min(...a[1].map(s => parseISO(s.event.startDate).getTime()));
|
||||
const bMinStart = Math.min(...b[1].map(s => parseISO(s.event.startDate).getTime()));
|
||||
return aMinStart - bMinStart;
|
||||
});
|
||||
|
||||
let currentBaseRow = 0;
|
||||
|
||||
// 각 색상 그룹별로 행 배치
|
||||
colorGroups.forEach((groupSegments) => {
|
||||
// 각 색상 그룹별로 행 배치 (시작일 순)
|
||||
sortedColorGroups.forEach(([, groupSegments]) => {
|
||||
const occupied: boolean[][] = [];
|
||||
|
||||
// 시작 컬럼 순으로 정렬
|
||||
@@ -280,6 +289,51 @@ export function assignEventRows(segments: WeekEventSegment[]): Map<string, numbe
|
||||
return rowMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 전역 이벤트 행 배치 계산 (월간 뷰 전체 기준)
|
||||
* - 먼저 시작한 이벤트가 끝날 때까지 같은 행 유지
|
||||
* - 나중에 시작한 이벤트는 아래 행으로 배치
|
||||
*/
|
||||
export function assignGlobalEventRows(events: ScheduleEvent[]): Map<string, number> {
|
||||
const rowMap = new Map<string, number>();
|
||||
|
||||
if (events.length === 0) return rowMap;
|
||||
|
||||
// 모든 이벤트를 시작일 순으로 정렬 (시작일 같으면 기간 긴 것 먼저)
|
||||
const sortedEvents = [...events].sort((a, b) => {
|
||||
const aStart = parseISO(a.startDate).getTime();
|
||||
const bStart = parseISO(b.startDate).getTime();
|
||||
if (aStart !== bStart) return aStart - bStart;
|
||||
const aDuration = parseISO(a.endDate).getTime() - aStart;
|
||||
const bDuration = parseISO(b.endDate).getTime() - bStart;
|
||||
return bDuration - aDuration;
|
||||
});
|
||||
|
||||
// 각 행의 점유 종료일 추적
|
||||
const rowEndDates: number[] = [];
|
||||
|
||||
sortedEvents.forEach((event) => {
|
||||
const eventStart = parseISO(event.startDate).getTime();
|
||||
const eventEnd = parseISO(event.endDate).getTime();
|
||||
|
||||
// 이 이벤트를 배치할 수 있는 가장 낮은 행 찾기
|
||||
let row = 0;
|
||||
while (row < rowEndDates.length) {
|
||||
// 이전 이벤트가 끝났으면 배치 가능
|
||||
if (rowEndDates[row] < eventStart) {
|
||||
break;
|
||||
}
|
||||
row++;
|
||||
}
|
||||
|
||||
// 행에 배치하고 종료일 업데이트
|
||||
rowEndDates[row] = eventEnd;
|
||||
rowMap.set(event.id, row);
|
||||
});
|
||||
|
||||
return rowMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* 월간 뷰에서 주 단위로 날짜 분할
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user