feat(service): Console_SendMail을 Windows 서비스로 변환

- MailService.cs 추가: ServiceBase 상속받는 Windows 서비스 클래스
- Program.cs 수정: 서비스/콘솔 모드 지원, 설치/제거 기능 추가
- 프로젝트 설정: System.ServiceProcess 참조 추가
- 배치 파일 추가: 서비스 설치/제거/콘솔실행 스크립트

주요 기능:
- Windows 서비스로 백그라운드 실행
- 명령행 인수로 모드 선택 (-install, -uninstall, -console)
- EventLog를 통한 서비스 로깅
- 안전한 서비스 시작/중지 처리

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
ChiKyun Kim
2025-09-11 09:08:40 +09:00
parent 777fcd5d89
commit 6bd4f84192
49 changed files with 46882 additions and 102 deletions

View File

@@ -0,0 +1,158 @@
<!-- 공통 네비게이션 -->
<nav id="main-navigation" class="glass-effect border-b border-white/10 relative z-40">
<div class="container mx-auto px-4">
<div class="flex items-center justify-between h-16">
<!-- 로고 및 브랜드 -->
<div class="flex items-center space-x-8">
<div class="flex items-center space-x-2">
<svg class="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z M8 5a2 2 0 012-2h4a2 2 0 012 2v2H8V5z"></path>
</svg>
<span class="text-xl font-bold text-white">GroupWare</span>
</div>
<!-- 데스크톱 메뉴 -->
<nav class="hidden md:flex space-x-1">
<a href="/DashBoard/"
class="nav-item px-3 py-2 rounded-md text-sm font-medium transition-colors text-white/60 hover:text-white hover:bg-white/10"
data-page="dashboard">
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z M8 5a2 2 0 012-2h4a2 2 0 012 2v2H8V5z"></path>
</svg>
대시보드
</a>
<a href="/Common/"
class="nav-item px-3 py-2 rounded-md text-sm font-medium transition-colors text-white/60 hover:text-white hover:bg-white/10"
data-page="common">
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
공용코드
</a>
<a href="/Jobreport/"
class="nav-item px-3 py-2 rounded-md text-sm font-medium transition-colors text-white/60 hover:text-white hover:bg-white/10"
data-page="jobreport">
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
</svg>
업무일지
</a>
<a href="/Kuntae/"
class="nav-item px-3 py-2 rounded-md text-sm font-medium transition-colors text-white/60 hover:text-white hover:bg-white/10"
data-page="kuntae">
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
근태관리
</a>
<a href="/Todo/"
class="nav-item px-3 py-2 rounded-md text-sm font-medium transition-colors text-white/60 hover:text-white hover:bg-white/10"
data-page="todo">
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2M12 12l2 2 4-4"></path>
</svg>
할일관리
</a>
<a href="/Project/"
class="nav-item px-3 py-2 rounded-md text-sm font-medium transition-colors text-white/60 hover:text-white hover:bg-white/10"
data-page="project">
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
</svg>
프로젝트
</a>
<a href="/Purchase/"
class="nav-item px-3 py-2 rounded-md text-sm font-medium transition-colors text-white/60 hover:text-white hover:bg-white/10"
data-page="purchase">
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z"></path>
</svg>
구매관리
</a>
<a href="/Customer/"
class="nav-item px-3 py-2 rounded-md text-sm font-medium transition-colors text-white/60 hover:text-white hover:bg-white/10"
data-page="customer">
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path>
</svg>
고객관리
</a>
</nav>
</div>
<!-- 우측 메뉴 -->
<div class="flex items-center space-x-4">
<div class="text-sm text-white/60">
<span>사용자</span>
</div>
<!-- 모바일 메뉴 버튼 -->
<button id="mobile-menu-button"
class="md:hidden text-white/60 hover:text-white focus:outline-none focus:text-white">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 6h16M4 12h16M4 18h16"></path>
</svg>
</button>
</div>
</div>
<!-- 모바일 메뉴 -->
<div id="mobile-menu" class="md:hidden border-t border-white/10 py-2 hidden">
<a href="/DashBoard/" class="nav-item-mobile block px-3 py-2 text-sm font-medium transition-colors text-white/60 hover:text-white hover:bg-white/10" data-page="dashboard">
<svg class="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z M8 5a2 2 0 012-2h4a2 2 0 012 2v2H8V5z"></path>
</svg>
대시보드
</a>
<a href="/Common/" class="nav-item-mobile block px-3 py-2 text-sm font-medium transition-colors text-white/60 hover:text-white hover:bg-white/10" data-page="common">
<svg class="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
공용코드
</a>
<a href="/Jobreport/" class="nav-item-mobile block px-3 py-2 text-sm font-medium transition-colors text-white/60 hover:text-white hover:bg-white/10" data-page="jobreport">
<svg class="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
</svg>
업무일지
</a>
<a href="/Kuntae/" class="nav-item-mobile block px-3 py-2 text-sm font-medium transition-colors text-white/60 hover:text-white hover:bg-white/10" data-page="kuntae">
<svg class="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
근태관리
</a>
<a href="/Todo/" class="nav-item-mobile block px-3 py-2 text-sm font-medium transition-colors text-white/60 hover:text-white hover:bg-white/10" data-page="todo">
<svg class="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2M12 12l2 2 4-4"></path>
</svg>
할일관리
</a>
<a href="/Project/" class="nav-item-mobile block px-3 py-2 text-sm font-medium transition-colors text-white/60 hover:text-white hover:bg-white/10" data-page="project">
<svg class="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
</svg>
프로젝트
</a>
<a href="/Purchase/" class="nav-item-mobile block px-3 py-2 text-sm font-medium transition-colors text-white/60 hover:text-white hover:bg-white/10" data-page="purchase">
<svg class="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z"></path>
</svg>
구매관리
</a>
<a href="/Customer/" class="nav-item-mobile block px-3 py-2 text-sm font-medium transition-colors text-white/60 hover:text-white hover:bg-white/10" data-page="customer">
<svg class="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path>
</svg>
고객관리
</a>
</div>
</div>
</nav>

View File

@@ -0,0 +1,490 @@
/* Tailwind CSS v3.3.0 - Custom Build for GroupWare React Components */
/*! tailwindcss v3.3.0 | MIT License | https://tailwindcss.com */
*,::after,::before{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}::after,::before{--tw-content:''}html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;tab-size:4;font-family:ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";font-feature-settings:normal;font-variation-settings:normal}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace, SFMono-Regular, "Roboto Mono", "Courier New", monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}textarea{resize:vertical}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}
/* Base Variables */
:root {
--color-white: 255 255 255;
--color-black: 0 0 0;
--color-gray-50: 249 250 251;
--color-gray-100: 243 244 246;
--color-gray-200: 229 231 235;
--color-gray-300: 209 213 219;
--color-gray-400: 156 163 175;
--color-gray-500: 107 114 128;
--color-gray-600: 75 85 99;
--color-gray-700: 55 65 81;
--color-gray-800: 31 41 55;
--color-gray-900: 17 24 39;
--color-blue-50: 239 246 255;
--color-blue-100: 219 234 254;
--color-blue-200: 191 219 254;
--color-blue-300: 147 197 253;
--color-blue-400: 96 165 250;
--color-blue-500: 59 130 246;
--color-blue-600: 37 99 235;
--color-blue-700: 29 78 216;
--color-blue-800: 30 64 175;
--color-blue-900: 30 58 138;
--color-indigo-50: 238 242 255;
--color-indigo-100: 224 231 255;
--color-indigo-200: 199 210 254;
--color-indigo-300: 165 180 252;
--color-indigo-400: 129 140 248;
--color-indigo-500: 99 102 241;
--color-indigo-600: 79 70 229;
--color-indigo-700: 67 56 202;
--color-indigo-800: 55 48 163;
--color-indigo-900: 49 46 129;
--color-purple-50: 245 243 255;
--color-purple-100: 237 233 254;
--color-purple-200: 221 214 254;
--color-purple-300: 196 181 253;
--color-purple-400: 167 139 250;
--color-purple-500: 139 92 246;
--color-purple-600: 124 58 237;
--color-purple-700: 109 40 217;
--color-purple-800: 91 33 182;
--color-purple-900: 76 29 149;
--color-green-50: 240 253 244;
--color-green-100: 220 252 231;
--color-green-200: 187 247 208;
--color-green-300: 134 239 172;
--color-green-400: 74 222 128;
--color-green-500: 34 197 94;
--color-green-600: 22 163 74;
--color-green-700: 21 128 61;
--color-green-800: 22 101 52;
--color-green-900: 20 83 45;
--color-yellow-50: 255 251 235;
--color-yellow-100: 254 243 199;
--color-yellow-200: 253 230 138;
--color-yellow-300: 252 211 77;
--color-yellow-400: 251 191 36;
--color-yellow-500: 245 158 11;
--color-yellow-600: 217 119 6;
--color-yellow-700: 180 83 9;
--color-yellow-800: 146 64 14;
--color-yellow-900: 120 53 15;
--color-red-50: 254 242 242;
--color-red-100: 254 226 226;
--color-red-200: 252 165 165;
--color-red-300: 248 113 113;
--color-red-400: 239 68 68;
--color-red-500: 239 68 68;
--color-red-600: 220 38 38;
--color-red-700: 185 28 28;
--color-red-800: 153 27 27;
--color-red-900: 127 29 29;
}
/* Utility Classes - Core */
.container { width: 100%; }
@media (min-width: 640px) { .container { max-width: 640px; } }
@media (min-width: 768px) { .container { max-width: 768px; } }
@media (min-width: 1024px) { .container { max-width: 1024px; } }
@media (min-width: 1280px) { .container { max-width: 1280px; } }
@media (min-width: 1536px) { .container { max-width: 1536px; } }
.mx-auto { margin-left: auto; margin-right: auto; }
.px-4 { padding-left: 1rem; padding-right: 1rem; }
.py-8 { padding-top: 2rem; padding-bottom: 2rem; }
/* Positioning */
.fixed { position: fixed; }
.relative { position: relative; }
.absolute { position: absolute; }
.inset-0 { inset: 0px; }
.top-4 { top: 1rem; }
.right-4 { right: 1rem; }
.top-full { top: 100%; }
.z-10 { z-index: 10; }
.z-40 { z-index: 40; }
.z-50 { z-index: 50; }
/* Display */
.block { display: block; }
.inline-block { display: inline-block; }
.inline { display: inline; }
.flex { display: flex; }
.inline-flex { display: inline-flex; }
.table { display: table; }
.table-cell { display: table-cell; }
.grid { display: grid; }
.hidden { display: none; }
/* Flexbox */
.flex-1 { flex: 1 1 0%; }
.flex-wrap { flex-wrap: wrap; }
.items-center { align-items: center; }
.items-end { align-items: flex-end; }
.justify-center { justify-content: center; }
.justify-between { justify-content: space-between; }
.justify-end { justify-content: flex-end; }
/* Grid */
.grid-cols-1 { grid-template-columns: repeat(1, minmax(0, 1fr)); }
.grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
@media (min-width: 768px) {
.md\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.md\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.md\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
@media (min-width: 1024px) {
.lg\:col-span-2 { grid-column: span 2 / span 2; }
.lg\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (min-width: 640px) {
.sm\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
/* Spacing */
.gap-2 { gap: 0.5rem; }
.gap-3 { gap: 0.75rem; }
.gap-4 { gap: 1rem; }
.gap-6 { gap: 1.5rem; }
.space-x-1 > :not([hidden]) ~ :not([hidden]) { --tw-space-x-reverse: 0; margin-right: calc(0.25rem * var(--tw-space-x-reverse)); margin-left: calc(0.25rem * calc(1 - var(--tw-space-x-reverse))); }
.space-x-2 > :not([hidden]) ~ :not([hidden]) { --tw-space-x-reverse: 0; margin-right: calc(0.5rem * var(--tw-space-x-reverse)); margin-left: calc(0.5rem * calc(1 - var(--tw-space-x-reverse))); }
.space-x-3 > :not([hidden]) ~ :not([hidden]) { --tw-space-x-reverse: 0; margin-right: calc(0.75rem * var(--tw-space-x-reverse)); margin-left: calc(0.75rem * calc(1 - var(--tw-space-x-reverse))); }
.space-x-4 > :not([hidden]) ~ :not([hidden]) { --tw-space-x-reverse: 0; margin-right: calc(1rem * var(--tw-space-x-reverse)); margin-left: calc(1rem * calc(1 - var(--tw-space-x-reverse))); }
.space-x-8 > :not([hidden]) ~ :not([hidden]) { --tw-space-x-reverse: 0; margin-right: calc(2rem * var(--tw-space-x-reverse)); margin-left: calc(2rem * calc(1 - var(--tw-space-x-reverse))); }
.space-y-1 > :not([hidden]) ~ :not([hidden]) { --tw-space-y-reverse: 0; margin-top: calc(0.25rem * calc(1 - var(--tw-space-y-reverse))); margin-bottom: calc(0.25rem * var(--tw-space-y-reverse)); }
.space-y-3 > :not([hidden]) ~ :not([hidden]) { --tw-space-y-reverse: 0; margin-top: calc(0.75rem * calc(1 - var(--tw-space-y-reverse))); margin-bottom: calc(0.75rem * var(--tw-space-y-reverse)); }
.space-y-4 > :not([hidden]) ~ :not([hidden]) { --tw-space-y-reverse: 0; margin-top: calc(1rem * calc(1 - var(--tw-space-y-reverse))); margin-bottom: calc(1rem * var(--tw-space-y-reverse)); }
/* Margins */
.m-2 { margin: 0.5rem; }
.mb-1 { margin-bottom: 0.25rem; }
.mb-2 { margin-bottom: 0.5rem; }
.mb-4 { margin-bottom: 1rem; }
.mb-6 { margin-bottom: 1.5rem; }
.mr-1 { margin-right: 0.25rem; }
.mr-2 { margin-right: 0.5rem; }
.mr-3 { margin-right: 0.75rem; }
.ml-2 { margin-left: 0.5rem; }
.ml-4 { margin-left: 1rem; }
.ml-auto { margin-left: auto; }
.mt-6 { margin-top: 1.5rem; }
/* Padding */
.p-1 { padding: 0.25rem; }
.p-3 { padding: 0.75rem; }
.p-4 { padding: 1rem; }
.p-6 { padding: 1.5rem; }
.p-8 { padding: 2rem; }
.px-2 { padding-left: 0.5rem; padding-right: 0.5rem; }
.px-3 { padding-left: 0.75rem; padding-right: 0.75rem; }
.px-4 { padding-left: 1rem; padding-right: 1rem; }
.px-6 { padding-left: 1.5rem; padding-right: 1.5rem; }
.py-0 { padding-top: 0px; padding-bottom: 0px; }
.py-1 { padding-top: 0.25rem; padding-bottom: 0.25rem; }
.py-2 { padding-top: 0.5rem; padding-bottom: 0.5rem; }
.py-3 { padding-top: 0.75rem; padding-bottom: 0.75rem; }
.py-4 { padding-top: 1rem; padding-bottom: 1rem; }
.pt-0 { padding-top: 0px; }
.pb-0 { padding-bottom: 0px; }
/* Size */
.w-4 { width: 1rem; }
.w-5 { width: 1.25rem; }
.w-6 { width: 1.5rem; }
.w-8 { width: 2rem; }
.w-12 { width: 3rem; }
.w-16 { width: 4rem; }
.w-20 { width: 5rem; }
.w-24 { width: 6rem; }
.w-28 { width: 7rem; }
.w-32 { width: 8rem; }
.w-40 { width: 10rem; }
.w-48 { width: 12rem; }
.w-full { width: 100%; }
.min-w-32 { min-width: 8rem; }
.min-w-48 { min-width: 12rem; }
.max-w-xs { max-width: 20rem; }
.max-w-md { max-width: 28rem; }
.max-w-2xl { max-width: 42rem; }
.h-4 { height: 1rem; }
.h-5 { height: 1.25rem; }
.h-6 { height: 1.5rem; }
.h-8 { height: 2rem; }
.h-10 { height: 2.5rem; }
.h-16 { height: 4rem; }
.min-h-screen { min-height: 100vh; }
.max-h-40 { max-height: 10rem; }
/* Background */
.bg-white { background-color: rgb(255 255 255); }
.bg-black { background-color: rgb(0 0 0); }
.bg-gray-500 { background-color: rgb(107 114 128); }
.bg-gray-800 { background-color: rgb(31 41 55); }
.bg-blue-300 { background-color: rgb(147 197 253); }
.bg-blue-500 { background-color: rgb(59 130 246); }
.bg-green-300 { background-color: rgb(134 239 172); }
.bg-green-500 { background-color: rgb(34 197 94); }
.bg-yellow-300 { background-color: rgb(252 211 77); }
.bg-yellow-500 { background-color: rgb(245 158 11); }
.bg-red-300 { background-color: rgb(248 113 113); }
.bg-red-400 { background-color: rgb(239 68 68); }
.bg-red-500 { background-color: rgb(239 68 68); }
.bg-purple-500 { background-color: rgb(139 92 246); }
.bg-orange-500 { background-color: rgb(249 115 22); }
/* Background with opacity */
.bg-white\/5 { background-color: rgb(255 255 255 / 0.05); }
.bg-white\/10 { background-color: rgb(255 255 255 / 0.1); }
.bg-white\/20 { background-color: rgb(255 255 255 / 0.2); }
.bg-white\/30 { background-color: rgb(255 255 255 / 0.3); }
.bg-gray-500\/20 { background-color: rgb(107 114 128 / 0.2); }
.bg-blue-500\/20 { background-color: rgb(59 130 246 / 0.2); }
.bg-green-500\/20 { background-color: rgb(34 197 94 / 0.2); }
.bg-yellow-500\/20 { background-color: rgb(245 158 11 / 0.2); }
.bg-red-500\/20 { background-color: rgb(239 68 68 / 0.2); }
.bg-purple-500\/20 { background-color: rgb(139 92 246 / 0.2); }
.bg-orange-500\/90 { background-color: rgb(249 115 22 / 0.9); }
.bg-blue-500\/90 { background-color: rgb(59 130 246 / 0.9); }
.bg-green-500\/90 { background-color: rgb(34 197 94 / 0.9); }
.bg-red-500\/90 { background-color: rgb(239 68 68 / 0.9); }
/* Gradient backgrounds */
.bg-gradient-to-br { background-image: linear-gradient(to bottom right, var(--tw-gradient-stops)); }
.from-blue-900 { --tw-gradient-from: rgb(30 58 138); --tw-gradient-to: rgb(30 58 138 / 0); --tw-gradient-stops: var(--tw-gradient-from), var(--tw-gradient-to); }
.via-purple-900 { --tw-gradient-to: rgb(76 29 149 / 0); --tw-gradient-stops: var(--tw-gradient-from), rgb(76 29 149), var(--tw-gradient-to); }
.to-indigo-900 { --tw-gradient-to: rgb(49 46 129); }
/* Text Colors */
.text-white { color: rgb(255 255 255); }
.text-gray-300 { color: rgb(209 213 219); }
.text-gray-500 { color: rgb(107 114 128); }
.text-blue-300 { color: rgb(147 197 253); }
.text-green-300 { color: rgb(134 239 172); }
.text-yellow-300 { color: rgb(252 211 77); }
.text-red-300 { color: rgb(248 113 113); }
.text-red-400 { color: rgb(239 68 68); }
.text-orange-100 { color: rgb(255 237 213); }
.text-orange-900 { color: rgb(154 52 18); }
.text-primary-200 { color: rgb(147 197 253); }
.text-primary-300 { color: rgb(147 197 253); }
.text-primary-400 { color: rgb(96 165 250); }
.text-success-200 { color: rgb(187 247 208); }
.text-success-300 { color: rgb(134 239 172); }
.text-success-400 { color: rgb(74 222 128); }
.text-warning-300 { color: rgb(252 211 77); }
.text-danger-300 { color: rgb(248 113 113); }
.text-danger-400 { color: rgb(239 68 68); }
/* Text with opacity */
.text-white\/50 { color: rgb(255 255 255 / 0.5); }
.text-white\/60 { color: rgb(255 255 255 / 0.6); }
.text-white\/70 { color: rgb(255 255 255 / 0.7); }
.text-white\/80 { color: rgb(255 255 255 / 0.8); }
/* Typography */
.text-xs { font-size: 0.75rem; line-height: 1rem; }
.text-sm { font-size: 0.875rem; line-height: 1.25rem; }
.text-base { font-size: 1rem; line-height: 1.5rem; }
.text-lg { font-size: 1.125rem; line-height: 1.75rem; }
.text-xl { font-size: 1.25rem; line-height: 1.75rem; }
.text-2xl { font-size: 1.5rem; line-height: 2rem; }
.font-medium { font-weight: 500; }
.font-semibold { font-weight: 600; }
.font-bold { font-weight: 700; }
.uppercase { text-transform: uppercase; }
.tracking-wider { letter-spacing: 0.05em; }
.text-left { text-align: left; }
.text-center { text-align: center; }
.truncate { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.underline { text-decoration-line: underline; }
.whitespace-nowrap { white-space: nowrap; }
/* Borders */
.border { border-width: 1px; }
.border-b { border-bottom-width: 1px; }
.border-l-4 { border-left-width: 4px; }
.border-r { border-right-width: 1px; }
.border-t { border-top-width: 1px; }
.border-white\/10 { border-color: rgb(255 255 255 / 0.1); }
.border-white\/20 { border-color: rgb(255 255 255 / 0.2); }
.border-white\/30 { border-color: rgb(255 255 255 / 0.3); }
.border-gray-500\/30 { border-color: rgb(107 114 128 / 0.3); }
.border-orange-700 { border-color: rgb(194 65 12); }
.divide-y > :not([hidden]) ~ :not([hidden]) { --tw-divide-y-reverse: 0; border-top-width: calc(1px * calc(1 - var(--tw-divide-y-reverse))); border-bottom-width: calc(1px * var(--tw-divide-y-reverse)); }
.divide-white\/10 > :not([hidden]) ~ :not([hidden]) { border-color: rgb(255 255 255 / 0.1); }
.divide-white\/20 > :not([hidden]) ~ :not([hidden]) { border-color: rgb(255 255 255 / 0.2); }
/* Border Radius */
.rounded { border-radius: 0.25rem; }
.rounded-md { border-radius: 0.375rem; }
.rounded-lg { border-radius: 0.5rem; }
.rounded-2xl { border-radius: 1rem; }
.rounded-full { border-radius: 9999px; }
/* Shadows */
.shadow-sm { box-shadow: 0 1px 2px 0 rgb(0 0 0 / 0.05); }
.shadow-lg { box-shadow: 0 10px 15px -3px rgb(0 0 0 / 0.1), 0 4px 6px -2px rgb(0 0 0 / 0.05); }
/* Overflow */
.overflow-hidden { overflow: hidden; }
.overflow-x-auto { overflow-x: auto; }
.overflow-y-auto { overflow-y: auto; }
/* Interactivity */
.cursor-pointer { cursor: pointer; }
.cursor-not-allowed { cursor: not-allowed; }
/* Focus */
.focus\:outline-none:focus { outline: 2px solid transparent; outline-offset: 2px; }
.focus\:ring-1:focus { --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color); box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); }
.focus\:ring-2:focus { --tw-ring-offset-shadow: var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color); --tw-ring-shadow: var(--tw-ring-inset) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color); box-shadow: var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow, 0 0 #0000); }
.focus\:ring-white\/50:focus { --tw-ring-color: rgb(255 255 255 / 0.5); }
.focus\:ring-primary-400:focus { --tw-ring-color: rgb(96 165 250); }
.focus\:border-transparent:focus { border-color: transparent; }
.focus\:ring-offset-0:focus { --tw-ring-offset-width: 0px; }
/* Hover Effects */
.hover\:bg-white\/10:hover { background-color: rgb(255 255 255 / 0.1); }
.hover\:bg-white\/20:hover { background-color: rgb(255 255 255 / 0.2); }
.hover\:bg-white\/30:hover { background-color: rgb(255 255 255 / 0.3); }
.hover\:bg-primary-600:hover { background-color: rgb(37 99 235); }
.hover\:bg-green-600:hover { background-color: rgb(22 163 74); }
.hover\:bg-red-600:hover { background-color: rgb(220 38 38); }
.hover\:bg-purple-600:hover { background-color: rgb(124 58 237); }
.hover\:text-white:hover { color: rgb(255 255 255); }
.hover\:text-blue-200:hover { color: rgb(191 219 254); }
.hover\:text-blue-300:hover { color: rgb(147 197 253); }
.hover\:text-primary-300:hover { color: rgb(147 197 253); }
/* Disabled */
.disabled\:opacity-50:disabled { opacity: 0.5; }
.disabled\:cursor-not-allowed:disabled { cursor: not-allowed; }
/* Transitions */
.transition-colors { transition-property: color, background-color, border-color, text-decoration-color, fill, stroke; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
.transition-all { transition-property: all; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
.transition-transform { transition-property: transform; transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1); transition-duration: 150ms; }
.duration-200 { transition-duration: 200ms; }
.duration-300 { transition-duration: 300ms; }
/* Transforms */
.transform { transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); }
.translate-x-0 { --tw-translate-x: 0px; transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); }
.rotate-180 { --tw-rotate: 180deg; transform: translate(var(--tw-translate-x), var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y)); }
/* Animations */
.animate-spin { animation: spin 1s linear infinite; }
.animate-pulse { animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
.animate-slide-up { animation: slideUp 0.3s ease-out; }
.animate-fade-in { animation: fadeIn 0.5s ease-in-out; }
@keyframes spin {
to { transform: rotate(360deg); }
}
@keyframes pulse {
50% { opacity: .5; }
}
@keyframes slideUp {
from { transform: translateY(10px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
/* Backdrop Filter */
.backdrop-blur-sm { backdrop-filter: blur(4px); }
/* Opacity */
.opacity-0 { opacity: 0; }
.opacity-50 { opacity: 0.5; }
.opacity-100 { opacity: 1; }
/* Visibility */
.invisible { visibility: hidden; }
.visible { visibility: visible; }
/* Responsive Design Utilities */
@media (min-width: 640px) {
.sm\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (min-width: 768px) {
.md\:flex { display: flex; }
.md\:hidden { display: none; }
.md\:grid-cols-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.md\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.md\:grid-cols-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
}
@media (min-width: 1024px) {
.lg\:col-span-2 { grid-column: span 2 / span 2; }
.lg\:grid-cols-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
/* Custom Tailwind Config Extensions */
.bg-primary-500 { background-color: rgb(59 130 246); }
.bg-primary-600 { background-color: rgb(37 99 235); }
.bg-success-500 { background-color: rgb(34 197 94); }
.bg-success-600 { background-color: rgb(22 163 74); }
.bg-warning-500 { background-color: rgb(245 158 11); }
.bg-warning-600 { background-color: rgb(217 119 6); }
.bg-danger-500 { background-color: rgb(239 68 68); }
.bg-danger-600 { background-color: rgb(220 38 38); }
.bg-primary-500\/20 { background-color: rgb(59 130 246 / 0.2); }
.bg-primary-500\/30 { background-color: rgb(59 130 246 / 0.3); }
.bg-success-500\/20 { background-color: rgb(34 197 94 / 0.2); }
.bg-success-500\/30 { background-color: rgb(34 197 94 / 0.3); }
.bg-warning-500\/20 { background-color: rgb(245 158 11 / 0.2); }
.bg-warning-500\/30 { background-color: rgb(245 158 11 / 0.3); }
.bg-danger-500\/20 { background-color: rgb(239 68 68 / 0.2); }
.bg-danger-500\/30 { background-color: rgb(239 68 68 / 0.3); }
/* Custom Utilities for the project */
.glass-effect {
background: rgba(255, 255, 255, 0.25);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.18);
}
.card-hover {
transition: all 0.3s ease;
}
.card-hover:hover {
transform: translateY(-5px);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 8px;
border: 2px solid rgba(255, 255, 255, 0.1);
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
}
.loading {
border: 3px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top: 3px solid #fff;
width: 20px;
height: 20px;
animation: spin 1s linear infinite;
}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,81 @@
// Tailwind CSS Configuration for GroupWare React Components
// This configuration includes all custom colors and extensions used in the React components
window.tailwind = {
config: {
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
},
success: {
50: '#f0fdf4',
100: '#dcfce7',
200: '#bbf7d0',
300: '#86efac',
400: '#4ade80',
500: '#22c55e',
600: '#16a34a',
700: '#15803d',
800: '#166534',
900: '#14532d',
},
warning: {
50: '#fffbeb',
100: '#fef3c7',
200: '#fde68a',
300: '#fcd34d',
400: '#fbbf24',
500: '#f59e0b',
600: '#d97706',
700: '#b45309',
800: '#92400e',
900: '#78350f',
},
danger: {
50: '#fef2f2',
100: '#fee2e2',
200: '#fecaca',
300: '#fca5a5',
400: '#f87171',
500: '#ef4444',
600: '#dc2626',
700: '#b91c1c',
800: '#991b1b',
900: '#7f1d1d',
}
},
animation: {
'fade-in': 'fadeIn 0.5s ease-in-out',
'slide-up': 'slideUp 0.3s ease-out',
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
}
}
}
}
}
};
// Apply configuration if tailwindcss is available
if (typeof tailwindcss !== 'undefined') {
tailwindcss.config = window.tailwind.config;
}

