feat(WEB): CEO 대시보드 캘린더에 이슈 통합 및 오늘의 이슈 UI 개선

- 캘린더에 오늘의 이슈 데이터 표시 기능 추가
- 이슈 클릭 시 상세 페이지 이동 기능 구현
- 캘린더 필터에 '이슈' 옵션 추가
- TodayIssueListItem에 date 필드 추가
- 오늘의 이슈 섹션 반응형 그리드 레이아웃 개선
- 필터 드롭다운에 항목별 건수 표시
- 캘린더 상세 목록 높이 동적 조절 (calc(100vh-400px))
- 테스트 URL 페이지 기능 개선

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-01-16 18:34:09 +09:00
parent abc3fea293
commit 736c29a007
9 changed files with 546 additions and 108 deletions

View File

@@ -1,7 +1,33 @@
'use client';
import { useState, useEffect } from 'react';
import { ExternalLink, Copy, Check, ChevronDown, ChevronRight, RefreshCw } from 'lucide-react';
import {
ExternalLink,
Copy,
Check,
ChevronDown,
ChevronRight,
RefreshCw,
Link,
Home,
Monitor,
BarChart3,
FileText,
type LucideIcon,
} from 'lucide-react';
// Lucide 아이콘 매핑 (이모지 대신 사용 - Chrome DevTools MCP JSON 직렬화 오류 방지)
const iconComponents: Record<string, LucideIcon> = {
Home,
Monitor,
BarChart3,
FileText,
};
function CategoryIcon({ name, className }: { name: string; className?: string }) {
const IconComponent = iconComponents[name] || FileText;
return <IconComponent className={className} />;
}
export interface UrlItem {
name: string;
@@ -97,7 +123,7 @@ function CategorySection({ category, baseUrl }: { category: UrlCategory; baseUrl
) : (
<ChevronRight className="w-5 h-5 text-gray-500" />
)}
<span className="text-xl">{category.icon}</span>
<CategoryIcon name={category.icon} className="w-5 h-5 text-blue-500" />
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{category.title}
</h2>
@@ -196,8 +222,9 @@ export default function ConstructionTestUrlsClient({ initialData, lastUpdated }:
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
🏭 URL
<h1 className="flex items-center gap-2 text-2xl font-bold text-gray-900 dark:text-white mb-2">
<Link className="w-6 h-6 text-blue-500" />
URL
</h1>
<button
onClick={handleRefresh}
@@ -212,10 +239,10 @@ export default function ConstructionTestUrlsClient({ initialData, lastUpdated }:
URL ({totalLinks})
</p>
<p className="text-xs text-gray-400 mt-1">
: {lastUpdated}
- : {lastUpdated}
</p>
<p className="text-xs text-green-600 dark:text-green-400 mt-1">
md
md
</p>
</div>
@@ -255,7 +282,7 @@ export default function ConstructionTestUrlsClient({ initialData, lastUpdated }:
{/* Footer */}
<div className="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-center text-sm text-gray-500">
<p>
📁 : <code className="bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded">claudedocs/[REF] construction-pages-test-urls.md</code>
: <code className="bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded">claudedocs/[REF] construction-pages-test-urls.md</code>
</p>
<p className="mt-1 text-green-600 dark:text-green-400">
md !

View File

@@ -4,18 +4,43 @@ import { promises as fs } from 'fs';
import path from 'path';
import type { UrlCategory, UrlItem } from './ConstructionTestUrlsClient';
// 아이콘 매핑
// 아이콘 매핑 (Lucide 아이콘 이름 사용 - 이모지는 Chrome DevTools MCP에서 JSON 직렬화 오류 발생)
const iconMap: Record<string, string> = {
'기본': '🏠',
'시스템': '💻',
'대시보드': '📊',
'기본': 'Home',
'시스템': 'Monitor',
'대시보드': 'BarChart3',
};
function getIcon(title: string): string {
for (const [key, icon] of Object.entries(iconMap)) {
if (title.includes(key)) return icon;
}
return '📄';
return 'FileText';
}
// 이모지를 텍스트로 변환 (Chrome DevTools MCP JSON 직렬화 오류 방지)
function convertEmojiToText(text: string): string {
// 먼저 특정 이모지를 의미있는 텍스트로 변환
let result = text
.replace(/✅/g, '[완료]')
.replace(/⚠️?/g, '[주의]')
.replace(/🧪/g, '[테스트]')
.replace(/🆕/g, '[NEW]')
.replace(/•/g, '-');
// 나머지 모든 이모지 및 특수 유니코드 문자 제거
// Unicode emoji 범위와 variation selectors 제거
result = result
.replace(/[\u{1F300}-\u{1F9FF}]/gu, '') //
.replace(/[\u{2600}-\u{26FF}]/gu, '') //
.replace(/[\u{2700}-\u{27BF}]/gu, '') //
.replace(/[\u{FE00}-\u{FE0F}]/gu, '') // Variation Selectors
.replace(/[\u{1F000}-\u{1F02F}]/gu, '') //
.replace(/[\u{1F0A0}-\u{1F0FF}]/gu, '') //
.replace(/[\u200D]/g, '') // Zero Width Joiner
.trim();
return result;
}
function parseTableRow(line: string): UrlItem | null {
@@ -25,9 +50,10 @@ function parseTableRow(line: string): UrlItem | null {
if (parts.length < 2) return null;
if (parts[0] === '페이지' || parts[0].startsWith('---')) return null;
const name = parts[0].replace(/\*\*/g, ''); // **bold** 제거
const name = convertEmojiToText(parts[0].replace(/\*\*/g, '')); // **bold** 제거 + 이모지 변환
const url = parts[1].replace(/`/g, ''); // backtick 제거
const status = parts[2] || undefined;
const rawStatus = parts[2] || '';
const status = convertEmojiToText(rawStatus) || undefined;
// URL이 /ko로 시작하는지 확인
if (!url.startsWith('/ko')) return null;
@@ -63,7 +89,7 @@ function parseMdFile(content: string): { categories: UrlCategory[]; lastUpdated:
categories.push(currentCategory);
}
const title = line.replace('## ', '').replace(/[🏠👥💰📦🏭⚙️📝📋💵]/g, '').trim();
const title = convertEmojiToText(line.replace('## ', ''));
currentCategory = {
title,
icon: getIcon(title),
@@ -81,7 +107,7 @@ function parseMdFile(content: string): { categories: UrlCategory[]; lastUpdated:
currentCategory.subCategories.push(currentSubCategory);
}
const subTitle = line.replace('### ', '').trim();
const subTitle = convertEmojiToText(line.replace('### ', ''));
// "메인 페이지"는 서브카테고리가 아니라 메인 아이템으로
if (subTitle === '메인 페이지') {
currentSubCategory = null;

View File

@@ -1,7 +1,59 @@
'use client';
import { useState, useEffect } from 'react';
import { ExternalLink, Copy, Check, ChevronDown, ChevronRight, RefreshCw } from 'lucide-react';
import {
ExternalLink,
Copy,
Check,
ChevronDown,
ChevronRight,
RefreshCw,
Link,
Home,
Users,
DollarSign,
Package,
Factory,
Boxes,
FlaskConical,
PackageCheck,
Settings,
FileText,
Wallet,
ClipboardList,
BarChart3,
User,
Building2,
CreditCard,
Headphones,
type LucideIcon,
} from 'lucide-react';
// Lucide 아이콘 매핑 (이모지 대신 사용 - Chrome DevTools MCP JSON 직렬화 오류 방지)
const iconComponents: Record<string, LucideIcon> = {
Home,
Users,
DollarSign,
Package,
Factory,
Boxes,
FlaskConical,
PackageCheck,
Settings,
FileText,
Wallet,
ClipboardList,
BarChart3,
User,
Building2,
CreditCard,
Headphones,
};
function CategoryIcon({ name, className }: { name: string; className?: string }) {
const IconComponent = iconComponents[name] || FileText;
return <IconComponent className={className} />;
}
export interface UrlItem {
name: string;
@@ -97,7 +149,7 @@ function CategorySection({ category, baseUrl }: { category: UrlCategory; baseUrl
) : (
<ChevronRight className="w-5 h-5 text-gray-500" />
)}
<span className="text-xl">{category.icon}</span>
<CategoryIcon name={category.icon} className="w-5 h-5 text-blue-500" />
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{category.title}
</h2>
@@ -196,8 +248,9 @@ export default function TestUrlsClient({ initialData, lastUpdated }: TestUrlsCli
{/* Header */}
<div className="mb-8">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900 dark:text-white mb-2">
🔗 URL
<h1 className="flex items-center gap-2 text-2xl font-bold text-gray-900 dark:text-white mb-2">
<Link className="w-6 h-6 text-blue-500" />
URL
</h1>
<button
onClick={handleRefresh}
@@ -212,10 +265,10 @@ export default function TestUrlsClient({ initialData, lastUpdated }: TestUrlsCli
URL ({totalLinks})
</p>
<p className="text-xs text-gray-400 mt-1">
: {lastUpdated}
- : {lastUpdated}
</p>
<p className="text-xs text-green-600 dark:text-green-400 mt-1">
md
md
</p>
</div>
@@ -255,7 +308,7 @@ export default function TestUrlsClient({ initialData, lastUpdated }: TestUrlsCli
{/* Footer */}
<div className="mt-8 pt-6 border-t border-gray-200 dark:border-gray-700 text-center text-sm text-gray-500">
<p>
📁 : <code className="bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded">claudedocs/[REF] all-pages-test-urls.md</code>
: <code className="bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded">claudedocs/[REF] all-pages-test-urls.md</code>
</p>
<p className="mt-1 text-green-600 dark:text-green-400">
md !

View File

@@ -4,34 +4,70 @@ import { promises as fs } from 'fs';
import path from 'path';
import type { UrlCategory, UrlItem } from './TestUrlsClient';
// 아이콘 매핑
// 아이콘 매핑 (Lucide 아이콘 이름 사용 - 이모지는 Chrome DevTools MCP에서 JSON 직렬화 오류 발생)
const iconMap: Record<string, string> = {
'기본': '🏠',
'인사관리': '👥',
'HR': '👥',
'판매관리': '💰',
'Sales': '💰',
'기준정보관리': '📦',
'Master Data': '📦',
'생산관리': '🏭',
'Production': '🏭',
'설정': '⚙️',
'Settings': '⚙️',
'전자결재': '📝',
'Approval': '📝',
'회계관리': '💵',
'Accounting': '💵',
'게시판': '📋',
'Board': '📋',
'보고서': '📊',
'Reports': '📊',
'기본': 'Home',
'인사관리': 'Users',
'HR': 'Users',
'판매관리': 'DollarSign',
'Sales': 'DollarSign',
'기준정보관리': 'Package',
'Master Data': 'Package',
'생산관리': 'Factory',
'Production': 'Factory',
'자재관리': 'Boxes',
'Material': 'Boxes',
'품질관리': 'FlaskConical',
'Quality': 'FlaskConical',
'출고관리': 'PackageCheck',
'Outbound': 'PackageCheck',
'설정': 'Settings',
'Settings': 'Settings',
'전자결재': 'FileText',
'Approval': 'FileText',
'회계관리': 'Wallet',
'Accounting': 'Wallet',
'게시판': 'ClipboardList',
'Board': 'ClipboardList',
'보고서': 'BarChart3',
'Reports': 'BarChart3',
'계정': 'User',
'회사': 'Building2',
'구독': 'CreditCard',
'고객센터': 'Headphones',
'Customer': 'Headphones',
};
function getIcon(title: string): string {
for (const [key, icon] of Object.entries(iconMap)) {
if (title.includes(key)) return icon;
}
return '📄';
return 'FileText';
}
// 이모지를 텍스트로 변환 (Chrome DevTools MCP JSON 직렬화 오류 방지)
function convertEmojiToText(text: string): string {
// 먼저 특정 이모지를 의미있는 텍스트로 변환
let result = text
.replace(/✅/g, '[완료]')
.replace(/⚠️?/g, '[주의]')
.replace(/🧪/g, '[테스트]')
.replace(/🆕/g, '[NEW]')
.replace(/•/g, '-');
// 나머지 모든 이모지 및 특수 유니코드 문자 제거
// Unicode emoji 범위와 variation selectors 제거
result = result
.replace(/[\u{1F300}-\u{1F9FF}]/gu, '') //
.replace(/[\u{2600}-\u{26FF}]/gu, '') //
.replace(/[\u{2700}-\u{27BF}]/gu, '') //
.replace(/[\u{FE00}-\u{FE0F}]/gu, '') // Variation Selectors
.replace(/[\u{1F000}-\u{1F02F}]/gu, '') //
.replace(/[\u{1F0A0}-\u{1F0FF}]/gu, '') //
.replace(/[\u200D]/g, '') // Zero Width Joiner
.trim();
return result;
}
function parseTableRow(line: string): UrlItem | null {
@@ -41,9 +77,10 @@ function parseTableRow(line: string): UrlItem | null {
if (parts.length < 2) return null;
if (parts[0] === '페이지' || parts[0].startsWith('---')) return null;
const name = parts[0].replace(/\*\*/g, ''); // **bold** 제거
const name = convertEmojiToText(parts[0].replace(/\*\*/g, '')); // **bold** 제거 + 이모지 변환
const url = parts[1].replace(/`/g, ''); // backtick 제거
const status = parts[2] || undefined;
const rawStatus = parts[2] || '';
const status = convertEmojiToText(rawStatus) || undefined;
// URL이 /ko로 시작하는지 확인
if (!url.startsWith('/ko')) return null;
@@ -79,7 +116,7 @@ function parseMdFile(content: string): { categories: UrlCategory[]; lastUpdated:
categories.push(currentCategory);
}
const title = line.replace('## ', '').replace(/[🏠👥💰📦🏭⚙️📝📋💵]/g, '').trim();
const title = convertEmojiToText(line.replace('## ', ''));
currentCategory = {
title,
icon: getIcon(title),
@@ -97,7 +134,7 @@ function parseMdFile(content: string): { categories: UrlCategory[]; lastUpdated:
currentCategory.subCategories.push(currentSubCategory);
}
const subTitle = line.replace('### ', '').trim();
const subTitle = convertEmojiToText(line.replace('### ', ''));
// "메인 페이지"는 서브카테고리가 아니라 메인 아이템으로
if (subTitle === '메인 페이지') {
currentSubCategory = null;

View File

@@ -287,6 +287,7 @@ export function CEODashboard() {
{dashboardSettings.calendar && (
<CalendarSection
schedules={data.calendarSchedules}
issues={data.todayIssueList}
onScheduleClick={handleScheduleClick}
onScheduleEdit={handleScheduleEdit}
/>

View File

@@ -21,6 +21,7 @@ export const mockData: CEODashboardData = {
badge: '수주 성공',
content: 'A전자 신규 수주 450,000,000원 확정',
time: '10분 전',
date: '2026-01-16',
needsApproval: false,
path: '/sales/order-management-sales',
},
@@ -29,6 +30,7 @@ export const mockData: CEODashboardData = {
badge: '주식 이슈',
content: 'B물산 미수금 15,000,000원 연체 15일',
time: '1시간 전',
date: '2026-01-16',
needsApproval: false,
path: '/accounting/receivables-status',
},
@@ -37,6 +39,7 @@ export const mockData: CEODashboardData = {
badge: '직정 제고',
content: '원자재 3종 안전재고 미달',
time: '20시간 전',
date: '2026-01-16',
needsApproval: false,
path: '/material/stock-status',
},
@@ -45,6 +48,7 @@ export const mockData: CEODashboardData = {
badge: '지출예상내역서',
content: '품의서명 외 5건 (2,500,000원)',
time: '20시간 전',
date: '2026-01-16',
needsApproval: true,
path: '/approval/inbox',
},
@@ -53,6 +57,7 @@ export const mockData: CEODashboardData = {
badge: '세금 신고',
content: '4분기 부가세 신고 D-15',
time: '20시간 전',
date: '2026-01-16',
needsApproval: false,
path: '/accounting/tax',
},
@@ -61,6 +66,7 @@ export const mockData: CEODashboardData = {
badge: '결재 요청',
content: '법인카드 사용 내역 승인 요청 (김철수)',
time: '30분 전',
date: '2026-01-16',
needsApproval: true,
path: '/approval/inbox',
},
@@ -69,6 +75,7 @@ export const mockData: CEODashboardData = {
badge: '수주 성공',
content: 'C건설 추가 발주 120,000,000원 확정',
time: '2시간 전',
date: '2026-01-16',
needsApproval: false,
path: '/sales/order-management-sales',
},
@@ -77,6 +84,7 @@ export const mockData: CEODashboardData = {
badge: '기타',
content: '신규 거래처 D산업 등록 완료',
time: '3시간 전',
date: '2026-01-16',
needsApproval: false,
path: '/accounting/vendors',
},
@@ -85,6 +93,7 @@ export const mockData: CEODashboardData = {
badge: '결재 요청',
content: '출장비 정산 승인 요청 (이영희)',
time: '4시간 전',
date: '2026-01-16',
needsApproval: true,
path: '/approval/inbox',
},
@@ -93,6 +102,7 @@ export const mockData: CEODashboardData = {
badge: '주식 이슈',
content: 'E물류 미수금 8,500,000원 연체 7일',
time: '5시간 전',
date: '2026-01-16',
needsApproval: false,
path: '/accounting/receivables-status',
},
@@ -101,6 +111,7 @@ export const mockData: CEODashboardData = {
badge: '직정 제고',
content: '부품 A-102 재고 부족 경고',
time: '6시간 전',
date: '2026-01-16',
needsApproval: false,
path: '/material/stock-status',
},
@@ -109,6 +120,7 @@ export const mockData: CEODashboardData = {
badge: '지출예상내역서',
content: '장비 구매 품의서 (15,000,000원)',
time: '8시간 전',
date: '2026-01-16',
needsApproval: true,
path: '/approval/inbox',
},
@@ -117,6 +129,7 @@ export const mockData: CEODashboardData = {
badge: '수주 성공',
content: 'F테크 유지보수 계약 연장 85,000,000원',
time: '어제',
date: '2026-01-15',
needsApproval: false,
path: '/sales/order-management-sales',
},
@@ -125,6 +138,7 @@ export const mockData: CEODashboardData = {
badge: '세금 신고',
content: '원천세 신고 완료',
time: '어제',
date: '2026-01-15',
needsApproval: false,
path: '/accounting/tax',
},
@@ -133,9 +147,165 @@ export const mockData: CEODashboardData = {
badge: '결재 요청',
content: '연차 사용 승인 요청 (박지민 외 2명)',
time: '어제',
date: '2026-01-15',
needsApproval: true,
path: '/hr/vacation-management',
},
// 추가 데이터 (스크롤 테스트용)
{
id: 'til16',
badge: '수주 성공',
content: 'G산업 신규 계약 250,000,000원 확정',
time: '2일 전',
date: '2026-01-14',
needsApproval: false,
path: '/sales/order-management-sales',
},
{
id: 'til17',
badge: '주식 이슈',
content: 'H물류 미수금 12,000,000원 연체 30일',
time: '2일 전',
date: '2026-01-14',
needsApproval: false,
path: '/accounting/receivables-status',
},
{
id: 'til18',
badge: '직정 제고',
content: '원자재 B-205 안전재고 미달 경고',
time: '2일 전',
date: '2026-01-14',
needsApproval: false,
path: '/material/stock-status',
},
{
id: 'til19',
badge: '지출예상내역서',
content: '사무용품 구매 품의서 (500,000원)',
time: '2일 전',
date: '2026-01-14',
needsApproval: true,
path: '/approval/inbox',
},
{
id: 'til20',
badge: '세금 신고',
content: '법인세 중간예납 D-30',
time: '2일 전',
date: '2026-01-14',
needsApproval: false,
path: '/accounting/tax',
},
{
id: 'til21',
badge: '결재 요청',
content: '해외출장 경비 승인 요청 (최민수)',
time: '3일 전',
date: '2026-01-13',
needsApproval: true,
path: '/approval/inbox',
},
{
id: 'til22',
badge: '수주 성공',
content: 'I테크 추가 발주 80,000,000원 확정',
time: '3일 전',
date: '2026-01-13',
needsApproval: false,
path: '/sales/order-management-sales',
},
{
id: 'til23',
badge: '기타',
content: '신규 거래처 J전자 등록 완료',
time: '3일 전',
date: '2026-01-13',
needsApproval: false,
path: '/accounting/vendors',
},
{
id: 'til24',
badge: '주식 이슈',
content: 'K상사 미수금 5,000,000원 연체 45일',
time: '3일 전',
date: '2026-01-13',
needsApproval: false,
path: '/accounting/receivables-status',
},
{
id: 'til25',
badge: '직정 제고',
content: '완제품 C-301 재고 부족 경고',
time: '4일 전',
date: '2026-01-12',
needsApproval: false,
path: '/material/stock-status',
},
{
id: 'til26',
badge: '지출예상내역서',
content: '마케팅 비용 품의서 (3,000,000원)',
time: '4일 전',
date: '2026-01-12',
needsApproval: true,
path: '/approval/inbox',
},
{
id: 'til27',
badge: '결재 요청',
content: '복리후생비 사용 승인 요청 (정영수)',
time: '4일 전',
date: '2026-01-12',
needsApproval: true,
path: '/approval/inbox',
},
{
id: 'til28',
badge: '수주 성공',
content: 'L건설 유지보수 계약 연장 45,000,000원',
time: '5일 전',
date: '2026-01-11',
needsApproval: false,
path: '/sales/order-management-sales',
},
{
id: 'til29',
badge: '기타',
content: '사내 시스템 업데이트 완료',
time: '5일 전',
date: '2026-01-11',
needsApproval: false,
path: '/settings',
},
{
id: 'til30',
badge: '세금 신고',
content: '지방세 납부 완료',
time: '5일 전',
date: '2026-01-11',
needsApproval: false,
path: '/accounting/tax',
},
// 1월 6일 (기획서 스크린샷 날짜) 이슈 데이터
{
id: 'til31',
badge: '직정 제고',
content: '원자재 3종 안전재고 미달',
time: '10일 전',
date: '2026-01-06',
needsApproval: false,
path: '/material/stock-status',
},
{
id: 'til32',
badge: '결재 요청',
content: '출장비 정산 승인 요청',
time: '10일 전',
date: '2026-01-06',
needsApproval: true,
path: '/approval/inbox',
},
],
dailyReport: {
date: '2026년 1월 5일 월요일',

View File

@@ -1,8 +1,10 @@
'use client';
import { useState, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
@@ -10,7 +12,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Plus } from 'lucide-react';
import { Plus, ExternalLink } from 'lucide-react';
import { ScheduleCalendar } from '@/components/common/ScheduleCalendar';
import type { ScheduleEvent } from '@/components/common/ScheduleCalendar/types';
import type {
@@ -18,10 +20,13 @@ import type {
CalendarViewType,
CalendarDeptFilterType,
CalendarTaskFilterType,
TodayIssueListItem,
TodayIssueListBadgeType,
} from '../types';
interface CalendarSectionProps {
schedules: CalendarScheduleItem[];
issues?: TodayIssueListItem[];
onScheduleClick?: (schedule: CalendarScheduleItem) => void;
onScheduleEdit?: (schedule: CalendarScheduleItem) => void;
}
@@ -31,9 +36,21 @@ const SCHEDULE_TYPE_COLORS: Record<string, string> = {
schedule: 'blue',
order: 'green',
construction: 'purple',
issue: 'red',
other: 'gray',
};
// 이슈 뱃지별 색상
const ISSUE_BADGE_COLORS: Record<TodayIssueListBadgeType, string> = {
'수주 성공': 'bg-blue-100 text-blue-700',
'주식 이슈': 'bg-purple-100 text-purple-700',
'직정 제고': 'bg-orange-100 text-orange-700',
'지출예상내역서': 'bg-green-100 text-green-700',
'세금 신고': 'bg-red-100 text-red-700',
'결재 요청': 'bg-yellow-100 text-yellow-700',
'기타': 'bg-gray-100 text-gray-700',
};
// 부서 필터 옵션
const DEPT_FILTER_OPTIONS: { value: CalendarDeptFilterType; label: string }[] = [
{ value: 'all', label: '전체' },
@@ -41,27 +58,41 @@ const DEPT_FILTER_OPTIONS: { value: CalendarDeptFilterType; label: string }[] =
{ value: 'personal', label: '개인' },
];
// 업무 필터 옵션
const TASK_FILTER_OPTIONS: { value: CalendarTaskFilterType; label: string }[] = [
// 업무 필터 옵션 (이슈 추가)
type ExtendedTaskFilterType = CalendarTaskFilterType | 'issue';
const TASK_FILTER_OPTIONS: { value: ExtendedTaskFilterType; label: string }[] = [
{ value: 'all', label: '전체' },
{ value: 'schedule', label: '일정' },
{ value: 'order', label: '발주' },
{ value: 'construction', label: '시공' },
{ value: 'issue', label: '이슈' },
];
export function CalendarSection({
schedules,
issues = [],
onScheduleClick,
onScheduleEdit,
}: CalendarSectionProps) {
const router = useRouter();
const [selectedDate, setSelectedDate] = useState<Date | null>(new Date());
const [currentDate, setCurrentDate] = useState(new Date());
const [viewType, setViewType] = useState<CalendarViewType>('month');
const [deptFilter, setDeptFilter] = useState<CalendarDeptFilterType>('all');
const [taskFilter, setTaskFilter] = useState<CalendarTaskFilterType>('all');
const [taskFilter, setTaskFilter] = useState<ExtendedTaskFilterType>('all');
// 날짜가 있는 이슈만 필터링
const issuesWithDate = useMemo(() => {
return issues.filter((issue) => issue.date);
}, [issues]);
// 필터링된 스케줄
const filteredSchedules = useMemo(() => {
// 이슈 필터일 경우 스케줄 제외
if (taskFilter === 'issue') {
return [];
}
let result = schedules;
// 업무 필터
@@ -75,31 +106,59 @@ export function CalendarSection({
return result;
}, [schedules, taskFilter, deptFilter]);
// ScheduleCalendar용 이벤트 변환
// 필터링된 이슈
const filteredIssues = useMemo(() => {
// 이슈 필터가 아니고 all도 아닌 경우 이슈 제외
if (taskFilter !== 'all' && taskFilter !== 'issue') {
return [];
}
return issuesWithDate;
}, [issuesWithDate, taskFilter]);
// ScheduleCalendar용 이벤트 변환 (스케줄 + 이슈 통합)
const calendarEvents: ScheduleEvent[] = useMemo(() => {
return filteredSchedules.map((schedule) => ({
const scheduleEvents = filteredSchedules.map((schedule) => ({
id: schedule.id,
// 기획서: [부서명] 제목 형식
title: schedule.department ? `[${schedule.department}] ${schedule.title}` : schedule.title,
startDate: schedule.startDate,
endDate: schedule.endDate,
color: SCHEDULE_TYPE_COLORS[schedule.type] || 'gray',
data: schedule,
data: { ...schedule, _type: 'schedule' as const },
}));
}, [filteredSchedules]);
// 선택된 날짜의 일정 목록
const selectedDateSchedules = useMemo(() => {
if (!selectedDate) return [];
const issueEvents = filteredIssues.map((issue) => ({
id: issue.id,
title: `[${issue.badge}] ${issue.content}`,
startDate: issue.date!,
endDate: issue.date!,
color: 'red',
data: { ...issue, _type: 'issue' as const },
}));
return [...scheduleEvents, ...issueEvents];
}, [filteredSchedules, filteredIssues]);
// 선택된 날짜의 일정 + 이슈 목록
const selectedDateItems = useMemo(() => {
if (!selectedDate) return { schedules: [], issues: [] };
// 로컬 타임존 기준으로 날짜 문자열 생성 (UTC 변환 방지)
const year = selectedDate.getFullYear();
const month = String(selectedDate.getMonth() + 1).padStart(2, '0');
const day = String(selectedDate.getDate()).padStart(2, '0');
const dateStr = `${year}-${month}-${day}`;
return filteredSchedules.filter((schedule) => {
const dateSchedules = filteredSchedules.filter((schedule) => {
return schedule.startDate <= dateStr && schedule.endDate >= dateStr;
});
}, [selectedDate, filteredSchedules]);
const dateIssues = filteredIssues.filter((issue) => issue.date === dateStr);
return { schedules: dateSchedules, issues: dateIssues };
}, [selectedDate, filteredSchedules, filteredIssues]);
// 총 건수 계산
const totalItemCount = selectedDateItems.schedules.length + selectedDateItems.issues.length;
// 날짜 포맷 (기획서: "1월 6일 화요일")
const formatSelectedDate = (date: Date) => {
@@ -202,13 +261,13 @@ export function CalendarSection({
onDateClick={handleDateClick}
onEventClick={handleEventClick}
onMonthChange={handleMonthChange}
maxEventsPerDay={2}
maxEventsPerDay={4}
weekStartsOn={1} // 월요일 시작 (기획서)
className="[&_.weekend]:bg-yellow-50"
/>
</div>
{/* 선택된 날짜 일정 목록 */}
{/* 선택된 날짜 일정 + 이슈 목록 */}
<div className="border rounded-lg p-4">
{/* 헤더: 날짜 + 일정등록 버튼 */}
<div className="flex items-center justify-between mb-2">
@@ -242,16 +301,17 @@ export function CalendarSection({
{/* 총 N건 */}
<div className="text-sm text-muted-foreground mb-4">
{selectedDateSchedules.length}
{totalItemCount}
</div>
{selectedDateSchedules.length === 0 ? (
{totalItemCount === 0 ? (
<div className="text-center py-8 text-muted-foreground">
.
</div>
) : (
<div className="space-y-3">
{selectedDateSchedules.map((schedule) => (
<div className="space-y-3 max-h-[calc(100vh-400px)] overflow-y-auto pr-1">
{/* 일정 목록 */}
{selectedDateItems.schedules.map((schedule) => (
<div
key={schedule.id}
className="p-3 border rounded-lg hover:bg-gray-50 transition-colors cursor-pointer"
@@ -267,6 +327,42 @@ export function CalendarSection({
</div>
</div>
))}
{/* 이슈 목록 */}
{selectedDateItems.issues.map((issue) => (
<div
key={issue.id}
className="p-3 border border-red-200 rounded-lg hover:bg-red-50 transition-colors cursor-pointer"
onClick={() => {
if (issue.path) {
router.push(`/ko${issue.path}`);
}
}}
>
{/* 뱃지 + 제목 */}
<div className="flex items-start gap-2 mb-1">
<Badge
variant="secondary"
className={`shrink-0 text-xs ${ISSUE_BADGE_COLORS[issue.badge]}`}
>
{issue.badge}
</Badge>
<span className="font-medium text-sm flex-1">
{issue.content}
</span>
</div>
{/* 시간 + 상세보기 */}
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>{issue.time}</span>
{issue.path && (
<span className="flex items-center gap-1 text-blue-600 hover:underline">
<ExternalLink className="h-3 w-3" />
</span>
)}
</div>
</div>
))}
</div>
)}
</div>

View File

@@ -1,6 +1,6 @@
'use client';
import { useState } from 'react';
import { useState, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
@@ -26,16 +26,16 @@ const BADGE_COLORS: Record<TodayIssueListBadgeType, string> = {
'기타': 'bg-gray-100 text-gray-700 hover:bg-gray-100',
};
// 필터 옵션
const FILTER_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: '수주 성공', label: '수주 성공' },
{ value: '주식 이슈', label: '주식 이슈' },
{ value: '직정 제고', label: '직정 제고' },
{ value: '지출예상내역서', label: '지출예상내역서' },
{ value: '세금 신고', label: '세금 신고' },
{ value: '결재 요청', label: '결재 요청' },
];
// 필터 옵션
const FILTER_KEYS = [
'all',
'수주 성공',
'주식 이슈',
'직정 제고',
'지출예상내역서',
'세금 신고',
'결재 요청',
] as const;
interface TodayIssueSectionProps {
items: TodayIssueListItem[];
@@ -49,6 +49,26 @@ export function TodayIssueSection({ items }: TodayIssueSectionProps) {
// 확인되지 않은 아이템만 필터링
const activeItems = items.filter((item) => !dismissedIds.has(item.id));
// 항목별 수량 계산
const itemCounts = useMemo(() => {
const counts: Record<string, number> = { all: activeItems.length };
FILTER_KEYS.forEach((key) => {
if (key !== 'all') {
counts[key] = activeItems.filter((item) => item.badge === key).length;
}
});
return counts;
}, [activeItems]);
// 필터 옵션 (수량 분리)
const filterOptions = useMemo(() => {
return FILTER_KEYS.map((key) => ({
value: key,
label: key === 'all' ? '전체' : key,
count: itemCounts[key] || 0,
}));
}, [itemCounts]);
// 필터링된 아이템
const filteredItems = filter === 'all'
? activeItems
@@ -83,59 +103,66 @@ export function TodayIssueSection({ items }: TodayIssueSectionProps) {
<Card>
<CardContent className="p-6">
{/* 헤더 */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center mb-4">
<h2 className="text-lg font-semibold text-gray-900"> </h2>
<Select value={filter} onValueChange={setFilter}>
<SelectTrigger className="w-32 h-9">
<SelectTrigger className="w-44 h-9 ml-auto">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
{FILTER_OPTIONS.map((option) => (
{filterOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
<div className="flex items-center justify-between w-full gap-3">
<span>{option.label}</span>
<span className="bg-gray-100 text-gray-600 text-xs px-2 py-0.5 rounded-full font-medium">
{option.count}
</span>
</div>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 리스트 */}
<div className="space-y-3 max-h-[400px] overflow-y-auto pr-1">
{/* 리스트 - 반응형 그리드 (4열 → 1열) */}
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-3 max-h-[400px] overflow-y-auto pr-1">
{filteredItems.length === 0 ? (
<div className="text-center py-8 text-gray-500">
<div className="col-span-full text-center py-8 text-gray-500">
.
</div>
) : (
filteredItems.map((item) => (
<div
key={item.id}
className="flex items-center justify-between p-4 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors cursor-pointer"
className="flex items-center gap-2 p-3 bg-white border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors cursor-pointer"
onClick={() => handleItemClick(item)}
>
{/* 좌측: 뱃지 + 내용 */}
<div className="flex items-center gap-3 flex-1 min-w-0">
<Badge
variant="secondary"
className={`shrink-0 ${BADGE_COLORS[item.badge]}`}
>
{item.badge}
</Badge>
<span className="text-sm text-gray-800 truncate">
{item.content}
</span>
</div>
{/* 뱃지 */}
<Badge
variant="secondary"
className={`shrink-0 text-xs ${BADGE_COLORS[item.badge]}`}
>
{item.badge}
</Badge>
{/* 우측: 시간 + 버튼 */}
<div className="flex items-center gap-3 shrink-0 ml-4" onClick={(e) => e.stopPropagation()}>
<span className="text-xs text-gray-500 whitespace-nowrap">
{item.time}
</span>
{/* 내용 */}
<span className="text-sm text-gray-800 truncate flex-1 min-w-0">
{item.content}
</span>
{/* 시간 */}
<span className="text-xs text-gray-500 whitespace-nowrap shrink-0">
{item.time}
</span>
{/* 버튼 */}
<div className="flex items-center gap-1 shrink-0" onClick={(e) => e.stopPropagation()}>
{item.needsApproval ? (
<div className="flex items-center gap-2">
<>
<Button
size="sm"
variant="default"
className="h-7 px-3 bg-blue-500 hover:bg-blue-600 text-white text-xs"
className="h-6 px-2 bg-blue-500 hover:bg-blue-600 text-white text-xs"
onClick={() => handleApprove(item)}
>
@@ -143,17 +170,17 @@ export function TodayIssueSection({ items }: TodayIssueSectionProps) {
<Button
size="sm"
variant="outline"
className="h-7 px-3 text-xs"
className="h-6 px-2 text-xs"
onClick={() => handleReject(item)}
>
</Button>
</div>
</>
) : (
<Button
size="sm"
variant="outline"
className="h-7 px-3 text-xs text-gray-600 hover:text-green-600 hover:border-green-600 hover:bg-green-50"
className="h-6 px-2 text-xs text-gray-600 hover:text-green-600 hover:border-green-600 hover:bg-green-50"
onClick={() => handleDismiss(item)}
>

View File

@@ -72,6 +72,7 @@ export interface TodayIssueListItem {
badge: TodayIssueListBadgeType;
content: string;
time: string; // "10분 전", "1시간 전" 등
date?: string; // ISO date string (캘린더 표시용)
needsApproval?: boolean; // 승인/반려 버튼 표시 여부
path?: string; // 클릭 시 이동할 경로
}