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:
@@ -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 파일 수정 후 새로고침하면 자동 반영!
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 파일 수정 후 새로고침하면 자동 반영!
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -287,6 +287,7 @@ export function CEODashboard() {
|
||||
{dashboardSettings.calendar && (
|
||||
<CalendarSection
|
||||
schedules={data.calendarSchedules}
|
||||
issues={data.todayIssueList}
|
||||
onScheduleClick={handleScheduleClick}
|
||||
onScheduleEdit={handleScheduleEdit}
|
||||
/>
|
||||
|
||||
@@ -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일 월요일',
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
>
|
||||
확인
|
||||
|
||||
@@ -72,6 +72,7 @@ export interface TodayIssueListItem {
|
||||
badge: TodayIssueListBadgeType;
|
||||
content: string;
|
||||
time: string; // "10분 전", "1시간 전" 등
|
||||
date?: string; // ISO date string (캘린더 표시용)
|
||||
needsApproval?: boolean; // 승인/반려 버튼 표시 여부
|
||||
path?: string; // 클릭 시 이동할 경로
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user