View File

@@ -0,0 +1,559 @@
// CommonApp.jsx - React Common Code Management Component for GroupWare
const { useState, useEffect, useRef } = React;
const CommonApp = () => {
// 상태 관리
const [groupData, setGroupData] = useState([]);
const [currentData, setCurrentData] = useState([]);
const [selectedGroupCode, setSelectedGroupCode] = useState(null);
const [selectedGroupName, setSelectedGroupName] = useState('');
const [loading, setLoading] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deleteTargetIdx, setDeleteTargetIdx] = useState(null);
const [editMode, setEditMode] = useState('add');
// 편집 폼 데이터
const [editData, setEditData] = useState({
idx: '',
grp: '',
code: '',
svalue: '',
ivalue: '',
fvalue: '',
svalue2: '',
memo: ''
});
// 페이지 로드시 초기 데이터 로드
useEffect(() => {
loadGroups();
}, []);
// API 호출 함수들
const loadGroups = async () => {
setLoading(true);
try {
const response = await fetch('http://127.0.0.1:7979/Common/GetGroups');
const data = await response.json();
setGroupData(data || []);
} catch (error) {
console.error('그룹 데이터 로드 중 오류 발생:', error);
showNotification('그룹 데이터 로드 중 오류가 발생했습니다.', 'error');
} finally {
setLoading(false);
}
};
const loadDataByGroup = async (grp) => {
setLoading(true);
try {
let url = 'http://127.0.0.1:7979/Common/GetList';
if (grp) {
url += '?grp=' + encodeURIComponent(grp);
}
const response = await fetch(url);
const data = await response.json();
setCurrentData(data || []);
} catch (error) {
console.error('데이터 로드 중 오류 발생:', error);
showNotification('데이터 로드 중 오류가 발생했습니다.', 'error');
} finally {
setLoading(false);
}
};
const saveData = async () => {
try {
const data = {
idx: parseInt(editData.idx) || 0,
grp: editData.grp,
code: editData.code,
svalue: editData.svalue,
ivalue: parseInt(editData.ivalue) || 0,
fvalue: parseFloat(editData.fvalue) || 0.0,
svalue2: editData.svalue2,
memo: editData.memo
};
setLoading(true);
const response = await fetch('http://127.0.0.1:7979/Common/Save', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
});
const result = await response.json();
if (result.Success) {
showNotification(result.Message, 'success');
setShowEditModal(false);
if (selectedGroupCode) {
loadDataByGroup(selectedGroupCode);
}
} else {
showNotification(result.Message, 'error');
}
} catch (error) {
console.error('저장 중 오류 발생:', error);
showNotification('저장 중 오류가 발생했습니다.', 'error');
} finally {
setLoading(false);
}
};
const deleteItem = async () => {
if (!deleteTargetIdx) return;
try {
setLoading(true);
const response = await fetch('http://127.0.0.1:7979/Common/Delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ idx: deleteTargetIdx })
});
const data = await response.json();
if (data.Success) {
showNotification(data.Message, 'success');
setShowDeleteModal(false);
if (selectedGroupCode) {
loadDataByGroup(selectedGroupCode);
}
} else {
showNotification(data.Message, 'error');
}
} catch (error) {
console.error('삭제 중 오류 발생:', error);
showNotification('삭제 중 오류가 발생했습니다.', 'error');
} finally {
setLoading(false);
setDeleteTargetIdx(null);
}
};
// 이벤트 핸들러들
const selectGroup = (code, name) => {
setSelectedGroupCode(code);
setSelectedGroupName(name);
loadDataByGroup(code);
};
const openAddModal = () => {
if (!selectedGroupCode) {
showNotification('먼저 코드그룹을 선택하세요.', 'warning');
return;
}
setEditMode('add');
setEditData({
idx: '',
grp: selectedGroupCode,
code: '',
svalue: '',
ivalue: '',
fvalue: '',
svalue2: '',
memo: ''
});
setShowEditModal(true);
};
const openEditModal = (item) => {
setEditMode('edit');
setEditData({
idx: item.idx,
grp: item.grp || '',
code: item.code || '',
svalue: item.svalue || '',
ivalue: item.ivalue || '',
fvalue: item.fvalue || '',
svalue2: item.svalue2 || '',
memo: item.memo || ''
});
setShowEditModal(true);
};
const openDeleteModal = (idx) => {
setDeleteTargetIdx(idx);
setShowDeleteModal(true);
setShowEditModal(false);
};
const closeModals = () => {
setShowEditModal(false);
setShowDeleteModal(false);
setDeleteTargetIdx(null);
};
const handleInputChange = (field, value) => {
setEditData(prev => ({ ...prev, [field]: value }));
};
const showNotification = (message, type = 'info') => {
// 기존 알림 제거
const existing = document.querySelectorAll('.notification-toast');
existing.forEach(el => el.remove());
const colors = {
info: 'bg-blue-500/90',
success: 'bg-green-500/90',
warning: 'bg-yellow-500/90',
error: 'bg-red-500/90'
};
const icons = {
info: '🔵',
success: '✅',
warning: '⚠️',
error: '❌'
};
const notification = document.createElement('div');
notification.className = `notification-toast fixed top-4 right-4 ${colors[type]} backdrop-blur-sm text-white px-4 py-3 rounded-lg z-50 shadow-lg border border-white/20`;
notification.innerHTML = `<div class="flex items-center"><span class="mr-2">${icons[type]}</span>${message}</div>`;
document.body.appendChild(notification);
setTimeout(() => {
notification.style.transform = 'translateX(100%)';
notification.style.opacity = '0';
setTimeout(() => notification.remove(), 300);
}, 3000);
};
// 키보드 이벤트
useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === 'Escape') {
closeModals();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, []);
return (
<div className="bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900 min-h-screen text-white">
{/* Navigation Component */}
<CommonNavigation currentPage="common" />
<div className="container mx-auto px-4 py-8">
{/* 2열 구조 메인 컨테이너 */}
<div className="flex gap-6 h-[calc(100vh-200px)]">
{/* 좌측: 코드그룹 리스트 */}
<div className="w-80">
<div className="glass-effect rounded-2xl h-full card-hover animate-slide-up flex flex-col">
<div className="p-4 border-b border-white/10">
<h3 className="text-lg font-semibold text-white flex items-center">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 11H5m14-7l-7 7-7-7M19 21l-7-7-7 7"></path>
</svg>
코드그룹 목록
</h3>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar p-2">
<div className="space-y-1">
{groupData.length === 0 ? (
<div className="text-white/70 text-center py-4">그룹 데이터가 없습니다.</div>
) : (
groupData.map(group => (
<div
key={group.code}
className={`cursor-pointer p-3 rounded-lg border border-white/20 hover:bg-white/10 transition-all ${
selectedGroupCode === group.code ? 'bg-white/20' : ''
}`}
onClick={() => selectGroup(group.code, group.memo)}
>
<div className="flex items-center">
<div className="w-8 h-8 bg-white/20 rounded-full flex items-center justify-center mr-3">
<span className="text-white text-sm font-medium">{group.code}</span>
</div>
<div className="flex-1">
<div className="text-white font-medium">{group.memo}</div>
</div>
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
{/* 우측: 상세 데이터 */}
<div className="flex-1">
<div className="glass-effect rounded-2xl h-full card-hover animate-slide-up flex flex-col">
{/* 상단 헤더 */}
<div className="p-4 border-b border-white/10 flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-white flex items-center">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<span>
{selectedGroupCode ? `${selectedGroupCode} - ${selectedGroupName}` : '코드그룹을 선택하세요'}
</span>
</h3>
<p className="text-white/70 text-sm mt-1">
<span className="text-white font-medium">{currentData.length}</span>
</p>
</div>
<button
onClick={openAddModal}
className="bg-white/20 hover:bg-white/30 backdrop-blur-sm text-white px-4 py-2 rounded-lg transition-all border border-white/30 flex items-center text-sm"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
추가
</button>
</div>
{/* 데이터 테이블 */}
<div className="flex-1 overflow-x-auto overflow-y-auto custom-scrollbar">
<table className="w-full">
<thead className="bg-white/10 sticky top-0">
<tr>
<th className="w-24 px-4 py-3 text-left text-xs font-medium text-white/70 uppercase tracking-wider">코드</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase tracking-wider">비고</th>
<th className="w-32 px-4 py-3 text-left text-xs font-medium text-white/70 uppercase tracking-wider">(문자열)</th>
<th className="w-20 px-4 py-3 text-left text-xs font-medium text-white/70 uppercase tracking-wider">(숫자)</th>
<th className="w-20 px-4 py-3 text-left text-xs font-medium text-white/70 uppercase tracking-wider">(실수)</th>
<th className="w-24 px-4 py-3 text-left text-xs font-medium text-white/70 uppercase tracking-wider">값2</th>
</tr>
</thead>
<tbody className="divide-y divide-white/10">
{currentData.length === 0 ? (
<tr>
<td colSpan="6" className="px-4 py-8 text-center text-white/70">
<svg className="w-12 h-12 mx-auto mb-2 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
{selectedGroupCode ? '데이터가 없습니다.' : '좌측에서 코드그룹을 선택하세요'}
</td>
</tr>
) : (
currentData.map(item => (
<tr
key={item.idx}
className="hover:bg-white/5 transition-colors cursor-pointer"
onClick={() => openEditModal(item)}
>
<td className="px-4 py-4 whitespace-nowrap text-sm text-white">{item.code || '-'}</td>
<td className="px-4 py-4 text-sm text-white">{item.memo || '-'}</td>
<td className="px-4 py-4 text-sm text-white max-w-32 truncate" title={item.svalue || '-'}>
{item.svalue || '-'}
</td>
<td className="px-4 py-4 whitespace-nowrap text-sm text-white">{item.ivalue || '0'}</td>
<td className="px-4 py-4 whitespace-nowrap text-sm text-white">{item.fvalue || '0.0'}</td>
<td className="px-4 py-4 whitespace-nowrap text-sm text-white">{item.svalue2 || '-'}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
{/* 로딩 인디케이터 */}
{loading && (
<div className="fixed top-4 right-4 bg-white/20 backdrop-blur-sm rounded-full px-4 py-2 text-white text-sm z-40">
<div className="flex items-center">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
데이터 로딩 ...
</div>
</div>
)}
</div>
{/* 추가/편집 모달 */}
{showEditModal && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50">
<div className="flex items-center justify-center min-h-screen p-4">
<div className="glass-effect rounded-2xl w-full max-w-2xl animate-slide-up">
{/* 모달 헤더 */}
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
<h2 className="text-xl font-semibold text-white">
{editMode === 'add' ? '공용코드 추가' : '공용코드 편집'}
</h2>
<button
onClick={closeModals}
className="text-white/70 hover:text-white transition-colors"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
{/* 모달 내용 */}
<div className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-white/70 mb-2">코드그룹 *</label>
<select
value={editData.grp}
onChange={(e) => handleInputChange('grp', e.target.value)}
required
className="w-full px-3 py-2 bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
>
<option value="" className="bg-gray-800 text-white">선택하세요</option>
{groupData.map(group => (
<option key={group.code} value={group.code} className="bg-gray-800 text-white">
{group.code}-{group.memo}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-white/70 mb-2">코드 *</label>
<input
type="text"
value={editData.code}
onChange={(e) => handleInputChange('code', e.target.value)}
required
className="w-full px-3 py-2 bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
placeholder="코드를 입력하세요"
/>
</div>
<div>
<label className="block text-sm font-medium text-white/70 mb-2">(문자열)</label>
<input
type="text"
value={editData.svalue}
onChange={(e) => handleInputChange('svalue', e.target.value)}
className="w-full px-3 py-2 bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
placeholder="문자열 값"
/>
</div>
<div>
<label className="block text-sm font-medium text-white/70 mb-2">(숫자)</label>
<input
type="number"
value={editData.ivalue}
onChange={(e) => handleInputChange('ivalue', e.target.value)}
className="w-full px-3 py-2 bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
placeholder="숫자 값"
/>
</div>
<div>
<label className="block text-sm font-medium text-white/70 mb-2">(실수)</label>
<input
type="number"
step="0.01"
value={editData.fvalue}
onChange={(e) => handleInputChange('fvalue', e.target.value)}
className="w-full px-3 py-2 bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
placeholder="실수 값"
/>
</div>
<div>
<label className="block text-sm font-medium text-white/70 mb-2">값2</label>
<input
type="text"
value={editData.svalue2}
onChange={(e) => handleInputChange('svalue2', e.target.value)}
className="w-full px-3 py-2 bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
placeholder="추가 문자열 값"
/>
</div>
</div>
<div className="mt-4">
<label className="block text-sm font-medium text-white/70 mb-2">비고</label>
<textarea
value={editData.memo}
onChange={(e) => handleInputChange('memo', e.target.value)}
rows="3"
className="w-full px-3 py-2 bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
placeholder="비고사항을 입력하세요"
></textarea>
</div>
</div>
{/* 모달 푸터 */}
<div className="px-6 py-4 border-t border-white/10 flex justify-between">
{editMode === 'edit' && (
<button
onClick={() => openDeleteModal(editData.idx)}
className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg transition-colors flex items-center"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
삭제
</button>
)}
<div className="flex gap-2 ml-auto">
<button
onClick={closeModals}
className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-colors"
>
취소
</button>
<button
onClick={saveData}
className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-colors"
>
저장
</button>
</div>
</div>
</div>
</div>
</div>
)}
{/* 삭제 확인 모달 */}
{showDeleteModal && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50">
<div className="flex items-center justify-center min-h-screen p-4">
<div className="glass-effect rounded-2xl w-full max-w-md animate-slide-up">
<div className="p-6">
<div className="flex items-center mb-4">
<div className="w-12 h-12 bg-red-100/20 rounded-full flex items-center justify-center mr-4">
<svg className="w-6 h-6 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.5 0L4.268 18.5c-.77.833.192 2.5 1.732 2.5z"></path>
</svg>
</div>
<div>
<h3 className="text-lg font-medium text-white">삭제 확인</h3>
<p className="text-sm text-white/70"> 작업은 되돌릴 없습니다.</p>
</div>
</div>
<p className="text-white/80 mb-6">
선택한 공용코드를 삭제하시겠습니까?<br/>
<span className="text-sm text-white/60"> 작업은 되돌릴 없습니다.</span>
</p>
<div className="flex justify-end gap-2">
<button
onClick={closeModals}
className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-colors"
>
취소
</button>
<button
onClick={deleteItem}
className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg transition-colors"
>
삭제
</button>
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
};

View File

@@ -0,0 +1,590 @@
const { useState, useEffect } = React;
function CommonCode() {
// 상태 관리
const [currentData, setCurrentData] = useState([]);
const [groupData, setGroupData] = useState([]);
const [selectedGroupCode, setSelectedGroupCode] = useState(null);
const [selectedGroupName, setSelectedGroupName] = useState('');
const [isLoading, setIsLoading] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [deleteTargetIdx, setDeleteTargetIdx] = useState(null);
const [editData, setEditData] = useState({
idx: '',
grp: '',
code: '',
svalue: '',
ivalue: '',
fvalue: '',
svalue2: '',
memo: ''
});
const [isEditMode, setIsEditMode] = useState(false);
// 컴포넌트 마운트 시 초기 데이터 로드
useEffect(() => {
loadGroups();
}, []);
// 코드그룹 목록 로드
const loadGroups = async () => {
setIsLoading(true);
try {
const response = await fetch('http://127.0.0.1:7979/Common/GetGroups');
const data = await response.json();
setGroupData(data || []);
} catch (error) {
console.error('그룹 데이터 로드 중 오류 발생:', error);
showNotification('그룹 데이터 로드 중 오류가 발생했습니다.', 'error');
} finally {
setIsLoading(false);
}
};
// 그룹 선택 처리
const selectGroup = async (code, name) => {
setSelectedGroupCode(code);
setSelectedGroupName(name);
await loadDataByGroup(code);
};
// 특정 그룹의 데이터 로드
const loadDataByGroup = async (grp) => {
setIsLoading(true);
try {
let url = 'http://127.0.0.1:7979/Common/GetList';
if (grp) {
url += '?grp=' + encodeURIComponent(grp);
}
const response = await fetch(url);
const data = await response.json();
setCurrentData(data || []);
} catch (error) {
console.error('데이터 로드 중 오류 발생:', error);
showNotification('데이터 로드 중 오류가 발생했습니다.', 'error');
} finally {
setIsLoading(false);
}
};
// 추가 모달 표시
const showAddModal = () => {
if (!selectedGroupCode) {
showNotification('먼저 코드그룹을 선택하세요.', 'warning');
return;
}
setIsEditMode(false);
setEditData({
idx: '',
grp: selectedGroupCode,
code: '',
svalue: '',
ivalue: '',
fvalue: '',
svalue2: '',
memo: ''
});
setShowEditModal(true);
};
// 편집 모달 표시
const editItem = (idx) => {
const item = currentData.find(x => x.idx === idx);
if (!item) return;
setIsEditMode(true);
setEditData({
idx: item.idx,
grp: item.grp || '',
code: item.code || '',
svalue: item.svalue || '',
ivalue: item.ivalue || '',
fvalue: item.fvalue || '',
svalue2: item.svalue2 || '',
memo: item.memo || ''
});
setShowEditModal(true);
};
// 데이터 저장
const saveData = async () => {
const form = document.getElementById('editForm');
if (!form.checkValidity()) {
form.reportValidity();
return;
}
const data = {
idx: parseInt(editData.idx) || 0,
grp: editData.grp,
code: editData.code,
svalue: editData.svalue,
ivalue: parseInt(editData.ivalue) || 0,
fvalue: parseFloat(editData.fvalue) || 0.0,
svalue2: editData.svalue2,
memo: editData.memo
};
setIsLoading(true);
try {
const response = await fetch('http://127.0.0.1:7979/Common/Save', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(data)
});
const result = await response.json();
if (result.Success) {
showNotification(result.Message, 'success');
setShowEditModal(false);
if (selectedGroupCode) {
await loadDataByGroup(selectedGroupCode);
}
} else {
showNotification(result.Message, 'error');
}
} catch (error) {
console.error('저장 중 오류 발생:', error);
showNotification('저장 중 오류가 발생했습니다.', 'error');
} finally {
setIsLoading(false);
}
};
// 삭제 확인
const confirmDelete = async () => {
if (!deleteTargetIdx) return;
setIsLoading(true);
try {
const response = await fetch('http://127.0.0.1:7979/Common/Delete', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ idx: deleteTargetIdx })
});
const data = await response.json();
if (data.Success) {
showNotification(data.Message, 'success');
setShowDeleteModal(false);
setDeleteTargetIdx(null);
if (selectedGroupCode) {
await loadDataByGroup(selectedGroupCode);
}
} else {
showNotification(data.Message, 'error');
}
} catch (error) {
console.error('삭제 중 오류 발생:', error);
showNotification('삭제 중 오류가 발생했습니다.', 'error');
} finally {
setIsLoading(false);
}
};
// 삭제 요청
const deleteCurrentItem = () => {
if (!editData.idx) {
showNotification('삭제할 항목이 없습니다.', 'warning');
return;
}
setDeleteTargetIdx(parseInt(editData.idx));
setShowEditModal(false);
setShowDeleteModal(true);
};
// 알림 표시 함수
const showNotification = (message, type = 'info') => {
const colors = {
info: 'bg-blue-500/90 backdrop-blur-sm',
success: 'bg-green-500/90 backdrop-blur-sm',
warning: 'bg-yellow-500/90 backdrop-blur-sm',
error: 'bg-red-500/90 backdrop-blur-sm'
};
const icons = {
info: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
),
success: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
),
warning: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.5 0L4.268 18.5c-.77.833.192 2.5 1.732 2.5z"></path>
</svg>
),
error: (
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
)
};
// React에서는 실제 DOM 조작 대신 Toast 라이브러리나 상태로 관리하는 것이 좋습니다
// 여기서는 기존 방식을 유지하되 React 컴포넌트 스타일로 구현
const notification = document.createElement('div');
notification.className = `fixed top-4 right-4 ${colors[type]} text-white px-4 py-3 rounded-lg z-50 transition-all duration-300 transform translate-x-0 opacity-100 shadow-lg border border-white/20`;
notification.innerHTML = `
<div class="flex items-center">
${type === 'info' ? '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>' : ''}
${type === 'success' ? '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>' : ''}
${type === 'warning' ? '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.5 0L4.268 18.5c-.77.833.192 2.5 1.732 2.5z"></path></svg>' : ''}
${type === 'error' ? '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path></svg>' : ''}
<span class="ml-2">${message}</span>
</div>
`;
notification.style.transform = 'translateX(100%)';
notification.style.opacity = '0';
document.body.appendChild(notification);
setTimeout(() => {
notification.style.transform = 'translateX(0)';
notification.style.opacity = '1';
}, 10);
setTimeout(() => {
notification.style.transform = 'translateX(100%)';
notification.style.opacity = '0';
setTimeout(() => notification.remove(), 300);
}, 3000);
};
// 키보드 이벤트 핸들러
useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === 'Escape') {
setShowEditModal(false);
setShowDeleteModal(false);
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, []);
// 입력 필드 변경 핸들러
const handleInputChange = (field, value) => {
setEditData(prev => ({ ...prev, [field]: value }));
};
return (
<div className="container mx-auto px-4 py-8">
{/* 2열 구조 메인 컨테이너 */}
<div className="flex gap-6 h-[calc(100vh-200px)]">
{/* 좌측: 코드그룹 리스트 */}
<div className="w-80">
<div className="glass-effect rounded-2xl h-full card-hover animate-slide-up flex flex-col">
<div className="p-4 border-b border-white/10">
<h3 className="text-lg font-semibold text-white flex items-center">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 11H5m14-7l-7 7-7-7M19 21l-7-7-7 7"></path>
</svg>
코드그룹 목록
</h3>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar p-2">
<div className="space-y-1">
{groupData.length === 0 ? (
<div className="text-white/70 text-center py-4">그룹 데이터가 없습니다.</div>
) : (
groupData.map(group => (
<div
key={group.code}
className={`group-item cursor-pointer p-3 rounded-lg border border-white/20 hover:bg-white/10 transition-all ${
selectedGroupCode === group.code ? 'bg-white/20' : ''
}`}
onClick={() => selectGroup(group.code, group.memo)}
>
<div className="flex items-center">
<div className="w-8 h-8 bg-white/20 rounded-full flex items-center justify-center mr-3">
<span className="text-white text-sm font-medium">{group.code}</span>
</div>
<div className="flex-1">
<div className="text-white font-medium">{group.memo}</div>
</div>
</div>
</div>
))
)}
</div>
</div>
</div>
</div>
{/* 우측: 상세 데이터 */}
<div className="flex-1">
<div className="glass-effect rounded-2xl h-full card-hover animate-slide-up flex flex-col">
{/* 상단 헤더 */}
<div className="p-4 border-b border-white/10 flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold text-white flex items-center">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<span>{selectedGroupCode ? `${selectedGroupCode} - ${selectedGroupName}` : '코드그룹을 선택하세요'}</span>
</h3>
<p className="text-white/70 text-sm mt-1"> <span className="text-white font-medium">{currentData.length}</span></p>
</div>
<button
onClick={showAddModal}
className="bg-white/20 hover:bg-white/30 backdrop-blur-sm text-white px-4 py-2 rounded-lg transition-all border border-white/30 flex items-center text-sm"
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
추가
</button>
</div>
{/* 데이터 테이블 */}
<div className="flex-1 overflow-x-auto overflow-y-auto custom-scrollbar">
<table className="w-full">
<thead className="bg-white/10 sticky top-0">
<tr>
<th className="w-24 px-4 py-3 text-left text-xs font-medium text-white/70 uppercase tracking-wider">코드</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase tracking-wider">비고</th>
<th className="w-32 px-4 py-3 text-left text-xs font-medium text-white/70 uppercase tracking-wider">(문자열)</th>
<th className="w-20 px-4 py-3 text-left text-xs font-medium text-white/70 uppercase tracking-wider">(숫자)</th>
<th className="w-20 px-4 py-3 text-left text-xs font-medium text-white/70 uppercase tracking-wider">(실수)</th>
<th className="w-24 px-4 py-3 text-left text-xs font-medium text-white/70 uppercase tracking-wider">값2</th>
</tr>
</thead>
<tbody className="divide-y divide-white/10">
{currentData.length === 0 ? (
<tr>
<td colSpan="6" className="px-4 py-8 text-center text-white/70">
<svg className="w-12 h-12 mx-auto mb-2 text-white/30" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
{selectedGroupCode ? '데이터가 없습니다.' : '좌측에서 코드그룹을 선택하세요'}
</td>
</tr>
) : (
currentData.map(item => (
<tr
key={item.idx}
className="hover:bg-white/5 transition-colors cursor-pointer"
onClick={() => editItem(item.idx)}
>
<td className="px-4 py-4 whitespace-nowrap text-sm text-white">{item.code || '-'}</td>
<td className="px-4 py-4 text-sm text-white">{item.memo || '-'}</td>
<td className="px-4 py-4 text-sm text-white svalue-cell" title={item.svalue || '-'}>{item.svalue || '-'}</td>
<td className="px-4 py-4 whitespace-nowrap text-sm text-white">{item.ivalue || '0'}</td>
<td className="px-4 py-4 whitespace-nowrap text-sm text-white">{item.fvalue || '0.0'}</td>
<td className="px-4 py-4 whitespace-nowrap text-sm text-white">{item.svalue2 || '-'}</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
</div>
{/* 로딩 인디케이터 */}
{isLoading && (
<div className="fixed top-4 right-4 bg-white/20 backdrop-blur-sm rounded-full px-4 py-2 text-white text-sm">
<div className="flex items-center">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
데이터 로딩 ...
</div>
</div>
)}
{/* 추가/편집 모달 */}
{showEditModal && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50">
<div className="flex items-center justify-center min-h-screen p-4">
<div className="glass-effect rounded-2xl w-full max-w-2xl animate-slide-up">
{/* 모달 헤더 */}
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
<h2 className="text-xl font-semibold text-white">
{isEditMode ? '공용코드 편집' : '공용코드 추가'}
</h2>
<button
onClick={() => setShowEditModal(false)}
className="text-white/70 hover:text-white transition-colors"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
{/* 모달 내용 */}
<form id="editForm" className="p-6">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-white/70 mb-2">코드그룹 *</label>
<select
value={editData.grp}
onChange={(e) => handleInputChange('grp', e.target.value)}
required
className="w-full px-3 py-2 bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
>
<option value="" className="bg-gray-800 text-white">선택하세요</option>
{groupData.map(group => (
<option key={group.code} value={group.code} className="bg-gray-800 text-white">
{group.code}-{group.memo}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-white/70 mb-2">코드 *</label>
<input
type="text"
value={editData.code}
onChange={(e) => handleInputChange('code', e.target.value)}
required
className="w-full px-3 py-2 bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
placeholder="코드를 입력하세요"
/>
</div>
<div>
<label className="block text-sm font-medium text-white/70 mb-2">(문자열)</label>
<input
type="text"
value={editData.svalue}
onChange={(e) => handleInputChange('svalue', e.target.value)}
className="w-full px-3 py-2 bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
placeholder="문자열 값"
/>
</div>
<div>
<label className="block text-sm font-medium text-white/70 mb-2">(숫자)</label>
<input
type="number"
value={editData.ivalue}
onChange={(e) => handleInputChange('ivalue', e.target.value)}
className="w-full px-3 py-2 bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
placeholder="숫자 값"
/>
</div>
<div>
<label className="block text-sm font-medium text-white/70 mb-2">(실수)</label>
<input
type="number"
step="0.01"
value={editData.fvalue}
onChange={(e) => handleInputChange('fvalue', e.target.value)}
className="w-full px-3 py-2 bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
placeholder="실수 값"
/>
</div>
<div>
<label className="block text-sm font-medium text-white/70 mb-2">값2</label>
<input
type="text"
value={editData.svalue2}
onChange={(e) => handleInputChange('svalue2', e.target.value)}
className="w-full px-3 py-2 bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
placeholder="추가 문자열 값"
/>
</div>
</div>
<div className="mt-4">
<label className="block text-sm font-medium text-white/70 mb-2">비고</label>
<textarea
value={editData.memo}
onChange={(e) => handleInputChange('memo', e.target.value)}
rows="3"
className="w-full px-3 py-2 bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
placeholder="비고사항을 입력하세요"
></textarea>
</div>
</form>
{/* 모달 푸터 */}
<div className="px-6 py-4 border-t border-white/10 flex justify-between">
<button
onClick={deleteCurrentItem}
disabled={!isEditMode}
className={`bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg transition-colors flex items-center ${
!isEditMode ? 'opacity-50 cursor-not-allowed' : ''
}`}
>
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
</svg>
삭제
</button>
<div className="flex gap-2">
<button
onClick={() => setShowEditModal(false)}
className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-colors"
>
취소
</button>
<button
onClick={saveData}
className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-colors"
>
저장
</button>
</div>
</div>
</div>
</div>
</div>
)}
{/* 삭제 확인 모달 */}
{showDeleteModal && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50">
<div className="flex items-center justify-center min-h-screen p-4">
<div className="glass-effect rounded-2xl w-full max-w-md animate-slide-up">
<div className="p-6">
<div className="flex items-center mb-4">
<div className="w-12 h-12 bg-red-100 rounded-full flex items-center justify-center mr-4">
<svg className="w-6 h-6 text-red-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.5 0L4.268 18.5c-.77.833.192 2.5 1.732 2.5z"></path>
</svg>
</div>
<div>
<h3 className="text-lg font-medium text-gray-900">삭제 확인</h3>
<p className="text-sm text-gray-500"> 작업은 되돌릴 없습니다.</p>
</div>
</div>
<p className="text-gray-700 mb-6">
선택한 공용코드를 삭제하시겠습니까?<br />
<span className="text-sm text-gray-500"> 작업은 되돌릴 없습니다.</span>
</p>
<div className="flex justify-end gap-2">
<button
onClick={() => { setShowDeleteModal(false); setDeleteTargetIdx(null); }}
className="bg-gray-300 hover:bg-gray-400 text-gray-700 px-4 py-2 rounded-lg transition-colors"
>
취소
</button>
<button
onClick={confirmDelete}
className="bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg transition-colors"
>
삭제
</button>
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,166 @@
// CommonNavigation.jsx - React Navigation Component for GroupWare
const CommonNavigation = ({ currentPage = 'dashboard' }) => {
const [menuItems, setMenuItems] = useState([]);
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
const [currentUser, setCurrentUser] = useState('사용자');
// 기본 메뉴 아이템 - React 경로 사용
const defaultMenuItems = [
{
key: 'dashboard',
title: '대시보드',
url: '/react/dashboard',
icon: 'M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z M8 5a2 2 0 012-2h4a2 2 0 012 2v2H8V5z',
isVisible: true,
sortOrder: 1
},
{
key: 'common',
title: '공용코드',
url: '/react/common',
icon: 'M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z',
isVisible: true,
sortOrder: 2
},
{
key: 'jobreport',
title: '업무일지',
url: '/react/jobreport',
icon: 'M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2',
isVisible: true,
sortOrder: 3
},
{
key: 'kuntae',
title: '근태관리',
url: '/react/kuntae',
icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z',
isVisible: true,
sortOrder: 4
},
{
key: 'todo',
title: '할일관리',
url: '/react/todo',
icon: 'M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2M12 12l2 2 4-4',
isVisible: true,
sortOrder: 5
},
{
key: 'project',
title: '프로젝트',
url: '/react/project',
icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10',
isVisible: true,
sortOrder: 6
},
{
key: 'purchase',
title: '구매관리',
url: '/Purchase/',
icon: 'M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z',
isVisible: true,
sortOrder: 7
},
{
key: 'customer',
title: '고객관리',
url: '/Customer/',
icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z',
isVisible: true,
sortOrder: 8
}
];
// 메뉴 아이템 로드 - defaultMenuItems 사용 (React 경로)
useEffect(() => {
setMenuItems(defaultMenuItems);
}, []);
// 보이는 메뉴 아이템만 정렬해서 반환
const visibleItems = menuItems
.filter(item => item.isVisible)
.sort((a, b) => a.sortOrder - b.sortOrder);
return (
<nav className="glass-effect border-b border-white/10 relative z-40">
<div className="container mx-auto px-4">
<div className="flex items-center justify-between h-16">
{/* 로고 및 브랜드 */}
<div className="flex items-center space-x-8">
<div className="flex items-center space-x-2">
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z M8 5a2 2 0 012-2h4a2 2 0 012 2v2H8V5z"></path>
</svg>
<span className="text-xl font-bold text-white">GroupWare</span>
</div>
{/* 데스크톱 메뉴 */}
<nav className="hidden md:flex space-x-1">
{visibleItems.map(item => (
<a
key={item.key}
href={item.url}
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
currentPage === item.key
? 'bg-white/20 text-white'
: 'text-white/60 hover:text-white hover:bg-white/10'
}`}
>
<svg className="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d={item.icon}></path>
</svg>
{item.title}
</a>
))}
</nav>
</div>
{/* 우측 메뉴 */}
<div className="flex items-center space-x-4">
<div className="text-sm text-white/60">
<span>{currentUser}</span>
</div>
{/* 모바일 메뉴 버튼 */}
<button
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
className="md:hidden text-white/60 hover:text-white focus:outline-none focus:text-white"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{mobileMenuOpen ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12"></path>
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16"></path>
)}
</svg>
</button>
</div>
</div>
{/* 모바일 메뉴 */}
{mobileMenuOpen && (
<div className="md:hidden border-t border-white/10 py-2">
{visibleItems.map(item => (
<a
key={item.key}
href={item.url}
className={`block px-3 py-2 text-sm font-medium transition-colors ${
currentPage === item.key
? 'bg-white/20 text-white'
: 'text-white/60 hover:text-white hover:bg-white/10'
}`}
onClick={() => setMobileMenuOpen(false)}
>
<svg className="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d={item.icon}></path>
</svg>
{item.title}
</a>
))}
</div>
)}
</div>
</nav>
);
};

View File

@@ -0,0 +1,669 @@
// DashboardApp.jsx - React Dashboard Component for GroupWare
const { useState, useEffect, useRef } = React;
function DashboardApp() {
// 상태 관리
const [dashboardData, setDashboardData] = useState({
presentCount: 0,
leaveCount: 0,
leaveRequestCount: 0,
purchaseCountNR: 0,
purchaseCountCR: 0
});
const [isLoading, setIsLoading] = useState(true);
const [lastUpdated, setLastUpdated] = useState('');
const [modals, setModals] = useState({
presentUsers: false,
holidayUsers: false,
holidayRequest: false,
purchaseNR: false,
purchaseCR: false
});
const [modalData, setModalData] = useState({
presentUsers: [],
holidayUsers: [],
holidayRequests: [],
purchaseNR: [],
purchaseCR: []
});
const [todoList, setTodoList] = useState([]);
// 모달 제어 함수
const showModal = (modalName) => {
setModals(prev => ({ ...prev, [modalName]: true }));
loadModalData(modalName);
};
const hideModal = (modalName) => {
setModals(prev => ({ ...prev, [modalName]: false }));
};
// Dashboard 데이터 로드
const loadDashboardData = async () => {
setIsLoading(true);
try {
// 실제 DashBoardController API 호출
const [
currentUserResponse,
leaveCountResponse,
holyRequestResponse,
purchaseWaitResponse
] = await Promise.all([
fetch('http://127.0.0.1:7979/DashBoard/GetCurrentUserCount'),
fetch('http://127.0.0.1:7979/DashBoard/TodayCountH'),
fetch('http://127.0.0.1:7979/DashBoard/GetHolydayRequestCount'),
fetch('http://127.0.0.1:7979/DashBoard/GetPurchaseWaitCount')
]);
// 현재 근무자 수 (JSON 응답)
const currentUserData = await currentUserResponse.json();
const presentCount = currentUserData.Count || 0;
// 휴가자 수 (텍스트 응답)
const leaveCountText = await leaveCountResponse.text();
const leaveCount = parseInt(leaveCountText.replace(/"/g, ''), 10) || 0;
// 휴가 요청 수 (JSON 응답)
const holyRequestData = await holyRequestResponse.json();
const leaveRequestCount = holyRequestData.HOLY || 0;
// 구매 대기 수 (JSON 응답)
const purchaseWaitData = await purchaseWaitResponse.json();
const purchaseCountNR = purchaseWaitData.NR || 0;
const purchaseCountCR = purchaseWaitData.CR || 0;
setDashboardData({
presentCount,
leaveCount,
leaveRequestCount,
purchaseCountNR,
purchaseCountCR
});
setLastUpdated(new Date().toLocaleString('ko-KR'));
} catch (error) {
console.error('대시보드 데이터 로드 실패:', error);
} finally {
setIsLoading(false);
}
};
// 모달 데이터 로드
const loadModalData = async (modalName) => {
try {
let endpoint = '';
switch (modalName) {
case 'presentUsers':
endpoint = 'http://127.0.0.1:7979/DashBoard/GetPresentUserList';
break;
case 'holidayUsers':
endpoint = 'http://127.0.0.1:7979/DashBoard/GetholyUser';
break;
case 'holidayRequest':
endpoint = 'http://127.0.0.1:7979/DashBoard/GetholyRequestUser';
break;
case 'purchaseNR':
endpoint = 'http://127.0.0.1:7979/DashBoard/GetPurchaseNRList';
break;
case 'purchaseCR':
endpoint = 'http://127.0.0.1:7979/DashBoard/GetPurchaseCRList';
break;
default:
return;
}
const response = await fetch(endpoint);
const data = await response.json();
setModalData(prev => ({
...prev,
[modalName]: Array.isArray(data) ? data : []
}));
} catch (error) {
console.error(`모달 데이터 로드 실패 (${modalName}):`, error);
setModalData(prev => ({
...prev,
[modalName]: []
}));
}
};
// Todo 목록 로드
const loadTodoList = async () => {
try {
const response = await fetch('http://127.0.0.1:7979/Todo/GetUrgentTodos');
const data = await response.json();
if (data.Success && data.Data) {
setTodoList(data.Data);
} else {
setTodoList([]);
}
} catch (error) {
console.error('Todo 목록 로드 실패:', error);
setTodoList([]);
}
};
// 자동 새로고침 (30초마다)
useEffect(() => {
loadDashboardData();
loadModalData('holidayUsers'); // 휴가자 목록 자동 로드
loadTodoList(); // Todo 목록 자동 로드
const interval = setInterval(() => {
loadDashboardData();
loadModalData('holidayUsers'); // 30초마다 휴가자 목록도 새로고침
loadTodoList(); // 30초마다 Todo 목록도 새로고침
}, 30000); // 30초
return () => clearInterval(interval);
}, []);
// 통계 카드 컴포넌트
const StatCard = ({ title, count, icon, color, onClick, isClickable = false }) => {
return (
<div
className={`glass-effect rounded-2xl p-6 card-hover animate-slide-up ${isClickable ? 'cursor-pointer' : ''}`}
onClick={onClick}
>
<div className="flex items-center justify-between">
<div>
<p className="text-white/70 text-sm font-medium">{title}</p>
<p className="text-3xl font-bold text-white">
{isLoading ? (
<div className="animate-pulse bg-white/20 h-8 w-16 rounded"></div>
) : count}
</p>
</div>
<div className={`w-12 h-12 ${color}/20 rounded-full flex items-center justify-center`}>
{icon}
</div>
</div>
</div>
);
};
// 테이블 모달 컴포넌트
const TableModal = ({ isOpen, onClose, title, headers, data, renderRow, maxWidth = 'max-w-4xl' }) => {
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50">
<div className="flex items-center justify-center min-h-screen p-4">
<div className={`glass-effect rounded-2xl w-full ${maxWidth} max-h-[80vh] overflow-hidden animate-slide-up`}>
{/* 모달 헤더 */}
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
<h2 className="text-xl font-semibold text-white flex items-center">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
{title}
</h2>
<button
onClick={onClose}
className="text-white/70 hover:text-white transition-colors"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
{/* 모달 내용 */}
<div className="overflow-x-auto max-h-[60vh] custom-scrollbar">
<table className="w-full">
<thead className="bg-white/10 sticky top-0">
<tr>
{headers.map((header, index) => (
<th key={index} className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">
{header}
</th>
))}
</tr>
</thead>
<tbody className="divide-y divide-white/10">
{data.length > 0 ? (
data.map((item, index) => renderRow(item, index))
) : (
<tr>
<td colSpan={headers.length} className="px-6 py-8 text-center text-white/50">
데이터가 없습니다.
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* 모달 푸터 */}
<div className="px-6 py-4 border-t border-white/10 flex justify-between items-center">
<p className="text-white/70 text-sm">
<span className="font-medium">{data.length}</span>
</p>
<button
onClick={onClose}
className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-colors"
>
닫기
</button>
</div>
</div>
</div>
</div>
);
};
return (
<div className="bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900 min-h-screen text-white">
<div className="container mx-auto px-4 py-8">
{/* 헤더 */}
<div className="text-center mb-8 animate-fade-in">
<h1 className="text-4xl font-bold mb-4">근태현황 대시보드</h1>
<p className="text-white/70">실시간 근태 업무 현황을 확인하세요</p>
{lastUpdated && (
<p className="text-white/50 text-sm mt-2">마지막 업데이트: {lastUpdated}</p>
)}
</div>
{/* 새로고침 버튼 */}
<div className="flex justify-end mb-6">
<button
onClick={loadDashboardData}
disabled={isLoading}
className="glass-effect rounded-lg px-4 py-2 text-white hover:bg-white/10 transition-all duration-300 disabled:opacity-50"
>
{isLoading ? (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2 inline-block"></div>
) : (
<svg className="w-4 h-4 mr-2 inline-block" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
)}
새로고침
</button>
</div>
{/* 통계 카드 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6 mb-8">
<StatCard
title="출근(대상)"
count={dashboardData.presentCount}
isClickable={true}
onClick={() => showModal('presentUsers')}
color="bg-success-500"
icon={
<svg className="w-6 h-6 text-success-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
}
/>
<StatCard
title="휴가"
count={dashboardData.leaveCount}
isClickable={true}
onClick={() => showModal('holidayUsers')}
color="bg-warning-500"
icon={
<svg className="w-6 h-6 text-warning-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
}
/>
<StatCard
title="휴가요청"
count={dashboardData.leaveRequestCount}
isClickable={true}
onClick={() => showModal('holidayRequest')}
color="bg-primary-500"
icon={
<svg className="w-6 h-6 text-primary-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
}
/>
<StatCard
title="구매요청(NR)"
count={dashboardData.purchaseCountNR}
isClickable={true}
onClick={() => showModal('purchaseNR')}
color="bg-danger-500"
icon={
<svg className="w-6 h-6 text-danger-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z"></path>
</svg>
}
/>
<StatCard
title="구매요청(CR)"
count={dashboardData.purchaseCountCR}
isClickable={true}
onClick={() => showModal('purchaseCR')}
color="bg-purple-500"
icon={
<svg className="w-6 h-6 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M16 11V7a4 4 0 00-8 0v4M5 9h14l1 12H4L5 9z"></path>
</svg>
}
/>
</div>
{/* 2칸 레이아웃: 좌측 휴가현황, 우측 할일 */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 animate-slide-up">
{/* 좌측: 휴가/기타 현황 */}
<div className="glass-effect rounded-2xl overflow-hidden">
<div className="px-6 py-4 border-b border-white/10">
<h2 className="text-xl font-semibold text-white flex items-center">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z"></path>
</svg>
휴가/기타 현황
</h2>
</div>
<div className="overflow-x-auto max-h-[460px] custom-scrollbar">
<table className="w-full">
<thead className="bg-white/10 sticky top-0">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase tracking-wider">이름</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase tracking-wider">형태</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase tracking-wider">종류</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase tracking-wider">기간</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase tracking-wider">사유</th>
</tr>
</thead>
<tbody className="divide-y divide-white/10">
{modalData.holidayUsers.map((user, index) => {
// 형태에 따른 색상 결정
const typeColorClass = (user.type === '휴가') ? 'bg-green-500/20 text-green-300' : 'bg-warning-500/20 text-warning-300';
// 종류에 따른 색상 결정
let cateColorClass = 'bg-warning-500/20 text-warning-300'; // 기본값
if (user.cate === '휴가') {
cateColorClass = 'bg-warning-500/20 text-warning-300'; // 노란색 계열
} else if (user.cate === '파견') {
cateColorClass = 'bg-purple-500/20 text-purple-300'; // 보라색 계열
} else {
cateColorClass = 'bg-warning-500/20 text-warning-300'; // 기타는 주황색 계열
}
// 기간 표시 형식 개선
let periodText = '';
if (user.sdate && user.edate) {
if (user.sdate === user.edate) {
periodText = user.sdate;
} else {
periodText = `${user.sdate}~${user.edate}`;
}
} else {
periodText = '-';
}
return (
<tr key={index} className="hover:bg-white/5">
<td className="px-4 py-3 text-white text-sm font-medium">{user.name || '이름 없음'}</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${typeColorClass}`}>
{user.type || 'N/A'}
</span>
</td>
<td className="px-4 py-3">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${cateColorClass}`}>
{user.cate || '종류 없음'}
</span>
</td>
<td className="px-4 py-3 text-white text-sm">{periodText}</td>
<td className="px-4 py-3 text-white/70 text-sm">{user.title || '사유 없음'}</td>
</tr>
);
})}
{modalData.holidayUsers.length === 0 && (
<tr>
<td colSpan="5" className="px-4 py-8 text-center text-white/50">
현재 휴가자가 없습니다
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{/* 우측: 할일 */}
<div className="glass-effect rounded-2xl overflow-hidden">
<div className="px-6 py-4 border-b border-white/10">
<h2 className="text-xl font-semibold text-white flex items-center justify-between">
<span className="flex items-center">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
</svg>
할일
</span>
<div className="flex items-center space-x-2">
<button
className="text-xs bg-primary-500/20 hover:bg-primary-500/30 text-primary-300 hover:text-primary-200 px-3 py-1 rounded-full transition-colors flex items-center"
onClick={() => alert('할일 추가 기능은 준비 중입니다.')}
>
<svg className="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
할일추가
</button>
<button
className="text-xs bg-white/20 hover:bg-white/30 px-3 py-1 rounded-full transition-colors"
onClick={() => window.location.href = '/react/todo'}
>
전체보기
</button>
</div>
</h2>
</div>
<div className="p-4">
<div className="space-y-3 max-h-[384px] overflow-y-auto custom-scrollbar">
{todoList.length > 0 ? (
todoList.map((todo, index) => {
const flagIcon = todo.flag ? '📌 ' : '';
// 상태별 클래스
const getTodoStatusClass = (status) => {
switch(status) {
case '0': return 'bg-gray-500/20 text-gray-300';
case '1': return 'bg-primary-500/20 text-primary-300';
case '2': return 'bg-danger-500/20 text-danger-300';
case '3': return 'bg-warning-500/20 text-warning-300';
case '5': return 'bg-success-500/20 text-success-300';
default: return 'bg-white/10 text-white/50';
}
};
const getTodoStatusText = (status) => {
switch(status) {
case '0': return '대기';
case '1': return '진행';
case '2': return '취소';
case '3': return '보류';
case '5': return '완료';
default: return '대기';
}
};
const getTodoSeqnoClass = (seqno) => {
switch(seqno) {
case '0': return 'bg-gray-500/20 text-gray-300';
case '1': return 'bg-success-500/20 text-success-300';
case '2': return 'bg-warning-500/20 text-warning-300';
case '3': return 'bg-danger-500/20 text-danger-300';
default: return 'bg-white/10 text-white/50';
}
};
const getTodoSeqnoText = (seqno) => {
switch(seqno) {
case '0': return '낮음';
case '1': return '보통';
case '2': return '높음';
case '3': return '긴급';
default: return '보통';
}
};
const statusClass = getTodoStatusClass(todo.status);
const statusText = getTodoStatusText(todo.status);
const seqnoClass = getTodoSeqnoClass(todo.seqno);
const seqnoText = getTodoSeqnoText(todo.seqno);
const expireText = todo.expire ? new Date(todo.expire).toLocaleDateString('ko-KR') : '';
const isExpired = todo.expire && new Date(todo.expire) < new Date();
const expireClass = isExpired ? 'text-danger-400' : 'text-white/60';
// 만료일이 지난 경우 배경을 적색계통으로 강조
const expiredBgClass = isExpired ? 'bg-danger-600/30 border-danger-400/40 hover:bg-danger-600/40' : 'bg-white/10 hover:bg-white/15 border-white/20';
return (
<div key={index} className={`${expiredBgClass} backdrop-blur-sm rounded-lg p-3 transition-colors cursor-pointer border`}
onClick={() => alert('Todo 상세보기 기능은 준비 중입니다.')}>
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-1">
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${statusClass}`}>
{statusText}
</span>
<span className={`inline-flex items-center px-2 py-1 rounded-full text-xs font-medium ${seqnoClass}`}>
{seqnoText}
</span>
</div>
{expireText && (
<span className={`text-xs ${expireClass}`}>
{expireText}
</span>
)}
</div>
<p className="text-white text-sm font-medium mb-1">
{flagIcon}{todo.title || '제목 없음'}
</p>
{todo.description && (
<p className="text-white/70 text-xs mb-2 line-clamp-2">
{todo.description}
</p>
)}
{todo.request && (
<p className="text-white/50 text-xs mt-2">요청자: {todo.request}</p>
)}
</div>
);
})
) : (
<div className="text-center text-white/50 py-8">
<svg className="w-8 h-8 mx-auto mb-2 opacity-50" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
</svg>
급한 할일이 없습니다
</div>
)}
</div>
</div>
</div>
</div>
{/* 출근 대상자 모달 */}
<TableModal
isOpen={modals.presentUsers}
onClose={() => hideModal('presentUsers')}
title="금일 출근 대상자 목록"
headers={['사번', '이름', '공정', '직급', '상태', '이메일']}
data={modalData.presentUsers}
renderRow={(user, index) => (
<tr key={index} className="hover:bg-white/5">
<td className="px-6 py-4 text-white text-sm">{user.id || 'N/A'}</td>
<td className="px-6 py-4 text-white text-sm">{user.name || '이름 없음'}</td>
<td className="px-6 py-4 text-white text-sm">{user.gname || 'N/A'}</td>
<td className="px-6 py-4 text-white text-sm">{user.level || 'N/A'}</td>
<td className="px-6 py-4 text-success-400 text-sm">출근</td>
<td className="px-6 py-4 text-white/70 text-sm">{user.email || 'N/A'}</td>
</tr>
)}
/>
{/* 휴가 요청 모달 */}
<TableModal
isOpen={modals.holidayRequest}
onClose={() => hideModal('holidayRequest')}
title="휴가 신청 목록"
maxWidth="max-w-6xl"
headers={['사번', '이름', '항목', '일자', '요청일', '요청시간', '비고']}
data={modalData.holidayRequests}
renderRow={(request, index) => (
<tr key={index} className="hover:bg-white/5">
<td className="px-6 py-4 text-white text-sm">{request.uid || 'N/A'}</td>
<td className="px-6 py-4 text-white text-sm">{request.name || '이름 없음'}</td>
<td className="px-6 py-4 text-white text-sm">{request.cate || 'N/A'}</td>
<td className="px-6 py-4 text-white text-sm">
{request.sdate && request.edate ? `${request.sdate} ~ ${request.edate}` : 'N/A'}
</td>
<td className="px-6 py-4 text-white text-sm">{request.holydays || 'N/A'}</td>
<td className="px-6 py-4 text-white text-sm">{request.holytimes || 'N/A'}</td>
<td className="px-6 py-4 text-white/70 text-sm">{request.HolyReason || request.remark || 'N/A'}</td>
</tr>
)}
/>
{/* 구매요청 NR 모달 */}
<TableModal
isOpen={modals.purchaseNR}
onClose={() => hideModal('purchaseNR')}
title="구매요청(NR) 목록"
maxWidth="max-w-7xl"
headers={['요청일', '공정', '품목', '규격', '단위', '수량', '단가', '금액']}
data={modalData.purchaseNR}
renderRow={(item, index) => (
<tr key={index} className="hover:bg-white/5">
<td className="px-6 py-4 text-white text-sm">{item.pdate || 'N/A'}</td>
<td className="px-6 py-4 text-white text-sm">{item.process || 'N/A'}</td>
<td className="px-6 py-4 text-white text-sm">{item.pumname || '품목 없음'}</td>
<td className="px-6 py-4 text-white text-sm">{item.pumscale || 'N/A'}</td>
<td className="px-6 py-4 text-white text-sm">{item.pumunit || 'N/A'}</td>
<td className="px-6 py-4 text-white text-sm">{item.pumqtyreq || 'N/A'}</td>
<td className="px-6 py-4 text-white text-sm">
{item.pumprice ? `${Number(item.pumprice).toLocaleString()}` : 'N/A'}
</td>
<td className="px-6 py-4 text-danger-400 text-sm font-medium">
{item.pumamt ? `${Number(item.pumamt).toLocaleString()}` : 'N/A'}
</td>
</tr>
)}
/>
{/* 구매요청 CR 모달 */}
<TableModal
isOpen={modals.purchaseCR}
onClose={() => hideModal('purchaseCR')}
title="구매요청(CR) 목록"
maxWidth="max-w-7xl"
headers={['요청일', '공정', '품목', '규격', '단위', '수량', '단가', '금액']}
data={modalData.purchaseCR}
renderRow={(item, index) => (
<tr key={index} className="hover:bg-white/5">
<td className="px-6 py-4 text-white text-sm">{item.pdate || 'N/A'}</td>
<td className="px-6 py-4 text-white text-sm">{item.process || 'N/A'}</td>
<td className="px-6 py-4 text-white text-sm">{item.pumname || '품목 없음'}</td>
<td className="px-6 py-4 text-white text-sm">{item.pumscale || 'N/A'}</td>
<td className="px-6 py-4 text-white text-sm">{item.pumunit || 'N/A'}</td>
<td className="px-6 py-4 text-white text-sm">{item.pumqtyreq || 'N/A'}</td>
<td className="px-6 py-4 text-white text-sm">
{item.pumprice ? `${Number(item.pumprice).toLocaleString()}` : 'N/A'}
</td>
<td className="px-6 py-4 text-purple-400 text-sm font-medium">
{item.pumamt ? `${Number(item.pumamt).toLocaleString()}` : 'N/A'}
</td>
</tr>
)}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,21 @@
// 개발중 경고 메시지 컴포넌트
function DevWarning({ show = false }) {
// show props가 false이거나 없으면 아무것도 렌더링하지 않음
if (!show) {
return null;
}
return (
<div className="dev-warning animate-slide-up">
<div className="flex items-center">
<svg className="icon" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"></path>
</svg>
<div>
<p className="title">🚧 개발중인 기능입니다</p>
<p className="description">일부 기능이 정상적으로 동작하지 않을 있습니다.</p>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,988 @@
const { useState, useEffect } = React;
function JobReport() {
// 상태 관리
const [jobData, setJobData] = useState([]);
const [filteredData, setFilteredData] = useState([]);
const [currentPage, setCurrentPage] = useState(1);
const [pageSize, setPageSize] = useState(25);
const [sortColumn, setSortColumn] = useState('pdate');
const [sortDirection, setSortDirection] = useState('desc');
const [isLoading, setIsLoading] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
const [editData, setEditData] = useState({});
const [isEditMode, setIsEditMode] = useState(false);
// 필터 상태
const [filters, setFilters] = useState({
startDate: '',
endDate: '',
status: '',
type: '',
user: '',
project: '',
search: ''
});
// 통계 데이터
const [statistics, setStatistics] = useState({
totalDays: 0,
todayHours: 0,
todayProgress: 0,
totalOT: 0,
activeProjects: 0
});
// 컴포넌트 마운트시 초기화
useEffect(() => {
initializeFilters();
loadJobData();
loadUserList();
}, []);
// 필터 변경 시 데이터 필터링
useEffect(() => {
filterData();
}, [jobData, filters]);
// 초기 필터 설정 (오늘부터 -2주)
const initializeFilters = () => {
const now = new Date();
const today = now.toISOString().split('T')[0];
const twoWeeksAgo = new Date(now.getTime() - (14 * 24 * 60 * 60 * 1000)).toISOString().split('T')[0];
setFilters(prev => ({
...prev,
startDate: twoWeeksAgo,
endDate: today
}));
};
// 업무일지 데이터 로드
const loadJobData = async () => {
setIsLoading(true);
try {
let url = '/Jobreport/GetJobData';
const params = new URLSearchParams();
if (filters.startDate) params.append('startDate', filters.startDate);
if (filters.endDate) params.append('endDate', filters.endDate);
if (filters.user) params.append('user', filters.user);
if (params.toString()) {
url += '?' + params.toString();
}
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: 데이터를 불러오는데 실패했습니다.`);
}
const responseText = await response.text();
const responseData = JSON.parse(responseText);
if (Array.isArray(responseData)) {
setJobData(responseData);
} else if (responseData.error) {
throw new Error(responseData.error);
} else {
setJobData([]);
}
} catch (error) {
console.error('Error loading job data:', error);
setJobData([]);
showNotification('데이터를 불러오는데 실패했습니다: ' + error.message, 'error');
} finally {
setIsLoading(false);
}
};
// 사용자 목록 로드
const loadUserList = async () => {
try {
const response = await fetch('/Jobreport/GetUsers');
if (response.ok) {
const data = await response.json();
// 사용자 데이터를 상태로 저장 (필요시)
}
} catch (error) {
console.error('사용자 목록 로드 중 오류:', error);
}
};
// 통계 업데이트
useEffect(() => {
updateStatistics();
}, [jobData]);
const updateStatistics = () => {
const totalDays = new Set(jobData.map(item => item.pdate)).size;
const totalOT = jobData.reduce((sum, item) => sum + (parseFloat(item.ot) || 0), 0);
const activeProjects = new Set(jobData.filter(item => item.status === '진행중').map(item => item.projectName)).size;
const now = new Date();
const today = now.toISOString().split('T')[0];
const todayData = jobData.filter(item => {
if (!item.pdate) return false;
const itemDate = item.pdate.toString();
if (itemDate.length >= 10) {
return itemDate.substring(0, 10) === today;
}
return false;
});
let todayHours = 0;
if (todayData.length > 0) {
todayHours = todayData.reduce((sum, item) => sum + (parseFloat(item.hrs) || 0), 0);
}
const todayProgress = (todayHours / 8) * 100;
setStatistics({
totalDays,
todayHours,
todayProgress,
totalOT,
activeProjects
});
};
// 데이터 필터링
const filterData = () => {
let filtered = jobData.filter(item => {
const statusMatch = !filters.status || item.status === filters.status;
const typeMatch = !filters.type || item.type === filters.type;
const projectMatch = !filters.project || item.projectName === filters.project;
const searchMatch = !filters.search ||
(item.description && item.description.toLowerCase().includes(filters.search.toLowerCase())) ||
(item.projectName && item.projectName.toLowerCase().includes(filters.search.toLowerCase())) ||
(item.requestpart && item.requestpart.toLowerCase().includes(filters.search.toLowerCase()));
return statusMatch && typeMatch && projectMatch && searchMatch;
});
// 정렬
filtered.sort((a, b) => {
let aVal = a[sortColumn];
let bVal = b[sortColumn];
if (sortColumn === 'pdate') {
aVal = new Date(aVal);
bVal = new Date(bVal);
} else if (['hrs', 'ot'].includes(sortColumn)) {
aVal = parseFloat(aVal) || 0;
bVal = parseFloat(bVal) || 0;
} else {
aVal = (aVal || '').toString().toLowerCase();
bVal = (bVal || '').toString().toLowerCase();
}
if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1;
if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1;
return 0;
});
setFilteredData(filtered);
setCurrentPage(1);
};
// 필터 변경 핸들러
const handleFilterChange = (field, value) => {
setFilters(prev => ({ ...prev, [field]: value }));
// 날짜 필터 변경시 데이터 다시 로드
if (field === 'startDate' || field === 'endDate' || field === 'user') {
setTimeout(() => loadJobData(), 100);
}
};
// 필터 초기화
const clearFilters = () => {
const now = new Date();
const today = now.toISOString().split('T')[0];
const twoWeeksAgo = new Date(now.getTime() - (14 * 24 * 60 * 60 * 1000)).toISOString().split('T')[0];
setFilters({
startDate: twoWeeksAgo,
endDate: today,
status: '',
type: '',
user: '',
project: '',
search: ''
});
setTimeout(() => loadJobData(), 100);
};
// 정렬 처리
const handleSort = (column) => {
if (sortColumn === column) {
setSortDirection(sortDirection === 'asc' ? 'desc' : 'asc');
} else {
setSortColumn(column);
setSortDirection('asc');
}
};
// 추가 모달 표시
const showAddJobModal = () => {
const today = new Date().toISOString().split('T')[0];
setIsEditMode(false);
setEditData({
idx: '',
pdate: today,
status: '진행 중',
projectName: '',
requestpart: '',
type: '',
hrs: '8',
ot: '',
otStart: '',
otEnd: '',
description: ''
});
setShowEditModal(true);
};
// 편집 모달 표시
const showEditJobModal = async (item) => {
try {
// 상세 정보 로드
const response = await fetch(`/Jobreport/GetJobDetail?id=${item.idx}`);
if (response.ok) {
const fullItem = await response.json();
item = fullItem.error ? item : fullItem;
}
} catch (error) {
console.warn('Failed to load full details, using truncated data:', error);
}
setIsEditMode(true);
setEditData({
idx: item.idx || '',
pdate: item.pdate || '',
status: item.status || '',
projectName: item.projectName || '',
requestpart: item.requestpart || '',
type: item.type || '',
hrs: item.hrs || '',
ot: item.ot || '',
otStart: item.otStart || '',
otEnd: item.otEnd || '',
description: item.description || ''
});
setShowEditModal(true);
};
// 저장 처리
const handleSave = async (event) => {
event.preventDefault();
const formData = new URLSearchParams();
Object.keys(editData).forEach(key => {
if (key !== 'idx' || editData.idx) {
formData.append(key, editData[key]);
}
});
try {
const url = isEditMode ? '/Jobreport/Edit' : '/Jobreport/Add';
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded'
},
body: formData
});
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${isEditMode ? '수정' : '추가'}에 실패했습니다.`);
}
setShowEditModal(false);
await loadJobData();
showNotification(`업무일지가 성공적으로 ${isEditMode ? '수정' : '추가'}되었습니다.`, 'success');
} catch (error) {
console.error('Error saving job:', error);
showNotification(`업무일지 ${isEditMode ? '수정' : '추가'} 중 오류가 발생했습니다: ` + error.message, 'error');
}
};
// 삭제 처리
const handleDelete = async () => {
if (!editData.idx) {
showNotification('삭제할 수 없는 항목입니다.', 'warning');
return;
}
if (!window.confirm('정말로 이 업무일지를 삭제하시겠습니까?')) {
return;
}
try {
const response = await fetch(`/Jobreport/Delete/${editData.idx}`, {
method: 'DELETE'
});
if (!response.ok) {
throw new Error('삭제에 실패했습니다.');
}
const result = await response.json();
if (result.success) {
setShowEditModal(false);
await loadJobData();
showNotification('업무일지가 성공적으로 삭제되었습니다.', 'success');
} else {
throw new Error(result.message || '삭제에 실패했습니다.');
}
} catch (error) {
console.error('Error deleting job:', error);
showNotification('업무일지 삭제 중 오류가 발생했습니다: ' + error.message, 'error');
}
};
// 엑셀 내보내기
const exportToExcel = () => {
if (filteredData.length === 0) {
showNotification('내보낼 데이터가 없습니다.', 'warning');
return;
}
const periodText = filters.startDate && filters.endDate ? `_${filters.startDate}_${filters.endDate}` : '';
const headers = ['날짜', '상태', '프로젝트명', '요청부서', '타입', '업무내용', '근무시간', '초과근무'];
const csvContent = [
headers.join(','),
...filteredData.map(item => [
formatDate(item.pdate),
item.status || '',
item.projectName || '',
item.requestpart || '',
item.type || '',
`"${(item.description || '').replace(/"/g, '""')}"`,
item.hrs || '',
item.ot || ''
].join(','))
].join('\n');
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `업무일지${periodText}_${new Date().toISOString().split('T')[0]}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
// 유틸리티 함수들
const formatDate = (dateString) => {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleDateString('ko-KR');
};
const getStatusColor = (status) => {
switch (status) {
case '진행 중': return 'bg-blue-100 text-blue-800';
case '진행 완료': return 'bg-green-100 text-green-800';
case '대기': return 'bg-yellow-100 text-yellow-800';
default: return 'bg-gray-100 text-gray-800';
}
};
// 알림 표시 함수
const showNotification = (message, type = 'info') => {
const colors = {
info: 'bg-blue-500/90 backdrop-blur-sm',
success: 'bg-green-500/90 backdrop-blur-sm',
warning: 'bg-yellow-500/90 backdrop-blur-sm',
error: 'bg-red-500/90 backdrop-blur-sm'
};
const notification = document.createElement('div');
notification.className = `fixed top-4 right-4 ${colors[type]} text-white px-4 py-3 rounded-lg z-50 transition-all duration-300 transform translate-x-0 opacity-100 shadow-lg border border-white/20`;
notification.innerHTML = `
<div class="flex items-center">
<span class="ml-2">${message}</span>
</div>
`;
notification.style.transform = 'translateX(100%)';
notification.style.opacity = '0';
document.body.appendChild(notification);
setTimeout(() => {
notification.style.transform = 'translateX(0)';
notification.style.opacity = '1';
}, 10);
setTimeout(() => {
notification.style.transform = 'translateX(100%)';
notification.style.opacity = '0';
setTimeout(() => notification.remove(), 300);
}, 3000);
};
// 페이지네이션
const maxPage = Math.ceil(filteredData.length / pageSize);
const startIndex = (currentPage - 1) * pageSize;
const endIndex = startIndex + pageSize;
const pageData = filteredData.slice(startIndex, endIndex);
// 프로젝트 목록 업데이트
const uniqueProjects = [...new Set(jobData.map(item => item.projectName).filter(Boolean))];
return (
<div className="container mx-auto px-4 py-8">
{/* 개발중 경고 메시지 */}
<div className="bg-orange-500 rounded-lg p-4 mb-6 border-l-4 border-orange-700 animate-slide-up shadow-lg">
<div className="flex items-center">
<svg className="w-5 h-5 text-orange-900 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"></path>
</svg>
<div>
<p className="text-white font-bold text-base">🚧 개발중인 기능입니다</p>
<p className="text-orange-100 text-sm font-medium">일부 기능이 정상적으로 동작하지 않을 있습니다.</p>
</div>
</div>
</div>
{/* 통계 카드 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-8 animate-slide-up">
<div className="glass-effect rounded-lg p-6 card-hover">
<div className="flex items-center">
<div className="p-3 bg-primary-500/20 rounded-lg">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-white/80"> 업무일수</p>
<p className="text-2xl font-bold text-white">{statistics.totalDays}</p>
</div>
</div>
</div>
<div className="glass-effect rounded-lg p-6 card-hover">
<div className="flex items-center">
<div className="p-3 bg-success-500/20 rounded-lg">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-white/80">오늘 근무시간</p>
<p className={`text-2xl font-bold ${statistics.todayHours < 8 ? 'text-red-300' : 'text-green-300'}`}>
{statistics.todayHours.toFixed(1)}h
</p>
<p className="text-sm text-white/60">(목표 8시간의 {statistics.todayProgress.toFixed(0)}%)</p>
</div>
</div>
</div>
<div className="glass-effect rounded-lg p-6 card-hover">
<div className="flex items-center">
<div className="p-3 bg-warning-500/20 rounded-lg">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
</svg>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-white/80"> 초과근무</p>
<p className="text-2xl font-bold text-white">{statistics.totalOT.toFixed(1)}h</p>
</div>
</div>
</div>
<div className="glass-effect rounded-lg p-6 card-hover">
<div className="flex items-center">
<div className="p-3 bg-purple-500/20 rounded-lg">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2H5a2 2 0 00-2-2z"></path>
</svg>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-white/80">진행중 프로젝트</p>
<p className="text-2xl font-bold text-white">{statistics.activeProjects}</p>
</div>
</div>
</div>
</div>
{/* 필터 및 검색 */}
<div className="glass-effect rounded-lg mb-6 animate-slide-up">
<div className="p-4">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* 좌측: 필터 컨트롤 */}
<div className="lg:col-span-2 space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label className="block text-xs font-medium text-white/80 mb-1">조회기간</label>
<div className="flex space-x-1">
<input
type="date"
value={filters.startDate}
onChange={(e) => handleFilterChange('startDate', e.target.value)}
className="flex-1 bg-white/20 border border-white/30 rounded-md px-2 py-1 text-white placeholder-white/60 focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent text-xs"
/>
<span className="flex items-center text-white/60 text-xs">~</span>
<input
type="date"
value={filters.endDate}
onChange={(e) => handleFilterChange('endDate', e.target.value)}
className="flex-1 bg-white/20 border border-white/30 rounded-md px-2 py-1 text-white placeholder-white/60 focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent text-xs"
/>
</div>
</div>
<div>
<label className="block text-xs font-medium text-white/80 mb-1">상태</label>
<select
value={filters.status}
onChange={(e) => handleFilterChange('status', e.target.value)}
className="w-full bg-white/20 border border-white/30 rounded-md px-2 py-1 text-white focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent text-xs"
>
<option value="">전체</option>
<option value="진행중">진행중</option>
<option value="완료">완료</option>
<option value="대기">대기</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-white/80 mb-1">타입</label>
<select
value={filters.type}
onChange={(e) => handleFilterChange('type', e.target.value)}
className="w-full bg-white/20 border border-white/30 rounded-md px-2 py-1 text-white focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent text-xs"
>
<option value="">전체</option>
<option value="개발">개발</option>
<option value="유지보수">유지보수</option>
<option value="분석">분석</option>
<option value="테스트">테스트</option>
<option value="문서작업">문서작업</option>
<option value="회의">회의</option>
<option value="기타">기타</option>
</select>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label className="block text-xs font-medium text-white/80 mb-1">프로젝트</label>
<select
value={filters.project}
onChange={(e) => handleFilterChange('project', e.target.value)}
className="w-full bg-white/20 border border-white/30 rounded-md px-2 py-1 text-white focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent text-xs"
>
<option value="">전체</option>
{uniqueProjects.map(project => (
<option key={project} value={project}>{project}</option>
))}
</select>
</div>
<div>
<label className="block text-xs font-medium text-white/80 mb-1">검색</label>
<input
type="text"
value={filters.search}
onChange={(e) => handleFilterChange('search', e.target.value)}
placeholder="업무 내용 검색..."
className="w-full bg-white/20 border border-white/30 rounded-md px-2 py-1 text-white placeholder-white/60 focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent text-xs"
/>
</div>
</div>
</div>
{/* 우측: 액션 버튼들 */}
<div className="flex flex-col space-y-2 justify-center">
<button
onClick={showAddJobModal}
className="bg-primary-500 hover:bg-primary-600 text-white px-4 py-2 rounded-md flex items-center justify-center transition-colors"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
업무일지 추가
</button>
<button
onClick={exportToExcel}
className="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded-md flex items-center justify-center transition-colors"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
엑셀 다운로드
</button>
<button
onClick={clearFilters}
className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-md flex items-center justify-center transition-colors"
>
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
필터 초기화
</button>
</div>
</div>
</div>
</div>
{/* 데이터 테이블 */}
<div className="glass-effect rounded-lg overflow-hidden animate-slide-up custom-scrollbar">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-white/20">
<thead className="bg-white/10">
<tr>
<th
className="px-6 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider cursor-pointer hover:bg-white/20 transition-colors"
onClick={() => handleSort('pdate')}
>
날짜 <svg className="w-4 h-4 inline ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7"></path>
</svg>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider cursor-pointer hover:bg-white/20 transition-colors"
onClick={() => handleSort('status')}
>
상태 <svg className="w-4 h-4 inline ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7"></path>
</svg>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider cursor-pointer hover:bg-white/20 transition-colors"
onClick={() => handleSort('hrs')}
>
근무시간 <svg className="w-4 h-4 inline ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7"></path>
</svg>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider cursor-pointer hover:bg-white/20 transition-colors"
onClick={() => handleSort('projectName')}
>
프로젝트명 <svg className="w-4 h-4 inline ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7"></path>
</svg>
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider">업무내용</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider cursor-pointer hover:bg-white/20 transition-colors"
onClick={() => handleSort('requestpart')}
>
요청부서 <svg className="w-4 h-4 inline ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7"></path>
</svg>
</th>
<th
className="px-6 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider cursor-pointer hover:bg-white/20 transition-colors"
onClick={() => handleSort('type')}
>
타입 <svg className="w-4 h-4 inline ml-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7"></path>
</svg>
</th>
</tr>
</thead>
<tbody className="divide-y divide-white/10">
{isLoading ? (
<tr>
<td colSpan="7" className="p-8 text-center">
<div className="inline-flex items-center">
<svg className="animate-spin -ml-1 mr-3 h-5 w-5 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
</svg>
<span className="text-white/80">데이터를 불러오는 ...</span>
</div>
</td>
</tr>
) : pageData.length === 0 ? (
<tr>
<td colSpan="7" className="p-8 text-center">
<svg className="w-12 h-12 text-white/60 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4"></path>
</svg>
<p className="text-white/70">업무일지 데이터가 없습니다.</p>
</td>
</tr>
) : (
pageData.map((item, index) => {
const hrs = parseFloat(item.hrs) || 0;
const ot = parseFloat(item.ot) || 0;
let workTimeDisplay = '';
if (hrs > 0) {
workTimeDisplay = hrs.toFixed(1);
if (ot > 0) {
workTimeDisplay += '+' + ot.toFixed(1);
}
} else {
workTimeDisplay = '-';
}
return (
<tr
key={item.idx || index}
className="hover:bg-white/10 cursor-pointer transition-colors"
onClick={() => showEditJobModal(item)}
>
<td className="px-6 py-4 whitespace-nowrap text-sm text-white">
{formatDate(item.pdate)}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(item.status)}`}>
{item.status || '-'}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-white">
{workTimeDisplay}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-white">
<div className="flex items-center">
<span>{item.projectName || '-'}</span>
</div>
</td>
<td className="px-6 py-4 text-sm text-white">
<div className="max-w-xs truncate" title={item.description || ''}>
{item.description || '-'}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-white">
{item.requestpart || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-white">
{item.type || '-'}
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
{/* 페이지네이션 */}
<div className="mt-6 flex items-center justify-between glass-effect rounded-lg p-4">
<div className="flex items-center space-x-2">
<span className="text-sm text-white/80">페이지당 :</span>
<select
value={pageSize}
onChange={(e) => {
setPageSize(parseInt(e.target.value));
setCurrentPage(1);
}}
className="bg-white/20 border border-white/30 rounded-md px-2 py-1 text-sm text-white"
>
<option value="10">10</option>
<option value="25">25</option>
<option value="50">50</option>
<option value="100">100</option>
</select>
</div>
<div className="flex items-center space-x-2">
<button
onClick={() => setCurrentPage(Math.max(1, currentPage - 1))}
disabled={currentPage <= 1}
className="px-3 py-1 border border-white/30 rounded-md text-sm text-white hover:bg-white/20 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
이전
</button>
<span className="text-sm text-white/80">{currentPage} / {maxPage}</span>
<button
onClick={() => setCurrentPage(Math.min(maxPage, currentPage + 1))}
disabled={currentPage >= maxPage}
className="px-3 py-1 border border-white/30 rounded-md text-sm text-white hover:bg-white/20 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
다음
</button>
</div>
</div>
{/* 편집 모달 */}
{showEditModal && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50">
<div className="flex items-center justify-center min-h-screen p-4">
<div className="glass-effect rounded-2xl w-full max-w-6xl animate-slide-up">
{/* 모달 헤더 */}
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
<h2 className="text-xl font-semibold text-white flex items-center">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
</svg>
업무일지 {isEditMode ? '편집' : '추가'}
</h2>
<button
onClick={() => setShowEditModal(false)}
className="text-white/70 hover:text-white transition-colors"
>
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
{/* 모달 내용 */}
<form onSubmit={handleSave} className="p-6">
<div className="grid grid-cols-1 lg:grid-cols-5 gap-8">
{/* 좌측: 기본 정보 */}
<div className="lg:col-span-2">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-white/70 text-sm font-medium mb-2">날짜 *</label>
<input
type="date"
value={editData.pdate || ''}
onChange={(e) => setEditData(prev => ({...prev, pdate: e.target.value}))}
required
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
/>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2">상태 *</label>
<select
value={editData.status || ''}
onChange={(e) => setEditData(prev => ({...prev, status: e.target.value}))}
required
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
>
<option value="">선택하세요</option>
<option value="진행 중">진행 </option>
<option value="완료">완료</option>
<option value="대기">대기</option>
<option value="보류">보류</option>
</select>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2">요청부서</label>
<input
type="text"
value={editData.requestpart || ''}
onChange={(e) => setEditData(prev => ({...prev, requestpart: e.target.value}))}
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
/>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2">타입</label>
<select
value={editData.type || ''}
onChange={(e) => setEditData(prev => ({...prev, type: e.target.value}))}
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
>
<option value="">선택하세요</option>
<option value="개발">개발</option>
<option value="유지보수">유지보수</option>
<option value="분석">분석</option>
<option value="테스트">테스트</option>
<option value="문서작업">문서작업</option>
<option value="회의">회의</option>
<option value="기타">기타</option>
</select>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2">근무시간 (시간) *</label>
<input
type="number"
step="any"
value={editData.hrs || ''}
onChange={(e) => setEditData(prev => ({...prev, hrs: e.target.value}))}
required
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
/>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2">초과근무 (시간)</label>
<input
type="number"
step="any"
value={editData.ot || ''}
onChange={(e) => setEditData(prev => ({...prev, ot: e.target.value}))}
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
/>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2">초과근무 시작시간</label>
<input
type="time"
value={editData.otStart || ''}
onChange={(e) => setEditData(prev => ({...prev, otStart: e.target.value}))}
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
/>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2">초과근무 종료시간</label>
<input
type="time"
value={editData.otEnd || ''}
onChange={(e) => setEditData(prev => ({...prev, otEnd: e.target.value}))}
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
/>
</div>
</div>
</div>
{/* 우측: 프로젝트명과 업무내용 */}
<div className="lg:col-span-3 space-y-4">
<div>
<label className="block text-white/70 text-sm font-medium mb-2">프로젝트명 *</label>
<input
type="text"
value={editData.projectName || ''}
onChange={(e) => setEditData(prev => ({...prev, projectName: e.target.value}))}
required
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
/>
</div>
<div className="flex-grow">
<label className="block text-white/70 text-sm font-medium mb-2">업무내용 *</label>
<textarea
value={editData.description || ''}
onChange={(e) => setEditData(prev => ({...prev, description: e.target.value}))}
rows="15"
required
className="w-full h-full min-h-[360px] bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all resize-vertical"
placeholder="상세한 업무 내용을 입력하세요..."
/>
</div>
</div>
</div>
</form>
{/* 모달 푸터 */}
<div className="px-6 py-4 border-t border-white/10 flex justify-between items-center bg-black/10 rounded-b-2xl">
{isEditMode && (
<button
type="button"
onClick={handleDelete}
className="bg-danger-500 hover:bg-danger-600 text-white px-4 py-2 rounded-lg transition-colors"
>
삭제
</button>
)}
<div className="flex space-x-3 ml-auto">
<button
type="button"
onClick={() => setShowEditModal(false)}
className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-colors"
>
취소
</button>
<button
type="submit"
onClick={handleSave}
className="bg-primary-500 hover:bg-primary-600 text-white px-6 py-2 rounded-lg transition-colors"
>
저장
</button>
</div>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,339 @@
const { useState, useEffect } = React;
function Kuntae() {
// 상태 관리
const [kuntaeData, setKuntaeData] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [filters, setFilters] = useState({
startDate: '',
endDate: ''
});
// 통계 데이터
const [statistics, setStatistics] = useState({
totalDays: 0,
approvedDays: 0,
pendingDays: 0,
rejectedDays: 0
});
// 컴포넌트 마운트시 초기화
useEffect(() => {
initializeFilters();
}, []);
// 초기 필터 설정 (이번 달)
const initializeFilters = () => {
const now = new Date();
const firstDay = new Date(now.getFullYear(), now.getMonth(), 1);
const lastDay = new Date(now.getFullYear(), now.getMonth() + 1, 0);
setFilters({
startDate: firstDay.toISOString().split('T')[0],
endDate: lastDay.toISOString().split('T')[0]
});
// 초기 데이터 로드
loadKuntaeData({
startDate: firstDay.toISOString().split('T')[0],
endDate: lastDay.toISOString().split('T')[0]
});
};
// 근태 데이터 로드
const loadKuntaeData = async (searchFilters = filters) => {
setIsLoading(true);
try {
let url = '/Kuntae/GetData';
const params = new URLSearchParams();
if (searchFilters.startDate) params.append('startDate', searchFilters.startDate);
if (searchFilters.endDate) params.append('endDate', searchFilters.endDate);
if (params.toString()) {
url += '?' + params.toString();
}
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}: 데이터를 불러오는데 실패했습니다.`);
}
const responseData = await response.json();
if (Array.isArray(responseData)) {
setKuntaeData(responseData);
updateStatistics(responseData);
} else if (responseData.error) {
throw new Error(responseData.error);
} else {
setKuntaeData([]);
updateStatistics([]);
}
} catch (error) {
console.error('Error loading kuntae data:', error);
setKuntaeData([]);
updateStatistics([]);
showNotification('데이터를 불러오는데 실패했습니다: ' + error.message, 'error');
} finally {
setIsLoading(false);
}
};
// 통계 업데이트
const updateStatistics = (data) => {
const totalDays = data.length;
const approvedDays = data.filter(item => item.status === '승인').length;
const pendingDays = data.filter(item => item.status === '대기').length;
const rejectedDays = data.filter(item => item.status === '반려').length;
setStatistics({
totalDays,
approvedDays,
pendingDays,
rejectedDays
});
};
// 필터 변경 핸들러
const handleFilterChange = (field, value) => {
setFilters(prev => ({ ...prev, [field]: value }));
};
// 검색 실행
const handleSearch = () => {
loadKuntaeData();
};
// 유틸리티 함수들
const formatDate = (dateString) => {
if (!dateString) return '-';
const date = new Date(dateString);
return date.toLocaleDateString('ko-KR');
};
const getStatusColor = (status) => {
switch (status) {
case '승인': return 'bg-green-100 text-green-800';
case '대기': return 'bg-yellow-100 text-yellow-800';
case '반려': return 'bg-red-100 text-red-800';
default: return 'bg-gray-100 text-gray-800';
}
};
// 알림 표시 함수
const showNotification = (message, type = 'info') => {
const colors = {
info: 'bg-blue-500/90 backdrop-blur-sm',
success: 'bg-green-500/90 backdrop-blur-sm',
warning: 'bg-yellow-500/90 backdrop-blur-sm',
error: 'bg-red-500/90 backdrop-blur-sm'
};
const notification = document.createElement('div');
notification.className = `fixed top-4 right-4 ${colors[type]} text-white px-4 py-3 rounded-lg z-50 transition-all duration-300 transform translate-x-0 opacity-100 shadow-lg border border-white/20`;
notification.innerHTML = `
<div class="flex items-center">
<span class="ml-2">${message}</span>
</div>
`;
notification.style.transform = 'translateX(100%)';
notification.style.opacity = '0';
document.body.appendChild(notification);
setTimeout(() => {
notification.style.transform = 'translateX(0)';
notification.style.opacity = '1';
}, 10);
setTimeout(() => {
notification.style.transform = 'translateX(100%)';
notification.style.opacity = '0';
setTimeout(() => notification.remove(), 300);
}, 3000);
};
return (
<div className="container mx-auto px-4 py-8">
{/* 개발중 경고 메시지 */}
<div className="bg-orange-500 rounded-lg p-4 mb-6 border-l-4 border-orange-700 animate-slide-up shadow-lg">
<div className="flex items-center">
<svg className="w-5 h-5 text-orange-900 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"></path>
</svg>
<div>
<p className="text-white font-bold text-base">🚧 개발중인 기능입니다</p>
<p className="text-orange-100 text-sm font-medium">일부 기능이 정상적으로 동작하지 않을 있습니다.</p>
</div>
</div>
</div>
{/* 검색 및 필터 섹션 */}
<div className="glass-effect rounded-lg p-6 mb-6 animate-slide-up">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-sm font-medium text-white/80 mb-2">시작일</label>
<input
type="date"
value={filters.startDate}
onChange={(e) => handleFilterChange('startDate', e.target.value)}
className="w-full px-3 py-2 bg-white/20 border border-white/30 rounded-md text-white placeholder-white/60 focus:outline-none focus:ring-2 focus:ring-white/50 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-white/80 mb-2">종료일</label>
<input
type="date"
value={filters.endDate}
onChange={(e) => handleFilterChange('endDate', e.target.value)}
className="w-full px-3 py-2 bg-white/20 border border-white/30 rounded-md text-white placeholder-white/60 focus:outline-none focus:ring-2 focus:ring-white/50 focus:border-transparent"
/>
</div>
<div className="flex items-end">
<button
onClick={handleSearch}
disabled={isLoading}
className="w-full glass-effect text-white px-4 py-2 rounded-md hover:bg-white/30 transition-colors duration-200 flex items-center justify-center disabled:opacity-50"
>
{isLoading ? (
<>
<div className="loading inline-block w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin mr-2"></div>
로딩중...
</>
) : (
'조회'
)}
</button>
</div>
</div>
</div>
{/* 통계 카드 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6 mb-6 animate-slide-up">
<div className="glass-effect rounded-lg p-6 card-hover">
<div className="flex items-center">
<div className="p-3 bg-primary-500/20 rounded-lg">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
</svg>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-white/80"> 신청건</p>
<p className="text-2xl font-bold text-white">{statistics.totalDays}</p>
</div>
</div>
</div>
<div className="glass-effect rounded-lg p-6 card-hover">
<div className="flex items-center">
<div className="p-3 bg-success-500/20 rounded-lg">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-white/80">승인</p>
<p className="text-2xl font-bold text-green-300">{statistics.approvedDays}</p>
</div>
</div>
</div>
<div className="glass-effect rounded-lg p-6 card-hover">
<div className="flex items-center">
<div className="p-3 bg-warning-500/20 rounded-lg">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-white/80">대기</p>
<p className="text-2xl font-bold text-yellow-300">{statistics.pendingDays}</p>
</div>
</div>
</div>
<div className="glass-effect rounded-lg p-6 card-hover">
<div className="flex items-center">
<div className="p-3 bg-red-500/20 rounded-lg">
<svg className="w-6 h-6 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</div>
<div className="ml-4">
<p className="text-sm font-medium text-white/80">반려</p>
<p className="text-2xl font-bold text-red-300">{statistics.rejectedDays}</p>
</div>
</div>
</div>
</div>
{/* 데이터 테이블 */}
<div className="glass-effect rounded-lg overflow-hidden animate-slide-up">
<div className="table-container custom-scrollbar" style={{ maxHeight: '600px', overflowY: 'auto' }}>
<table className="min-w-full divide-y divide-white/20">
<thead className="bg-white/10 sticky top-0">
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider">신청일</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider">시작일</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider">종료일</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider">휴가종류</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider">일수</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider">상태</th>
<th className="px-6 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider">사유</th>
</tr>
</thead>
<tbody className="divide-y divide-white/10">
{isLoading ? (
<tr>
<td colSpan="7" className="p-8 text-center">
<div className="inline-flex items-center">
<div className="loading inline-block w-5 h-5 border-3 border-white/30 border-t-white rounded-full animate-spin mr-3"></div>
<span className="text-white/80">데이터를 불러오는 ...</span>
</div>
</td>
</tr>
) : kuntaeData.length === 0 ? (
<tr>
<td colSpan="7" className="p-8 text-center">
<svg className="w-12 h-12 text-white/60 mx-auto mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
<p className="text-white/70">근태 데이터가 없습니다.</p>
</td>
</tr>
) : (
kuntaeData.map((item, index) => (
<tr key={item.idx || index} className="hover:bg-white/10 transition-colors">
<td className="px-6 py-4 whitespace-nowrap text-sm text-white">
{formatDate(item.requestDate)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-white">
{formatDate(item.startDate)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-white">
{formatDate(item.endDate)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-white">
{item.type || '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-white">
{item.days || '0'}
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex px-2 py-1 text-xs font-semibold rounded-full ${getStatusColor(item.status)}`}>
{item.status || '-'}
</span>
</td>
<td className="px-6 py-4 text-sm text-white">
<div className="max-w-xs truncate" title={item.reason || ''}>
{item.reason || '-'}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,308 @@
// LoginApp.jsx - React Login Component for GroupWare
const { useState, useEffect, useRef } = React;
function LoginApp() {
const [formData, setFormData] = useState({
gcode: '',
userId: '',
password: '',
rememberMe: false
});
const [userGroups, setUserGroups] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [message, setMessage] = useState({ type: '', text: '', show: false });
const [isFormReady, setIsFormReady] = useState(false);
const gcodeRef = useRef(null);
const userIdRef = useRef(null);
const passwordRef = useRef(null);
// 메시지 표시 함수
const showMessage = (type, text) => {
setMessage({ type, text, show: true });
setTimeout(() => {
setMessage(prev => ({ ...prev, show: false }));
}, 3000);
};
// 폼 데이터 업데이트
const handleInputChange = (e) => {
const { name, value, type, checked } = e.target;
setFormData(prev => ({
...prev,
[name]: type === 'checkbox' ? checked : value
}));
};
// 사용자 그룹 목록 로드
const loadUserGroups = async () => {
try {
const response = await fetch('/DashBoard/GetUserGroups');
const data = await response.json();
// 유효한 그룹만 필터링
const validGroups = data.filter(group => group.gcode && group.name);
setUserGroups(validGroups);
// 이전 로그인 정보 로드
await loadPreviousLoginInfo();
} catch (error) {
console.error('그룹 목록 로드 중 오류 발생:', error);
showMessage('error', '부서 목록을 불러오는 중 오류가 발생했습니다.');
}
};
// 이전 로그인 정보 로드
const loadPreviousLoginInfo = async () => {
try {
const response = await fetch('/Home/GetPreviousLoginInfo');
const result = await response.json();
if (result.Success && result.Data) {
const { Gcode, UserId } = result.Data;
setFormData(prev => ({
...prev,
gcode: Gcode || '',
userId: UserId ? UserId.split(';')[0] : ''
}));
// 포커스 설정
setTimeout(() => {
if (Gcode && UserId) {
passwordRef.current?.focus();
} else {
gcodeRef.current?.focus();
}
}, 100);
}
setIsFormReady(true);
} catch (error) {
console.error('이전 로그인 정보 로드 중 오류 발생:', error);
setIsFormReady(true);
setTimeout(() => {
gcodeRef.current?.focus();
}, 100);
}
};
// 로그인 처리
const handleLogin = async (e) => {
e.preventDefault();
const { gcode, userId, password, rememberMe } = formData;
// 유효성 검사
if (!gcode || !userId || !password) {
showMessage('error', '그룹코드/사용자ID/비밀번호를 입력해주세요.');
return;
}
setIsLoading(true);
try {
const response = await fetch('/Home/Login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
Gcode: gcode,
UserId: userId,
Password: password,
RememberMe: rememberMe
})
});
const data = await response.json();
if (data.Success) {
// 로그인 성공
showMessage('success', data.Message);
// WebView2에 로그인 성공 메시지 전송
if (window.chrome && window.chrome.webview) {
window.chrome.webview.postMessage('LOGIN_SUCCESS');
}
// 리다이렉트 URL이 있으면 이동
if (data.RedirectUrl) {
setTimeout(() => {
window.location.href = data.RedirectUrl;
}, 1000);
}
} else {
// 로그인 실패
showMessage('error', data.Message || '로그인에 실패했습니다.');
}
} catch (error) {
console.error('로그인 요청 중 오류 발생:', error);
showMessage('error', '서버 연결에 실패했습니다. 다시 시도해주세요.');
} finally {
setIsLoading(false);
}
};
// 컴포넌트 마운트 시 실행
useEffect(() => {
loadUserGroups();
}, []);
return (
<div className="gradient-bg min-h-screen flex items-center justify-center p-4">
<div className="w-full max-w-md">
{/* 로그인 카드 */}
<div className="glass-effect rounded-3xl p-8 card-hover animate-bounce-in">
{/* 로고 및 제목 */}
<div className="text-center mb-8 animate-fade-in">
<div className="w-16 h-16 bg-white/20 rounded-full flex items-center justify-center mx-auto mb-4">
<svg className="w-8 h-8 text-white" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
</div>
<h1 className="text-2xl font-bold text-white mb-2">GroupWare</h1>
<p className="text-white/70 text-sm">로그인하여 시스템에 접속하세요</p>
</div>
{/* 로그인 폼 */}
<form onSubmit={handleLogin} className="space-y-6 animate-slide-up">
{/* Gcode 드롭다운 */}
<div className="relative">
<select
ref={gcodeRef}
name="gcode"
value={formData.gcode}
onChange={handleInputChange}
className="input-field w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white focus:outline-none focus:border-primary-400 input-focus appearance-none"
required
disabled={!isFormReady}
>
<option value="" className="text-gray-800">부서를 선택하세요</option>
{userGroups.map(group => (
<option key={group.gcode} value={group.gcode} className="text-gray-800">
{group.name}
</option>
))}
</select>
<div className="absolute right-3 top-3 pointer-events-none">
<svg className="w-5 h-5 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7"></path>
</svg>
</div>
</div>
{/* 사용자 ID 입력 */}
<div className="relative">
<input
ref={userIdRef}
type="text"
name="userId"
value={formData.userId}
onChange={handleInputChange}
className="input-field w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-white/60 focus:outline-none focus:border-primary-400 input-focus"
placeholder="사원번호"
required
disabled={!isFormReady}
/>
<div className="absolute right-3 top-3">
<svg className="w-5 h-5 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"></path>
</svg>
</div>
</div>
{/* 비밀번호 입력 */}
<div className="relative">
<input
ref={passwordRef}
type="password"
name="password"
value={formData.password}
onChange={handleInputChange}
className="input-field w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-white/60 focus:outline-none focus:border-primary-400 input-focus"
placeholder="비밀번호"
required
disabled={!isFormReady}
/>
<div className="absolute right-3 top-3">
<svg className="w-5 h-5 text-white/40" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
</div>
</div>
{/* 로그인 버튼 */}
<button
type="submit"
disabled={isLoading || !isFormReady}
className="w-full bg-primary-500 hover:bg-primary-600 disabled:opacity-50 disabled:cursor-not-allowed text-white font-semibold py-3 px-4 rounded-xl transition-all duration-300 transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-primary-400 focus:ring-offset-2 focus:ring-offset-transparent"
>
<span className="flex items-center justify-center">
{isLoading ? (
<>
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
로그인 ...
</>
) : (
<>
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"></path>
</svg>
로그인
</>
)}
</span>
</button>
</form>
{/* 추가 옵션 */}
<div className="mt-6 text-center">
<div className="flex items-center justify-center space-x-4 text-sm">
<label className="flex items-center text-white/70 hover:text-white cursor-pointer transition-colors">
<input
type="checkbox"
name="rememberMe"
checked={formData.rememberMe}
onChange={handleInputChange}
className="mr-2 w-4 h-4 text-primary-500 bg-white/10 border-white/20 rounded focus:ring-primary-400 focus:ring-2"
/>
로그인 정보 저장
</label>
<a href="#" className="text-primary-300 hover:text-primary-200 transition-colors">비밀번호 찾기</a>
</div>
</div>
</div>
{/* 푸터 */}
<div className="text-center mt-6 animate-fade-in">
<p className="text-white/50 text-sm">
© 2024 GroupWare System. All rights reserved.
</p>
</div>
{/* 메시지 표시 */}
{message.show && (
<div className={`fixed top-4 left-1/2 transform -translate-x-1/2 px-6 py-3 rounded-lg shadow-lg animate-slide-up ${
message.type === 'error' ? 'bg-red-500' : 'bg-green-500'
} text-white`}>
<div className="flex items-center">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{message.type === 'error' ? (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
) : (
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M5 13l4 4L19 7"></path>
)}
</svg>
<span>{message.text}</span>
</div>
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,884 @@
const { useState, useEffect } = React;
function Project() {
// 상태 관리
const [projects, setProjects] = useState([]);
const [filteredProjects, setFilteredProjects] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [currentUser, setCurrentUser] = useState('사용자');
const [currentPage, setCurrentPage] = useState(1);
const [itemsPerPage] = useState(10);
// 모달 상태
const [showProjectModal, setShowProjectModal] = useState(false);
const [showDeleteModal, setShowDeleteModal] = useState(false);
const [currentProjectIdx, setCurrentProjectIdx] = useState(null);
// 필터 상태
const [filters, setFilters] = useState({
status: '진행',
search: '',
manager: 'my'
});
// UI 상태
const [showFilters, setShowFilters] = useState(false);
const [showColumnDropdown, setShowColumnDropdown] = useState(false);
const [visibleColumns, setVisibleColumns] = useState({
serial: false,
plant: false,
line: false,
package: false,
staff: false,
process: false,
expire: false,
delivery: false,
design: false,
electric: false,
program: false,
budgetDue: false,
budget: false,
jasmin: false
});
// 프로젝트 폼 상태
const [projectForm, setProjectForm] = useState({
idx: 0,
name: '',
process: '',
sdate: '',
edate: '',
ddate: '',
odate: '',
userManager: '',
status: '진행',
memo: ''
});
// 통계 상태
const [statusCounts, setStatusCounts] = useState({
진행: 0,
완료: 0,
대기: 0,
중단: 0
});
// 컴포넌트 마운트시 초기화
useEffect(() => {
getCurrentUser();
loadProjects();
}, []);
// 필터 변경시 프로젝트 목록 새로 로드
useEffect(() => {
loadProjects();
}, [filters.status, filters.manager]);
// 검색어 변경시 필터링
useEffect(() => {
filterData();
}, [filters.search, projects]);
// 현재 사용자 정보 가져오기
const getCurrentUser = async () => {
try {
const response = await fetch('/Common/GetCurrentUser');
const data = await response.json();
if (data.Success && data.Data) {
const userName = data.Data.userName || data.Data.name || '사용자';
setCurrentUser(userName);
}
} catch (error) {
console.error('Error getting current user:', error);
}
};
// 프로젝트 목록 로드
const loadProjects = async () => {
setIsLoading(true);
try {
const response = await fetch(`/Project/GetProjects?status=${filters.status}&userFilter=${filters.manager}`);
const data = await response.json();
if (data.Success) {
const projectData = data.Data || [];
setProjects(projectData);
setFilteredProjects(projectData);
updateStatusCounts(projectData);
if (data.CurrentUser) {
setCurrentUser(data.CurrentUser);
}
} else {
console.error('Error:', data.Message);
showNotification('데이터를 불러오는데 실패했습니다.', 'error');
}
} catch (error) {
console.error('Error:', error);
showNotification('데이터를 불러오는데 실패했습니다.', 'error');
} finally {
setIsLoading(false);
}
};
// 상태별 카운트 업데이트
const updateStatusCounts = (projectData) => {
const counts = { 진행: 0, 완료: 0, 대기: 0, 중단: 0 };
projectData.forEach(project => {
const status = project.상태 || '진행';
counts[status] = (counts[status] || 0) + 1;
});
setStatusCounts(counts);
};
// 데이터 필터링
const filterData = () => {
if (!filters.search.trim()) {
setFilteredProjects(projects);
setCurrentPage(1);
return;
}
const searchTerm = filters.search.toLowerCase();
const filtered = projects.filter(project => {
return (project.프로젝트명 && project.프로젝트명.toLowerCase().includes(searchTerm)) ||
(project.프로젝트공정 && project.프로젝트공정.toLowerCase().includes(searchTerm)) ||
(project.자산번호 && project.자산번호.toLowerCase().includes(searchTerm)) ||
(project.장비모델 && project.장비모델.toLowerCase().includes(searchTerm)) ||
(project.시리얼번호 && project.시리얼번호.toLowerCase().includes(searchTerm)) ||
(project.프로젝트관리자 && project.프로젝트관리자.toLowerCase().includes(searchTerm)) ||
(project.설계담당 && project.설계담당.toLowerCase().includes(searchTerm)) ||
(project.전장담당 && project.전장담당.toLowerCase().includes(searchTerm)) ||
(project.프로그램담당 && project.프로그램담당.toLowerCase().includes(searchTerm));
});
setFilteredProjects(filtered);
setCurrentPage(1);
};
// 프로젝트 추가 모달 표시
const showAddProjectModal = () => {
setCurrentProjectIdx(null);
setProjectForm({
idx: 0,
name: '',
process: '',
sdate: '',
edate: '',
ddate: '',
odate: '',
userManager: '',
status: '진행',
memo: ''
});
setShowProjectModal(true);
};
// 프로젝트 편집
const editProject = async (idx) => {
setCurrentProjectIdx(idx);
setIsLoading(true);
try {
const response = await fetch(`/Project/GetProject?id=${idx}`);
const data = await response.json();
if (data.Success && data.Data) {
const project = data.Data;
setProjectForm({
idx: project.idx,
name: project.프로젝트명 || '',
process: project.프로젝트공정 || '',
sdate: project.시작일 || '',
edate: project.완료일 || '',
ddate: project.만료일 || '',
odate: project.출고일 || '',
userManager: project.프로젝트관리자 || '',
status: project.상태 || '진행',
memo: project.memo || ''
});
setShowProjectModal(true);
} else {
showNotification('프로젝트 정보를 불러오는데 실패했습니다: ' + data.Message, 'error');
}
} catch (error) {
console.error('Error:', error);
showNotification('프로젝트 정보를 불러오는데 실패했습니다.', 'error');
} finally {
setIsLoading(false);
}
};
// 프로젝트 저장 (추가/수정)
const saveProject = async (e) => {
e.preventDefault();
setIsLoading(true);
const projectData = {
...projectForm,
idx: currentProjectIdx ? parseInt(projectForm.idx) : 0,
sdate: projectForm.sdate || null,
edate: projectForm.edate || null,
ddate: projectForm.ddate || null,
odate: projectForm.odate || null
};
const url = currentProjectIdx ? '/Project/UpdateProject' : '/Project/CreateProject';
const method = currentProjectIdx ? 'PUT' : 'POST';
try {
const response = await fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(projectData)
});
const data = await response.json();
if (data.Success) {
showNotification(data.Message || (currentProjectIdx ? '프로젝트가 수정되었습니다.' : '프로젝트가 추가되었습니다.'), 'success');
setShowProjectModal(false);
loadProjects();
} else {
showNotification('오류: ' + data.Message, 'error');
}
} catch (error) {
console.error('Error:', error);
showNotification('저장 중 오류가 발생했습니다.', 'error');
} finally {
setIsLoading(false);
}
};
// 프로젝트 삭제
const deleteProject = async () => {
if (!currentProjectIdx) return;
setIsLoading(true);
try {
const response = await fetch(`/Project/DeleteProject?id=${currentProjectIdx}`, {
method: 'DELETE'
});
const data = await response.json();
if (data.Success) {
showNotification(data.Message || '프로젝트가 삭제되었습니다.', 'success');
setShowDeleteModal(false);
setShowProjectModal(false);
loadProjects();
} else {
showNotification('오류: ' + data.Message, 'error');
}
} catch (error) {
console.error('Error:', error);
showNotification('삭제 중 오류가 발생했습니다.', 'error');
} finally {
setIsLoading(false);
}
};
// 필터 초기화
const clearFilters = () => {
setFilters({
status: '진행',
search: '',
manager: 'my'
});
};
// 엑셀 다운로드
const exportToExcel = () => {
if (filteredProjects.length === 0) {
showNotification('내보낼 데이터가 없습니다.', 'warning');
return;
}
const headers = ['상태', '자산번호', '장비모델', '시리얼번호', '우선순위', '요청국가', '요청공장', '요청라인', '요청부서패키지', '요청자', '프로젝트공정', '시작일', '완료일', '만료일', '출고일', '프로젝트명', '프로젝트관리자', '설계담당', '전장담당', '프로그램담당', '예산만기일', '예산', '웹관리번호'];
const csvContent = [
headers.join(','),
...filteredProjects.map(project => [
project.상태 || '',
project.자산번호 || '',
project.장비모델 || '',
project.시리얼번호 || '',
project.우선순위 || '',
project.요청국가 || '',
project.요청공장 || '',
project.요청라인 || '',
project.요청부서패키지 || '',
project.요청자 || '',
project.프로젝트공정 || '',
project.시작일 || '',
project.완료일 || '',
project.만료일 || '',
project.출고일 || '',
project.프로젝트명 || '',
project.프로젝트관리자 || '',
project.설계담당 || '',
project.전장담당 || '',
project.프로그램담당 || '',
project.예산만기일 || '',
project.예산 || '',
project.웹관리번호 || ''
].join(','))
].join('\n');
const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `프로젝트목록_${new Date().toISOString().split('T')[0]}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
// 유틸리티 함수들
const getStatusClass = (status) => {
switch(status) {
case '진행': return 'bg-blue-500/20 text-blue-300';
case '완료': return 'bg-green-500/20 text-green-300';
case '대기': return 'bg-yellow-500/20 text-yellow-300';
case '중단': return 'bg-red-500/20 text-red-300';
default: return 'bg-gray-500/20 text-gray-300';
}
};
const formatDateToMonthDay = (dateString) => {
if (!dateString) return '';
try {
const date = new Date(dateString);
if (isNaN(date.getTime())) return dateString;
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${month}-${day}`;
} catch (error) {
return dateString;
}
};
// 알림 표시 함수
const showNotification = (message, type = 'info') => {
const colors = {
info: 'bg-blue-500/90 backdrop-blur-sm',
success: 'bg-green-500/90 backdrop-blur-sm',
warning: 'bg-yellow-500/90 backdrop-blur-sm',
error: 'bg-red-500/90 backdrop-blur-sm'
};
const notification = document.createElement('div');
notification.className = `fixed top-4 right-4 ${colors[type]} text-white px-4 py-3 rounded-lg z-50 transition-all duration-300 transform translate-x-0 opacity-100 shadow-lg border border-white/20`;
notification.innerHTML = `
<div class="flex items-center">
<span class="ml-2">${message}</span>
</div>
`;
notification.style.transform = 'translateX(100%)';
notification.style.opacity = '0';
document.body.appendChild(notification);
setTimeout(() => {
notification.style.transform = 'translateX(0)';
notification.style.opacity = '1';
}, 10);
setTimeout(() => {
notification.style.transform = 'translateX(100%)';
notification.style.opacity = '0';
setTimeout(() => notification.remove(), 300);
}, 3000);
};
// 페이지네이션 계산
const totalPages = Math.ceil(filteredProjects.length / itemsPerPage);
const startIndex = (currentPage - 1) * itemsPerPage;
const endIndex = startIndex + itemsPerPage;
const paginatedProjects = filteredProjects.slice(startIndex, endIndex);
const changePage = (page) => {
if (page >= 1 && page <= totalPages) {
setCurrentPage(page);
}
};
return (
<div className="container mx-auto px-4 py-8">
{/* 프로젝트 목록 */}
<div className="glass-effect rounded-lg overflow-hidden animate-slide-up">
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
<h2 className="text-xl font-semibold text-white flex items-center">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"></path>
</svg>
프로젝트 목록
</h2>
<div className="flex space-x-3">
<button onClick={showAddProjectModal} className="bg-primary-500 hover:bg-primary-600 text-white px-4 py-2 rounded-lg transition-colors flex items-center text-sm" title="프로젝트 추가">
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
프로젝트 추가
</button>
<button onClick={() => setShowFilters(!showFilters)} className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-colors flex items-center text-sm" title="필터 표시/숨김">
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707L13 14v6a1 1 0 01-.707.293l-4-1A1 1 0 018 19v-5L0.293 7.293A1 1 0 010 6.586V4z"></path>
</svg>
필터
<svg className={`w-4 h-4 ml-2 transition-transform ${showFilters ? 'rotate-180' : ''}`} fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
</div>
</div>
{/* 상태별 카드 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4 p-4 border-b border-white/10">
<div className="bg-white/10 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-blue-300 mb-1">{statusCounts.진행}</div>
<div className="text-sm text-white/60">진행</div>
</div>
<div className="bg-white/10 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-green-300 mb-1">{statusCounts.완료}</div>
<div className="text-sm text-white/60">완료</div>
</div>
<div className="bg-white/10 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-yellow-300 mb-1">{statusCounts.대기}</div>
<div className="text-sm text-white/60">대기</div>
</div>
<div className="bg-white/10 rounded-lg p-4 text-center">
<div className="text-2xl font-bold text-red-300 mb-1">{statusCounts.중단}</div>
<div className="text-sm text-white/60">중단</div>
</div>
</div>
{/* 필터 영역 */}
{showFilters && (
<div className="p-4 border-b border-white/10 animate-slide-up">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
{/* 좌측: 필터 컨트롤 */}
<div className="lg:col-span-2 space-y-3">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label className="block text-xs font-medium text-white/80 mb-1">상태</label>
<select
value={filters.status}
onChange={(e) => setFilters({...filters, status: e.target.value})}
className="w-full bg-white/20 border border-white/30 rounded-md px-2 py-1 text-white focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent text-xs"
>
<option value="진행">진행</option>
<option value="완료">완료</option>
<option value="대기">대기</option>
<option value="중단">중단</option>
</select>
</div>
<div>
<label className="block text-xs font-medium text-white/80 mb-1">검색</label>
<input
type="text"
value={filters.search}
onChange={(e) => setFilters({...filters, search: e.target.value})}
placeholder="프로젝트명 검색..."
className="w-full bg-white/20 border border-white/30 rounded-md px-2 py-1 text-white placeholder-white/60 focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent text-xs"
/>
</div>
<div>
<label className="block text-xs font-medium text-white/80 mb-1">담당자</label>
<select
value={filters.manager}
onChange={(e) => setFilters({...filters, manager: e.target.value})}
className="w-full bg-white/20 border border-white/30 rounded-md px-2 py-1 text-white focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent text-xs"
>
<option value="my"> 프로젝트</option>
<option value="all">전체 프로젝트</option>
</select>
</div>
</div>
</div>
{/* 우측: 액션 버튼들 */}
<div className="flex flex-wrap gap-2 justify-end">
<div className="relative">
<button onClick={() => setShowColumnDropdown(!showColumnDropdown)} className="bg-purple-500 hover:bg-purple-600 text-white px-4 py-2 rounded-lg flex items-center transition-colors text-sm" title="컬럼 표시/숨김">
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7m0 10a2 2 0 002 2h2a2 2 0 002-2V7a2 2 0 00-2-2H9z"></path>
</svg>
컬럼 설정
</button>
{showColumnDropdown && (
<div className="absolute top-full right-0 bg-gray-800/95 backdrop-blur-sm border border-white/20 rounded-lg p-3 min-w-48 z-50 mt-1">
<div className="text-sm font-medium text-white/80 mb-2">표시할 컬럼 선택</div>
<div className="space-y-1 text-sm max-h-40 overflow-y-auto">
{Object.entries({
serial: '시리얼번호',
plant: '요청공장',
line: '요청라인',
package: '요청부서패키지',
staff: '요청자',
process: '프로젝트공정',
expire: '만료일',
delivery: '출고일',
design: '설계담당',
electric: '전장담당',
program: '프로그램담당',
budgetDue: '예산만기일',
budget: '예산',
jasmin: '웹관리번호'
}).map(([key, label]) => (
<label key={key} className="flex items-center text-white cursor-pointer hover:text-blue-300">
<input
type="checkbox"
checked={visibleColumns[key]}
onChange={(e) => setVisibleColumns({...visibleColumns, [key]: e.target.checked})}
className="mr-2"
/>
{label}
</label>
))}
</div>
</div>
)}
</div>
<button onClick={exportToExcel} className="bg-green-500 hover:bg-green-600 text-white px-4 py-2 rounded-lg flex items-center transition-colors text-sm" title="엑셀 다운로드">
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"></path>
</svg>
엑셀 다운로드
</button>
<button onClick={clearFilters} className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg flex items-center transition-colors text-sm" title="필터 초기화">
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"></path>
</svg>
필터 초기화
</button>
</div>
</div>
</div>
)}
{/* 데이터 테이블 */}
<div className="px-4 py-3 bg-white/5">
<div className="text-sm text-white/70 font-medium">프로젝트 현황</div>
</div>
<div className="overflow-x-auto custom-scrollbar">
<table className="w-full divide-y divide-white/20">
<thead className="bg-white/10">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-20">상태</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 min-w-32">프로젝트명</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-24">자산번호</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-28">장비모델</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-24">우선순위</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-20">요청국가</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-28">프로젝트관리자</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-24">시작일</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-24">완료일</th>
{visibleColumns.serial && <th className="px-3 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-24">시리얼번호</th>}
{visibleColumns.plant && <th className="px-3 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-20">요청공장</th>}
{visibleColumns.line && <th className="px-3 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-20">요청라인</th>}
{visibleColumns.package && <th className="px-3 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-28">요청부서패키지</th>}
{visibleColumns.staff && <th className="px-3 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-20">요청자</th>}
{visibleColumns.process && <th className="px-3 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-24">프로젝트공정</th>}
{visibleColumns.expire && <th className="px-3 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-24">만료일</th>}
{visibleColumns.delivery && <th className="px-3 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-24">출고일</th>}
{visibleColumns.design && <th className="px-3 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-24">설계담당</th>}
{visibleColumns.electric && <th className="px-3 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-24">전장담당</th>}
{visibleColumns.program && <th className="px-3 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-24">프로그램담당</th>}
{visibleColumns.budgetDue && <th className="px-3 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-24">예산만기일</th>}
{visibleColumns.budget && <th className="px-3 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider border-r border-white/20 w-20">예산</th>}
{visibleColumns.jasmin && <th className="px-3 py-3 text-left text-xs font-medium text-white/80 uppercase tracking-wider w-24">웹관리번호</th>}
</tr>
</thead>
<tbody className="divide-y divide-white/10">
{isLoading ? (
<tr>
<td colSpan="23" className="px-6 py-4 text-center">
<div className="inline-flex items-center">
<div className="loading inline-block w-5 h-5 border-3 border-white/30 border-t-white rounded-full animate-spin mr-3"></div>
<span className="text-white/80">데이터를 불러오는 ...</span>
</div>
</td>
</tr>
) : paginatedProjects.length === 0 ? (
<tr>
<td colSpan="23" className="px-6 py-4 text-center text-white/60">데이터가 없습니다.</td>
</tr>
) : (
paginatedProjects.map(project => (
<tr key={project.idx} onClick={() => editProject(project.idx)} className="hover:bg-white/10 cursor-pointer transition-colors">
<td className="px-4 py-4 text-sm border-r border-white/20">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${getStatusClass(project.상태)}`}>
{project.상태 || '진행'}
</span>
</td>
<td className="px-4 py-4 text-sm border-r border-white/20 font-medium">{project.프로젝트명 || ''}</td>
<td className="px-4 py-4 text-sm border-r border-white/20">{project.자산번호 || ''}</td>
<td className="px-4 py-4 text-sm border-r border-white/20">{project.장비모델 || ''}</td>
<td className="px-4 py-4 text-sm border-r border-white/20">{project.우선순위 || ''}</td>
<td className="px-4 py-4 text-sm border-r border-white/20">{project.요청국가 || ''}</td>
<td className="px-4 py-4 text-sm border-r border-white/20">{project.프로젝트관리자 || ''}</td>
<td className="px-4 py-4 text-sm border-r border-white/20">{formatDateToMonthDay(project.시작일)}</td>
<td className="px-4 py-4 text-sm border-r border-white/20">{formatDateToMonthDay(project.완료일)}</td>
{visibleColumns.serial && <td className="px-3 py-4 text-sm border-r border-white/20">{project.시리얼번호 || ''}</td>}
{visibleColumns.plant && <td className="px-3 py-4 text-sm border-r border-white/20">{project.요청공장 || ''}</td>}
{visibleColumns.line && <td className="px-3 py-4 text-sm border-r border-white/20">{project.요청라인 || ''}</td>}
{visibleColumns.package && <td className="px-3 py-4 text-sm border-r border-white/20">{project.요청부서패키지 || ''}</td>}
{visibleColumns.staff && <td className="px-3 py-4 text-sm border-r border-white/20">{project.요청자 || ''}</td>}
{visibleColumns.process && <td className="px-3 py-4 text-sm border-r border-white/20">{project.프로젝트공정 || ''}</td>}
{visibleColumns.expire && <td className="px-3 py-4 text-sm border-r border-white/20">{formatDateToMonthDay(project.만료일)}</td>}
{visibleColumns.delivery && <td className="px-3 py-4 text-sm border-r border-white/20">{formatDateToMonthDay(project.출고일)}</td>}
{visibleColumns.design && <td className="px-3 py-4 text-sm border-r border-white/20">{project.설계담당 || ''}</td>}
{visibleColumns.electric && <td className="px-3 py-4 text-sm border-r border-white/20">{project.전장담당 || ''}</td>}
{visibleColumns.program && <td className="px-3 py-4 text-sm border-r border-white/20">{project.프로그램담당 || ''}</td>}
{visibleColumns.budgetDue && <td className="px-3 py-4 text-sm border-r border-white/20">{formatDateToMonthDay(project.예산만기일)}</td>}
{visibleColumns.budget && <td className="px-3 py-4 text-sm border-r border-white/20">{project.예산 || ''}</td>}
{visibleColumns.jasmin && (
<td className="px-3 py-4 text-sm">
{project.웹관리번호 ? (
<a href={`https://scwa.amkor.co.kr/jasmine/view/${project.웹관리번호}`} target="_blank" rel="noopener noreferrer" className="text-blue-300 hover:text-blue-200 underline">
{project.웹관리번호}
</a>
) : ''}
</td>
)}
</tr>
))
)}
</tbody>
</table>
</div>
{/* 페이징 */}
<div className="px-4 py-4 border-t border-white/10">
<div className="flex items-center justify-between">
<div className="text-sm text-white/60">
{filteredProjects.length} {startIndex + 1}-{Math.min(endIndex, filteredProjects.length)} 표시
</div>
<div className="flex items-center space-x-2">
<button onClick={() => changePage(currentPage - 1)} disabled={currentPage <= 1} className="px-3 py-1 bg-white/20 hover:bg-white/30 text-white rounded-md text-sm disabled:opacity-50 disabled:cursor-not-allowed">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 19l-7-7 7-7"></path>
</svg>
</button>
<div className="flex space-x-1">
{Array.from({length: Math.min(5, totalPages)}, (_, i) => {
const pageNum = Math.max(1, currentPage - 2) + i;
if (pageNum > totalPages) return null;
return (
<button
key={pageNum}
onClick={() => changePage(pageNum)}
className={`px-3 py-1 rounded-md text-sm transition-colors ${
pageNum === currentPage
? 'bg-primary-500 text-white'
: 'bg-white/20 hover:bg-white/30 text-white'
}`}
>
{pageNum}
</button>
);
})}
</div>
<button onClick={() => changePage(currentPage + 1)} disabled={currentPage >= totalPages} className="px-3 py-1 bg-white/20 hover:bg-white/30 text-white rounded-md text-sm disabled:opacity-50 disabled:cursor-not-allowed">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5l7 7-7 7"></path>
</svg>
</button>
</div>
</div>
</div>
</div>
{/* 프로젝트 추가/편집 모달 */}
{showProjectModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50">
<div className="flex items-center justify-center min-h-screen p-4">
<div className="glass-effect rounded-lg w-full max-w-2xl animate-slide-up">
<div className="p-6">
<div className="flex justify-between items-center mb-6">
<h3 className="text-xl font-semibold">{currentProjectIdx ? '프로젝트 편집' : '프로젝트 추가'}</h3>
<button onClick={() => setShowProjectModal(false)} className="text-white/60 hover:text-white">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
<form onSubmit={saveProject}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-white/80 mb-2">프로젝트명 *</label>
<input
type="text"
value={projectForm.name}
onChange={(e) => setProjectForm({...projectForm, name: e.target.value})}
required
className="w-full bg-white/20 border border-white/30 rounded-md px-3 py-2 text-white placeholder-white/60 focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-white/80 mb-2">부서</label>
<input
type="text"
value={projectForm.process}
onChange={(e) => setProjectForm({...projectForm, process: e.target.value})}
className="w-full bg-white/20 border border-white/30 rounded-md px-3 py-2 text-white placeholder-white/60 focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-white/80 mb-2">시작일</label>
<input
type="date"
value={projectForm.sdate}
onChange={(e) => setProjectForm({...projectForm, sdate: e.target.value})}
className="w-full bg-white/20 border border-white/30 rounded-md px-3 py-2 text-white focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-white/80 mb-2">종료일</label>
<input
type="date"
value={projectForm.edate}
onChange={(e) => setProjectForm({...projectForm, edate: e.target.value})}
className="w-full bg-white/20 border border-white/30 rounded-md px-3 py-2 text-white focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-white/80 mb-2">개발완료일</label>
<input
type="date"
value={projectForm.ddate}
onChange={(e) => setProjectForm({...projectForm, ddate: e.target.value})}
className="w-full bg-white/20 border border-white/30 rounded-md px-3 py-2 text-white focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-white/80 mb-2">운영개시일</label>
<input
type="date"
value={projectForm.odate}
onChange={(e) => setProjectForm({...projectForm, odate: e.target.value})}
className="w-full bg-white/20 border border-white/30 rounded-md px-3 py-2 text-white focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-sm font-medium text-white/80 mb-2">담당자</label>
<input
type="text"
value={projectForm.userManager}
onChange={(e) => setProjectForm({...projectForm, userManager: e.target.value})}
className="w-full bg-white/20 border border-white/30 rounded-md px-3 py-2 text-white placeholder-white/60 focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent"
/>
</div>
<div>
<label className="block text-sm font-medium text-white/80 mb-2">상태</label>
<select
value={projectForm.status}
onChange={(e) => setProjectForm({...projectForm, status: e.target.value})}
className="w-full bg-white/20 border border-white/30 rounded-md px-3 py-2 text-white focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent"
>
<option value="진행">진행</option>
<option value="완료">완료</option>
<option value="대기">대기</option>
<option value="중단">중단</option>
</select>
</div>
</div>
<div className="mb-4">
<label className="block text-sm font-medium text-white/80 mb-2">메모</label>
<textarea
value={projectForm.memo}
onChange={(e) => setProjectForm({...projectForm, memo: e.target.value})}
rows="3"
className="w-full bg-white/20 border border-white/30 rounded-md px-3 py-2 text-white placeholder-white/60 focus:outline-none focus:ring-1 focus:ring-white/50 focus:border-transparent"
></textarea>
</div>
<div className="flex justify-between items-center">
{currentProjectIdx && (
<button type="button" onClick={() => setShowDeleteModal(true)} className="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-md transition-colors">
삭제
</button>
)}
<div className="flex space-x-2 ml-auto">
<button type="button" onClick={() => setShowProjectModal(false)} className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-md transition-colors">
취소
</button>
<button type="submit" className="bg-primary-500 hover:bg-primary-600 text-white px-4 py-2 rounded-md transition-colors">
저장
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
)}
{/* 삭제 확인 모달 */}
{showDeleteModal && (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50">
<div className="flex items-center justify-center min-h-screen p-4">
<div className="glass-effect rounded-lg w-full max-w-md animate-slide-up">
<div className="p-6">
<div className="flex items-center mb-4">
<svg className="w-8 h-8 text-red-400 mr-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L3.732 16.5c-.77.833.192 2.5 1.732 2.5z"></path>
</svg>
<h3 className="text-lg font-semibold">삭제 확인</h3>
</div>
<p className="text-white/80 mb-6">선택한 프로젝트를 삭제하시겠습니까?<br><span className="text-sm text-gray-500"> 작업은 되돌릴 없습니다.</span></p>
<div className="flex justify-end space-x-2">
<button onClick={() => setShowDeleteModal(false)} className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-md transition-colors">
취소
</button>
<button onClick={deleteProject} className="bg-red-500 hover:bg-red-600 text-white px-4 py-2 rounded-md transition-colors">
삭제
</button>
</div>
</div>
</div>
</div>
</div>
)}
{/* 로딩 인디케이터 */}
{isLoading && (
<div className="fixed top-4 right-4 bg-white/20 backdrop-blur-sm rounded-full px-4 py-2 text-white text-sm z-40">
<div className="flex items-center">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
처리 ...
</div>
</div>
)}
{/* 외부 클릭시 드롭다운 닫기 */}
{showColumnDropdown && (
<div
className="fixed inset-0 z-10"
onClick={() => setShowColumnDropdown(false)}
></div>
)}
</div>
);
}

View File

@@ -0,0 +1,147 @@
// TestApp.jsx - React Test Component for GroupWare
const { useState, useEffect } = React;
function TestApp() {
const [status, setStatus] = useState('loading');
const [counter, setCounter] = useState(0);
const [serverTime, setServerTime] = useState('');
const [apiTest, setApiTest] = useState({ status: 'pending', message: '' });
// 컴포넌트가 마운트될 때 실행
useEffect(() => {
// React가 정상적으로 로드되었음을 표시
setTimeout(() => {
setStatus('success');
setServerTime(new Date().toLocaleString('ko-KR'));
}, 1000);
// API 테스트
testAPI();
}, []);
// GroupWare API 테스트 함수
const testAPI = async () => {
try {
// Home 컨트롤러 테스트
const response = await fetch('/Home');
if (response.ok) {
setApiTest({ status: 'success', message: 'API 연결 성공' });
} else {
setApiTest({ status: 'warning', message: `API 응답: ${response.status}` });
}
} catch (error) {
setApiTest({ status: 'error', message: `API 오류: ${error.message}` });
}
};
const buttonStyle = {
padding: '10px 20px',
margin: '5px',
border: 'none',
borderRadius: '5px',
cursor: 'pointer',
fontSize: '14px'
};
return (
<div>
<div className={`status ${status === 'success' ? 'success' : 'loading'}`}>
{status === 'success' ? (
<div>
<h3> React 컴포넌트가 성공적으로 로드되었습니다!</h3>
<p><strong>현재 시간:</strong> {serverTime}</p>
<p><strong>파일 위치:</strong> /react/TestApp.jsx</p>
</div>
) : (
<h3>React 컴포넌트를 로딩 중입니다...</h3>
)}
</div>
{status === 'success' && (
<div>
<div className="status">
<h3>📊 상태 관리 테스트</h3>
<p><strong>카운터:</strong> {counter}</p>
<button
onClick={() => setCounter(counter + 1)}
style={{
...buttonStyle,
backgroundColor: '#007bff',
color: 'white'
}}
>
증가 (+1)
</button>
<button
onClick={() => setCounter(counter - 1)}
style={{
...buttonStyle,
backgroundColor: '#dc3545',
color: 'white'
}}
>
감소 (-1)
</button>
<button
onClick={() => setCounter(0)}
style={{
...buttonStyle,
backgroundColor: '#6c757d',
color: 'white'
}}
>
리셋
</button>
</div>
<div className={`status ${
apiTest.status === 'success' ? 'success' :
apiTest.status === 'error' ? 'error' : 'loading'
}`}>
<h3>🌐 GroupWare API 연결 테스트</h3>
<p><strong>상태:</strong> {apiTest.message}</p>
<p><strong>테스트 엔드포인트:</strong> /Home</p>
<button
onClick={testAPI}
style={{
...buttonStyle,
backgroundColor: '#28a745',
color: 'white'
}}
>
API 다시 테스트
</button>
</div>
<div className="status">
<h3>📋 React + OWIN 통합 테스트 체크리스트</h3>
<ul style={{ textAlign: 'left' }}>
<li> OWIN 정적 파일 서빙</li>
<li> React 라이브러리 로딩 (CDN)</li>
<li> JSX 파일 분리 로딩</li>
<li> JSX 컴파일 (Babel)</li>
<li> React Hooks (useState, useEffect)</li>
<li> 이벤트 핸들링</li>
<li> API 호출 (fetch)</li>
<li> 반응형 UI 업데이트</li>
<li> 컴포넌트 모듈화</li>
</ul>
</div>
<div className="status success">
<h3>🎉 통합 테스트 결과</h3>
<p>GroupWare + OWIN + React 환경이 성공적으로 구성되었습니다!</p>
<p><strong>다음 단계:</strong></p>
<ul style={{ textAlign: 'left' }}>
<li>React Router 추가 (SPA 라우팅)</li>
<li>상태 관리 라이브러리 추가 (Redux/Zustand)</li>
<li>UI 컴포넌트 라이브러리 추가 (Material-UI/Ant Design)</li>
<li>번들링 도구 설정 (Webpack/Vite)</li>
<li>TypeScript 지원 추가</li>
</ul>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,811 @@
const { useState, useEffect } = React;
function Todo() {
// 상태 관리
const [todos, setTodos] = useState([]);
const [activeTodos, setActiveTodos] = useState([]);
const [completedTodos, setCompletedTodos] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [currentTab, setCurrentTab] = useState('active');
const [currentEditId, setCurrentEditId] = useState(null);
// 모달 상태
const [showAddModal, setShowAddModal] = useState(false);
const [showEditModal, setShowEditModal] = useState(false);
// 폼 상태
const [todoForm, setTodoForm] = useState({
title: '',
remark: '',
expire: '',
seqno: 0,
flag: false,
request: '',
status: '0'
});
const [editForm, setEditForm] = useState({
idx: 0,
title: '',
remark: '',
expire: '',
seqno: 0,
flag: false,
request: '',
status: '0'
});
// 컴포넌트 마운트시 할일 목록 로드
useEffect(() => {
loadTodos();
}, []);
// 할일 목록을 활성/완료로 분리
useEffect(() => {
const active = todos.filter(todo => (todo.status || '0') !== '5');
const completed = todos.filter(todo => (todo.status || '0') === '5');
setActiveTodos(active);
setCompletedTodos(completed);
}, [todos]);
// 할일 목록 로드
const loadTodos = async () => {
setIsLoading(true);
try {
const response = await fetch('/Todo/GetTodos');
const data = await response.json();
if (data.Success) {
setTodos(data.Data || []);
} else {
showNotification(data.Message || '할일 목록을 불러올 수 없습니다.', 'error');
}
} catch (error) {
console.error('할일 목록 로드 중 오류:', error);
showNotification('서버 연결에 실패했습니다.', 'error');
} finally {
setIsLoading(false);
}
};
// 새 할일 추가
const addTodo = async (e) => {
e.preventDefault();
if (!todoForm.remark.trim()) {
showNotification('할일 내용을 입력해주세요.', 'error');
return;
}
setIsLoading(true);
try {
const response = await fetch('/Todo/CreateTodo', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...todoForm,
seqno: parseInt(todoForm.seqno),
expire: todoForm.expire || null,
request: todoForm.request || null
})
});
const data = await response.json();
if (data.Success) {
setShowAddModal(false);
setTodoForm({
title: '',
remark: '',
expire: '',
seqno: 0,
flag: false,
request: '',
status: '0'
});
loadTodos();
showNotification(data.Message || '할일이 추가되었습니다.', 'success');
} else {
showNotification(data.Message || '할일 추가에 실패했습니다.', 'error');
}
} catch (error) {
console.error('할일 추가 중 오류:', error);
showNotification('서버 연결에 실패했습니다.', 'error');
} finally {
setIsLoading(false);
}
};
// 할일 수정
const updateTodo = async () => {
if (!editForm.remark.trim()) {
showNotification('할일 내용을 입력해주세요.', 'error');
return;
}
setIsLoading(true);
try {
const response = await fetch('/Todo/UpdateTodo', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
...editForm,
seqno: parseInt(editForm.seqno),
expire: editForm.expire || null,
request: editForm.request || null
})
});
const data = await response.json();
if (data.Success) {
setShowEditModal(false);
setCurrentEditId(null);
loadTodos();
showNotification(data.Message || '할일이 수정되었습니다.', 'success');
} else {
showNotification(data.Message || '할일 수정에 실패했습니다.', 'error');
}
} catch (error) {
console.error('할일 수정 중 오류:', error);
showNotification('서버 연결에 실패했습니다.', 'error');
} finally {
setIsLoading(false);
}
};
// 상태 업데이트 (편집 모달에서 바로 서버에 반영)
const updateTodoStatus = async (status) => {
if (!currentEditId) return;
const formData = {
...editForm,
status: status,
seqno: parseInt(editForm.seqno),
expire: editForm.expire || null,
request: editForm.request || null
};
if (!formData.remark.trim()) {
showNotification('할일 내용을 입력해주세요.', 'error');
return;
}
setIsLoading(true);
try {
const response = await fetch('/Todo/UpdateTodo', {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData)
});
const data = await response.json();
if (data.Success) {
setShowEditModal(false);
setCurrentEditId(null);
loadTodos();
showNotification(`상태가 '${getStatusText(status)}'(으)로 변경되었습니다.`, 'success');
} else {
showNotification(data.Message || '상태 변경에 실패했습니다.', 'error');
}
} catch (error) {
console.error('상태 변경 중 오류:', error);
showNotification('서버 연결에 실패했습니다.', 'error');
} finally {
setIsLoading(false);
}
};
// 할일 편집 모달 열기
const editTodo = async (id) => {
setCurrentEditId(id);
setIsLoading(true);
try {
const response = await fetch(`/Todo/GetTodo?id=${id}`);
const data = await response.json();
if (data.Success && data.Data) {
const todo = data.Data;
setEditForm({
idx: todo.idx,
title: todo.title || '',
remark: todo.remark || '',
expire: todo.expire ? new Date(todo.expire).toISOString().split('T')[0] : '',
seqno: todo.seqno || 0,
flag: todo.flag || false,
request: todo.request || '',
status: todo.status || '0'
});
setShowEditModal(true);
} else {
showNotification(data.Message || '할일 정보를 불러올 수 없습니다.', 'error');
}
} catch (error) {
console.error('할일 조회 중 오류:', error);
showNotification('서버 연결에 실패했습니다.', 'error');
} finally {
setIsLoading(false);
}
};
// 할일 삭제
const deleteTodo = async (id) => {
if (!confirm('정말로 이 할일을 삭제하시겠습니까?')) {
return;
}
setIsLoading(true);
try {
const response = await fetch(`/Todo/DeleteTodo?id=${id}`, {
method: 'DELETE'
});
const data = await response.json();
if (data.Success) {
loadTodos();
showNotification(data.Message || '할일이 삭제되었습니다.', 'success');
} else {
showNotification(data.Message || '할일 삭제에 실패했습니다.', 'error');
}
} catch (error) {
console.error('할일 삭제 중 오류:', error);
showNotification('서버 연결에 실패했습니다.', 'error');
} finally {
setIsLoading(false);
}
};
// 유틸리티 함수들
const getStatusClass = (status) => {
switch(status) {
case '0': return 'bg-gray-500/20 text-gray-300';
case '1': return 'bg-primary-500/20 text-primary-300';
case '2': return 'bg-danger-500/20 text-danger-300';
case '3': return 'bg-warning-500/20 text-warning-300';
case '5': return 'bg-success-500/20 text-success-300';
default: return 'bg-white/10 text-white/50';
}
};
const getStatusText = (status) => {
switch(status) {
case '0': return '대기';
case '1': return '진행';
case '2': return '취소';
case '3': return '보류';
case '5': return '완료';
default: return '대기';
}
};
const getSeqnoClass = (seqno) => {
switch(seqno) {
case 1: return 'bg-primary-500/20 text-primary-300';
case 2: return 'bg-warning-500/20 text-warning-300';
case 3: return 'bg-danger-500/20 text-danger-300';
default: return 'bg-white/10 text-white/50';
}
};
const getSeqnoText = (seqno) => {
switch(seqno) {
case 1: return '중요';
case 2: return '매우 중요';
case 3: return '긴급';
default: return '보통';
}
};
const formatDate = (dateString) => {
if (!dateString) return '-';
return new Date(dateString).toLocaleDateString('ko-KR');
};
// 알림 표시 함수
const showNotification = (message, type = 'info') => {
const colors = {
info: 'bg-blue-500/90 backdrop-blur-sm',
success: 'bg-green-500/90 backdrop-blur-sm',
warning: 'bg-yellow-500/90 backdrop-blur-sm',
error: 'bg-red-500/90 backdrop-blur-sm'
};
const notification = document.createElement('div');
notification.className = `fixed top-4 right-4 ${colors[type]} text-white px-4 py-3 rounded-lg z-50 transition-all duration-300 transform translate-x-0 opacity-100 shadow-lg border border-white/20`;
notification.innerHTML = `
<div class="flex items-center">
<span class="ml-2">${message}</span>
</div>
`;
notification.style.transform = 'translateX(100%)';
notification.style.opacity = '0';
document.body.appendChild(notification);
setTimeout(() => {
notification.style.transform = 'translateX(0)';
notification.style.opacity = '1';
}, 10);
setTimeout(() => {
notification.style.transform = 'translateX(100%)';
notification.style.opacity = '0';
setTimeout(() => notification.remove(), 300);
}, 3000);
};
// 할일 행 렌더링
const renderTodoRow = (todo, includeOkdate = false) => {
const statusClass = getStatusClass(todo.status);
const statusText = getStatusText(todo.status);
const flagClass = todo.flag ? 'bg-warning-500/20 text-warning-300' : 'bg-white/10 text-white/50';
const flagText = todo.flag ? '고정' : '일반';
const seqnoClass = getSeqnoClass(todo.seqno);
const seqnoText = getSeqnoText(todo.seqno);
const expireText = formatDate(todo.expire);
const isExpired = todo.expire && new Date(todo.expire) < new Date();
const expireClass = isExpired ? 'text-danger-400' : 'text-white/80';
const okdateText = formatDate(todo.okdate);
const okdateClass = todo.okdate ? 'text-success-400' : 'text-white/80';
return (
<tr key={todo.idx} className="hover:bg-white/5 transition-colors cursor-pointer" onClick={() => editTodo(todo.idx)}>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${statusClass}`}>
{statusText}
</span>
</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${flagClass}`}>
{flagText}
</span>
</td>
<td className="px-6 py-4 text-white">{todo.title || '제목 없음'}</td>
<td className="px-6 py-4 text-white/80 max-w-xs truncate">{todo.remark || ''}</td>
<td className="px-6 py-4 text-white/80">{todo.request || '-'}</td>
<td className="px-6 py-4 whitespace-nowrap">
<span className={`inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium ${seqnoClass}`}>
{seqnoText}
</span>
</td>
<td className={`px-6 py-4 whitespace-nowrap ${expireClass}`}>{expireText}</td>
{includeOkdate && <td className={`px-6 py-4 whitespace-nowrap ${okdateClass}`}>{okdateText}</td>}
<td className="px-6 py-4 whitespace-nowrap text-sm" onClick={(e) => e.stopPropagation()}>
<button onClick={() => editTodo(todo.idx)} className="text-primary-400 hover:text-primary-300 mr-3 transition-colors">
수정
</button>
<button onClick={() => deleteTodo(todo.idx)} className="text-danger-400 hover:text-danger-300 transition-colors">
삭제
</button>
</td>
</tr>
);
};
return (
<div className="container mx-auto px-4 py-8">
{/* 할일 목록 */}
<div className="glass-effect rounded-2xl overflow-hidden animate-slide-up">
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
<h2 className="text-xl font-semibold text-white flex items-center">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2"></path>
</svg>
할일 목록
</h2>
<button onClick={() => setShowAddModal(true)} className="bg-primary-500 hover:bg-primary-600 text-white px-4 py-2 rounded-lg transition-colors flex items-center text-sm">
<svg className="w-4 h-4 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
할일 추가
</button>
</div>
{/* 탭 메뉴 */}
<div className="px-6 py-2 border-b border-white/10">
<div className="flex space-x-1 bg-white/5 rounded-lg p-1">
<button onClick={() => setCurrentTab('active')} className={`flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 ${
currentTab === 'active'
? 'text-white bg-white/20 shadow-sm'
: 'text-white/60 hover:text-white hover:bg-white/10'
}`}>
<div className="flex items-center justify-center space-x-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path>
</svg>
<span>진행중인 할일</span>
<span className="px-2 py-0.5 text-xs bg-primary-500/30 text-primary-200 rounded-full">{activeTodos.length}</span>
</div>
</button>
<button onClick={() => setCurrentTab('completed')} className={`flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 ${
currentTab === 'completed'
? 'text-white bg-white/20 shadow-sm'
: 'text-white/60 hover:text-white hover:bg-white/10'
}`}>
<div className="flex items-center justify-center space-x-2">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<span>완료된 할일</span>
<span className="px-2 py-0.5 text-xs bg-success-500/30 text-success-200 rounded-full">{completedTodos.length}</span>
</div>
</button>
</div>
</div>
{/* 진행중인 할일 테이블 */}
{currentTab === 'active' && (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-white/10">
<tr>
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">진행상태</th>
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">플래그</th>
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">제목</th>
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">내용</th>
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">요청자</th>
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">중요도</th>
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">만료일</th>
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">작업</th>
</tr>
</thead>
<tbody className="divide-y divide-white/10">
{isLoading ? (
<tr>
<td colSpan="8" className="p-8 text-center">
<div className="inline-flex items-center">
<div className="loading inline-block w-5 h-5 border-3 border-white/30 border-t-white rounded-full animate-spin mr-3"></div>
<span className="text-white/80">데이터를 불러오는 ...</span>
</div>
</td>
</tr>
) : activeTodos.length === 0 ? (
<tr>
<td colSpan="8" className="px-6 py-8 text-center text-white/50">
진행중인 할일이 없습니다
</td>
</tr>
) : (
activeTodos.map(todo => renderTodoRow(todo, false))
)}
</tbody>
</table>
</div>
)}
{/* 완료된 할일 테이블 */}
{currentTab === 'completed' && (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-white/10">
<tr>
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">진행상태</th>
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">플래그</th>
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">제목</th>
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">내용</th>
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">요청자</th>
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">중요도</th>
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">만료일</th>
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">완료일</th>
<th className="px-6 py-4 text-left text-xs font-medium text-white/70 uppercase tracking-wider">작업</th>
</tr>
</thead>
<tbody className="divide-y divide-white/10">
{isLoading ? (
<tr>
<td colSpan="9" className="p-8 text-center">
<div className="inline-flex items-center">
<div className="loading inline-block w-5 h-5 border-3 border-white/30 border-t-white rounded-full animate-spin mr-3"></div>
<span className="text-white/80">데이터를 불러오는 ...</span>
</div>
</td>
</tr>
) : completedTodos.length === 0 ? (
<tr>
<td colSpan="9" className="px-6 py-8 text-center text-white/50">
완료된 할일이 없습니다
</td>
</tr>
) : (
completedTodos.map(todo => renderTodoRow(todo, true))
)}
</tbody>
</table>
</div>
)}
</div>
{/* 로딩 인디케이터 */}
{isLoading && (
<div className="fixed top-4 right-4 bg-white/20 backdrop-blur-sm rounded-full px-4 py-2 text-white text-sm">
<div className="flex items-center">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2"></div>
처리 ...
</div>
</div>
)}
{/* 새 할일 추가 모달 */}
{showAddModal && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50">
<div className="flex items-center justify-center min-h-screen p-4">
<div className="glass-effect rounded-2xl w-full max-w-2xl animate-slide-up">
{/* 모달 헤더 */}
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
<h2 className="text-xl font-semibold text-white flex items-center">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
할일 추가
</h2>
<button onClick={() => setShowAddModal(false)} className="text-white/70 hover:text-white transition-colors">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
{/* 모달 내용 */}
<div className="p-6">
<form onSubmit={addTodo} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-white/70 text-sm font-medium mb-2">제목 (선택사항)</label>
<input
type="text"
value={todoForm.title}
onChange={(e) => setTodoForm({...todoForm, title: e.target.value})}
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
placeholder="할일 제목을 입력하세요"
/>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2">만료일 (선택사항)</label>
<input
type="date"
value={todoForm.expire}
onChange={(e) => setTodoForm({...todoForm, expire: e.target.value})}
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
/>
</div>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2">내용 *</label>
<textarea
value={todoForm.remark}
onChange={(e) => setTodoForm({...todoForm, remark: e.target.value})}
rows="3"
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
placeholder="할일 내용을 입력하세요 (필수)"
required
></textarea>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2">요청자</label>
<input
type="text"
value={todoForm.request}
onChange={(e) => setTodoForm({...todoForm, request: e.target.value})}
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
placeholder="업무 요청자를 입력하세요"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-white/70 text-sm font-medium mb-2">진행상태</label>
<div className="flex flex-wrap gap-2">
{[
{value: '0', label: '대기', class: 'bg-gray-500/20 text-gray-300'},
{value: '1', label: '진행', class: 'bg-primary-500/20 text-primary-300'},
{value: '3', label: '보류', class: 'bg-warning-500/20 text-warning-300'},
{value: '2', label: '취소', class: 'bg-danger-500/20 text-danger-300'},
{value: '5', label: '완료', class: 'bg-success-500/20 text-success-300'}
].map(status => (
<button
key={status.value}
type="button"
onClick={() => setTodoForm({...todoForm, status: status.value})}
className={`px-3 py-1 rounded-lg text-xs font-medium transition-all ${
todoForm.status === status.value
? status.class + ' border-' + status.class.split(' ')[0].replace('bg-', '').replace('/20', '/30')
: 'bg-white/10 text-white/50 border border-white/20 hover:bg-white/20'
}`}
>
{status.label}
</button>
))}
</div>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2">중요도</label>
<select
value={todoForm.seqno}
onChange={(e) => setTodoForm({...todoForm, seqno: parseInt(e.target.value)})}
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
>
<option value="0">보통</option>
<option value="1">중요</option>
<option value="2">매우 중요</option>
<option value="3">긴급</option>
</select>
</div>
<div className="flex items-end">
<label className="flex items-center text-white/70 text-sm font-medium">
<input
type="checkbox"
checked={todoForm.flag}
onChange={(e) => setTodoForm({...todoForm, flag: e.target.checked})}
className="mr-2 text-primary-500 focus:ring-primary-400 focus:ring-offset-0 rounded"
/>
플래그 (상단 고정)
</label>
</div>
</div>
</form>
</div>
{/* 모달 푸터 */}
<div className="px-6 py-4 border-t border-white/10 flex justify-end space-x-3">
<button type="button" onClick={() => setShowAddModal(false)} className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-colors">
취소
</button>
<button type="submit" form="todoForm" onClick={addTodo} className="bg-primary-500 hover:bg-primary-600 text-white px-6 py-2 rounded-lg transition-colors flex items-center">
<svg className="w-4 h-4 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 6v6m0 0v6m0-6h6m-6 0H6"></path>
</svg>
추가
</button>
</div>
</div>
</div>
</div>
)}
{/* 수정 모달 */}
{showEditModal && (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50">
<div className="flex items-center justify-center min-h-screen p-4">
<div className="glass-effect rounded-2xl w-full max-w-2xl animate-slide-up">
{/* 모달 헤더 */}
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
<h2 className="text-xl font-semibold text-white flex items-center">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"></path>
</svg>
할일 수정
</h2>
<button onClick={() => {setShowEditModal(false); setCurrentEditId(null);}} className="text-white/70 hover:text-white transition-colors">
<svg className="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
{/* 모달 내용 */}
<div className="p-6">
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-white/70 text-sm font-medium mb-2">제목 (선택사항)</label>
<input
type="text"
value={editForm.title}
onChange={(e) => setEditForm({...editForm, title: e.target.value})}
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
placeholder="할일 제목을 입력하세요"
/>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2">만료일 (선택사항)</label>
<input
type="date"
value={editForm.expire}
onChange={(e) => setEditForm({...editForm, expire: e.target.value})}
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
/>
</div>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2">내용 *</label>
<textarea
value={editForm.remark}
onChange={(e) => setEditForm({...editForm, remark: e.target.value})}
rows="3"
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
placeholder="할일 내용을 입력하세요 (필수)"
required
></textarea>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2">요청자</label>
<input
type="text"
value={editForm.request}
onChange={(e) => setEditForm({...editForm, request: e.target.value})}
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
placeholder="업무 요청자를 입력하세요"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-white/70 text-sm font-medium mb-2">진행상태</label>
<div className="flex flex-wrap gap-2">
{[
{value: '0', label: '대기', class: 'bg-gray-500/20 text-gray-300'},
{value: '1', label: '진행', class: 'bg-primary-500/20 text-primary-300'},
{value: '3', label: '보류', class: 'bg-warning-500/20 text-warning-300'},
{value: '2', label: '취소', class: 'bg-danger-500/20 text-danger-300'},
{value: '5', label: '완료', class: 'bg-success-500/20 text-success-300'}
].map(status => (
<button
key={status.value}
type="button"
onClick={() => updateTodoStatus(status.value)}
className={`px-3 py-1 rounded-lg text-xs font-medium transition-all ${
editForm.status === status.value
? status.class + ' border-' + status.class.split(' ')[0].replace('bg-', '').replace('/20', '/30')
: 'bg-white/10 text-white/50 border border-white/20 hover:bg-white/20'
}`}
>
{status.label}
</button>
))}
</div>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2">중요도</label>
<select
value={editForm.seqno}
onChange={(e) => setEditForm({...editForm, seqno: parseInt(e.target.value)})}
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
>
<option value="0">보통</option>
<option value="1">중요</option>
<option value="2">매우 중요</option>
<option value="3">긴급</option>
</select>
</div>
<div>
<label className="flex items-center text-white/70 text-sm font-medium mt-6">
<input
type="checkbox"
checked={editForm.flag}
onChange={(e) => setEditForm({...editForm, flag: e.target.checked})}
className="mr-2 text-primary-500 focus:ring-primary-400 focus:ring-offset-0 rounded"
/>
플래그 (상단 고정)
</label>
</div>
</div>
</div>
</div>
{/* 모달 푸터 */}
<div className="px-6 py-4 border-t border-white/10 flex justify-end space-x-3">
<button onClick={() => {setShowEditModal(false); setCurrentEditId(null);}} className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-colors">
취소
</button>
<button onClick={updateTodo} className="bg-primary-500 hover:bg-primary-600 text-white px-6 py-2 rounded-lg transition-colors">
수정
</button>
</div>
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,270 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>공용코드관리 (React)</title>
<link rel="stylesheet" href="/lib/css/tailwind.min.css">
<script src="/lib/js/tailwind-config.js"></script>
<!-- Tailwind 설정 -->
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
},
success: {
50: '#f0fdf4',
100: '#dcfce7',
200: '#bbf7d0',
300: '#86efac',
400: '#4ade80',
500: '#22c55e',
600: '#16a34a',
700: '#15803d',
800: '#166534',
900: '#14532d',
},
warning: {
50: '#fffbeb',
100: '#fef3c7',
200: '#fde68a',
300: '#fcd34d',
400: '#fbbf24',
500: '#f59e0b',
600: '#d97706',
700: '#b45309',
800: '#92400e',
900: '#78350f',
},
danger: {
50: '#fef2f2',
100: '#fee2e2',
200: '#fecaca',
300: '#fca5a5',
400: '#f87171',
500: '#ef4444',
600: '#dc2626',
700: '#b91c1c',
800: '#991b1b',
900: '#7f1d1d',
}
},
animation: {
'fade-in': 'fadeIn 0.5s ease-in-out',
'slide-up': 'slideUp 0.3s ease-out',
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
}
}
}
}
}
</script>
<style>
.glass-effect {
background: rgba(255, 255, 255, 0.25);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.18);
}
.gradient-bg {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.card-hover {
transition: all 0.3s ease;
}
.card-hover:hover {
transform: translateY(-5px);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
/* 스크롤바 스타일링 */
.custom-scrollbar::-webkit-scrollbar {
width: 16px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 8px;
border: 2px solid rgba(255, 255, 255, 0.1);
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
}
/* 애니메이션 */
.animate-fade-in {
animation: fadeIn 0.5s ease-in-out;
}
.animate-slide-up {
animation: slideUp 0.3s ease-out;
}
/* 셀렉트 박스 옵션 스타일링 */
select option {
background-color: #374151 !important;
color: white !important;
}
select option:hover {
background-color: #4B5563 !important;
}
select option:checked {
background-color: #6366F1 !important;
}
/* 테이블 셀 텍스트 오버플로우 처리 */
.truncate {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 값(문자열) 열 최대 너비 제한 */
.svalue-cell {
max-width: 128px; /* w-32 = 128px */
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 개발중 경고 스타일 */
.dev-warning {
position: fixed;
bottom: 20px;
right: 20px;
background: linear-gradient(135deg, #fbbf24, #f59e0b);
color: white;
padding: 12px 20px;
border-radius: 12px;
box-shadow: 0 10px 25px rgba(245, 158, 11, 0.3);
z-index: 1000;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.dev-warning .icon {
width: 20px;
height: 20px;
margin-right: 8px;
flex-shrink: 0;
}
.dev-warning .title {
font-weight: 600;
margin: 0;
font-size: 14px;
}
.dev-warning .description {
font-size: 12px;
opacity: 0.9;
margin: 2px 0 0 0;
}
</style>
</head>
<body class="bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900 min-h-screen text-white">
<!-- 로딩 스켈레톤 UI -->
<div id="react-common-app" class="animate-fade-in">
<div class="container mx-auto px-4 py-8">
<div class="flex gap-6 h-[calc(100vh-200px)]">
<!-- 좌측 스켈레톤 -->
<div class="w-80">
<div class="glass-effect rounded-2xl h-full animate-pulse">
<div class="p-4 border-b border-white/10">
<div class="h-6 bg-white/20 rounded w-3/4"></div>
</div>
<div class="p-4 space-y-3">
<div class="h-16 bg-white/10 rounded-lg"></div>
<div class="h-16 bg-white/10 rounded-lg"></div>
<div class="h-16 bg-white/10 rounded-lg"></div>
</div>
</div>
</div>
<!-- 우측 스켈레톤 -->
<div class="flex-1">
<div class="glass-effect rounded-2xl h-full animate-pulse">
<div class="p-4 border-b border-white/10 flex justify-between items-center">
<div class="h-6 bg-white/20 rounded w-1/3"></div>
<div class="h-8 bg-white/20 rounded w-20"></div>
</div>
<div class="p-4">
<div class="space-y-3">
<div class="h-10 bg-white/10 rounded"></div>
<div class="h-10 bg-white/10 rounded"></div>
<div class="h-10 bg-white/10 rounded"></div>
<div class="h-10 bg-white/10 rounded"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- React Local -->
<script crossorigin src="/lib/js/react.development.js"></script>
<script crossorigin src="/lib/js/react-dom.development.js"></script>
<script src="/lib/js/babel.min.js"></script>
<!-- 공통 네비게이션 컴포넌트 -->
<script type="text/babel" src="/react/component/CommonNavigation"></script>
<!-- 개발중 경고 컴포넌트 -->
<script type="text/babel" src="/react/component/DevWarning"></script>
<!-- App 컴포넌트 -->
<script type="text/babel">
const { useState, useEffect } = React;
function App() {
return (
<div>
<CommonNavigation currentPage="common" />
<CommonCode />
</div>
);
}
</script>
<!-- 공용코드 컴포넌트 로드 -->
<script type="text/babel" src="/react/component/CommonCode"></script>
<!-- 앱 초기화 -->
<script type="text/babel">
const root = ReactDOM.createRoot(document.getElementById('react-common-app'));
root.render(<App />);
</script>
</body>
</html>

View File

@@ -0,0 +1,297 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<meta name="version" content="v2.0-20250905-react">
<title>근태현황 대시보드 (React)</title>
<link rel="stylesheet" href="/lib/css/tailwind.min.css">
<script src="/lib/js/tailwind-config.js"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
},
success: {
50: '#f0fdf4',
100: '#dcfce7',
200: '#bbf7d0',
300: '#86efac',
400: '#4ade80',
500: '#22c55e',
600: '#16a34a',
700: '#15803d',
800: '#166534',
900: '#14532d',
},
warning: {
50: '#fffbeb',
100: '#fef3c7',
200: '#fde68a',
300: '#fcd34d',
400: '#fbbf24',
500: '#f59e0b',
600: '#d97706',
700: '#b45309',
800: '#92400e',
900: '#78350f',
},
danger: {
50: '#fef2f2',
100: '#fee2e2',
200: '#fecaca',
300: '#fca5a5',
400: '#f87171',
500: '#ef4444',
600: '#dc2626',
700: '#b91c1c',
800: '#991b1b',
900: '#7f1d1d',
}
},
animation: {
'fade-in': 'fadeIn 0.5s ease-in-out',
'slide-up': 'slideUp 0.3s ease-out',
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(10px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
}
}
}
}
}
</script>
<style>
.glass-effect {
background: rgba(255, 255, 255, 0.25);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.18);
}
.gradient-bg {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.card-hover {
transition: all 0.3s ease;
}
.card-hover:hover {
transform: translateY(-5px);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
/* 스크롤바 스타일링 */
.custom-scrollbar::-webkit-scrollbar {
width: 16px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 8px;
border: 2px solid rgba(255, 255, 255, 0.1);
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
}
/* React 컴포넌트 로딩 스타일 */
.react-dashboard-container {
min-height: 100vh;
}
.loading-skeleton {
background: linear-gradient(90deg, rgba(255,255,255,0.1) 25%, rgba(255,255,255,0.2) 50%, rgba(255,255,255,0.1) 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
}
@keyframes loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
</style>
</head>
<body>
<div id="react-dashboard-app" class="react-dashboard-container">
<!-- 로딩 화면 -->
<div class="bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900 min-h-screen text-white">
<div class="container mx-auto px-4 py-8">
<!-- 로딩 헤더 -->
<div class="text-center mb-8">
<div class="loading-skeleton h-10 w-80 mx-auto rounded-lg mb-4"></div>
<div class="loading-skeleton h-6 w-60 mx-auto rounded-lg mb-2"></div>
<div class="loading-skeleton h-4 w-40 mx-auto rounded-lg"></div>
</div>
<!-- 로딩 통계 카드들 -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6 mb-8">
<div class="glass-effect rounded-2xl p-6">
<div class="flex items-center justify-between">
<div>
<div class="loading-skeleton h-4 w-16 rounded mb-2"></div>
<div class="loading-skeleton h-8 w-12 rounded"></div>
</div>
<div class="w-12 h-12 bg-success-500/20 rounded-full flex items-center justify-center">
<div class="loading-skeleton w-6 h-6 rounded"></div>
</div>
</div>
</div>
<div class="glass-effect rounded-2xl p-6">
<div class="flex items-center justify-between">
<div>
<div class="loading-skeleton h-4 w-16 rounded mb-2"></div>
<div class="loading-skeleton h-8 w-12 rounded"></div>
</div>
<div class="w-12 h-12 bg-warning-500/20 rounded-full flex items-center justify-center">
<div class="loading-skeleton w-6 h-6 rounded"></div>
</div>
</div>
</div>
<div class="glass-effect rounded-2xl p-6">
<div class="flex items-center justify-between">
<div>
<div class="loading-skeleton h-4 w-16 rounded mb-2"></div>
<div class="loading-skeleton h-8 w-12 rounded"></div>
</div>
<div class="w-12 h-12 bg-primary-500/20 rounded-full flex items-center justify-center">
<div class="loading-skeleton w-6 h-6 rounded"></div>
</div>
</div>
</div>
<div class="glass-effect rounded-2xl p-6">
<div class="flex items-center justify-between">
<div>
<div class="loading-skeleton h-4 w-20 rounded mb-2"></div>
<div class="loading-skeleton h-8 w-12 rounded"></div>
</div>
<div class="w-12 h-12 bg-danger-500/20 rounded-full flex items-center justify-center">
<div class="loading-skeleton w-6 h-6 rounded"></div>
</div>
</div>
</div>
<div class="glass-effect rounded-2xl p-6">
<div class="flex items-center justify-between">
<div>
<div class="loading-skeleton h-4 w-20 rounded mb-2"></div>
<div class="loading-skeleton h-8 w-12 rounded"></div>
</div>
<div class="w-12 h-12 bg-purple-500/20 rounded-full flex items-center justify-center">
<div class="loading-skeleton w-6 h-6 rounded"></div>
</div>
</div>
</div>
</div>
<!-- 로딩 추가 정보 -->
<div class="glass-effect rounded-2xl p-6">
<div class="loading-skeleton h-8 w-32 rounded mb-4"></div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
<div class="text-center">
<div class="w-16 h-16 bg-success-500/20 rounded-full mx-auto mb-2">
<div class="loading-skeleton w-full h-full rounded-full"></div>
</div>
<div class="loading-skeleton h-4 w-16 rounded mx-auto mb-1"></div>
<div class="loading-skeleton h-3 w-24 rounded mx-auto"></div>
</div>
<div class="text-center">
<div class="w-16 h-16 bg-warning-500/20 rounded-full mx-auto mb-2">
<div class="loading-skeleton w-full h-full rounded-full"></div>
</div>
<div class="loading-skeleton h-4 w-16 rounded mx-auto mb-1"></div>
<div class="loading-skeleton h-3 w-24 rounded mx-auto"></div>
</div>
<div class="text-center">
<div class="w-16 h-16 bg-primary-500/20 rounded-full mx-auto mb-2">
<div class="loading-skeleton w-full h-full rounded-full"></div>
</div>
<div class="loading-skeleton h-4 w-16 rounded mx-auto mb-1"></div>
<div class="loading-skeleton h-3 w-24 rounded mx-auto"></div>
</div>
</div>
</div>
<!-- 로딩 텍스트 -->
<div class="text-center mt-8">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto mb-4"></div>
<p class="text-white/70">React Dashboard 컴포넌트를 로딩 중입니다...</p>
</div>
</div>
</div>
</div>
<!-- React Local -->
<script crossorigin src="/lib/js/react.development.js"></script>
<script crossorigin src="/lib/js/react-dom.development.js"></script>
<script src="/lib/js/babel.min.js"></script>
<!-- 공통 컴포넌트 로드 -->
<script type="text/babel" src="/react/component/CommonNavigation"></script>
<script type="text/babel" src="/react/component/DevWarning"></script>
<!-- Dashboard 컴포넌트 -->
<script type="text/babel" src="/react/component/DashboardApp"></script>
<!-- 앱 초기화 -->
<script type="text/babel">
const { useState, useEffect } = React;
function App() {
console.log('✅ App 컴포넌트 렌더링 시작');
console.log('📊 CommonNavigation 사용 가능:', typeof CommonNavigation);
console.log('📊 DashboardApp 사용 가능:', typeof DashboardApp);
console.log('📊 DevWarning 사용 가능:', typeof DevWarning);
return (
<div>
<CommonNavigation currentPage="dashboard" />
<DashboardApp />
<DevWarning show={false} />
</div>
);
}
// 루트 렌더링
const root = ReactDOM.createRoot(document.getElementById('react-dashboard-app'));
root.render(<App />);
console.log('✅ React Dashboard 앱이 마운트되었습니다.');
</script>
</body>
</html>

View File

@@ -0,0 +1,261 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>업무일지 (React)</title>
<link rel="stylesheet" href="/lib/css/tailwind.min.css">
<script src="/lib/js/tailwind-config.js"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
.glass-effect {
background: rgba(255, 255, 255, 0.25);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.18);
}
.gradient-bg {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.card-hover {
transition: all 0.3s ease;
}
.card-hover:hover {
transform: translateY(-5px);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
/* 스크롤바 스타일링 */
.custom-scrollbar::-webkit-scrollbar {
width: 16px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 8px;
border: 2px solid rgba(255, 255, 255, 0.1);
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
}
/* 애니메이션 */
.animate-fade-in {
animation: fadeIn 0.5s ease-in-out;
}
.animate-slide-up {
animation: slideUp 0.3s ease-out;
}
/* 셀렉트 박스 옵션 스타일링 */
select option {
background-color: #374151 !important;
color: white !important;
}
select option:hover {
background-color: #4B5563 !important;
}
select option:checked {
background-color: #6366F1 !important;
}
/* 개발중 경고 스타일 */
.dev-warning {
position: fixed;
bottom: 20px;
right: 20px;
background: linear-gradient(135deg, #fbbf24, #f59e0b);
color: white;
padding: 12px 20px;
border-radius: 12px;
box-shadow: 0 10px 25px rgba(245, 158, 11, 0.3);
z-index: 1000;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.dev-warning .icon {
width: 20px;
height: 20px;
margin-right: 8px;
flex-shrink: 0;
}
.dev-warning .title {
font-weight: 600;
margin: 0;
font-size: 14px;
}
.dev-warning .description {
font-size: 12px;
opacity: 0.9;
margin: 2px 0 0 0;
}
</style>
</head>
<body class="bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900 min-h-screen text-white">
<!-- 로딩 스켈레톤 UI -->
<div id="react-jobreport-app" class="animate-fade-in">
<div class="container mx-auto px-4 py-8">
{/* 스켈레톤 UI */}
<div className="space-y-6">
{/* 경고 메시지 스켈레톤 */}
<div className="bg-white/10 rounded-lg p-4 animate-pulse">
<div className="h-6 bg-white/20 rounded w-3/4"></div>
</div>
{/* 통계 카드 스켈레톤 */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<div className="glass-effect rounded-lg p-6 animate-pulse">
<div className="flex items-center">
<div className="w-12 h-12 bg-white/20 rounded-lg mr-4"></div>
<div>
<div className="h-4 bg-white/20 rounded w-20 mb-2"></div>
<div className="h-6 bg-white/20 rounded w-12"></div>
</div>
</div>
</div>
<div className="glass-effect rounded-lg p-6 animate-pulse">
<div className="flex items-center">
<div className="w-12 h-12 bg-white/20 rounded-lg mr-4"></div>
<div>
<div className="h-4 bg-white/20 rounded w-24 mb-2"></div>
<div className="h-6 bg-white/20 rounded w-16"></div>
</div>
</div>
</div>
<div className="glass-effect rounded-lg p-6 animate-pulse">
<div className="flex items-center">
<div className="w-12 h-12 bg-white/20 rounded-lg mr-4"></div>
<div>
<div className="h-4 bg-white/20 rounded w-20 mb-2"></div>
<div className="h-6 bg-white/20 rounded w-14"></div>
</div>
</div>
</div>
<div className="glass-effect rounded-lg p-6 animate-pulse">
<div className="flex items-center">
<div className="w-12 h-12 bg-white/20 rounded-lg mr-4"></div>
<div>
<div className="h-4 bg-white/20 rounded w-28 mb-2"></div>
<div className="h-6 bg-white/20 rounded w-8"></div>
</div>
</div>
</div>
</div>
{/* 필터 스켈레톤 */}
<div className="glass-effect rounded-lg p-4 animate-pulse">
<div className="grid grid-cols-3 gap-4">
<div className="col-span-2 space-y-3">
<div className="grid grid-cols-3 gap-3">
<div className="h-8 bg-white/10 rounded"></div>
<div className="h-8 bg-white/10 rounded"></div>
<div className="h-8 bg-white/10 rounded"></div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="h-8 bg-white/10 rounded"></div>
<div className="h-8 bg-white/10 rounded"></div>
</div>
</div>
<div className="space-y-2">
<div className="h-10 bg-white/10 rounded"></div>
<div className="h-10 bg-white/10 rounded"></div>
<div className="h-10 bg-white/10 rounded"></div>
</div>
</div>
</div>
{/* 테이블 스켈레톤 */}
<div className="glass-effect rounded-lg p-4 animate-pulse">
<div className="space-y-3">
<div className="grid grid-cols-7 gap-4">
<div className="h-6 bg-white/20 rounded"></div>
<div className="h-6 bg-white/20 rounded"></div>
<div className="h-6 bg-white/20 rounded"></div>
<div className="h-6 bg-white/20 rounded"></div>
<div className="h-6 bg-white/20 rounded"></div>
<div className="h-6 bg-white/20 rounded"></div>
<div className="h-6 bg-white/20 rounded"></div>
</div>
<div className="space-y-2">
<div className="grid grid-cols-7 gap-4">
<div className="h-4 bg-white/10 rounded"></div>
<div className="h-4 bg-white/10 rounded"></div>
<div className="h-4 bg-white/10 rounded"></div>
<div className="h-4 bg-white/10 rounded"></div>
<div className="h-4 bg-white/10 rounded"></div>
<div className="h-4 bg-white/10 rounded"></div>
<div className="h-4 bg-white/10 rounded"></div>
</div>
<div className="grid grid-cols-7 gap-4">
<div className="h-4 bg-white/10 rounded"></div>
<div className="h-4 bg-white/10 rounded"></div>
<div className="h-4 bg-white/10 rounded"></div>
<div className="h-4 bg-white/10 rounded"></div>
<div className="h-4 bg-white/10 rounded"></div>
<div className="h-4 bg-white/10 rounded"></div>
<div className="h-4 bg-white/10 rounded"></div>
</div>
<div className="grid grid-cols-7 gap-4">
<div className="h-4 bg-white/10 rounded"></div>
<div className="h-4 bg-white/10 rounded"></div>
<div className="h-4 bg-white/10 rounded"></div>
<div className="h-4 bg-white/10 rounded"></div>
<div className="h-4 bg-white/10 rounded"></div>
<div className="h-4 bg-white/10 rounded"></div>
<div className="h-4 bg-white/10 rounded"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- React Local -->
<script crossorigin src="/lib/js/react.development.js"></script>
<script crossorigin src="/lib/js/react-dom.development.js"></script>
<script src="/lib/js/babel.min.js"></script>
<!-- 공통 컴포넌트 로드 -->
<script type="text/babel" src="/react/component/CommonNavigation"></script>
<script type="text/babel" src="/react/component/DevWarning"></script>
<!-- 앱 컴포넌트 -->
<script type="text/babel">
const { useState, useEffect } = React;
function App() {
return (
<div>
<CommonNavigation currentPage="jobreport" />
<JobReport />
<DevWarning />
</div>
);
}
</script>
<!-- 업무일지 컴포넌트 로드 -->
<script type="text/babel" src="/react/component/JobReport"></script>
<!-- 앱 초기화 -->
<script type="text/babel">
const root = ReactDOM.createRoot(document.getElementById('react-jobreport-app'));
root.render(<App />);
</script>
</body>
</html>

View File

@@ -0,0 +1,120 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>React JSX Test - GroupWare</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
margin: 0;
padding: 20px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
}
.container {
max-width: 900px;
margin: 0 auto;
background-color: white;
padding: 30px;
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
margin-bottom: 30px;
padding-bottom: 20px;
border-bottom: 2px solid #f0f0f0;
}
.header h1 {
color: #333;
margin: 0;
font-size: 2.5em;
}
.header p {
color: #666;
font-size: 1.2em;
margin: 10px 0;
}
.status {
padding: 20px;
margin: 20px 0;
border-radius: 8px;
}
.success {
background-color: #d4edda;
border: 1px solid #c3e6cb;
color: #155724;
}
.loading {
background-color: #fff3cd;
border: 1px solid #ffeaa7;
color: #856404;
}
.error {
background-color: #f8d7da;
border: 1px solid #f5c6cb;
color: #721c24;
}
.tech-info {
background-color: #e3f2fd;
border: 1px solid #90caf9;
color: #0d47a1;
margin: 20px 0;
padding: 15px;
border-radius: 8px;
}
.version-info {
font-size: 0.9em;
color: #666;
text-align: center;
margin-top: 30px;
padding: 15px;
background-color: #f8f9fa;
border-radius: 8px;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🚀 React JSX Integration</h1>
<p>GroupWare + OWIN + React + JSX 모듈화 테스트</p>
<div class="tech-info">
<strong>기술 스택:</strong> C# WinForms + OWIN + React 18 + Babel JSX + 모듈화된 컴포넌트
</div>
</div>
<div id="react-app">
<div class="status loading">
React JSX 컴포넌트를 로딩 중입니다...
</div>
</div>
<div class="version-info">
<p><strong>환경:</strong> .NET Framework 4.6 + OWIN + React 18.2.0</p>
<p><strong>포트:</strong> 7979 | <strong>파일 위치:</strong> /react-jsx-test.html</p>
</div>
</div>
<!-- React & ReactDOM CDN -->
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<!-- Babel standalone for JSX -->
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<!-- JSX Component Import -->
<script type="text/babel" src="/react/component/TestApp"></script>
<!-- App Initialization -->
<script type="text/babel">
// JSX 컴포넌트를 DOM에 렌더링
const root = ReactDOM.createRoot(document.getElementById('react-app'));
root.render(<TestApp />);
console.log('✅ React JSX 컴포넌트가 성공적으로 마운트되었습니다.');
console.log('📁 컴포넌트 파일: /react/TestApp.jsx');
console.log('🌐 테스트 페이지: /react-jsx-test.html');
</script>
</body>
</html>

View File

@@ -0,0 +1,241 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>근태관리 (React)</title>
<link rel="stylesheet" href="/lib/css/tailwind.min.css">
<script src="/lib/js/tailwind-config.js"></script>
<style>
.glass-effect {
background: rgba(255, 255, 255, 0.25);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.18);
}
.gradient-bg {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.card-hover {
transition: all 0.3s ease;
}
.card-hover:hover {
transform: translateY(-5px);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
.loading {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid rgba(255, 255, 255, 0.3);
border-top: 3px solid #ffffff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.table-container {
max-height: 600px;
overflow-y: auto;
}
/* 스크롤바 스타일링 */
.custom-scrollbar::-webkit-scrollbar {
width: 16px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 8px;
border: 2px solid rgba(255, 255, 255, 0.1);
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
}
/* 애니메이션 */
.animate-fade-in {
animation: fadeIn 0.5s ease-in-out;
}
.animate-slide-up {
animation: slideUp 0.3s ease-out;
}
/* 개발중 경고 스타일 */
.dev-warning {
position: fixed;
bottom: 20px;
right: 20px;
background: linear-gradient(135deg, #fbbf24, #f59e0b);
color: white;
padding: 12px 20px;
border-radius: 12px;
box-shadow: 0 10px 25px rgba(245, 158, 11, 0.3);
z-index: 1000;
border: 1px solid rgba(255, 255, 255, 0.2);
}
.dev-warning .icon {
width: 20px;
height: 20px;
margin-right: 8px;
flex-shrink: 0;
}
.dev-warning .title {
font-weight: 600;
margin: 0;
font-size: 14px;
}
.dev-warning .description {
font-size: 12px;
opacity: 0.9;
margin: 2px 0 0 0;
}
</style>
</head>
<body class="bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900 min-h-screen text-white">
<!-- 로딩 스켈레톤 UI -->
<div id="react-kuntae-app" class="animate-fade-in">
<div class="container mx-auto px-4 py-8">
<div class="space-y-6">
{/* 경고 메시지 스켈레톤 */}
<div class="bg-white/10 rounded-lg p-4 animate-pulse">
<div class="h-6 bg-white/20 rounded w-3/4"></div>
</div>
{/* 필터 스켈레톤 */}
<div class="glass-effect rounded-lg p-6 animate-pulse">
<div class="grid grid-cols-3 gap-4">
<div class="h-16 bg-white/10 rounded"></div>
<div class="h-16 bg-white/10 rounded"></div>
<div class="h-16 bg-white/10 rounded"></div>
</div>
</div>
{/* 통계 카드 스켈레톤 */}
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
<div class="glass-effect rounded-lg p-6 animate-pulse">
<div class="flex items-center">
<div class="w-12 h-12 bg-white/20 rounded-lg mr-4"></div>
<div>
<div class="h-4 bg-white/20 rounded w-20 mb-2"></div>
<div class="h-6 bg-white/20 rounded w-12"></div>
</div>
</div>
</div>
<div class="glass-effect rounded-lg p-6 animate-pulse">
<div class="flex items-center">
<div class="w-12 h-12 bg-white/20 rounded-lg mr-4"></div>
<div>
<div class="h-4 bg-white/20 rounded w-16 mb-2"></div>
<div class="h-6 bg-white/20 rounded w-8"></div>
</div>
</div>
</div>
<div class="glass-effect rounded-lg p-6 animate-pulse">
<div class="flex items-center">
<div class="w-12 h-12 bg-white/20 rounded-lg mr-4"></div>
<div>
<div class="h-4 bg-white/20 rounded w-16 mb-2"></div>
<div class="h-6 bg-white/20 rounded w-8"></div>
</div>
</div>
</div>
<div class="glass-effect rounded-lg p-6 animate-pulse">
<div class="flex items-center">
<div class="w-12 h-12 bg-white/20 rounded-lg mr-4"></div>
<div>
<div class="h-4 bg-white/20 rounded w-16 mb-2"></div>
<div class="h-6 bg-white/20 rounded w-8"></div>
</div>
</div>
</div>
</div>
{/* 테이블 스켈레톤 */}
<div class="glass-effect rounded-lg p-4 animate-pulse">
<div class="space-y-3">
<div class="grid grid-cols-7 gap-4">
<div class="h-6 bg-white/20 rounded"></div>
<div class="h-6 bg-white/20 rounded"></div>
<div class="h-6 bg-white/20 rounded"></div>
<div class="h-6 bg-white/20 rounded"></div>
<div class="h-6 bg-white/20 rounded"></div>
<div class="h-6 bg-white/20 rounded"></div>
<div class="h-6 bg-white/20 rounded"></div>
</div>
<div class="space-y-2">
<div class="grid grid-cols-7 gap-4">
<div class="h-4 bg-white/10 rounded"></div>
<div class="h-4 bg-white/10 rounded"></div>
<div class="h-4 bg-white/10 rounded"></div>
<div class="h-4 bg-white/10 rounded"></div>
<div class="h-4 bg-white/10 rounded"></div>
<div class="h-4 bg-white/10 rounded"></div>
<div class="h-4 bg-white/10 rounded"></div>
</div>
<div class="grid grid-cols-7 gap-4">
<div class="h-4 bg-white/10 rounded"></div>
<div class="h-4 bg-white/10 rounded"></div>
<div class="h-4 bg-white/10 rounded"></div>
<div class="h-4 bg-white/10 rounded"></div>
<div class="h-4 bg-white/10 rounded"></div>
<div class="h-4 bg-white/10 rounded"></div>
<div class="h-4 bg-white/10 rounded"></div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- React Local -->
<script crossorigin src="/lib/js/react.development.js"></script>
<script crossorigin src="/lib/js/react-dom.development.js"></script>
<script src="/lib/js/babel.min.js"></script>
<!-- 공통 컴포넌트 로드 -->
<script type="text/babel" src="/react/component/CommonNavigation"></script>
<script type="text/babel" src="/react/component/DevWarning"></script>
<!-- 근태관리 컴포넌트 로드 -->
<script type="text/babel" src="/react/component/Kuntae"></script>
<!-- 앱 컴포넌트 -->
<script type="text/babel">
const { useState, useEffect } = React;
function App() {
return (
<div>
<CommonNavigation currentPage="kuntae" />
<Kuntae />
<DevWarning />
</div>
);
}
// 루트 렌더링
const root = ReactDOM.createRoot(document.getElementById('react-kuntae-app'));
root.render(<App />);
</script>
</body>
</html>

View File

@@ -0,0 +1,177 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>로그인 - GroupWare (React)</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
colors: {
primary: {
50: '#eff6ff',
100: '#dbeafe',
200: '#bfdbfe',
300: '#93c5fd',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
},
success: {
50: '#f0fdf4',
100: '#dcfce7',
200: '#bbf7d0',
300: '#86efac',
400: '#4ade80',
500: '#22c55e',
600: '#16a34a',
700: '#15803d',
800: '#166534',
900: '#14532d',
},
warning: {
50: '#fffbeb',
100: '#fef3c7',
200: '#fde68a',
300: '#fcd34d',
400: '#fbbf24',
500: '#f59e0b',
600: '#d97706',
700: '#b45309',
800: '#92400e',
900: '#78350f',
},
danger: {
50: '#fef2f2',
100: '#fee2e2',
200: '#fecaca',
300: '#fca5a5',
400: '#f87171',
500: '#ef4444',
600: '#dc2626',
700: '#b91c1c',
800: '#991b1b',
900: '#7f1d1d',
}
},
animation: {
'fade-in': 'fadeIn 0.6s ease-in-out',
'slide-up': 'slideUp 0.4s ease-out',
'bounce-in': 'bounceIn 0.6s ease-out',
'pulse-slow': 'pulse 3s cubic-bezier(0.4, 0, 0.6, 1) infinite',
},
keyframes: {
fadeIn: {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
slideUp: {
'0%': { transform: 'translateY(20px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
bounceIn: {
'0%': { transform: 'scale(0.3)', opacity: '0' },
'50%': { transform: 'scale(1.05)' },
'70%': { transform: 'scale(0.9)' },
'100%': { transform: 'scale(1)', opacity: '1' },
}
}
}
}
}
</script>
<style>
.glass-effect {
background: rgba(255, 255, 255, 0.25);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.18);
}
.gradient-bg {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.card-hover {
transition: all 0.3s ease;
}
.card-hover:hover {
transform: translateY(-2px);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
.input-focus {
transition: all 0.3s ease;
}
.input-focus:focus {
transform: scale(1.02);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.floating-label {
transition: all 0.3s ease;
}
.input-field:focus + .floating-label,
.input-field:not(:placeholder-shown) + .floating-label {
transform: translateY(-1.5rem) scale(0.85);
color: #3b82f6;
}
/* 드롭다운 스타일 */
select.input-field option {
background-color: #1f2937;
color: white;
}
select.input-field:focus option:checked {
background-color: #3b82f6;
}
select.input-field option:hover {
background-color: #374151;
}
/* React 컴포넌트 전용 스타일 */
.react-login-container {
min-height: 100vh;
}
</style>
</head>
<body>
<div id="react-login-app" class="react-login-container">
<div class="gradient-bg min-h-screen flex items-center justify-center p-4">
<div class="w-full max-w-md">
<div class="glass-effect rounded-3xl p-8 card-hover">
<div class="text-center">
<div class="w-16 h-16 bg-white/20 rounded-full flex items-center justify-center mx-auto mb-4">
<svg class="w-8 h-8 text-white animate-pulse" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"></path>
</svg>
</div>
<h1 class="text-2xl font-bold text-white mb-2">GroupWare</h1>
<p class="text-white/70 text-sm">React 로그인 컴포넌트를 로딩 중입니다...</p>
<div class="mt-6">
<div class="animate-spin rounded-full h-8 w-8 border-b-2 border-white mx-auto"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- React & ReactDOM CDN -->
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<!-- Babel standalone for JSX -->
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<!-- React Login Component -->
<script type="text/babel" src="/react/component/LoginApp"></script>
<!-- App Initialization -->
<script type="text/babel">
const root = ReactDOM.createRoot(document.getElementById('react-login-app'));
root.render(<LoginApp />);
console.log('✅ React 로그인 페이지가 성공적으로 마운트되었습니다.');
console.log('📁 컴포넌트 파일: /react/LoginApp.jsx');
console.log('🌐 페이지 URL: /react/login');
</script>
</body>
</html>

View File

@@ -0,0 +1,191 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>프로젝트 관리 - GroupWare (React)</title>
<link rel="stylesheet" href="/lib/css/tailwind.min.css">
<script src="/lib/js/tailwind-config.js"></script>
<style>
.glass-effect {
background: rgba(255, 255, 255, 0.1);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2);
}
.animate-slide-up {
animation: slideUp 0.5s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
}
.bg-primary-500 {
background-color: #3b82f6;
}
.bg-primary-600 {
background-color: #2563eb;
}
.hover\:bg-primary-600:hover {
background-color: #2563eb;
}
.bg-green-500 {
background-color: #10b981;
}
.bg-green-600 {
background-color: #059669;
}
.hover\:bg-green-600:hover {
background-color: #059669;
}
.bg-red-500 {
background-color: #ef4444;
}
.bg-red-600 {
background-color: #dc2626;
}
.hover\:bg-red-600:hover {
background-color: #dc2626;
}
select option {
background-color: #1f2937;
color: white;
padding: 8px;
}
select option:hover {
background-color: #374151;
}
select option:checked {
background-color: #3b82f6;
}
select option:focus {
background-color: #374151;
}
.loading {
border: 3px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top: 3px solid #fff;
width: 20px;
height: 20px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
</head>
<body class="bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900 min-h-screen text-white">
<div id="react-project">
<!-- 스켈레톤 로딩 UI -->
<div class="container mx-auto px-4 py-8">
<div class="glass-effect rounded-lg overflow-hidden animate-pulse">
<div class="px-6 py-4 border-b border-white/10 flex items-center justify-between">
<div class="h-6 bg-white/20 rounded w-40"></div>
<div class="flex space-x-3">
<div class="h-10 bg-white/20 rounded w-32"></div>
<div class="h-10 bg-white/20 rounded w-24"></div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 p-4">
<div class="bg-white/10 rounded-lg p-4">
<div class="h-8 bg-white/20 rounded mb-2"></div>
<div class="h-4 bg-white/20 rounded w-16"></div>
</div>
<div class="bg-white/10 rounded-lg p-4">
<div class="h-8 bg-white/20 rounded mb-2"></div>
<div class="h-4 bg-white/20 rounded w-16"></div>
</div>
<div class="bg-white/10 rounded-lg p-4">
<div class="h-8 bg-white/20 rounded mb-2"></div>
<div class="h-4 bg-white/20 rounded w-16"></div>
</div>
<div class="bg-white/10 rounded-lg p-4">
<div class="h-8 bg-white/20 rounded mb-2"></div>
<div class="h-4 bg-white/20 rounded w-16"></div>
</div>
</div>
<div class="p-6">
<div class="space-y-4">
<div class="h-4 bg-white/20 rounded w-full"></div>
<div class="h-4 bg-white/20 rounded w-3/4"></div>
<div class="h-4 bg-white/20 rounded w-1/2"></div>
</div>
</div>
</div>
</div>
</div>
<!-- React Local -->
<script crossorigin src="/lib/js/react.development.js"></script>
<script crossorigin src="/lib/js/react-dom.development.js"></script>
<script src="/lib/js/babel.min.js"></script>
<!-- 공통 컴포넌트 로드 -->
<script type="text/babel" src="/react/component/CommonNavigation"></script>
<script type="text/babel" src="/react/component/DevWarning"></script>
<!-- Project 컴포넌트 -->
<script type="text/babel" src="/react/component/Project"></script>
<!-- 앱 초기화 -->
<script type="text/babel">
const { useState, useEffect } = React;
function App() {
return (
<div>
<CommonNavigation currentPage="project" />
<Project />
<DevWarning />
</div>
);
}
// 루트 렌더링
const root = ReactDOM.createRoot(document.getElementById('react-project'));
root.render(<App />);
</script>
</body>
</html>

View File

@@ -0,0 +1,197 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>React Test Page - GroupWare</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif;
margin: 0;
padding: 20px;
background-color: #f5f5f5;
}
.container {
max-width: 800px;
margin: 0 auto;
background-color: white;
padding: 30px;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
.header {
text-align: center;
margin-bottom: 30px;
}
.status {
padding: 20px;
margin: 20px 0;
border-radius: 5px;
}
.success {
background-color: #d4edda;
border: 1px solid #c3e6cb;
color: #155724;
}
.loading {
background-color: #fff3cd;
border: 1px solid #ffeaa7;
color: #856404;
}
.error {
background-color: #f8d7da;
border: 1px solid #f5c6cb;
color: #721c24;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🚀 React Integration Test</h1>
<p>GroupWare + OWIN + React 통합 테스트</p>
</div>
<div id="react-app">
<div class="status loading">
React 컴포넌트를 로딩 중입니다...
</div>
</div>
</div>
<!-- React & ReactDOM CDN -->
<script crossorigin src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<!-- Babel standalone for JSX -->
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<!-- React Component -->
<script type="text/babel">
const { useState, useEffect } = React;
// 메인 React 컴포넌트
function ReactTestApp() {
const [status, setStatus] = useState('loading');
const [counter, setCounter] = useState(0);
const [serverTime, setServerTime] = useState('');
const [apiTest, setApiTest] = useState({ status: 'pending', message: '' });
// 컴포넌트가 마운트될 때 실행
useEffect(() => {
// React가 정상적으로 로드되었음을 표시
setTimeout(() => {
setStatus('success');
setServerTime(new Date().toLocaleString('ko-KR'));
}, 1000);
// API 테스트 (GroupWare의 기존 컨트롤러 테스트)
testAPI();
}, []);
// GroupWare API 테스트 함수
const testAPI = async () => {
try {
// Home 컨트롤러 테스트 (기존에 있을 것으로 예상)
const response = await fetch('/Home');
if (response.ok) {
setApiTest({ status: 'success', message: 'API 연결 성공' });
} else {
setApiTest({ status: 'warning', message: `API 응답: ${response.status}` });
}
} catch (error) {
setApiTest({ status: 'error', message: `API 오류: ${error.message}` });
}
};
return (
<div>
<div className={`status ${status === 'success' ? 'success' : 'loading'}`}>
{status === 'success' ? (
<div>
<h3> React 컴포넌트가 성공적으로 로드되었습니다!</h3>
<p><strong>현재 시간:</strong> {serverTime}</p>
</div>
) : (
<h3>React 컴포넌트를 로딩 중입니다...</h3>
)}
</div>
{status === 'success' && (
<div>
<div className="status">
<h3>📊 상태 관리 테스트</h3>
<p><strong>카운터:</strong> {counter}</p>
<button
onClick={() => setCounter(counter + 1)}
style={{
padding: '10px 20px',
marginRight: '10px',
backgroundColor: '#007bff',
color: 'white',
border: 'none',
borderRadius: '5px',
cursor: 'pointer'
}}
>
증가 (+1)
</button>
<button
onClick={() => setCounter(0)}
style={{
padding: '10px 20px',
backgroundColor: '#6c757d',
color: 'white',
border: 'none',
borderRadius: '5px',
cursor: 'pointer'
}}
>
리셋
</button>
</div>
<div className={`status ${
apiTest.status === 'success' ? 'success' :
apiTest.status === 'error' ? 'error' : 'loading'
}`}>
<h3>🌐 API 연결 테스트</h3>
<p><strong>상태:</strong> {apiTest.message}</p>
<button
onClick={testAPI}
style={{
padding: '10px 20px',
backgroundColor: '#28a745',
color: 'white',
border: 'none',
borderRadius: '5px',
cursor: 'pointer'
}}
>
API 다시 테스트
</button>
</div>
<div className="status">
<h3>📋 통합 테스트 체크리스트</h3>
<ul style={{ textAlign: 'left' }}>
<li> OWIN 정적 파일 서빙</li>
<li> React 라이브러리 로딩 (CDN)</li>
<li> JSX 컴파일 (Babel)</li>
<li> React Hooks (useState, useEffect)</li>
<li> 이벤트 핸들링</li>
<li> API 호출 (fetch)</li>
<li> 반응형 UI 업데이트</li>
</ul>
</div>
</div>
)}
</div>
);
}
// React 컴포넌트를 DOM에 렌더링
const root = ReactDOM.createRoot(document.getElementById('react-app'));
root.render(<ReactTestApp />);
</script>
</body>
</html>

View File

@@ -0,0 +1,115 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate">
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<meta name="version" content="v1.0-20250127">
<title>할일 관리 - GroupWare (React)</title>
<link rel="stylesheet" href="/lib/css/tailwind.min.css">
<script src="/lib/js/tailwind-config.js"></script>
<style>
.glass-effect {
background: rgba(255, 255, 255, 0.25);
backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.18);
}
.gradient-bg {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}
.card-hover {
transition: all 0.3s ease;
}
.card-hover:hover {
transform: translateY(-5px);
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
}
.custom-scrollbar::-webkit-scrollbar {
width: 8px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.1);
border-radius: 8px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.3);
border-radius: 8px;
border: 2px solid rgba(255, 255, 255, 0.1);
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.5);
}
.loading {
border: 3px solid rgba(255, 255, 255, 0.3);
border-radius: 50%;
border-top: 3px solid #fff;
width: 20px;
height: 20px;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
</style>
</head>
<body class="bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900 min-h-screen text-white">
<div id="react-todo">
<!-- 스켈레톤 로딩 UI -->
<div class="container mx-auto px-4 py-8">
<div class="glass-effect rounded-2xl overflow-hidden animate-pulse">
<div class="px-6 py-4 border-b border-white/10 flex items-center justify-between">
<div class="h-6 bg-white/20 rounded w-40"></div>
<div class="h-10 bg-white/20 rounded w-32"></div>
</div>
<div class="p-6">
<div class="space-y-4">
<div class="h-4 bg-white/20 rounded w-full"></div>
<div class="h-4 bg-white/20 rounded w-3/4"></div>
<div class="h-4 bg-white/20 rounded w-1/2"></div>
</div>
</div>
</div>
</div>
</div>
<!-- React Local -->
<script crossorigin src="/lib/js/react.development.js"></script>
<script crossorigin src="/lib/js/react-dom.development.js"></script>
<script src="/lib/js/babel.min.js"></script>
<!-- 공통 컴포넌트 로드 -->
<script type="text/babel" src="/react/component/CommonNavigation"></script>
<script type="text/babel" src="/react/component/DevWarning"></script>
<!-- Todo 컴포넌트 -->
<script type="text/babel" src="/react/component/Todo"></script>
<!-- 앱 초기화 -->
<script type="text/babel">
const { useState, useEffect } = React;
function App() {
return (
<div>
<CommonNavigation currentPage="todo" />
<Todo />
<DevWarning />
</div>
);
}
// 루트 렌더링
const root = ReactDOM.createRoot(document.getElementById('react-todo'));
root.render(<App />);
</script>
</body>
</html>