feat: apply dark glassmorphism theme to License list and JobReport daily summary dialog
This commit is contained in:
@@ -12,6 +12,7 @@ import HolidayRequest from '@/pages/HolidayRequest';
|
|||||||
import { comms } from '@/communication';
|
import { comms } from '@/communication';
|
||||||
import { UserInfo } from '@/types';
|
import { UserInfo } from '@/types';
|
||||||
import { Loader2 } from 'lucide-react';
|
import { Loader2 } from 'lucide-react';
|
||||||
|
import { ThemeProvider } from '@/context/ThemeContext';
|
||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const [isConnected, setIsConnected] = useState(false);
|
const [isConnected, setIsConnected] = useState(false);
|
||||||
@@ -68,7 +69,7 @@ export default function App() {
|
|||||||
// 로그인 상태 체크 중
|
// 로그인 상태 체크 중
|
||||||
if (isLoggedIn === null) {
|
if (isLoggedIn === null) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900 flex items-center justify-center">
|
<div className="min-h-screen gradient-bg flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<Loader2 className="w-10 h-10 text-white animate-spin mx-auto mb-4" />
|
<Loader2 className="w-10 h-10 text-white animate-spin mx-auto mb-4" />
|
||||||
<p className="text-white/70">로그인 상태 확인 중...</p>
|
<p className="text-white/70">로그인 상태 확인 중...</p>
|
||||||
@@ -84,40 +85,42 @@ export default function App() {
|
|||||||
|
|
||||||
// 로그인 됨 → 메인 앱 표시
|
// 로그인 됨 → 메인 앱 표시
|
||||||
return (
|
return (
|
||||||
<HashRouter>
|
<ThemeProvider>
|
||||||
<Routes>
|
<HashRouter>
|
||||||
<Route element={<Layout isConnected={isConnected} user={user} />}>
|
<Routes>
|
||||||
<Route path="/" element={<Dashboard />} />
|
<Route element={<Layout isConnected={isConnected} user={user} />}>
|
||||||
<Route path="/dashboard" element={<Dashboard />} />
|
<Route path="/" element={<Dashboard />} />
|
||||||
<Route path="/todo" element={<Todo />} />
|
<Route path="/dashboard" element={<Dashboard />} />
|
||||||
<Route path="/kuntae" element={<Kuntae />} />
|
<Route path="/todo" element={<Todo />} />
|
||||||
<Route path="/holiday-request" element={<HolidayRequest />} />
|
<Route path="/kuntae" element={<Kuntae />} />
|
||||||
<Route path="/jobreport" element={<Jobreport />} />
|
<Route path="/holiday-request" element={<HolidayRequest />} />
|
||||||
<Route path="/project" element={<Project />} />
|
<Route path="/jobreport" element={<Jobreport />} />
|
||||||
<Route path="/common" element={<CommonCodePage />} />
|
<Route path="/project" element={<Project />} />
|
||||||
<Route path="/items" element={<ItemsPage />} />
|
<Route path="/common" element={<CommonCodePage />} />
|
||||||
<Route path="/customs" element={<Customs />} />
|
<Route path="/items" element={<ItemsPage />} />
|
||||||
<Route path="/user/list" element={<UserListPage />} />
|
<Route path="/customs" element={<Customs />} />
|
||||||
<Route path="/user/auth" element={<UserAuthPage />} />
|
<Route path="/user/list" element={<UserListPage />} />
|
||||||
<Route path="/monthly-work" element={<MonthlyWorkPage />} />
|
<Route path="/user/auth" element={<UserAuthPage />} />
|
||||||
<Route path="/mail-form" element={<MailFormPage />} />
|
<Route path="/monthly-work" element={<MonthlyWorkPage />} />
|
||||||
<Route path="/note" element={<Note />} />
|
<Route path="/mail-form" element={<MailFormPage />} />
|
||||||
<Route path="/patch-list" element={<PatchList />} />
|
<Route path="/note" element={<Note />} />
|
||||||
<Route path="/bug-report" element={<BugReport />} />
|
<Route path="/patch-list" element={<PatchList />} />
|
||||||
<Route path="/mail-list" element={<MailList />} />
|
<Route path="/bug-report" element={<BugReport />} />
|
||||||
<Route path="/license" element={<LicenseList />} />
|
<Route path="/mail-list" element={<MailList />} />
|
||||||
<Route path="/partlist" element={<PartList />} />
|
<Route path="/license" element={<LicenseList />} />
|
||||||
</Route>
|
<Route path="/partlist" element={<PartList />} />
|
||||||
</Routes>
|
</Route>
|
||||||
{/* Tailwind Breakpoint Indicator - 개발용 */}
|
</Routes>
|
||||||
<div className="fixed bottom-2 right-2 z-50 bg-black/80 text-white text-xs px-2 py-1 rounded font-mono">
|
{/* Tailwind Breakpoint Indicator - 개발용 */}
|
||||||
<span className="sm:hidden">XS</span>
|
<div className="fixed bottom-2 right-2 z-50 bg-black/80 text-white text-xs px-2 py-1 rounded font-mono">
|
||||||
<span className="hidden sm:inline md:hidden">SM</span>
|
<span className="sm:hidden">XS</span>
|
||||||
<span className="hidden md:inline lg:hidden">MD</span>
|
<span className="hidden sm:inline md:hidden">SM</span>
|
||||||
<span className="hidden lg:inline xl:hidden">LG</span>
|
<span className="hidden md:inline lg:hidden">MD</span>
|
||||||
<span className="hidden xl:inline 2xl:hidden">XL</span>
|
<span className="hidden lg:inline xl:hidden">LG</span>
|
||||||
<span className="hidden 2xl:inline">2XL</span>
|
<span className="hidden xl:inline 2xl:hidden">XL</span>
|
||||||
</div>
|
<span className="hidden 2xl:inline">2XL</span>
|
||||||
</HashRouter>
|
</div>
|
||||||
|
</HashRouter>
|
||||||
|
</ThemeProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,10 +45,11 @@ import type {
|
|||||||
LicenseItem,
|
LicenseItem,
|
||||||
PartListItem,
|
PartListItem,
|
||||||
HolidayRequest,
|
HolidayRequest,
|
||||||
|
SettingsModel,
|
||||||
} from '@/types';
|
} from '@/types';
|
||||||
|
|
||||||
// WebView2 환경 감지
|
// WebView2 환경 감지
|
||||||
const isWebView = typeof window !== 'undefined' &&
|
const isWebView = typeof window !== 'undefined' &&
|
||||||
window.chrome?.webview?.hostObjects !== undefined;
|
window.chrome?.webview?.hostObjects !== undefined;
|
||||||
|
|
||||||
const machine: MachineBridgeInterface | null = isWebView
|
const machine: MachineBridgeInterface | null = isWebView
|
||||||
@@ -152,7 +153,7 @@ class CommunicationLayer {
|
|||||||
if (msg.requestId && msg.requestId !== requestId) {
|
if (msg.requestId && msg.requestId !== requestId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
clearTimeout(timeoutId);
|
clearTimeout(timeoutId);
|
||||||
this.listeners = this.listeners.filter(cb => cb !== handler);
|
this.listeners = this.listeners.filter(cb => cb !== handler);
|
||||||
resolve(msg.data as T);
|
resolve(msg.data as T);
|
||||||
@@ -802,6 +803,32 @@ class CommunicationLayer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== Settings API =====
|
||||||
|
|
||||||
|
public async getSettings(): Promise<ApiResponse<SettingsModel>> {
|
||||||
|
if (isWebView && machine) {
|
||||||
|
const result = await machine.GetSettings();
|
||||||
|
// MachineBridge returns JSON string of the object directly
|
||||||
|
// WebSocket wrap it in { type, data }
|
||||||
|
// This helper handles webview vs websocket differences usually...
|
||||||
|
// Wait, MachineBridge.Settings.cs returns JsonConvert.SerializeObject(Pub.setting).
|
||||||
|
// So result is string "{"Disable8HourOver": ...}"
|
||||||
|
return { Success: true, Data: JSON.parse(result) };
|
||||||
|
} else {
|
||||||
|
return this.wsRequest<ApiResponse<SettingsModel>>('GET_SETTINGS', 'SETTINGS_DATA');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public async saveSettings(settings: Partial<SettingsModel>): Promise<ApiResponse> {
|
||||||
|
if (isWebView && machine) {
|
||||||
|
const result = await machine.SaveSettings(JSON.stringify(settings));
|
||||||
|
return JSON.parse(result);
|
||||||
|
} else {
|
||||||
|
return this.wsRequest<ApiResponse>('SAVE_SETTINGS', 'SETTINGS_SAVED', { settings });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
public async getJobReportDetail(idx: number): Promise<ApiResponse<JobReportItem>> {
|
public async getJobReportDetail(idx: number): Promise<ApiResponse<JobReportItem>> {
|
||||||
if (isWebView && machine) {
|
if (isWebView && machine) {
|
||||||
const result = await machine.Jobreport_GetDetail(idx);
|
const result = await machine.Jobreport_GetDetail(idx);
|
||||||
|
|||||||
277
Project/frontend/src/components/DateRangePicker.tsx
Normal file
277
Project/frontend/src/components/DateRangePicker.tsx
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
import React, { useState, useRef, useEffect } from 'react';
|
||||||
|
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon } from 'lucide-react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
interface DateRangePickerProps {
|
||||||
|
startDate: string; // YYYY-MM-DD
|
||||||
|
endDate: string; // YYYY-MM-DD
|
||||||
|
onChange: (startDate: string, endDate: string) => void;
|
||||||
|
align?: 'horizontal' | 'vertical';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function DateRangePicker({ startDate, endDate, onChange, align = 'horizontal' }: DateRangePickerProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Internal state for delayed update
|
||||||
|
const [localStart, setLocalStart] = useState(startDate);
|
||||||
|
const [localEnd, setLocalEnd] = useState(endDate);
|
||||||
|
|
||||||
|
// View states for the two calendars
|
||||||
|
const [leftViewDate, setLeftViewDate] = useState(new Date());
|
||||||
|
const [rightViewDate, setRightViewDate] = useState(new Date());
|
||||||
|
|
||||||
|
// Sync props to internal state and init view dates on open
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setLocalStart(startDate);
|
||||||
|
setLocalEnd(endDate);
|
||||||
|
|
||||||
|
const sDate = startDate ? new Date(startDate) : new Date();
|
||||||
|
const eDate = endDate ? new Date(endDate) : new Date();
|
||||||
|
|
||||||
|
setLeftViewDate(new Date(sDate.getFullYear(), sDate.getMonth(), 1));
|
||||||
|
setRightViewDate(new Date(eDate.getFullYear(), eDate.getMonth(), 1));
|
||||||
|
}
|
||||||
|
}, [isOpen, startDate, endDate]);
|
||||||
|
|
||||||
|
// Close on click outside
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const formatDate = (date: Date) => {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setPreset = (type: 'today' | 'tomorrow' | 'thisWeek' | 'thisMonth' | 'lastMonth') => {
|
||||||
|
const today = new Date();
|
||||||
|
let newStart = today;
|
||||||
|
let newEnd = today;
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'today':
|
||||||
|
break;
|
||||||
|
case 'tomorrow':
|
||||||
|
const tomorrow = new Date(today);
|
||||||
|
tomorrow.setDate(today.getDate() + 1);
|
||||||
|
newStart = tomorrow;
|
||||||
|
newEnd = tomorrow;
|
||||||
|
break;
|
||||||
|
case 'thisWeek':
|
||||||
|
const day = today.getDay();
|
||||||
|
const diff = today.getDate() - day; // Sunday start
|
||||||
|
newStart = new Date(today.setDate(diff));
|
||||||
|
newEnd = new Date(today.setDate(diff + 6));
|
||||||
|
break;
|
||||||
|
case 'thisMonth':
|
||||||
|
newStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||||
|
newEnd = new Date(today.getFullYear(), today.getMonth() + 1, 0);
|
||||||
|
break;
|
||||||
|
case 'lastMonth':
|
||||||
|
newStart = new Date(today.getFullYear(), today.getMonth() - 1, 1);
|
||||||
|
newEnd = new Date(today.getFullYear(), today.getMonth(), 0);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sStr = formatDate(newStart);
|
||||||
|
const eStr = formatDate(newEnd);
|
||||||
|
setLocalStart(sStr);
|
||||||
|
setLocalEnd(eStr);
|
||||||
|
|
||||||
|
// Update views to match selection
|
||||||
|
setLeftViewDate(new Date(newStart.getFullYear(), newStart.getMonth(), 1));
|
||||||
|
setRightViewDate(new Date(newEnd.getFullYear(), newEnd.getMonth(), 1));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleConfirm = () => {
|
||||||
|
onChange(localStart, localEnd);
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCancel = () => {
|
||||||
|
setIsOpen(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
const toggleOpen = () => setIsOpen(!isOpen);
|
||||||
|
|
||||||
|
// --- Calendar Logic ---
|
||||||
|
const getDaysInMonth = (year: number, month: number) => new Date(year, month + 1, 0).getDate();
|
||||||
|
const getFirstDayOfMonth = (year: number, month: number) => new Date(year, month, 1).getDay();
|
||||||
|
|
||||||
|
const renderCalendarGrid = (viewDate: Date, setViewDate: (d: Date) => void, type: 'start' | 'end') => {
|
||||||
|
const year = viewDate.getFullYear();
|
||||||
|
const month = viewDate.getMonth();
|
||||||
|
const daysInMonth = getDaysInMonth(year, month);
|
||||||
|
const firstDay = getFirstDayOfMonth(year, month);
|
||||||
|
|
||||||
|
const days = [];
|
||||||
|
for (let i = 0; i < firstDay; i++) {
|
||||||
|
days.push(<div key={`empty-${i}`} className="h-8 w-8" />);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let day = 1; day <= daysInMonth; day++) {
|
||||||
|
const current = new Date(year, month, day);
|
||||||
|
const dateStr = formatDate(current);
|
||||||
|
|
||||||
|
// Only highlight the date corresponding to the specific calendar type
|
||||||
|
const isSelected = type === 'start'
|
||||||
|
? localStart === dateStr
|
||||||
|
: localEnd === dateStr;
|
||||||
|
|
||||||
|
days.push(
|
||||||
|
<button
|
||||||
|
key={day}
|
||||||
|
onClick={() => handleDateClick(dateStr, type)}
|
||||||
|
className={clsx(
|
||||||
|
"h-8 w-8 rounded-full flex items-center justify-center text-xs transition-all relative",
|
||||||
|
// Selected
|
||||||
|
isSelected ? "bg-blue-600 text-white font-bold hover:bg-blue-500 z-10 ring-2 ring-blue-400/50" : "text-white/80 hover:bg-white/10"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{day}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-[240px]">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between mb-2 px-2">
|
||||||
|
<button onClick={() => {
|
||||||
|
const d = new Date(viewDate);
|
||||||
|
d.setMonth(d.getMonth() - 1);
|
||||||
|
setViewDate(d);
|
||||||
|
}} className="p-1 hover:bg-white/10 rounded text-white/70">
|
||||||
|
<ChevronLeft className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<span className="text-sm font-semibold text-white">
|
||||||
|
{year}년 {month + 1}월
|
||||||
|
</span>
|
||||||
|
<button onClick={() => {
|
||||||
|
const d = new Date(viewDate);
|
||||||
|
d.setMonth(d.getMonth() + 1);
|
||||||
|
setViewDate(d);
|
||||||
|
}} className="p-1 hover:bg-white/10 rounded text-white/70">
|
||||||
|
<ChevronRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/* Days Header */}
|
||||||
|
<div className="grid grid-cols-7 gap-1 text-center mb-1">
|
||||||
|
{['일', '월', '화', '수', '목', '금', '토'].map(day => (
|
||||||
|
<div key={day} className="text-[10px] text-white/40 font-medium py-1">
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{/* Days Grid */}
|
||||||
|
<div className="grid grid-cols-7 gap-1">
|
||||||
|
{days}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDateClick = (dateStr: string, type: 'start' | 'end') => {
|
||||||
|
if (type === 'start') {
|
||||||
|
setLocalStart(dateStr);
|
||||||
|
// Validation: Start cannot be after End
|
||||||
|
if (localEnd && new Date(dateStr) > new Date(localEnd)) {
|
||||||
|
setLocalEnd(dateStr);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setLocalEnd(dateStr);
|
||||||
|
// Validation: End cannot be before Start
|
||||||
|
if (localStart && new Date(dateStr) < new Date(localStart)) {
|
||||||
|
setLocalStart(dateStr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative inline-block" ref={containerRef}>
|
||||||
|
{/* Trigger */}
|
||||||
|
<div
|
||||||
|
onClick={toggleOpen}
|
||||||
|
className={clsx(
|
||||||
|
"bg-white/5 border border-white/10 rounded-lg px-3 py-2 cursor-pointer hover:bg-white/10 transition-colors w-fit",
|
||||||
|
align === 'vertical' ? 'flex flex-col items-start gap-1' : 'flex items-center gap-2'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{align === 'horizontal' ? (
|
||||||
|
<>
|
||||||
|
<CalendarIcon className="w-4 h-4 text-white/50" />
|
||||||
|
<span className="text-white text-sm font-medium">
|
||||||
|
{startDate} <span className="text-white/50 mx-1">~</span> {endDate}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-white/50 text-xs">시작</span>
|
||||||
|
<span className="text-white text-sm font-medium tracking-wider">{startDate}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-white/50 text-xs">종료</span>
|
||||||
|
<span className="text-white text-sm font-medium tracking-wider">{endDate}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Popover */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute top-full left-0 mt-2 z-[9999] bg-[#1e1e2e] border border-white/10 rounded-xl shadow-xl p-4 w-auto backdrop-blur-md">
|
||||||
|
|
||||||
|
{/* Presets Grid */}
|
||||||
|
<div className="flex flex-wrap gap-2 mb-4 justify-center">
|
||||||
|
<button onClick={() => setPreset('today')} className="px-3 py-1.5 bg-white/5 hover:bg-white/10 rounded-full text-xs text-white/80 border border-white/5 transition-colors">오늘</button>
|
||||||
|
<button onClick={() => setPreset('tomorrow')} className="px-3 py-1.5 bg-white/5 hover:bg-white/10 rounded-full text-xs text-white/80 border border-white/5 transition-colors">내일</button>
|
||||||
|
<button onClick={() => setPreset('thisWeek')} className="px-3 py-1.5 bg-white/5 hover:bg-white/10 rounded-full text-xs text-white/80 border border-white/5 transition-colors">이번주</button>
|
||||||
|
<button onClick={() => setPreset('thisMonth')} className="px-3 py-1.5 bg-white/5 hover:bg-white/10 rounded-full text-xs text-white/80 border border-white/5 transition-colors">이번달</button>
|
||||||
|
<button onClick={() => setPreset('lastMonth')} className="px-3 py-1.5 bg-white/5 hover:bg-white/10 rounded-full text-xs text-white/80 border border-white/5 transition-colors">저번달</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-px bg-white/10 mb-4" />
|
||||||
|
|
||||||
|
{/* Dual Calendars Container */}
|
||||||
|
<div className="flex flex-col md:flex-row gap-6">
|
||||||
|
{renderCalendarGrid(leftViewDate, setLeftViewDate, 'start')}
|
||||||
|
|
||||||
|
{/* Mobile/Desktop separator or spacer */}
|
||||||
|
<div className="hidden md:block w-px bg-white/10 self-stretch my-2"></div>
|
||||||
|
|
||||||
|
{renderCalendarGrid(rightViewDate, setRightViewDate, 'end')}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-px bg-white/10 my-4" />
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={handleCancel}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm text-white/70 hover:bg-white/10 transition-colors"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleConfirm}
|
||||||
|
className="px-4 py-2 rounded-lg text-sm bg-blue-600 hover:bg-blue-500 text-white font-medium transition-colors"
|
||||||
|
>
|
||||||
|
확인
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
13
Project/frontend/src/components/DevelopmentNotice.tsx
Normal file
13
Project/frontend/src/components/DevelopmentNotice.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
|
||||||
|
|
||||||
|
export function DevelopmentNotice() {
|
||||||
|
return (
|
||||||
|
<div className="bg-gradient-to-r from-[#ff1493] via-[#ff69b4] to-[#ff1493] border-2 border-white/50 rounded-xl p-4 flex items-center justify-center shadow-[0_0_25px_rgba(255,20,147,0.6)] backdrop-blur-md mb-6">
|
||||||
|
<span className="text-white font-black flex items-center gap-3 drop-shadow-[0_2px_4px_rgba(0,0,0,0.3)] tracking-tight">
|
||||||
|
<span className="text-2xl animate-bounce">✨</span>
|
||||||
|
<span className="uppercase italic">Development Mode:</span> 현재 개발중인 페이지입니다. 데이터가 정확하지 않을 수 있으며 오류가 발생할 수 있습니다.
|
||||||
|
<span className="text-2xl animate-bounce" style={{ animationDelay: '0.2s' }}>✨</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
213
Project/frontend/src/components/UserSelector.tsx
Normal file
213
Project/frontend/src/components/UserSelector.tsx
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||||
|
import { Search, Check, ChevronDown, User } from 'lucide-react';
|
||||||
|
import clsx from 'clsx';
|
||||||
|
|
||||||
|
export interface UserInfo {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
process?: string;
|
||||||
|
level?: number;
|
||||||
|
useJobReport?: boolean;
|
||||||
|
outdate?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserSelectorProps {
|
||||||
|
users: UserInfo[];
|
||||||
|
selectedIds: string[]; // Always array for internal consistency, or handle single/multi logic
|
||||||
|
onChange: (ids: string[]) => void;
|
||||||
|
multiSelect?: boolean;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
// Filter Options
|
||||||
|
minLevel?: number;
|
||||||
|
onlyJobReportUsers?: boolean;
|
||||||
|
includeResigned?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function UserSelector({
|
||||||
|
users,
|
||||||
|
selectedIds,
|
||||||
|
onChange,
|
||||||
|
multiSelect = false,
|
||||||
|
placeholder = '담당자 선택',
|
||||||
|
className
|
||||||
|
}: UserSelectorProps) {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Filter and Sort users
|
||||||
|
const filteredAndSortedUsers = useMemo(() => {
|
||||||
|
let result = users;
|
||||||
|
|
||||||
|
// Filter
|
||||||
|
if (searchQuery.trim()) {
|
||||||
|
const query = searchQuery.toLowerCase();
|
||||||
|
result = result.filter(user =>
|
||||||
|
user.name.toLowerCase().includes(query) ||
|
||||||
|
user.id.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: Process -> Name
|
||||||
|
return result.sort((a, b) => {
|
||||||
|
// Compare Process
|
||||||
|
const processA = a.process || '';
|
||||||
|
const processB = b.process || '';
|
||||||
|
if (processA !== processB) {
|
||||||
|
if (processA < processB) return -1;
|
||||||
|
if (processA > processB) return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare Name
|
||||||
|
return a.name.localeCompare(b.name);
|
||||||
|
});
|
||||||
|
}, [users, searchQuery]);
|
||||||
|
|
||||||
|
// Handle outside click
|
||||||
|
useEffect(() => {
|
||||||
|
function handleClickOutside(event: MouseEvent) {
|
||||||
|
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||||
|
setIsOpen(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
document.addEventListener('mousedown', handleClickOutside);
|
||||||
|
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleSelect = (userId: string) => {
|
||||||
|
if (multiSelect) {
|
||||||
|
if (selectedIds.includes(userId)) {
|
||||||
|
onChange(selectedIds.filter(id => id !== userId));
|
||||||
|
} else {
|
||||||
|
onChange([...selectedIds, userId]);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Single select: toggle or set
|
||||||
|
if (selectedIds.includes(userId)) {
|
||||||
|
onChange([]); // Deselect if clicked again? Or just prevent? Usually deselect is allowed.
|
||||||
|
} else {
|
||||||
|
onChange([userId]);
|
||||||
|
setIsOpen(false); // Close on selection for single mode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectedUserList = useMemo(() => users.filter(u => selectedIds.includes(u.id)), [users, selectedIds]);
|
||||||
|
|
||||||
|
const getDisplayText = () => {
|
||||||
|
if (selectedIds.length === 0) return placeholder;
|
||||||
|
|
||||||
|
if (selectedUserList.length === 0) {
|
||||||
|
return selectedIds[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedUserList.length === 1) {
|
||||||
|
return `${selectedUserList[0].name}(${selectedUserList[0].id})`;
|
||||||
|
}
|
||||||
|
return `${selectedUserList[0].name} 외 ${selectedUserList.length - 1}명`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={clsx("relative z-30", className)} ref={containerRef}>
|
||||||
|
{/* Trigger Button */}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setIsOpen(!isOpen)}
|
||||||
|
className={clsx(
|
||||||
|
"flex items-center justify-between w-full h-[60px] px-3 bg-white/5 border border-white/10 rounded-lg text-white transition-colors focus:outline-none focus:ring-2 focus:ring-primary-400 hover:bg-white/10",
|
||||||
|
isOpen && "ring-2 ring-primary-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-3 overflow-hidden text-left">
|
||||||
|
<User className="w-5 h-5 text-white/50 flex-shrink-0" />
|
||||||
|
<div className="flex flex-col justify-center gap-0.5">
|
||||||
|
{selectedUserList.length === 1 ? (
|
||||||
|
<>
|
||||||
|
<div className="text-sm font-medium leading-none flex items-center">
|
||||||
|
{selectedUserList[0].name}
|
||||||
|
<span className="text-white/40 text-xs ml-1 font-normal">({selectedUserList[0].id})</span>
|
||||||
|
</div>
|
||||||
|
{selectedUserList[0].process ? (
|
||||||
|
<div className="text-xs text-white/60 leading-none">
|
||||||
|
{selectedUserList[0].process}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-white/30 leading-none">
|
||||||
|
-
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="truncate text-sm">
|
||||||
|
{getDisplayText()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ChevronDown className="w-4 h-4 text-white/50 ml-2 flex-shrink-0" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* Popover */}
|
||||||
|
{isOpen && (
|
||||||
|
<div className="absolute top-full mt-2 w-[360px] bg-[#1e293b] border border-white/10 rounded-xl shadow-2xl overflow-hidden animate-fade-in-up z-50">
|
||||||
|
<div className="p-2 space-y-2">
|
||||||
|
{/* Search Input */}
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-white/50" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="사용자 검색"
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="w-full h-8 bg-black/20 border border-white/10 rounded pl-8 pr-2 text-xs text-white focus:outline-none focus:border-primary-500 placeholder-white/30"
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User List */}
|
||||||
|
<div className="max-h-60 overflow-y-auto space-y-1 scrollbar-thin scrollbar-thumb-white/10 scrollbar-track-transparent pr-1">
|
||||||
|
{filteredAndSortedUsers.length === 0 ? (
|
||||||
|
<div className="text-center text-white/40 text-xs py-4">
|
||||||
|
검색 결과가 없습니다.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredAndSortedUsers.map(user => {
|
||||||
|
const isSelected = selectedIds.includes(user.id);
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={user.id}
|
||||||
|
onClick={() => handleSelect(user.id)}
|
||||||
|
className={clsx(
|
||||||
|
"w-full flex items-center gap-2 px-2 py-1.5 rounded text-left transition-colors text-sm",
|
||||||
|
isSelected ? "bg-primary-500/20 text-primary-200" : "text-white/80 hover:bg-white/10"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className={clsx(
|
||||||
|
"w-4 h-4 rounded border flex items-center justify-center transition-colors flex-shrink-0",
|
||||||
|
isSelected ? "bg-primary-500 border-primary-500" : "border-white/30"
|
||||||
|
)}>
|
||||||
|
{isSelected && <Check className="w-3 h-3 text-white" />}
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{user.process && (
|
||||||
|
<span className="text-[10px] text-primary-300 bg-primary-500/10 px-1 rounded flex-shrink-0">
|
||||||
|
{user.process}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="text-xs font-medium truncate">{user.name}</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-[10px] text-white/50">{user.id}</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
|
|
||||||
|
|
||||||
export function DevelopmentNotice() {
|
|
||||||
return (
|
|
||||||
<div className="bg-yellow-500/10 border border-yellow-500/20 rounded-lg p-4 flex items-center justify-center animate-pulse">
|
|
||||||
<span className="text-yellow-400 font-medium flex items-center gap-2">
|
|
||||||
<span className="text-xl">⚠️</span>
|
|
||||||
현재 개발중인 페이지입니다. 데이터가 정확하지 않을 수 있습니다.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { X, Save, Calendar, Clock, MapPin, User, FileText, AlertCircle } from 'lucide-react';
|
import { X, Save, Calendar, Clock, MapPin, User, FileText, AlertCircle } from 'lucide-react';
|
||||||
import { comms } from '../../communication';
|
import { comms } from '../../communication';
|
||||||
import { DevelopmentNotice } from '../common/DevelopmentNotice';
|
import { DevelopmentNotice } from "../DevelopmentNotice";
|
||||||
import { HolidayRequest, CommonCode } from '@/types';
|
import { HolidayRequest, CommonCode } from '@/types';
|
||||||
|
|
||||||
interface HolidayRequestDialogProps {
|
interface HolidayRequestDialogProps {
|
||||||
@@ -36,7 +36,7 @@ export function HolidayRequestDialog({
|
|||||||
backup: []
|
backup: []
|
||||||
});
|
});
|
||||||
const [users, setUsers] = useState<Array<{ id: string; name: string }>>([]);
|
const [users, setUsers] = useState<Array<{ id: string; name: string }>>([]);
|
||||||
const [adminComments, setAdminComments] = useState<CommonCode[]>([]); // Code 54
|
|
||||||
|
|
||||||
// Form State
|
// Form State
|
||||||
const [formData, setFormData] = useState<HolidayRequest>({
|
const [formData, setFormData] = useState<HolidayRequest>({
|
||||||
@@ -356,14 +356,14 @@ export function HolidayRequestDialog({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-fade-in">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-fade-in">
|
||||||
<div className="bg-[#1e1e2e] rounded-2xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto border border-white/10">
|
<div className="bg-paper rounded-2xl shadow-[0_0_40px_rgba(var(--color-primary),0.4)] w-full max-w-4xl max-h-[90vh] overflow-y-auto border-2 border-primary">
|
||||||
{/* Header */}
|
{/* Header - Lively Gradient */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 bg-white/5">
|
<div className="flex items-center justify-between px-6 py-4 bg-gradient-to-r from-primary-500 via-primary-400 to-primary-600">
|
||||||
<h2 className="text-xl font-bold text-white flex items-center">
|
<h2 className="text-xl font-bold text-white flex items-center drop-shadow-md">
|
||||||
<Calendar className="w-5 h-5 mr-2 text-primary-400" />
|
<Calendar className="w-6 h-6 mr-2 text-white animate-pulse" />
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
<button onClick={onClose} className="text-white/50 hover:text-white transition-colors">
|
<button onClick={onClose} className="text-white/80 hover:text-white transition-colors bg-white/10 hover:bg-white/20 rounded-full p-1">
|
||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -378,10 +378,10 @@ export function HolidayRequestDialog({
|
|||||||
{/* Left Column: Inputs */}
|
{/* Left Column: Inputs */}
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
{/* Request Type */}
|
{/* Request Type */}
|
||||||
<div className="flex gap-4 p-4 bg-white/5 rounded-lg border border-white/5">
|
<div className="flex gap-4 p-4 bg-primary-500/10 rounded-lg border-2 border-primary-500/30">
|
||||||
<label className={`flex items-center gap-2 ${(isReadOnly || (formData.idx > 0 && initialData?.cate !== '대체' && initialData?.cate !== '외출')) ? 'cursor-not-allowed' : 'cursor-pointer'}`}>
|
<label className={`flex items-center gap-2 ${(isReadOnly || (formData.idx > 0 && initialData?.cate !== '대체' && initialData?.cate !== '외출')) ? 'cursor-not-allowed' : 'cursor-pointer'}`}>
|
||||||
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${requestType === 'day' ? 'border-green-400' : 'border-white/30'}`}>
|
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${requestType === 'day' ? 'border-primary-500' : 'border-primary-500/30'}`}>
|
||||||
{requestType === 'day' && <div className="w-2 h-2 rounded-full bg-green-400" />}
|
{requestType === 'day' && <div className="w-2 h-2 rounded-full bg-primary-500" />}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
@@ -391,11 +391,11 @@ export function HolidayRequestDialog({
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
disabled={isReadOnly || (formData.idx > 0 && initialData?.cate !== '대체' && initialData?.cate !== '외출')}
|
disabled={isReadOnly || (formData.idx > 0 && initialData?.cate !== '대체' && initialData?.cate !== '외출')}
|
||||||
/>
|
/>
|
||||||
<span className="font-medium text-white/90">일반휴가</span>
|
<span className="font-bold text-primary-400">일반휴가</span>
|
||||||
</label>
|
</label>
|
||||||
<label className={`flex items-center gap-2 ${(isReadOnly || (formData.idx > 0 && initialData?.cate !== '대체')) ? 'cursor-not-allowed' : 'cursor-pointer'}`}>
|
<label className={`flex items-center gap-2 ${(isReadOnly || (formData.idx > 0 && initialData?.cate !== '대체')) ? 'cursor-not-allowed' : 'cursor-pointer'}`}>
|
||||||
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${requestType === 'time' ? 'border-green-400' : 'border-white/30'}`}>
|
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${requestType === 'time' ? 'border-primary-500' : 'border-primary-500/30'}`}>
|
||||||
{requestType === 'time' && <div className="w-2 h-2 rounded-full bg-green-400" />}
|
{requestType === 'time' && <div className="w-2 h-2 rounded-full bg-primary-500" />}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
@@ -405,11 +405,11 @@ export function HolidayRequestDialog({
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
disabled={isReadOnly || (formData.idx > 0 && initialData?.cate !== '대체')}
|
disabled={isReadOnly || (formData.idx > 0 && initialData?.cate !== '대체')}
|
||||||
/>
|
/>
|
||||||
<span className="font-medium text-white/90">대체휴가</span>
|
<span className="font-bold text-primary-400">대체휴가</span>
|
||||||
</label>
|
</label>
|
||||||
<label className={`flex items-center gap-2 ${isReadOnly ? 'cursor-not-allowed' : 'cursor-pointer'}`}>
|
<label className={`flex items-center gap-2 ${isReadOnly ? 'cursor-not-allowed' : 'cursor-pointer'}`}>
|
||||||
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${requestType === 'out' ? 'border-green-400' : 'border-white/30'}`}>
|
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${requestType === 'out' ? 'border-primary-500' : 'border-primary-500/30'}`}>
|
||||||
{requestType === 'out' && <div className="w-2 h-2 rounded-full bg-green-400" />}
|
{requestType === 'out' && <div className="w-2 h-2 rounded-full bg-primary-500" />}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
type="radio"
|
type="radio"
|
||||||
@@ -419,24 +419,24 @@ export function HolidayRequestDialog({
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
disabled={isReadOnly}
|
disabled={isReadOnly}
|
||||||
/>
|
/>
|
||||||
<span className="font-medium text-white/90">외출</span>
|
<span className="font-bold text-primary-400">외출</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User Selection (Admin only) */}
|
{/* User Selection (Admin only) */}
|
||||||
{userLevel >= 5 && (
|
{userLevel >= 5 && (
|
||||||
<div className="grid grid-cols-1 gap-2">
|
<div className="grid grid-cols-1 gap-2">
|
||||||
<label className="text-sm font-medium text-white/70 flex items-center gap-2">
|
<label className="text-sm font-bold text-primary-400 flex items-center gap-2">
|
||||||
<User className="w-4 h-4" /> 신청자
|
<User className="w-4 h-4" /> 신청자
|
||||||
</label>
|
</label>
|
||||||
<select
|
<select
|
||||||
value={formData.uid}
|
value={formData.uid}
|
||||||
onChange={(e) => setFormData({ ...formData, uid: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, uid: e.target.value })}
|
||||||
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
|
className="w-full px-3 py-2 bg-primary-500/10 border-2 border-primary-500/30 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-500"
|
||||||
disabled={isReadOnly || formData.idx > 0}
|
disabled={isReadOnly || formData.idx > 0}
|
||||||
>
|
>
|
||||||
{users.map(user => (
|
{users.map(user => (
|
||||||
<option key={user.id} value={user.id} className="bg-[#1e1e2e]">{user.name} ({user.id})</option>
|
<option key={user.id} value={user.id} className="bg-bg-paper">{user.name} ({user.id})</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -445,26 +445,26 @@ export function HolidayRequestDialog({
|
|||||||
{/* Date & Time */}
|
{/* Date & Time */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-white/70 flex items-center gap-2">
|
<label className="text-sm font-bold text-primary-400 flex items-center gap-2">
|
||||||
<Calendar className="w-4 h-4" /> 시작일
|
<Calendar className="w-4 h-4" /> 시작일
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={formData.sdate}
|
value={formData.sdate}
|
||||||
onChange={(e) => setFormData({ ...formData, sdate: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, sdate: e.target.value })}
|
||||||
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:bg-white/10"
|
className="w-full px-3 py-2 bg-primary-500/10 border-2 border-primary-500/30 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:bg-primary-500/5"
|
||||||
disabled={isReadOnly}
|
disabled={isReadOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-white/70 flex items-center gap-2">
|
<label className="text-sm font-bold text-primary-400 flex items-center gap-2">
|
||||||
<Calendar className="w-4 h-4" /> 종료일
|
<Calendar className="w-4 h-4" /> 종료일
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={formData.edate}
|
value={formData.edate}
|
||||||
onChange={(e) => setFormData({ ...formData, edate: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, edate: e.target.value })}
|
||||||
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:bg-white/10"
|
className="w-full px-3 py-2 bg-primary-500/10 border-2 border-primary-500/30 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:bg-primary-500/5"
|
||||||
disabled={isReadOnly}
|
disabled={isReadOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -475,21 +475,21 @@ export function HolidayRequestDialog({
|
|||||||
{/* Category & Reason */}
|
{/* Category & Reason */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-white/70 flex items-center gap-2">
|
<label className="text-sm font-bold text-primary-400 flex items-center gap-2">
|
||||||
<FileText className="w-4 h-4" /> 구분
|
<FileText className="w-4 h-4" /> 구분
|
||||||
</label>
|
</label>
|
||||||
{requestType === 'day' ? (
|
{requestType === 'day' ? (
|
||||||
<select
|
<select
|
||||||
value={formData.cate}
|
value={formData.cate}
|
||||||
onChange={(e) => setFormData({ ...formData, cate: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, cate: e.target.value })}
|
||||||
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:bg-white/10"
|
className="w-full px-3 py-2 bg-primary-500/10 border-2 border-primary-500/30 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:bg-primary-500/5"
|
||||||
disabled={isReadOnly}
|
disabled={isReadOnly}
|
||||||
>
|
>
|
||||||
{codes.cate.map(code => {
|
{codes.cate.map(code => {
|
||||||
const val = code.memo || (code as any).Memo || code.svalue || (code as any).SValue || (code as any).value || (code as any).Value || (code as any).name || (code as any).Name;
|
const val = code.memo || (code as any).Memo || code.svalue || (code as any).SValue || (code as any).value || (code as any).Value || (code as any).name || (code as any).Name;
|
||||||
const key = code.code || (code as any).Code || (code as any).key || (code as any).Key || val;
|
const key = code.code || (code as any).Code || (code as any).key || (code as any).Key || val;
|
||||||
return (
|
return (
|
||||||
<option key={key} value={val} className="bg-[#1e1e2e]">
|
<option key={key} value={val} className="bg-bg-paper">
|
||||||
{val}
|
{val}
|
||||||
</option>
|
</option>
|
||||||
);
|
);
|
||||||
@@ -500,12 +500,12 @@ export function HolidayRequestDialog({
|
|||||||
type="text"
|
type="text"
|
||||||
value={formData.cate}
|
value={formData.cate}
|
||||||
readOnly
|
readOnly
|
||||||
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white/50 cursor-not-allowed"
|
className="w-full px-3 py-2 bg-primary-500/10 border-2 border-primary-500/30 rounded-lg text-white/50 cursor-not-allowed"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-white/70 flex items-center gap-2">
|
<label className="text-sm font-bold text-primary-400 flex items-center gap-2">
|
||||||
<AlertCircle className="w-4 h-4" /> 사유
|
<AlertCircle className="w-4 h-4" /> 사유
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -513,7 +513,7 @@ export function HolidayRequestDialog({
|
|||||||
list="reason-list"
|
list="reason-list"
|
||||||
value={formData.HolyReason || ''}
|
value={formData.HolyReason || ''}
|
||||||
onChange={(e) => setFormData({ ...formData, HolyReason: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, HolyReason: e.target.value })}
|
||||||
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:bg-white/10"
|
className="w-full px-3 py-2 bg-primary-500/10 border-2 border-primary-500/30 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:bg-primary-500/5"
|
||||||
placeholder="입력 또는 선택"
|
placeholder="입력 또는 선택"
|
||||||
disabled={isReadOnly}
|
disabled={isReadOnly}
|
||||||
/>
|
/>
|
||||||
@@ -532,7 +532,7 @@ export function HolidayRequestDialog({
|
|||||||
{/* Location & Backup */}
|
{/* Location & Backup */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-white/70 flex items-center gap-2">
|
<label className="text-sm font-bold text-primary-400 flex items-center gap-2">
|
||||||
<MapPin className="w-4 h-4" /> 행선지
|
<MapPin className="w-4 h-4" /> 행선지
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -540,7 +540,7 @@ export function HolidayRequestDialog({
|
|||||||
list="location-list"
|
list="location-list"
|
||||||
value={formData.HolyLocation || ''}
|
value={formData.HolyLocation || ''}
|
||||||
onChange={(e) => setFormData({ ...formData, HolyLocation: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, HolyLocation: e.target.value })}
|
||||||
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:bg-white/10"
|
className="w-full px-3 py-2 bg-primary-500/10 border-2 border-primary-500/30 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:bg-primary-500/5"
|
||||||
placeholder="입력 또는 선택"
|
placeholder="입력 또는 선택"
|
||||||
disabled={isReadOnly}
|
disabled={isReadOnly}
|
||||||
/>
|
/>
|
||||||
@@ -555,7 +555,7 @@ export function HolidayRequestDialog({
|
|||||||
</datalist>
|
</datalist>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-white/70 flex items-center gap-2">
|
<label className="text-sm font-bold text-primary-400 flex items-center gap-2">
|
||||||
<User className="w-4 h-4" /> 업무대행
|
<User className="w-4 h-4" /> 업무대행
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -563,7 +563,7 @@ export function HolidayRequestDialog({
|
|||||||
list="backup-list"
|
list="backup-list"
|
||||||
value={formData.HolyBackup || ''}
|
value={formData.HolyBackup || ''}
|
||||||
onChange={(e) => setFormData({ ...formData, HolyBackup: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, HolyBackup: e.target.value })}
|
||||||
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:bg-white/10"
|
className="w-full px-3 py-2 bg-primary-500/10 border-2 border-primary-500/30 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:bg-primary-500/5"
|
||||||
placeholder="입력 또는 선택"
|
placeholder="입력 또는 선택"
|
||||||
disabled={isReadOnly}
|
disabled={isReadOnly}
|
||||||
/>
|
/>
|
||||||
@@ -582,24 +582,24 @@ export function HolidayRequestDialog({
|
|||||||
{/* Days & Times (Manual Override) */}
|
{/* Days & Times (Manual Override) */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-white/70">일수</label>
|
<label className="text-sm font-bold text-primary-400">일수</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.5"
|
step="0.5"
|
||||||
value={formData.HolyDays}
|
value={formData.HolyDays}
|
||||||
onChange={(e) => setFormData({ ...formData, HolyDays: parseFloat(e.target.value) })}
|
onChange={(e) => setFormData({ ...formData, HolyDays: parseFloat(e.target.value) })}
|
||||||
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:bg-white/10 disabled:text-white/30 disabled:cursor-not-allowed"
|
className="w-full px-3 py-2 bg-primary-500/10 border-2 border-primary-500/30 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:bg-primary-500/5 disabled:text-primary-200/30 disabled:cursor-not-allowed"
|
||||||
disabled={isReadOnly || requestType !== 'day'}
|
disabled={isReadOnly || requestType !== 'day'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-white/70">시간</label>
|
<label className="text-sm font-bold text-primary-400">시간</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
step="0.5"
|
step="0.5"
|
||||||
value={formData.HolyTimes}
|
value={formData.HolyTimes}
|
||||||
onChange={(e) => setFormData({ ...formData, HolyTimes: parseFloat(e.target.value) })}
|
onChange={(e) => setFormData({ ...formData, HolyTimes: parseFloat(e.target.value) })}
|
||||||
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:bg-white/10 disabled:text-white/30 disabled:cursor-not-allowed"
|
className="w-full px-3 py-2 bg-primary-500/10 border-2 border-primary-500/30 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:bg-primary-500/5 disabled:text-primary-200/30 disabled:cursor-not-allowed"
|
||||||
disabled={isReadOnly || requestType === 'day'}
|
disabled={isReadOnly || requestType === 'day'}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -609,53 +609,51 @@ export function HolidayRequestDialog({
|
|||||||
{requestType === 'out' && (
|
{requestType === 'out' && (
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-white/70 flex items-center gap-2">
|
<label className="text-sm font-bold text-primary-400 flex items-center gap-2">
|
||||||
<Clock className="w-4 h-4" /> 시작 시간
|
<Clock className="w-4 h-4" /> 시작 시간
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="time"
|
type="time"
|
||||||
value={formData.stime}
|
value={formData.stime}
|
||||||
onChange={(e) => setFormData({ ...formData, stime: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, stime: e.target.value })}
|
||||||
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:bg-white/10"
|
className="w-full px-3 py-2 bg-primary-500/10 border-2 border-primary-500/30 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:bg-primary-500/5"
|
||||||
disabled={isReadOnly}
|
disabled={isReadOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-white/70 flex items-center gap-2">
|
<label className="text-sm font-bold text-primary-400 flex items-center gap-2">
|
||||||
<Clock className="w-4 h-4" /> 종료 시간
|
<Clock className="w-4 h-4" /> 종료 시간
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
type="time"
|
type="time"
|
||||||
value={formData.etime}
|
value={formData.etime}
|
||||||
onChange={(e) => setFormData({ ...formData, etime: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, etime: e.target.value })}
|
||||||
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:bg-white/10"
|
className="w-full px-3 py-2 bg-primary-500/10 border-2 border-primary-500/30 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:bg-primary-500/5"
|
||||||
disabled={isReadOnly}
|
disabled={isReadOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Right Column: Remark */}
|
{/* Right Column: Remark */}
|
||||||
<div className="md:col-span-1 h-full">
|
<div className="md:col-span-1 h-full">
|
||||||
<div className="flex flex-col h-full space-y-2">
|
<div className="flex flex-col h-full space-y-2">
|
||||||
<label className="text-sm font-medium text-white/70">비고</label>
|
<label className="text-sm font-medium text-primary-200">비고</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={formData.Remark}
|
value={formData.Remark}
|
||||||
onChange={(e) => setFormData({ ...formData, Remark: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, Remark: e.target.value })}
|
||||||
className="w-full flex-1 px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400 resize-none min-h-[200px] disabled:bg-white/10"
|
className="w-full flex-1 px-3 py-2 bg-primary-500/10 border border-primary-500/30 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400 resize-none min-h-[200px] disabled:bg-primary-500/5"
|
||||||
placeholder="비고 사항을 입력하세요..."
|
placeholder="비고 사항을 입력하세요..."
|
||||||
disabled={isReadOnly}
|
disabled={isReadOnly}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Admin Response & Confirmation (Moved to Right) */}
|
{/* Admin Response & Confirmation (Moved to Right) */}
|
||||||
<div className="p-4 bg-primary-500/10 rounded-lg space-y-4 border border-primary-500/20 mt-4">
|
<div className="p-4 bg-primary-500/10 rounded-lg space-y-4 border border-primary-500/30 mt-4">
|
||||||
<h3 className="font-semibold text-primary-400">관리자 승인</h3>
|
<h3 className="font-semibold text-primary-400">관리자 승인</h3>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
<label className={`flex items-center gap-2 ${userLevel < 5 ? 'cursor-not-allowed' : 'cursor-pointer'}`}>
|
<label className={`flex items-center gap-2 ${userLevel < 5 ? 'cursor-not-allowed' : 'cursor-pointer'}`}>
|
||||||
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${formData.conf === 0 ? 'border-primary-400' : 'border-white/30'}`}>
|
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${formData.conf === 0 ? 'border-primary-400' : 'border-primary-500/30'}`}>
|
||||||
{formData.conf === 0 && <div className="w-2 h-2 rounded-full bg-primary-400" />}
|
{formData.conf === 0 && <div className="w-2 h-2 rounded-full bg-primary-400" />}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
@@ -666,10 +664,10 @@ export function HolidayRequestDialog({
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
disabled={userLevel < 5}
|
disabled={userLevel < 5}
|
||||||
/>
|
/>
|
||||||
<span className="text-white/70">미승인</span>
|
<span className="text-primary-200">미승인</span>
|
||||||
</label>
|
</label>
|
||||||
<label className={`flex items-center gap-2 ${userLevel < 5 ? 'cursor-not-allowed' : 'cursor-pointer'}`}>
|
<label className={`flex items-center gap-2 ${userLevel < 5 ? 'cursor-not-allowed' : 'cursor-pointer'}`}>
|
||||||
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${formData.conf === 1 ? 'border-green-400' : 'border-white/30'}`}>
|
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${formData.conf === 1 ? 'border-green-400' : 'border-primary-500/30'}`}>
|
||||||
{formData.conf === 1 && <div className="w-2 h-2 rounded-full bg-green-400" />}
|
{formData.conf === 1 && <div className="w-2 h-2 rounded-full bg-green-400" />}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
@@ -680,10 +678,10 @@ export function HolidayRequestDialog({
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
disabled={userLevel < 5}
|
disabled={userLevel < 5}
|
||||||
/>
|
/>
|
||||||
<span className="text-white/70">승인</span>
|
<span className="text-primary-200">승인</span>
|
||||||
</label>
|
</label>
|
||||||
<label className={`flex items-center gap-2 ${userLevel < 5 ? 'cursor-not-allowed' : 'cursor-pointer'}`}>
|
<label className={`flex items-center gap-2 ${userLevel < 5 ? 'cursor-not-allowed' : 'cursor-pointer'}`}>
|
||||||
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${formData.conf === 2 ? 'border-red-400' : 'border-white/30'}`}>
|
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${formData.conf === 2 ? 'border-red-400' : 'border-primary-500/30'}`}>
|
||||||
{formData.conf === 2 && <div className="w-2 h-2 rounded-full bg-red-400" />}
|
{formData.conf === 2 && <div className="w-2 h-2 rounded-full bg-red-400" />}
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
@@ -694,24 +692,18 @@ export function HolidayRequestDialog({
|
|||||||
className="hidden"
|
className="hidden"
|
||||||
disabled={userLevel < 5}
|
disabled={userLevel < 5}
|
||||||
/>
|
/>
|
||||||
<span className="text-white/70">반려</span>
|
<span className="text-primary-200">반려</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-white/70">관리자 메모</label>
|
<label className="text-sm font-medium text-primary-200">관리자 메모</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
list="adminCommentsList"
|
|
||||||
value={formData.Response}
|
value={formData.Response}
|
||||||
onChange={(e) => setFormData({ ...formData, Response: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, Response: e.target.value })}
|
||||||
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:bg-white/10"
|
className="w-full px-3 py-2 bg-primary-500/10 border border-primary-500/30 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:bg-primary-500/5"
|
||||||
disabled={userLevel < 5}
|
disabled={userLevel < 5}
|
||||||
/>
|
/>
|
||||||
<datalist id="adminCommentsList">
|
|
||||||
{adminComments.map((item) => (
|
|
||||||
<option key={(item as any).code || (item as any).Code} value={(item as any).memo || (item as any).Memo || (item as any).svalue || (item as any).SValue} />
|
|
||||||
))}
|
|
||||||
</datalist>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -719,10 +711,10 @@ export function HolidayRequestDialog({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-white/10 bg-white/5">
|
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-primary-500/30 bg-primary-500/10">
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-4 py-2 text-white/70 hover:text-white hover:bg-white/10 rounded-lg transition-colors font-medium"
|
className="px-4 py-2 text-primary-200 hover:text-white hover:bg-primary-500/20 rounded-lg transition-colors font-medium"
|
||||||
>
|
>
|
||||||
취소
|
취소
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { X, ChevronLeft, ChevronRight, Download } from 'lucide-react';
|
import { X, ChevronLeft, ChevronRight, Download, Calendar, Users, Clock } from 'lucide-react';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
import { comms } from '@/communication';
|
import { comms } from '@/communication';
|
||||||
import { HolidayItem } from '@/types';
|
import { HolidayItem } from '@/types';
|
||||||
|
|
||||||
@@ -177,16 +178,16 @@ export function JobReportDayDialog({ isOpen, onClose, initialMonth }: JobReportD
|
|||||||
|
|
||||||
// 셀 색상 결정
|
// 셀 색상 결정
|
||||||
const getCellStyle = (data: { hrs: number; ot: number; jobtype: string } | undefined, isHoliday: boolean) => {
|
const getCellStyle = (data: { hrs: number; ot: number; jobtype: string } | undefined, isHoliday: boolean) => {
|
||||||
if (!data) return 'text-gray-400';
|
if (!data) return 'text-white/20';
|
||||||
|
|
||||||
if (data.jobtype === '휴가') return 'text-red-500 font-medium';
|
if (data.jobtype === '휴가') return 'text-danger-400 font-black bg-danger-500/10';
|
||||||
if (isHoliday) return 'text-green-500 font-medium';
|
if (isHoliday) return 'text-success-400 font-bold bg-success-500/10 underline underline-offset-4';
|
||||||
|
|
||||||
if (data.hrs > 8) return 'text-blue-500 font-medium';
|
if (data.hrs > 8) return 'text-primary-400 font-bold underline underline-offset-2';
|
||||||
if (data.hrs < 8) return 'text-red-500';
|
if (data.hrs < 8) return 'text-danger-500 font-medium';
|
||||||
if (data.ot > 0) return 'text-purple-500 font-medium';
|
if (data.ot > 0) return 'text-warning-400 font-bold italic';
|
||||||
|
|
||||||
return 'text-white';
|
return 'text-white/70';
|
||||||
};
|
};
|
||||||
|
|
||||||
// 셀 내용 포맷
|
// 셀 내용 포맷
|
||||||
@@ -201,10 +202,10 @@ export function JobReportDayDialog({ isOpen, onClose, initialMonth }: JobReportD
|
|||||||
|
|
||||||
// 엑셀 내보내기 (간단한 CSV)
|
// 엑셀 내보내기 (간단한 CSV)
|
||||||
const exportToExcel = () => {
|
const exportToExcel = () => {
|
||||||
let csv = '사원명,' + dayColumns.map(c => `${c.day}(${c.dayOfWeek})`).join(',') + ',합계\n';
|
let csv = '사원명,' + dayColumns.map((c: DayColumn) => `${c.day}(${c.dayOfWeek})`).join(',') + ',합계\n';
|
||||||
|
|
||||||
userRows.forEach(row => {
|
userRows.forEach((row: UserRow) => {
|
||||||
const cells = dayColumns.map(col => {
|
const cells = dayColumns.map((col: DayColumn) => {
|
||||||
const data = row.dailyData.get(col.day);
|
const data = row.dailyData.get(col.day);
|
||||||
return formatCellContent(data, col.isHoliday);
|
return formatCellContent(data, col.isHoliday);
|
||||||
});
|
});
|
||||||
@@ -222,125 +223,207 @@ export function JobReportDayDialog({ isOpen, onClose, initialMonth }: JobReportD
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in" onClick={onClose}>
|
||||||
<div className="bg-gradient-to-br from-gray-900 to-gray-800 rounded-2xl shadow-2xl w-full max-w-7xl max-h-[90vh] flex flex-col">
|
<div
|
||||||
|
className="glass-effect rounded-3xl w-full max-w-7xl max-h-[95vh] overflow-hidden flex flex-col shadow-2xl border border-white/10 animate-scale-in"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="flex items-center justify-between p-6 border-b border-white/10">
|
<div className="px-8 py-6 border-b border-white/10 flex items-center justify-between bg-white/5">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-6">
|
||||||
<h2 className="text-2xl font-bold text-white">일별 근무시간 집계</h2>
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex items-center gap-2">
|
<div className="p-2.5 bg-primary-500/20 rounded-xl">
|
||||||
|
<Clock className="w-6 h-6 text-primary-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-white leading-tight">일별 근무시간 집계</h2>
|
||||||
|
<p className="text-xs text-white/40 uppercase tracking-widest font-medium mt-0.5">Daily Working Hours Summary</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 월 선택 UI */}
|
||||||
|
<div className="flex items-center gap-1 bg-white/5 p-1 rounded-xl border border-white/10">
|
||||||
<button
|
<button
|
||||||
onClick={() => changeMonth(-1)}
|
onClick={() => changeMonth(-1)}
|
||||||
className="p-2 hover:bg-white/10 rounded-lg transition-colors"
|
className="p-2 hover:bg-white/10 rounded-lg text-white/50 hover:text-white transition-all active:scale-95"
|
||||||
|
title="이전 달"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="w-5 h-5 text-white" />
|
<ChevronLeft className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
<span className="text-lg font-medium text-white min-w-[100px] text-center">
|
<div className="px-4 flex items-center gap-2">
|
||||||
{currentMonth}
|
<Calendar className="w-4 h-4 text-primary-400" />
|
||||||
</span>
|
<span className="text-sm font-bold text-white font-mono min-w-[80px] text-center italic tracking-wider">
|
||||||
|
{currentMonth}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => changeMonth(1)}
|
onClick={() => changeMonth(1)}
|
||||||
className="p-2 hover:bg-white/10 rounded-lg transition-colors"
|
className="p-2 hover:bg-white/10 rounded-lg text-white/50 hover:text-white transition-all active:scale-95"
|
||||||
|
title="다음 달"
|
||||||
>
|
>
|
||||||
<ChevronRight className="w-5 h-5 text-white" />
|
<ChevronRight className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={exportToExcel}
|
onClick={exportToExcel}
|
||||||
className="px-4 py-2 bg-green-500 hover:bg-green-600 text-white rounded-lg transition-colors flex items-center gap-2"
|
className="px-5 py-2.5 bg-green-500 hover:bg-green-600 text-white border border-green-500/20 rounded-xl transition-all font-bold flex items-center gap-2 shadow-lg shadow-green-500/20 active:scale-95"
|
||||||
>
|
>
|
||||||
<Download className="w-4 h-4" />
|
<Download className="w-4 h-4" />
|
||||||
내보내기
|
<span>CSV 내보내기</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="p-2 hover:bg-white/10 rounded-lg transition-colors"
|
className="p-2.5 text-white/30 hover:text-white hover:bg-white/10 rounded-xl transition-all"
|
||||||
>
|
>
|
||||||
<X className="w-6 h-6 text-white" />
|
<X className="w-6 h-6" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 테이블 */}
|
{/* 테이블 컨텐츠 */}
|
||||||
<div className="flex-1 overflow-auto p-6">
|
<div className="flex-1 overflow-hidden flex flex-col p-6 bg-white/[0.02]">
|
||||||
{loading ? (
|
<div className="flex-1 glass-effect rounded-2xl border border-white/10 flex flex-col overflow-hidden shadow-2xl">
|
||||||
<div className="flex items-center justify-center h-full">
|
{loading ? (
|
||||||
<div className="text-white/50">데이터를 불러오는 중...</div>
|
<div className="flex-1 flex flex-col items-center justify-center">
|
||||||
</div>
|
<RefreshCw className="w-12 h-12 text-primary-500/30 animate-spin mb-4" />
|
||||||
) : (
|
<p className="text-white/40 font-medium">데이터를 분석하고 집계하는 중...</p>
|
||||||
<table className="w-full border-collapse">
|
</div>
|
||||||
<thead className="sticky top-0 bg-gray-800 z-10">
|
) : (
|
||||||
<tr>
|
<div className="flex-1 overflow-auto custom-scrollbar">
|
||||||
<th className="px-3 py-2 text-left text-xs font-medium text-white/70 uppercase border border-white/10 bg-gray-800">
|
<table className="w-full border-separate border-spacing-0">
|
||||||
사원명
|
<thead className="sticky top-0 z-20">
|
||||||
</th>
|
<tr>
|
||||||
{dayColumns.map(col => (
|
<th className="px-6 py-4 text-left text-xs font-bold text-white/70 uppercase tracking-widest bg-gray-900 border-b border-r border-white/10 sticky left-0 z-30">
|
||||||
<th
|
사원명
|
||||||
key={col.day}
|
</th>
|
||||||
className={`px-2 py-2 text-center text-xs font-medium uppercase border border-white/10 ${col.isHoliday ? 'bg-green-900/30 text-green-400' :
|
{dayColumns.map(col => (
|
||||||
col.dayOfWeek === '일' ? 'bg-red-900/30 text-red-400' :
|
<th
|
||||||
col.dayOfWeek === '토' ? 'bg-blue-900/30 text-blue-400' :
|
key={col.day}
|
||||||
'bg-gray-800 text-white/70'
|
className={clsx(
|
||||||
}`}
|
"px-2 py-3 text-center text-[10px] font-black uppercase tracking-tighter border-b border-r border-white/5 bg-gray-900/95 backdrop-blur-sm min-w-[50px]",
|
||||||
title={col.holidayMemo}
|
col.isHoliday ? 'text-danger-400 bg-danger-500/10' :
|
||||||
>
|
col.dayOfWeek === '일' ? 'text-danger-400' :
|
||||||
{col.day}<br />({col.dayOfWeek})
|
col.dayOfWeek === '토' ? 'text-primary-400' :
|
||||||
</th>
|
'text-white/40'
|
||||||
))}
|
)}
|
||||||
<th className="px-3 py-2 text-center text-xs font-medium text-white/70 uppercase border border-white/10 bg-gray-800">
|
title={col.holidayMemo}
|
||||||
합계
|
>
|
||||||
</th>
|
<div className="flex flex-col items-center">
|
||||||
</tr>
|
<span className="text-sm font-mono leading-none">{col.day}</span>
|
||||||
</thead>
|
<span className="mt-1 opacity-60 leading-none">{col.dayOfWeek}</span>
|
||||||
<tbody>
|
</div>
|
||||||
{userRows.length === 0 ? (
|
</th>
|
||||||
<tr>
|
))}
|
||||||
<td colSpan={dayColumns.length + 2} className="px-4 py-8 text-center text-white/50">
|
<th className="px-4 py-4 text-center text-xs font-bold text-primary-400 uppercase tracking-widest bg-gray-900 border-b border-white/10 sticky right-0 z-30 shadow-[-4px_0_12px_rgba(0,0,0,0.5)]">
|
||||||
조회된 데이터가 없습니다.
|
통계 (Hr + OT)
|
||||||
</td>
|
</th>
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
userRows.map(row => (
|
|
||||||
<tr key={row.uid} className="hover:bg-white/5 transition-colors">
|
|
||||||
<td className="px-3 py-2 text-sm text-white border border-white/10 whitespace-nowrap">
|
|
||||||
{row.uname}
|
|
||||||
</td>
|
|
||||||
{dayColumns.map(col => {
|
|
||||||
const data = row.dailyData.get(col.day);
|
|
||||||
return (
|
|
||||||
<td
|
|
||||||
key={col.day}
|
|
||||||
className={`px-2 py-2 text-center text-sm border border-white/10 ${getCellStyle(data, col.isHoliday)}`}
|
|
||||||
>
|
|
||||||
{formatCellContent(data, col.isHoliday)}
|
|
||||||
</td>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
<td className="px-3 py-2 text-center text-sm text-white border border-white/10 font-medium whitespace-nowrap">
|
|
||||||
{row.totalHrs.toFixed(1)}+{row.totalOt.toFixed(1)}(*{row.totalHolidayOt.toFixed(1)})
|
|
||||||
</td>
|
|
||||||
</tr>
|
</tr>
|
||||||
))
|
</thead>
|
||||||
)}
|
<tbody className="divide-y divide-white/[0.03]">
|
||||||
</tbody>
|
{userRows.length === 0 ? (
|
||||||
</table>
|
<tr>
|
||||||
)}
|
<td colSpan={dayColumns.length + 2} className="px-4 py-32 text-center">
|
||||||
|
<Users className="w-20 h-20 text-white/5 mx-auto mb-4" />
|
||||||
|
<p className="text-white/20 font-medium">조회된 데이터가 없습니다.</p>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
userRows.map((row: UserRow) => (
|
||||||
|
<tr key={row.uid} className="hover:bg-white/[0.03] transition-colors group">
|
||||||
|
<td className="px-6 py-2.5 text-sm font-bold text-white bg-gray-900/50 border-r border-white/10 sticky left-0 z-10 backdrop-blur-md group-hover:bg-primary-500/10 transition-colors">
|
||||||
|
{row.uname}
|
||||||
|
</td>
|
||||||
|
{dayColumns.map((col: DayColumn) => {
|
||||||
|
const data = row.dailyData.get(col.day);
|
||||||
|
return (
|
||||||
|
<td
|
||||||
|
key={col.day}
|
||||||
|
className={clsx(
|
||||||
|
"px-2 py-2 text-center text-xs border-r border-white/[0.02] last:border-r-0",
|
||||||
|
getCellStyle(data, col.isHoliday)
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="font-mono font-medium tracking-tighter italic opacity-90">
|
||||||
|
{formatCellContent(data, col.isHoliday)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<td className="px-4 py-2.5 text-center bg-gray-900/50 border-l border-white/10 sticky right-0 z-10 backdrop-blur-md shadow-[-4px_0_12px_rgba(0,0,0,0.5)] group-hover:bg-primary-500/10 transition-colors">
|
||||||
|
<div className="flex items-center justify-center gap-1.5 font-mono text-xs font-bold">
|
||||||
|
<span className="text-white tracking-widest">{row.totalHrs.toFixed(1)}</span>
|
||||||
|
<span className="text-white/20 italic">+</span>
|
||||||
|
<span className="text-primary-400 tracking-widest">{row.totalOt.toFixed(1)}</span>
|
||||||
|
{row.totalHolidayOt > 0 && (
|
||||||
|
<span className="text-warning-400 ml-1 text-[10px] bg-warning-500/10 px-1 rounded">
|
||||||
|
*{row.totalHolidayOt.toFixed(1)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 범례 */}
|
{/* 하단 범례 (Legend) */}
|
||||||
<div className="px-6 py-4 border-t border-white/10 bg-gray-800/50">
|
<div className="px-8 py-4 border-t border-white/10 bg-white/5 flex items-center gap-6 overflow-x-auto custom-scrollbar no-scrollbar">
|
||||||
<div className="flex flex-wrap gap-4 text-xs text-white/70">
|
<span className="text-[10px] font-black text-white/30 uppercase tracking-[0.2em] shrink-0 border-r border-white/10 pr-6 mr-2">Legend</span>
|
||||||
<div><span className="text-gray-400">--</span> : 자료없음</div>
|
<div className="flex items-center gap-6 text-[11px] font-bold whitespace-nowrap">
|
||||||
<div><span className="text-red-500">휴가</span> : 휴가</div>
|
<div className="flex items-center gap-2">
|
||||||
<div><span className="text-green-500">*8+2</span> : 휴일근무</div>
|
<span className="w-5 h-5 rounded-md bg-white/5 flex items-center justify-center text-gray-500 font-mono italic">--</span>
|
||||||
<div><span className="text-blue-500">9+0</span> : 8시간 초과</div>
|
<span className="text-white/40">기록 없음</span>
|
||||||
<div><span className="text-red-500">7+0</span> : 8시간 미만</div>
|
</div>
|
||||||
<div><span className="text-purple-500">8+2</span> : 8시간+OT</div>
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="px-2 py-0.5 rounded-md bg-danger-500/20 text-danger-400 font-bold">휴가</span>
|
||||||
|
<span className="text-white/40">연차/반차</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="px-2 py-0.5 rounded-md bg-success-500/20 text-success-400 font-mono font-bold">*8+4</span>
|
||||||
|
<span className="text-white/40">휴일 근무</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="px-2 py-0.5 rounded-md bg-primary-500/20 text-primary-400 font-mono font-bold underline">10+0</span>
|
||||||
|
<span className="text-white/40">기본 초과</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="px-2 py-0.5 rounded-md bg-danger-500/10 text-danger-500 font-mono font-bold">6+0</span>
|
||||||
|
<span className="text-white/40">기본 미달</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="px-2 py-0.5 rounded-md bg-warning-500/20 text-warning-400 font-mono font-bold italic">8+2</span>
|
||||||
|
<span className="text-white/40">OT 발생</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const RefreshCw = ({ className }: { className?: string }) => (
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
width="24" height="24"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="2"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
|
||||||
|
<path d="M21 3v5h-5" />
|
||||||
|
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
|
||||||
|
<path d="M3 21v-5h5" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
|||||||
import { X, RefreshCw } from 'lucide-react';
|
import { X, RefreshCw } from 'lucide-react';
|
||||||
import { comms } from '@/communication';
|
import { comms } from '@/communication';
|
||||||
import { JobReportTypeItem } from '@/types';
|
import { JobReportTypeItem } from '@/types';
|
||||||
|
import { DevelopmentNotice } from '@/components/DevelopmentNotice';
|
||||||
|
|
||||||
interface JobreportTypeModalProps {
|
interface JobreportTypeModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -65,6 +66,7 @@ export function JobreportTypeModal({
|
|||||||
|
|
||||||
{/* 컨텐츠 */}
|
{/* 컨텐츠 */}
|
||||||
<div className="p-6">
|
<div className="p-6">
|
||||||
|
<DevelopmentNotice />
|
||||||
<div className="mb-4 text-white/70 text-sm">
|
<div className="mb-4 text-white/70 text-sm">
|
||||||
기간: {startDate} ~ {endDate}
|
기간: {startDate} ~ {endDate}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -116,7 +116,7 @@ export function KuntaeEditModal({ isOpen, onClose, onSave, initialData, mode }:
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in">
|
||||||
<div className="bg-[#1e1e2e] rounded-2xl shadow-2xl w-full max-w-lg border border-white/10 overflow-hidden">
|
<div className="bg-bg-paper rounded-2xl shadow-2xl w-full max-w-lg border border-white/10 overflow-hidden">
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="px-6 py-4 border-b border-white/10 flex justify-between items-center bg-white/5">
|
<div className="px-6 py-4 border-b border-white/10 flex justify-between items-center bg-white/5">
|
||||||
<h2 className="text-xl font-bold text-white flex items-center">
|
<h2 className="text-xl font-bold text-white flex items-center">
|
||||||
@@ -142,7 +142,7 @@ export function KuntaeEditModal({ isOpen, onClose, onSave, initialData, mode }:
|
|||||||
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
|
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
|
||||||
>
|
>
|
||||||
{CATE_OPTIONS.map(opt => (
|
{CATE_OPTIONS.map(opt => (
|
||||||
<option key={opt} value={opt} className="bg-[#1e1e2e]">{opt}</option>
|
<option key={opt} value={opt} className="bg-bg-paper">{opt}</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ interface LayoutProps {
|
|||||||
|
|
||||||
export function Layout({ isConnected, user }: LayoutProps) {
|
export function Layout({ isConnected, user }: LayoutProps) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900">
|
<div className="min-h-screen gradient-bg">
|
||||||
<div className="flex flex-col h-screen overflow-hidden">
|
<div className="flex flex-col h-screen overflow-hidden">
|
||||||
{/* Top Navigation Header */}
|
{/* Top Navigation Header */}
|
||||||
<Header isConnected={isConnected} />
|
<Header isConnected={isConnected} />
|
||||||
|
|||||||
21
Project/frontend/src/components/layout/SettingsButton.tsx
Normal file
21
Project/frontend/src/components/layout/SettingsButton.tsx
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Settings } from 'lucide-react';
|
||||||
|
import { SettingsDialog } from '../settings/SettingsDialog';
|
||||||
|
|
||||||
|
export function SettingsButton() {
|
||||||
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => setIsOpen(true)}
|
||||||
|
className="flex items-center justify-center p-2 rounded-lg text-white/70 hover:text-white hover:bg-white/10 transition-colors"
|
||||||
|
title="설정"
|
||||||
|
>
|
||||||
|
<Settings className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<SettingsDialog isOpen={isOpen} onClose={() => setIsOpen(false)} />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Clock, Wifi, WifiOff } from 'lucide-react';
|
import { Clock, Wifi, WifiOff } from 'lucide-react';
|
||||||
import { UserInfoButton } from './UserInfoButton';
|
import { UserInfoButton } from './UserInfoButton';
|
||||||
|
import { SettingsButton } from './SettingsButton';
|
||||||
import { comms } from '@/communication';
|
import { comms } from '@/communication';
|
||||||
|
|
||||||
interface StatusBarProps {
|
interface StatusBarProps {
|
||||||
@@ -39,6 +40,8 @@ export function StatusBar({ userName, userDept, isConnected }: StatusBarProps) {
|
|||||||
<footer className="glass-effect px-4 py-2 flex items-center justify-between text-sm">
|
<footer className="glass-effect px-4 py-2 flex items-center justify-between text-sm">
|
||||||
{/* Left: User Info */}
|
{/* Left: User Info */}
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
|
{/* Settings Button */}
|
||||||
|
<SettingsButton />
|
||||||
<UserInfoButton userName={userName} userDept={userDept} />
|
<UserInfoButton userName={userName} userDept={userDept} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { X, Save, Trash2 } from 'lucide-react';
|
import { X, Save, Trash2, ShieldCheck, Info, Package, Truck, User, Calendar, FileText, CheckCircle, XCircle } from 'lucide-react';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import type { LicenseItem } from '@/types';
|
import type { LicenseItem } from '@/types';
|
||||||
|
|
||||||
@@ -86,65 +87,88 @@ export function LicenseEditDialog({ item, isOpen, onClose, onSave, onDelete }: L
|
|||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}>
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in" onClick={onClose}>
|
||||||
<div className="glass-effect rounded-lg w-full max-w-3xl max-h-[90vh] overflow-y-auto m-4" onClick={(e) => e.stopPropagation()}>
|
<div className="glass-effect rounded-3xl w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col shadow-2xl border border-white/10 animate-scale-in" onClick={(e) => e.stopPropagation()}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between p-4 border-b border-white/10">
|
<div className="px-8 py-6 border-b border-white/10 flex items-center justify-between bg-white/5">
|
||||||
<h2 className="text-xl font-semibold text-white">
|
<div className="flex items-center gap-4">
|
||||||
{formData.idx ? '라이선스 수정' : '라이선스 추가'}
|
<div className="p-2.5 bg-primary-500/20 rounded-xl">
|
||||||
</h2>
|
<ShieldCheck className="w-6 h-6 text-primary-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xl font-bold text-white leading-tight">
|
||||||
|
{formData.idx ? '라이선스 수정' : '라이선스 추가'}
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-white/40 uppercase tracking-widest font-medium mt-0.5">Edit License Details</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="text-white/70 hover:text-white transition-colors"
|
className="p-2 text-white/30 hover:text-white hover:bg-white/10 rounded-xl transition-all"
|
||||||
>
|
>
|
||||||
<X className="w-6 h-6" />
|
<X className="w-6 h-6" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="p-6 space-y-6">
|
<div className="flex-1 overflow-y-auto custom-scrollbar p-8 space-y-8">
|
||||||
{/* 기본 정보 */}
|
{/* 기본 정보 */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-sm font-semibold text-white/90 flex items-center space-x-2 border-b border-white/10 pb-2">
|
<h3 className="text-sm font-bold text-primary-400 flex items-center gap-2 uppercase tracking-widest py-1 border-b border-white/5 mb-4">
|
||||||
<span>기본 정보</span>
|
<Info className="w-4 h-4" />
|
||||||
|
기본 정보
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-12 gap-4">
|
<div className="grid grid-cols-12 gap-5">
|
||||||
<div className="col-span-1 flex items-center">
|
<div className="col-span-12 md:col-span-2">
|
||||||
<label className="flex items-center space-x-2 cursor-pointer">
|
<label className="block text-xs font-bold text-white/30 uppercase tracking-widest mb-2 px-1">상태</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormData({ ...formData, expire: !formData.expire })}
|
||||||
|
className={clsx(
|
||||||
|
"w-full px-4 py-2.5 rounded-xl border transition-all flex items-center justify-center gap-2 font-bold text-sm",
|
||||||
|
formData.expire
|
||||||
|
? "bg-danger-500/20 border-danger-500/30 text-danger-400"
|
||||||
|
: "bg-success-500/20 border-success-500/30 text-success-400"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{formData.expire ? (
|
||||||
|
<><XCircle className="w-4 h-4" /> 만료</>
|
||||||
|
) : (
|
||||||
|
<><CheckCircle className="w-4 h-4" /> 유효</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-12 md:col-span-5">
|
||||||
|
<label className="block text-xs font-bold text-white/30 uppercase tracking-widest mb-2 px-1">제품명 *</label>
|
||||||
|
<div className="relative group">
|
||||||
|
<Package className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-white/20 group-focus-within:text-primary-400 transition-colors" />
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="text"
|
||||||
checked={formData.expire || false}
|
value={formData.name || ''}
|
||||||
onChange={(e) => setFormData({ ...formData, expire: e.target.checked })}
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
className="w-4 h-4"
|
className="w-full pl-10 pr-4 py-2.5 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all font-medium"
|
||||||
|
placeholder="제품명을 입력하세요"
|
||||||
/>
|
/>
|
||||||
<span className="text-sm text-white/70">만료</span>
|
</div>
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-5">
|
<div className="col-span-12 md:col-span-2">
|
||||||
<label className="block text-sm text-white/70 mb-1">제품명 *</label>
|
<label className="block text-xs font-bold text-white/30 uppercase tracking-widest mb-2 px-1">버전</label>
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.name || ''}
|
|
||||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
||||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="col-span-3">
|
|
||||||
<label className="block text-sm text-white/70 mb-1">버전</label>
|
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.version || ''}
|
value={formData.version || ''}
|
||||||
onChange={(e) => setFormData({ ...formData, version: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, version: e.target.value })}
|
||||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500"
|
className="w-full px-4 py-2.5 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all font-medium"
|
||||||
|
placeholder="v1.0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-3">
|
<div className="col-span-12 md:col-span-3">
|
||||||
<label className="block text-sm text-white/70 mb-1">자재번호</label>
|
<label className="block text-xs font-bold text-white/30 uppercase tracking-widest mb-2 px-1">자재번호</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.meterialNo || ''}
|
value={formData.meterialNo || ''}
|
||||||
onChange={(e) => setFormData({ ...formData, meterialNo: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, meterialNo: e.target.value })}
|
||||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500"
|
className="w-full px-4 py-2.5 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all font-medium font-mono"
|
||||||
|
placeholder="M-0000"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -152,26 +176,27 @@ export function LicenseEditDialog({ item, isOpen, onClose, onSave, onDelete }: L
|
|||||||
|
|
||||||
{/* 공급 정보 */}
|
{/* 공급 정보 */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-sm font-semibold text-white/90 flex items-center space-x-2 border-b border-white/10 pb-2">
|
<h3 className="text-sm font-bold text-primary-400 flex items-center gap-2 uppercase tracking-widest py-1 border-b border-white/5 mb-4">
|
||||||
<span>공급 정보</span>
|
<Truck className="w-4 h-4" />
|
||||||
|
공급 정보
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-5">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-white/70 mb-1">공급업체</label>
|
<label className="block text-xs font-bold text-white/30 uppercase tracking-widest mb-2 px-1">공급업체</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.supply || ''}
|
value={formData.supply || ''}
|
||||||
onChange={(e) => setFormData({ ...formData, supply: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, supply: e.target.value })}
|
||||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500"
|
className="w-full px-4 py-2.5 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all font-medium"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-white/70 mb-1">제조사</label>
|
<label className="block text-xs font-bold text-white/30 uppercase tracking-widest mb-2 px-1">제조사</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.manu || ''}
|
value={formData.manu || ''}
|
||||||
onChange={(e) => setFormData({ ...formData, manu: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, manu: e.target.value })}
|
||||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500"
|
className="w-full px-4 py-2.5 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all font-medium"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -179,35 +204,36 @@ export function LicenseEditDialog({ item, isOpen, onClose, onSave, onDelete }: L
|
|||||||
|
|
||||||
{/* 사용 정보 */}
|
{/* 사용 정보 */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-sm font-semibold text-white/90 flex items-center space-x-2 border-b border-white/10 pb-2">
|
<h3 className="text-sm font-bold text-primary-400 flex items-center gap-2 uppercase tracking-widest py-1 border-b border-white/5 mb-4">
|
||||||
<span>사용 정보</span>
|
<User className="w-4 h-4" />
|
||||||
|
사용 정보
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-12 gap-4">
|
<div className="grid grid-cols-12 gap-5">
|
||||||
<div className="col-span-2">
|
<div className="col-span-12 md:col-span-2">
|
||||||
<label className="block text-sm text-white/70 mb-1">수량</label>
|
<label className="block text-xs font-bold text-white/30 uppercase tracking-widest mb-2 px-1">수량</label>
|
||||||
<input
|
<input
|
||||||
type="number"
|
type="number"
|
||||||
value={formData.qty || 1}
|
value={formData.qty || 1}
|
||||||
onChange={(e) => setFormData({ ...formData, qty: parseInt(e.target.value) || 1 })}
|
onChange={(e) => setFormData({ ...formData, qty: parseInt(e.target.value) || 1 })}
|
||||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500"
|
className="w-full px-4 py-2.5 bg-white/5 border border-white/10 rounded-xl text-white focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all font-bold text-center"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-4">
|
<div className="col-span-12 md:col-span-4">
|
||||||
<label className="block text-sm text-white/70 mb-1">사용자</label>
|
<label className="block text-xs font-bold text-white/30 uppercase tracking-widest mb-2 px-1">사용자</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.uids || ''}
|
value={formData.uids || ''}
|
||||||
onChange={(e) => setFormData({ ...formData, uids: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, uids: e.target.value })}
|
||||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500"
|
className="w-full px-4 py-2.5 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all font-medium"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="col-span-6">
|
<div className="col-span-12 md:col-span-6">
|
||||||
<label className="block text-sm text-white/70 mb-1">S/N</label>
|
<label className="block text-xs font-bold text-white/30 uppercase tracking-widest mb-2 px-1">S/N</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.serialNo || ''}
|
value={formData.serialNo || ''}
|
||||||
onChange={(e) => setFormData({ ...formData, serialNo: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, serialNo: e.target.value })}
|
||||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500"
|
className="w-full px-4 py-2.5 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all font-medium font-mono"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -215,74 +241,82 @@ export function LicenseEditDialog({ item, isOpen, onClose, onSave, onDelete }: L
|
|||||||
|
|
||||||
{/* 기간 정보 */}
|
{/* 기간 정보 */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-sm font-semibold text-white/90 flex items-center space-x-2 border-b border-white/10 pb-2">
|
<h3 className="text-sm font-bold text-primary-400 flex items-center gap-2 uppercase tracking-widest py-1 border-b border-white/5 mb-4">
|
||||||
<span>기간 정보</span>
|
<Calendar className="w-4 h-4" />
|
||||||
|
기간 정보
|
||||||
</h3>
|
</h3>
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-5">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-white/70 mb-1">시작일</label>
|
<label className="block text-xs font-bold text-white/30 uppercase tracking-widest mb-2 px-1">시작일</label>
|
||||||
<input
|
<div className="relative group">
|
||||||
type="date"
|
<Calendar className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-white/20 group-focus-within:text-primary-400 transition-colors pointer-events-none" />
|
||||||
value={formData.sdate || ''}
|
<input
|
||||||
onChange={(e) => setFormData({ ...formData, sdate: e.target.value })}
|
type="date"
|
||||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500"
|
value={formData.sdate || ''}
|
||||||
/>
|
onChange={(e) => setFormData({ ...formData, sdate: e.target.value })}
|
||||||
|
className="w-full pl-10 pr-4 py-2.5 bg-white/5 border border-white/10 rounded-xl text-white focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all font-medium custom-calendar-icon"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm text-white/70 mb-1">종료일</label>
|
<label className="block text-xs font-bold text-white/30 uppercase tracking-widest mb-2 px-1">종료일</label>
|
||||||
<input
|
<div className="relative group">
|
||||||
type="date"
|
<Calendar className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-white/20 group-focus-within:text-primary-400 transition-colors pointer-events-none" />
|
||||||
value={formData.edate || ''}
|
<input
|
||||||
onChange={(e) => setFormData({ ...formData, edate: e.target.value })}
|
type="date"
|
||||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500"
|
value={formData.edate || ''}
|
||||||
/>
|
onChange={(e) => setFormData({ ...formData, edate: e.target.value })}
|
||||||
|
className="w-full pl-10 pr-4 py-2.5 bg-white/5 border border-white/10 rounded-xl text-white focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all font-medium custom-calendar-icon"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 비고 */}
|
{/* 비고 */}
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<h3 className="text-sm font-semibold text-white/90 flex items-center space-x-2 border-b border-white/10 pb-2">
|
<h3 className="text-sm font-bold text-primary-400 flex items-center gap-2 uppercase tracking-widest py-1 border-b border-white/5 mb-4">
|
||||||
<span>비고</span>
|
<FileText className="w-4 h-4" />
|
||||||
|
비고
|
||||||
</h3>
|
</h3>
|
||||||
<textarea
|
<textarea
|
||||||
value={formData.remark || ''}
|
value={formData.remark || ''}
|
||||||
onChange={(e) => setFormData({ ...formData, remark: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, remark: e.target.value })}
|
||||||
rows={3}
|
rows={4}
|
||||||
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500 resize-none"
|
className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-2xl text-white placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all font-medium resize-none"
|
||||||
placeholder="추가 메모를 입력하세요..."
|
placeholder="추가 메모를 입력하세요..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="flex items-center justify-between p-4 border-t border-white/10">
|
<div className="px-8 py-6 border-t border-white/10 flex items-center justify-between bg-white/5">
|
||||||
<div>
|
<div>
|
||||||
{formData.idx && onDelete && (
|
{formData.idx && onDelete && (
|
||||||
<button
|
<button
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="flex items-center space-x-2 px-4 py-2 bg-red-500 hover:bg-red-600 disabled:bg-gray-600 text-white rounded-lg transition-colors"
|
className="flex items-center gap-2 px-5 py-2.5 bg-danger-500/10 hover:bg-danger-500/20 text-danger-400 border border-danger-500/20 rounded-xl transition-all font-bold group active:scale-95"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4 group-hover:shake" />
|
||||||
<span>삭제</span>
|
<span>삭제</span>
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="px-4 py-2 bg-gray-600 hover:bg-gray-700 disabled:bg-gray-800 text-white rounded-lg transition-colors"
|
className="px-6 py-2.5 bg-white/5 hover:bg-white/10 text-white/70 hover:text-white border border-white/10 rounded-xl transition-all font-bold active:scale-95"
|
||||||
>
|
>
|
||||||
취소
|
취소
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
className="flex items-center space-x-2 px-4 py-2 bg-blue-500 hover:bg-blue-600 disabled:bg-gray-600 text-white rounded-lg transition-colors"
|
className="flex items-center gap-2 px-8 py-2.5 bg-primary-500 hover:bg-primary-600 text-white border border-primary-500/20 rounded-xl transition-all font-bold shadow-lg shadow-primary-500/20 active:scale-95 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
<Save className="w-4 h-4" />
|
<Save className={clsx("w-4 h-4", saving && "animate-spin")} />
|
||||||
<span>{saving ? '저장 중...' : '저장'}</span>
|
<span>{saving ? '저장 중...' : '저장'}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,12 +4,14 @@ import {
|
|||||||
FolderOpen,
|
FolderOpen,
|
||||||
Download,
|
Download,
|
||||||
Search,
|
Search,
|
||||||
X,
|
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
XCircle,
|
XCircle,
|
||||||
|
ShieldCheck,
|
||||||
|
RefreshCw,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
import { comms } from '@/communication';
|
import { comms } from '@/communication';
|
||||||
import { LicenseEditDialog } from './LicenseEditDialog';
|
import { LicenseEditDialog } from './LicenseEditDialog';
|
||||||
import type { LicenseItem } from '@/types';
|
import type { LicenseItem } from '@/types';
|
||||||
@@ -229,149 +231,215 @@ export function LicenseList() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-4">
|
<div className="flex flex-col h-full animate-fade-in bg-white/[0.02]">
|
||||||
{/* Header */}
|
{/* 리스트 헤더 */}
|
||||||
<div className="flex items-center justify-between">
|
<div className="bg-white/5 border-b border-white/10 px-6 py-4 flex items-center justify-between backdrop-blur-md sticky top-0 z-20">
|
||||||
<h1 className="text-2xl font-bold text-white">라이선스 관리</h1>
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex items-center space-x-2">
|
<div className="p-2 bg-primary-500/20 rounded-lg">
|
||||||
|
<ShieldCheck className="w-6 h-6 text-primary-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h2 className="text-lg font-bold text-white leading-tight">라이선스 관리</h2>
|
||||||
|
<p className="text-[10px] text-white/40 uppercase tracking-wider font-medium">License Management</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* 검색 바 */}
|
||||||
|
<div className="relative group w-80">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/20 group-focus-within:text-primary-400 transition-colors" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchText}
|
||||||
|
onChange={(e) => setSearchText(e.target.value)}
|
||||||
|
placeholder="검색어 입력..."
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-xl pl-9 pr-8 py-1.5 text-xs text-white placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all backdrop-blur-sm"
|
||||||
|
/>
|
||||||
|
{searchText && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchText('')}
|
||||||
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-white/20 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<XCircle className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 건수 */}
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-3 py-1.5 rounded-xl border border-white/10 h-[38px]">
|
||||||
|
<span className="text-primary-400 font-bold text-sm">{filteredList.length}</span>
|
||||||
|
<span className="text-white/40 text-[10px] uppercase">건</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 새로고침 */}
|
||||||
<button
|
<button
|
||||||
onClick={handleAdd}
|
onClick={loadData}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="flex items-center space-x-2 px-4 py-2 bg-blue-500 hover:bg-blue-600 disabled:bg-gray-600 text-white rounded-lg transition-colors"
|
className="p-2 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl text-white/70 hover:text-white transition-all disabled:opacity-50"
|
||||||
|
title="새로고침"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4" />
|
<RefreshCw className={clsx("w-4 h-4", loading && "animate-spin")} />
|
||||||
<span>추가</span>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* CSV */}
|
||||||
<button
|
<button
|
||||||
onClick={handleExportCSV}
|
onClick={handleExportCSV}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="flex items-center space-x-2 px-4 py-2 bg-green-500 hover:bg-green-600 disabled:bg-gray-600 text-white rounded-lg transition-colors"
|
className="p-2 bg-white/5 hover:bg-green-500/20 border border-white/10 rounded-xl text-white/70 hover:text-green-400 transition-all disabled:opacity-50"
|
||||||
|
title="CSV 내보내기"
|
||||||
>
|
>
|
||||||
<Download className="w-4 h-4" />
|
<Download className="w-4 h-4" />
|
||||||
<span>CSV</span>
|
</button>
|
||||||
|
|
||||||
|
{/* 추가 */}
|
||||||
|
<button
|
||||||
|
onClick={handleAdd}
|
||||||
|
className="p-2 bg-primary-500 hover:bg-primary-600 border border-white/20 rounded-xl text-white transition-all shadow-lg shadow-primary-500/20 active:scale-95"
|
||||||
|
title="추가"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Search */}
|
<div className="flex-1 overflow-hidden flex flex-col p-6">
|
||||||
<div className="flex items-center space-x-2">
|
{/* 테이블 메인 섹션 */}
|
||||||
<div className="flex-1 relative">
|
<div className="flex-1 glass-effect rounded-2xl border border-white/10 flex flex-col overflow-hidden shadow-2xl">
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/50" />
|
{/* 컬럼 헤더 */}
|
||||||
<input
|
<div className="bg-white/10 px-6 py-3 border-b border-white/5 flex items-center gap-4 sticky top-0 z-10">
|
||||||
type="text"
|
<div className="w-12 text-center text-xs font-medium text-white/70 uppercase">상태</div>
|
||||||
value={searchText}
|
<div className="flex-1 flex items-center gap-4">
|
||||||
onChange={(e) => setSearchText(e.target.value)}
|
<div className="w-1/4 text-xs font-medium text-white/70 uppercase">제품명</div>
|
||||||
placeholder="검색 (제품명, 버전, 공급업체, 제조사, S/N, 자재번호, 비고)"
|
<div className="w-1/4 text-xs font-medium text-white/70 uppercase">버전</div>
|
||||||
className="w-full pl-10 pr-10 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-blue-500"
|
<div className="w-20 text-xs font-medium text-white/70 uppercase text-center">수량</div>
|
||||||
/>
|
<div className="w-32 text-xs font-medium text-white/70 uppercase">사용자</div>
|
||||||
{searchText && (
|
<div className="flex-1 text-xs font-medium text-white/70 uppercase">S/N</div>
|
||||||
<button
|
|
||||||
onClick={() => setSearchText('')}
|
|
||||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-white/50 hover:text-white"
|
|
||||||
>
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Table */}
|
|
||||||
<div className="glass-effect rounded-lg overflow-hidden">
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="w-full border-collapse">
|
|
||||||
<thead className="bg-white/10">
|
|
||||||
<tr>
|
|
||||||
<th className="px-4 py-3 text-center text-sm font-semibold text-white border-r border-white/10 w-16">상태</th>
|
|
||||||
<th className="px-4 py-3 text-left text-sm font-semibold text-white border-r border-white/10" style={{ width: '25%' }}>제품명</th>
|
|
||||||
<th className="px-4 py-3 text-left text-sm font-semibold text-white border-r border-white/10" style={{ width: '25%' }}>버전</th>
|
|
||||||
<th className="px-4 py-3 text-left text-sm font-semibold text-white border-r border-white/10 w-20">수량</th>
|
|
||||||
<th className="px-4 py-3 text-left text-sm font-semibold text-white border-r border-white/10" style={{ width: '12%' }}>사용자</th>
|
|
||||||
<th className="px-4 py-3 text-left text-sm font-semibold text-white" style={{ width: '15%' }}>S/N</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{loading && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={6} className="px-4 py-8 text-center text-white/70">
|
|
||||||
로딩 중...
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
{!loading && paginatedList.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={6} className="px-4 py-8 text-center text-white/70">
|
|
||||||
데이터가 없습니다.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
{!loading &&
|
|
||||||
paginatedList.map((item) => (
|
|
||||||
<tr
|
|
||||||
key={item.idx}
|
|
||||||
onClick={() => handleRowClick(item)}
|
|
||||||
className={`border-t border-white/10 hover:bg-white/10 cursor-pointer transition-colors ${
|
|
||||||
item.expire ? 'bg-red-500/10' : ''
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<td className="px-4 py-3 text-center border-r border-white/10">
|
|
||||||
<div className="flex justify-center" title={item.expire ? '만료' : '유효'}>
|
|
||||||
{item.expire ? (
|
|
||||||
<XCircle className="w-5 h-5 text-red-500" />
|
|
||||||
) : (
|
|
||||||
<CheckCircle className="w-5 h-5 text-green-500" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-white border-r border-white/10 max-w-xs">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<button
|
|
||||||
onClick={(e) => handleOpenFolder(item, e)}
|
|
||||||
className="p-1 text-yellow-400 hover:text-yellow-300 transition-colors flex-shrink-0"
|
|
||||||
title="폴더 열기"
|
|
||||||
>
|
|
||||||
<FolderOpen className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<span className="break-words">{item.name}</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-white border-r border-white/10 break-words">{item.version}</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-white border-r border-white/10">{item.qty}</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-white border-r border-white/10 break-words max-w-[8rem]">{item.uids}</td>
|
|
||||||
<td className="px-4 py-3 text-sm text-white break-words">{item.serialNo}</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Pagination */}
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<div className="flex items-center justify-between px-4 py-3 border-t border-white/10">
|
|
||||||
<div className="text-sm text-white/70">
|
|
||||||
전체 {filteredList.length}건 중 {(currentPage - 1) * pageSize + 1}~
|
|
||||||
{Math.min(currentPage * pageSize, filteredList.length)}건
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<button
|
|
||||||
onClick={goToPreviousPage}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
className="p-2 text-white/70 hover:text-white disabled:text-white/30 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
<span className="text-sm text-white">
|
|
||||||
{currentPage} / {totalPages}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={goToNextPage}
|
|
||||||
disabled={currentPage === totalPages}
|
|
||||||
className="p-2 text-white/70 hover:text-white disabled:text-white/30 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<ChevronRight className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
<div className="divide-y divide-white/5 overflow-y-auto custom-scrollbar flex-1">
|
||||||
|
{loading ? (
|
||||||
|
<div className="px-6 py-12 text-center">
|
||||||
|
<RefreshCw className="w-10 h-10 mx-auto mb-4 animate-spin text-primary-500/50" />
|
||||||
|
<p className="text-white/50 font-medium text-sm">데이터를 불러오는 중...</p>
|
||||||
|
</div>
|
||||||
|
) : paginatedList.length === 0 ? (
|
||||||
|
<div className="px-6 py-20 text-center">
|
||||||
|
<div className="relative inline-block mb-4">
|
||||||
|
<ShieldCheck className="w-16 h-16 mx-auto text-white/10" />
|
||||||
|
</div>
|
||||||
|
<p className="text-white/30 font-medium">조회된 라이선스가 없습니다.</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
paginatedList.map((item) => (
|
||||||
|
<div
|
||||||
|
key={item.idx}
|
||||||
|
onClick={() => handleRowClick(item)}
|
||||||
|
className={clsx(
|
||||||
|
"px-6 py-3 hover:bg-white/[0.03] transition-all cursor-pointer group flex items-center gap-4 border-b border-white/[0.02]",
|
||||||
|
item.expire && "bg-danger-500/[0.03]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="w-12 flex justify-center shrink-0">
|
||||||
|
<div className={clsx(
|
||||||
|
"w-8 h-8 rounded-lg flex items-center justify-center transition-all group-hover:scale-110",
|
||||||
|
item.expire ? "bg-danger-500/20 text-danger-400" : "bg-success-500/20 text-success-400"
|
||||||
|
)}>
|
||||||
|
{item.expire ? (
|
||||||
|
<XCircle className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 flex items-center gap-4 min-w-0">
|
||||||
|
<div className="w-1/4 min-w-0 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleOpenFolder(item, e)}
|
||||||
|
className="p-1 text-warning-400 hover:text-warning-300 transition-colors shrink-0"
|
||||||
|
title="폴더 열기"
|
||||||
|
>
|
||||||
|
<FolderOpen className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<span className="text-sm font-bold text-white group-hover:text-primary-400 transition-colors truncate">
|
||||||
|
{item.name}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-1/4 min-w-0">
|
||||||
|
<span className="text-sm text-white/60 truncate" title={item.version}>{item.version || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-20 shrink-0 text-center">
|
||||||
|
<span className="text-sm font-medium text-white/70">{item.qty || 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-32 shrink-0">
|
||||||
|
<span className="text-sm text-white/50 truncate" title={item.uids}>{item.uids || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="text-xs font-mono text-white/40 truncate" title={item.serialNo}>{item.serialNo || '-'}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pagination */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="bg-white/5 px-6 py-3 border-t border-white/10 flex items-center justify-between backdrop-blur-md">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-white/30 text-[10px] uppercase font-bold tracking-wider">Page</span>
|
||||||
|
<span className="text-white text-sm font-mono font-bold">{currentPage}</span>
|
||||||
|
<span className="text-white/20 text-xs italic">of</span>
|
||||||
|
<span className="text-white/60 text-sm font-mono">{totalPages}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={goToPreviousPage}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-white/10 text-white/70 hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-all"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 mx-2">
|
||||||
|
{totalPages <= 5 ? (
|
||||||
|
[...Array(totalPages)].map((_, i) => (
|
||||||
|
<button
|
||||||
|
key={i + 1}
|
||||||
|
onClick={() => setCurrentPage(i + 1)}
|
||||||
|
className={clsx(
|
||||||
|
"w-8 h-8 rounded-lg text-xs font-bold transition-all",
|
||||||
|
currentPage === i + 1
|
||||||
|
? "bg-primary-500 text-white shadow-lg shadow-primary-500/30"
|
||||||
|
: "text-white/40 hover:bg-white/10 hover:text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{i + 1}
|
||||||
|
</button>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<span className="text-white/40 text-xs px-2">{currentPage} / {totalPages}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={goToNextPage}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="p-1.5 rounded-lg hover:bg-white/10 text-white/70 hover:text-white disabled:opacity-30 disabled:cursor-not-allowed transition-all"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-white/30 text-[10px] uppercase font-bold tracking-wider">Total</span>
|
||||||
|
<span className="text-primary-400 text-sm font-mono font-bold leading-none">{filteredList.length}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Edit Dialog */}
|
{/* Edit Dialog */}
|
||||||
|
|||||||
290
Project/frontend/src/components/settings/SettingsDialog.tsx
Normal file
290
Project/frontend/src/components/settings/SettingsDialog.tsx
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
|
import { X, Settings as SettingsIcon, Monitor, Palette, Save } from 'lucide-react';
|
||||||
|
import { useTheme } from '@/context/ThemeContext';
|
||||||
|
import { comms } from '@/communication';
|
||||||
|
import { SettingsModel } from '@/types';
|
||||||
|
|
||||||
|
|
||||||
|
interface SettingsDialogProps {
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
const [activeTab, setActiveTab] = useState<'general' | 'theme'>('theme');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [settings, setSettings] = useState<SettingsModel | null>(null);
|
||||||
|
|
||||||
|
// 설정 로드
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
loadSettings();
|
||||||
|
}
|
||||||
|
}, [isOpen]);
|
||||||
|
|
||||||
|
const loadSettings = async () => {
|
||||||
|
setLoading(true);
|
||||||
|
console.log('Settings Load Start: comms.getSettings() call');
|
||||||
|
try {
|
||||||
|
const response = await comms.getSettings();
|
||||||
|
console.log('Settings response:', response);
|
||||||
|
// 백엔드에서 객체를 직접 반환함 (ApiResponse 래퍼 없음)
|
||||||
|
// Settings response: {Xml: {...}, Theme: "...", ...}
|
||||||
|
if (response) {
|
||||||
|
// @ts-ignore
|
||||||
|
const settingsData = response as SettingsModel;
|
||||||
|
setSettings(settingsData);
|
||||||
|
|
||||||
|
// DB에 저장된 테마가 있다면 현재 테마와 동기화
|
||||||
|
if (settingsData.Theme && settingsData.Theme !== theme) {
|
||||||
|
// DB 테마가 현재 로컬 테마와 다르다면?
|
||||||
|
// 일단 UI 상의 선택 상태는 DB 값으로 설정.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('설정 로드 실패:', error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!settings) return;
|
||||||
|
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
// 1. 백엔드 저장
|
||||||
|
const response = await comms.saveSettings(settings);
|
||||||
|
|
||||||
|
if (response.Success) {
|
||||||
|
// 2. 테마 변경 적용 (Context 업데이트)
|
||||||
|
if (settings.Theme && settings.Theme !== theme) {
|
||||||
|
setTheme(settings.Theme as any);
|
||||||
|
}
|
||||||
|
alert('설정이 저장되었습니다.');
|
||||||
|
onClose();
|
||||||
|
} else {
|
||||||
|
alert('저장 실패: ' + (response.Message || '알 수 없는 오류'));
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('설정 저장 실패:', error);
|
||||||
|
alert('저장 중 오류가 발생했습니다.');
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleThemeSelect = (selectedTheme: string) => {
|
||||||
|
if (settings) {
|
||||||
|
setSettings({ ...settings, Theme: selectedTheme });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCheckboxChange = (field: keyof SettingsModel, checked: boolean) => {
|
||||||
|
if (settings) {
|
||||||
|
setSettings({ ...settings, [field]: checked });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
return createPortal(
|
||||||
|
<div className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||||
|
<div className="glass-effect rounded-2xl w-full max-w-2xl animate-slide-up mx-4 overflow-hidden flex flex-col max-h-[80vh]">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between bg-white/5">
|
||||||
|
<h2 className="text-xl font-bold text-white flex items-center">
|
||||||
|
<SettingsIcon className="w-5 h-5 mr-2 text-primary-400" />
|
||||||
|
환경 설정
|
||||||
|
</h2>
|
||||||
|
<button onClick={onClose} className="text-white/70 hover:text-white transition-colors">
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
{/* Sidebar Tabs */}
|
||||||
|
<div className="w-48 border-r border-white/10 bg-black/20 p-4 space-y-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('general')}
|
||||||
|
className={`w-full text-left px-4 py-3 rounded-lg flex items-center space-x-3 transition-colors ${activeTab === 'general' ? 'bg-primary-500/20 text-primary-300' : 'text-white/70 hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Monitor className="w-4 h-4" />
|
||||||
|
<span>일반</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('theme')}
|
||||||
|
className={`w-full text-left px-4 py-3 rounded-lg flex items-center space-x-3 transition-colors ${activeTab === 'theme' ? 'bg-primary-500/20 text-primary-300' : 'text-white/70 hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Palette className="w-4 h-4" />
|
||||||
|
<span>테마</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content Area */}
|
||||||
|
<div className="flex-1 p-6 overflow-y-auto custom-scrollbar bg-black/10">
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center justify-center h-full text-white/50">
|
||||||
|
<span className="animate-spin mr-2">⏳</span> 설정을 불러오는 중...
|
||||||
|
</div>
|
||||||
|
) : !settings ? (
|
||||||
|
<div className="text-center text-red-400">설정 데이터를 불러오지 못했습니다.</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{activeTab === 'general' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold text-white/90 mb-4">기본 설정</h3>
|
||||||
|
|
||||||
|
<label className="flex items-center space-x-3 cursor-pointer group">
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="sr-only peer"
|
||||||
|
checked={settings.FullScreen}
|
||||||
|
onChange={(e) => handleCheckboxChange('FullScreen', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<div className="w-10 h-6 bg-white/20 rounded-full peer peer-checked:bg-primary-500 peer-focus:ring-2 peer-focus:ring-primary-500/50 transition-all"></div>
|
||||||
|
<div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full transition-all peer-checked:translate-x-4"></div>
|
||||||
|
</div>
|
||||||
|
<span className="text-white/80 group-hover:text-white transition-colors">전체 화면으로 시작</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center space-x-3 cursor-pointer group">
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="sr-only peer"
|
||||||
|
checked={settings.DupWindow}
|
||||||
|
onChange={(e) => handleCheckboxChange('DupWindow', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<div className="w-10 h-6 bg-white/20 rounded-full peer peer-checked:bg-primary-500 peer-focus:ring-2 peer-focus:ring-primary-500/50 transition-all"></div>
|
||||||
|
<div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full transition-all peer-checked:translate-x-4"></div>
|
||||||
|
</div>
|
||||||
|
<span className="text-white/80 group-hover:text-white transition-colors">창 중복 실행 허용</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center space-x-3 cursor-pointer group">
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="sr-only peer"
|
||||||
|
checked={settings.Disable8HourOver}
|
||||||
|
onChange={(e) => handleCheckboxChange('Disable8HourOver', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<div className="w-10 h-6 bg-white/20 rounded-full peer peer-checked:bg-primary-500 peer-focus:ring-2 peer-focus:ring-primary-500/50 transition-all"></div>
|
||||||
|
<div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full transition-all peer-checked:translate-x-4"></div>
|
||||||
|
</div>
|
||||||
|
<span className="text-white/80 group-hover:text-white transition-colors">8시간 초과 근무 경고 비활성화</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'theme' && (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h3 className="text-lg font-semibold text-white/90">테마 선택</h3>
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
{/* Dark Theme */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleThemeSelect('dark')}
|
||||||
|
className={`relative group p-4 rounded-xl border-2 transition-all duration-300 text-left ${settings.Theme === 'dark' || (!settings.Theme && theme === 'dark')
|
||||||
|
? 'border-primary-500 bg-primary-500/10'
|
||||||
|
: 'border-white/10 hover:border-white/30 hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-white font-medium">Dark (기본)</span>
|
||||||
|
{settings.Theme === 'dark' && <div className="w-2 h-2 rounded-full bg-primary-500"></div>}
|
||||||
|
</div>
|
||||||
|
<div className="h-16 rounded-lg bg-[#1e1e2e] border border-white/10 flex overflow-hidden">
|
||||||
|
<div className="w-1/4 bg-[#181825] border-r border-white/5"></div>
|
||||||
|
<div className="flex-1 p-2">
|
||||||
|
<div className="h-2 w-2/3 bg-white/10 rounded mb-2"></div>
|
||||||
|
<div className="h-8 w-full bg-[#1e1e2e] border border-primary-500/30 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* PSH_PINK Theme */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleThemeSelect('PSH_PINK')}
|
||||||
|
className={`relative group p-4 rounded-xl border-2 transition-all duration-300 text-left ${settings.Theme === 'PSH_PINK'
|
||||||
|
? 'border-[#FF00FF] bg-[#FF00FF]/10'
|
||||||
|
: 'border-white/10 hover:border-white/30 hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-white font-medium">PSH Pink</span>
|
||||||
|
{settings.Theme === 'PSH_PINK' && <div className="w-2 h-2 rounded-full bg-[#FF00FF]"></div>}
|
||||||
|
</div>
|
||||||
|
<div className="h-16 rounded-lg bg-[#2D0A1E] border border-[#FF66FF]/30 flex overflow-hidden">
|
||||||
|
<div className="w-1/4 bg-[#1A0512] border-r border-[#FF66FF]/20"></div>
|
||||||
|
<div className="flex-1 p-2">
|
||||||
|
<div className="h-2 w-2/3 bg-[#FF66FF]/20 rounded mb-2"></div>
|
||||||
|
<div className="h-8 w-full bg-[#3D0D28] border border-[#FF00FF] rounded relative overflow-hidden">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-[#FF00FF]/10 to-transparent"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* JW_SKY Theme */}
|
||||||
|
<button
|
||||||
|
onClick={() => handleThemeSelect('JW_SKY')}
|
||||||
|
className={`relative group p-4 rounded-xl border-2 transition-all duration-300 text-left ${settings.Theme === 'JW_SKY'
|
||||||
|
? 'border-sky-400 bg-sky-400/10'
|
||||||
|
: 'border-white/10 hover:border-white/30 hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-white font-medium">JW Sky</span>
|
||||||
|
{settings.Theme === 'JW_SKY' && <div className="w-2 h-2 rounded-full bg-sky-400"></div>}
|
||||||
|
</div>
|
||||||
|
<div className="h-16 rounded-lg bg-[#0F172A] border border-sky-400/30 flex overflow-hidden">
|
||||||
|
<div className="w-1/4 bg-[#020617] border-r border-sky-400/20"></div>
|
||||||
|
<div className="flex-1 p-2">
|
||||||
|
<div className="h-2 w-2/3 bg-sky-400/20 rounded mb-2"></div>
|
||||||
|
<div className="h-8 w-full bg-[#1E293B] border border-sky-400 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer */}
|
||||||
|
<div className="px-6 py-4 border-t border-white/10 bg-white/5 flex justify-end space-x-3">
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 rounded-lg text-white/70 hover:text-white hover:bg-white/5 transition-colors"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={saving || loading}
|
||||||
|
className="px-6 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white shadow-lg shadow-primary-500/30 transition-all flex items-center disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<span className="animate-spin mr-2">⏳</span>
|
||||||
|
) : (
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
)}
|
||||||
|
저장
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>,
|
||||||
|
document.body
|
||||||
|
);
|
||||||
|
}
|
||||||
65
Project/frontend/src/components/settings/SettingsGeneral.tsx
Normal file
65
Project/frontend/src/components/settings/SettingsGeneral.tsx
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
import { SettingsModel } from '@/types';
|
||||||
|
|
||||||
|
interface SettingsGeneralProps {
|
||||||
|
settings: SettingsModel;
|
||||||
|
onChange: (field: keyof SettingsModel, checked: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsGeneral({ settings, onChange }: SettingsGeneralProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="bg-white/5 p-4 rounded-lg overflow-auto max-h-96">
|
||||||
|
<h3 className="text-white/90 font-medium mb-2">반환된 설정 데이터 (Debug)</h3>
|
||||||
|
<pre className="text-xs text-white/70 whitespace-pre-wrap font-mono">
|
||||||
|
{JSON.stringify(settings, null, 2)}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="text-lg font-semibold text-white/90 mb-4">기본 설정</h3>
|
||||||
|
|
||||||
|
<label className="flex items-center space-x-3 cursor-pointer group">
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="sr-only peer"
|
||||||
|
checked={settings.FullScreen}
|
||||||
|
onChange={(e) => onChange('FullScreen', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<div className="w-10 h-6 bg-white/20 rounded-full peer peer-checked:bg-primary-500 peer-focus:ring-2 peer-focus:ring-primary-500/50 transition-all"></div>
|
||||||
|
<div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full transition-all peer-checked:translate-x-4"></div>
|
||||||
|
</div>
|
||||||
|
<span className="text-white/80 group-hover:text-white transition-colors">전체 화면으로 시작</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center space-x-3 cursor-pointer group">
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="sr-only peer"
|
||||||
|
checked={settings.DupWindow}
|
||||||
|
onChange={(e) => onChange('DupWindow', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<div className="w-10 h-6 bg-white/20 rounded-full peer peer-checked:bg-primary-500 peer-focus:ring-2 peer-focus:ring-primary-500/50 transition-all"></div>
|
||||||
|
<div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full transition-all peer-checked:translate-x-4"></div>
|
||||||
|
</div>
|
||||||
|
<span className="text-white/80 group-hover:text-white transition-colors">창 중복 실행 허용</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label className="flex items-center space-x-3 cursor-pointer group">
|
||||||
|
<div className="relative">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="sr-only peer"
|
||||||
|
checked={settings.Disable8HourOver}
|
||||||
|
onChange={(e) => onChange('Disable8HourOver', e.target.checked)}
|
||||||
|
/>
|
||||||
|
<div className="w-10 h-6 bg-white/20 rounded-full peer peer-checked:bg-primary-500 peer-focus:ring-2 peer-focus:ring-primary-500/50 transition-all"></div>
|
||||||
|
<div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full transition-all peer-checked:translate-x-4"></div>
|
||||||
|
</div>
|
||||||
|
<span className="text-white/80 group-hover:text-white transition-colors">8시간 초과 근무 경고 비활성화</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
82
Project/frontend/src/components/settings/SettingsTheme.tsx
Normal file
82
Project/frontend/src/components/settings/SettingsTheme.tsx
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
import { SettingsModel } from '@/types';
|
||||||
|
import { Theme } from '@/context/ThemeContext';
|
||||||
|
|
||||||
|
interface SettingsThemeProps {
|
||||||
|
settings: SettingsModel;
|
||||||
|
currentTheme: Theme;
|
||||||
|
onSelect: (theme: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SettingsTheme({ settings, currentTheme, onSelect }: SettingsThemeProps) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h3 className="text-lg font-semibold text-white/90">테마 선택</h3>
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
{/* Dark Theme */}
|
||||||
|
<button
|
||||||
|
onClick={() => onSelect('dark')}
|
||||||
|
className={`relative group p-4 rounded-xl border-2 transition-all duration-300 text-left ${settings.Theme === 'dark' || (!settings.Theme && currentTheme === 'dark')
|
||||||
|
? 'border-primary-500 bg-primary-500/10'
|
||||||
|
: 'border-white/10 hover:border-white/30 hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-white font-medium">Dark (기본)</span>
|
||||||
|
{(settings.Theme === 'dark') && <div className="w-2 h-2 rounded-full bg-primary-500"></div>}
|
||||||
|
</div>
|
||||||
|
<div className="h-16 rounded-lg bg-[#1e1e2e] border border-white/10 flex overflow-hidden">
|
||||||
|
<div className="w-1/4 bg-[#181825] border-r border-white/5"></div>
|
||||||
|
<div className="flex-1 p-2">
|
||||||
|
<div className="h-2 w-2/3 bg-white/10 rounded mb-2"></div>
|
||||||
|
<div className="h-8 w-full bg-[#1e1e2e] border border-primary-500/30 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* PSH_PINK Theme */}
|
||||||
|
<button
|
||||||
|
onClick={() => onSelect('PSH_PINK')}
|
||||||
|
className={`relative group p-4 rounded-xl border-2 transition-all duration-300 text-left ${settings.Theme === 'PSH_PINK'
|
||||||
|
? 'border-[#FF00FF] bg-[#FF00FF]/10'
|
||||||
|
: 'border-white/10 hover:border-white/30 hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-white font-medium">PSH Pink</span>
|
||||||
|
{settings.Theme === 'PSH_PINK' && <div className="w-2 h-2 rounded-full bg-[#FF00FF]"></div>}
|
||||||
|
</div>
|
||||||
|
<div className="h-16 rounded-lg bg-[#2D0A1E] border border-[#FF66FF]/30 flex overflow-hidden">
|
||||||
|
<div className="w-1/4 bg-[#1A0512] border-r border-[#FF66FF]/20"></div>
|
||||||
|
<div className="flex-1 p-2">
|
||||||
|
<div className="h-2 w-2/3 bg-[#FF66FF]/20 rounded mb-2"></div>
|
||||||
|
<div className="h-8 w-full bg-[#3D0D28] border border-[#FF00FF] rounded relative overflow-hidden">
|
||||||
|
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-[#FF00FF]/10 to-transparent"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* JW_SKY Theme */}
|
||||||
|
<button
|
||||||
|
onClick={() => onSelect('JW_SKY')}
|
||||||
|
className={`relative group p-4 rounded-xl border-2 transition-all duration-300 text-left ${settings.Theme === 'JW_SKY'
|
||||||
|
? 'border-sky-400 bg-sky-400/10'
|
||||||
|
: 'border-white/10 hover:border-white/30 hover:bg-white/5'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<span className="text-white font-medium">JW Sky</span>
|
||||||
|
{settings.Theme === 'JW_SKY' && <div className="w-2 h-2 rounded-full bg-sky-400"></div>}
|
||||||
|
</div>
|
||||||
|
<div className="h-16 rounded-lg bg-[#0F172A] border border-sky-400/30 flex overflow-hidden">
|
||||||
|
<div className="w-1/4 bg-[#020617] border-r border-sky-400/20"></div>
|
||||||
|
<div className="flex-1 p-2">
|
||||||
|
<div className="h-2 w-2/3 bg-sky-400/20 rounded mb-2"></div>
|
||||||
|
<div className="h-8 w-full bg-[#1E293B] border border-sky-400 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,8 +1,9 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { X, Save, Key, User, Mail, Building2, Briefcase, Calendar, FileText } from 'lucide-react';
|
import { X, Save, Key, User, Mail, Building2, Briefcase, Calendar, FileText, Palette } from 'lucide-react';
|
||||||
import { clsx } from 'clsx';
|
import { clsx } from 'clsx';
|
||||||
import { comms } from '@/communication';
|
import { comms } from '@/communication';
|
||||||
import { UserInfoDetail } from '@/types';
|
import { UserInfoDetail } from '@/types';
|
||||||
|
import { useTheme, Theme } from '@/context/ThemeContext';
|
||||||
|
|
||||||
interface UserInfoDialogProps {
|
interface UserInfoDialogProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -125,6 +126,7 @@ export function UserInfoDialog({ isOpen, onClose, userId, onSave }: UserInfoDial
|
|||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [showPasswordDialog, setShowPasswordDialog] = useState(false);
|
const [showPasswordDialog, setShowPasswordDialog] = useState(false);
|
||||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||||
|
const { theme, setTheme } = useTheme();
|
||||||
|
|
||||||
const [formData, setFormData] = useState<UserInfoDetail>({
|
const [formData, setFormData] = useState<UserInfoDetail>({
|
||||||
Id: '',
|
Id: '',
|
||||||
@@ -221,6 +223,10 @@ export function UserInfoDialog({ isOpen, onClose, userId, onSave }: UserInfoDial
|
|||||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleThemeChange = (newTheme: Theme) => {
|
||||||
|
setTheme(newTheme);
|
||||||
|
};
|
||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -249,6 +255,52 @@ export function UserInfoDialog({ isOpen, onClose, userId, onSave }: UserInfoDial
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
{/* 테마 설정 섹션 */}
|
||||||
|
<div className="bg-white/5 rounded-lg p-4 mb-6">
|
||||||
|
<h3 className="text-white font-medium mb-3 flex items-center gap-2">
|
||||||
|
<Palette className="w-4 h-4 text-purple-400" />
|
||||||
|
테마 설정
|
||||||
|
</h3>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<button
|
||||||
|
onClick={() => handleThemeChange('dark')}
|
||||||
|
className={clsx(
|
||||||
|
'px-4 py-3 rounded-lg border-2 transition-all flex flex-col items-center gap-2',
|
||||||
|
theme === 'dark'
|
||||||
|
? 'border-blue-500 bg-blue-500/20 text-white'
|
||||||
|
: 'border-white/10 bg-white/5 text-white/50 hover:bg-white/10 hover:border-white/30'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="w-full h-2 rounded-full bg-gradient-to-r from-blue-600 to-purple-600"></div>
|
||||||
|
<span className="text-sm font-medium">기본 (Dark)</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleThemeChange('PSH_PINK')}
|
||||||
|
className={clsx(
|
||||||
|
'px-4 py-3 rounded-lg border-2 transition-all flex flex-col items-center gap-2',
|
||||||
|
theme === 'PSH_PINK'
|
||||||
|
? 'border-pink-500 bg-pink-500/20 text-white'
|
||||||
|
: 'border-white/10 bg-white/5 text-white/50 hover:bg-white/10 hover:border-white/30'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="w-full h-2 rounded-full bg-gradient-to-r from-pink-500 to-rose-500"></div>
|
||||||
|
<span className="text-sm font-medium">발랄한 핑크</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => handleThemeChange('JW_SKY')}
|
||||||
|
className={clsx(
|
||||||
|
'px-4 py-3 rounded-lg border-2 transition-all flex flex-col items-center gap-2',
|
||||||
|
theme === 'JW_SKY'
|
||||||
|
? 'border-sky-500 bg-sky-500/20 text-white'
|
||||||
|
: 'border-white/10 bg-white/5 text-white/50 hover:bg-white/10 hover:border-white/30'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="w-full h-2 rounded-full bg-gradient-to-r from-sky-400 to-blue-500"></div>
|
||||||
|
<span className="text-sm font-medium">시원한 하늘</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 기본 정보 */}
|
{/* 기본 정보 */}
|
||||||
<div className="grid grid-cols-2 gap-4">
|
<div className="grid grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
74
Project/frontend/src/context/ThemeContext.tsx
Normal file
74
Project/frontend/src/context/ThemeContext.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||||
|
import { comms } from '@/communication';
|
||||||
|
|
||||||
|
export type Theme = 'dark' | 'PSH_PINK' | 'JW_SKY';
|
||||||
|
|
||||||
|
interface ThemeContextType {
|
||||||
|
theme: Theme;
|
||||||
|
setTheme: (theme: Theme) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
|
||||||
|
|
||||||
|
const THEME_KEY = 'app-theme';
|
||||||
|
|
||||||
|
export function ThemeProvider({ children }: { children: ReactNode }) {
|
||||||
|
const [theme, setTheme] = useState<Theme>(() => {
|
||||||
|
const savedTheme = localStorage.getItem(THEME_KEY);
|
||||||
|
return (savedTheme as Theme) || 'dark';
|
||||||
|
});
|
||||||
|
|
||||||
|
// 백엔드 설정 로드
|
||||||
|
useEffect(() => {
|
||||||
|
const loadSettings = async () => {
|
||||||
|
try {
|
||||||
|
const response = await comms.getSettings();
|
||||||
|
if (response.Success && response.Data?.Theme) {
|
||||||
|
const savedTheme = response.Data.Theme as Theme;
|
||||||
|
if (['dark', 'PSH_PINK', 'JW_SKY'].includes(savedTheme)) {
|
||||||
|
setTheme(savedTheme);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load theme from settings:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
loadSettings();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const root = window.document.documentElement;
|
||||||
|
|
||||||
|
// 이전 테마 클래스 제거
|
||||||
|
root.classList.remove('dark', 'theme-pink', 'theme-sky');
|
||||||
|
|
||||||
|
// 새 테마 클래스 추가
|
||||||
|
switch (theme) {
|
||||||
|
case 'dark':
|
||||||
|
root.classList.add('dark');
|
||||||
|
break;
|
||||||
|
case 'PSH_PINK':
|
||||||
|
root.classList.add('theme-pink');
|
||||||
|
break;
|
||||||
|
case 'JW_SKY':
|
||||||
|
root.classList.add('theme-sky');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
localStorage.setItem(THEME_KEY, theme);
|
||||||
|
}, [theme]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ theme, setTheme }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useTheme() {
|
||||||
|
const context = useContext(ThemeContext);
|
||||||
|
if (context === undefined) {
|
||||||
|
throw new Error('useTheme must be used within a ThemeProvider');
|
||||||
|
}
|
||||||
|
return context;
|
||||||
|
}
|
||||||
@@ -2,21 +2,106 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
|
@layer base {
|
||||||
|
:root {
|
||||||
|
/* Default Dark Theme (Purple/Blue base) */
|
||||||
|
--bg-main: #111827; /* gray-900 like */
|
||||||
|
--bg-paper: #1f2937; /* gray-800 like */
|
||||||
|
--bg-gradient-start: #1e3a8a; /* blue-900 */
|
||||||
|
--bg-gradient-mid: #581c87; /* purple-900 */
|
||||||
|
--bg-gradient-end: #312e81; /* indigo-900 */
|
||||||
|
|
||||||
|
--text-primary: #f9fafb; /* gray-50 */
|
||||||
|
--text-secondary: #9ca3af; /* gray-400 */
|
||||||
|
--text-muted: #6b7280; /* gray-500 */
|
||||||
|
|
||||||
|
--border-color: rgba(255, 255, 255, 0.1);
|
||||||
|
--border-base: #374151; /* gray-700 */
|
||||||
|
|
||||||
|
--color-primary: 59, 130, 246; /* blue-500 (RGB) */
|
||||||
|
--color-primary-light: 96, 165, 250; /* blue-400 */
|
||||||
|
--color-primary-dark: 37, 99, 235; /* blue-600 */
|
||||||
|
|
||||||
|
--color-accent: 139, 92, 246; /* violet-500 */
|
||||||
|
|
||||||
|
--glass-bg: rgba(255, 255, 255, 0.25);
|
||||||
|
--glass-border: rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-pink {
|
||||||
|
/* "PSH_PINK" Theme - Magenta & Pink */
|
||||||
|
/* Background: Pinkish base */
|
||||||
|
--bg-main: #501025; /* Deep pink/wine */
|
||||||
|
--bg-paper: #1f0510; /* Very dark pink */
|
||||||
|
--bg-gradient-start: #be185d; /* pink-700 */
|
||||||
|
--bg-gradient-mid: #9d174d; /* pink-800 */
|
||||||
|
--bg-gradient-end: #831843; /* pink-900 */
|
||||||
|
|
||||||
|
--text-primary: #fce7f3; /* pink-100 */
|
||||||
|
--text-secondary: #fbcfe8; /* pink-200 */
|
||||||
|
--text-muted: #f9a8d4; /* pink-300 */
|
||||||
|
|
||||||
|
--border-color: rgba(255, 192, 203, 0.4); /* Pink border */
|
||||||
|
--border-base: #9d174d; /* pink-800 */
|
||||||
|
|
||||||
|
/* Primary: Magenta (#FF00FF -> 255, 0, 255) */
|
||||||
|
--color-primary: 255, 0, 255; /* Magenta */
|
||||||
|
--color-primary-light: 255, 105, 180; /* HotPink */
|
||||||
|
--color-primary-dark: 199, 21, 133; /* MediumVioletRed */
|
||||||
|
|
||||||
|
/* Accent: Pink (#FFC0CB -> 255, 192, 203) */
|
||||||
|
--color-accent: 255, 192, 203; /* Pink */
|
||||||
|
|
||||||
|
--glass-bg: rgba(255, 0, 255, 0.1);
|
||||||
|
--glass-border: rgba(255, 192, 203, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-sky {
|
||||||
|
/* "JW_SKY" Theme - Sky Blue & White/Blue */
|
||||||
|
--bg-main: #0c4a6e; /* sky-900 */
|
||||||
|
--bg-paper: #082f49; /* sky-950 */
|
||||||
|
--bg-gradient-start: #38bdf8; /* sky-400 */
|
||||||
|
--bg-gradient-mid: #0ea5e9; /* sky-500 */
|
||||||
|
--bg-gradient-end: #0284c7; /* sky-600 */
|
||||||
|
|
||||||
|
--text-primary: #f0f9ff; /* sky-50 */
|
||||||
|
--text-secondary: #bae6fd; /* sky-200 */
|
||||||
|
--text-muted: #7dd3fc; /* sky-300 */
|
||||||
|
|
||||||
|
--border-color: rgba(186, 230, 253, 0.3); /* sky-200 / 0.3 */
|
||||||
|
--border-base: #0369a1; /* sky-700 */
|
||||||
|
|
||||||
|
--color-primary: 14, 165, 233; /* sky-500 */
|
||||||
|
--color-primary-light: 56, 189, 248; /* sky-400 */
|
||||||
|
--color-primary-dark: 2, 132, 199; /* sky-600 */
|
||||||
|
|
||||||
|
--color-accent: 255, 255, 255; /* White accent */
|
||||||
|
|
||||||
|
--glass-bg: rgba(255, 255, 255, 0.2);
|
||||||
|
--glass-border: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--bg-main);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.glass-effect {
|
.glass-effect {
|
||||||
background: rgba(255, 255, 255, 0.25);
|
background: var(--glass-bg);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.18);
|
border: 1px solid var(--glass-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 드롭다운 메뉴용 불투명 배경 */
|
/* 드롭다운 메뉴용 불투명 배경 */
|
||||||
.glass-effect-solid {
|
.glass-effect-solid {
|
||||||
background: rgba(30, 41, 59, 0.95);
|
background: var(--bg-paper);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
border: 1px solid var(--border-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gradient-bg {
|
.gradient-bg {
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, var(--bg-gradient-start) 0%, var(--bg-gradient-end) 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-hover {
|
.card-hover {
|
||||||
@@ -49,14 +134,14 @@
|
|||||||
|
|
||||||
/* 드롭다운 스타일 */
|
/* 드롭다운 스타일 */
|
||||||
select option {
|
select option {
|
||||||
background-color: #1f2937;
|
background-color: var(--bg-paper);
|
||||||
color: white;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
select:focus option:checked {
|
select:focus option:checked {
|
||||||
background-color: #3b82f6;
|
background-color: rgb(var(--color-primary));
|
||||||
}
|
}
|
||||||
|
|
||||||
select option:hover {
|
select option:hover {
|
||||||
background-color: #374151;
|
background-color: var(--bg-gradient-mid);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { FileText, Search, RefreshCw, Calendar, Edit3, User, Plus } from 'lucide-react';
|
import {
|
||||||
|
FileText,
|
||||||
|
Search,
|
||||||
|
RefreshCw,
|
||||||
|
Calendar,
|
||||||
|
Edit3,
|
||||||
|
User,
|
||||||
|
Plus,
|
||||||
|
MessageSquare,
|
||||||
|
ChevronRight,
|
||||||
|
X,
|
||||||
|
} from 'lucide-react';
|
||||||
import { comms } from '@/communication';
|
import { comms } from '@/communication';
|
||||||
import { BoardItem } from '@/types';
|
import { BoardItem } from '@/types';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
|
||||||
interface BoardListProps {
|
interface BoardListProps {
|
||||||
bidx: number;
|
bidx: number;
|
||||||
@@ -11,9 +23,9 @@ interface BoardListProps {
|
|||||||
categories?: { value: string; label: string; color: string }[];
|
categories?: { value: string; label: string; color: string }[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function BoardList({
|
export function BoardList({
|
||||||
bidx,
|
bidx,
|
||||||
title,
|
title,
|
||||||
icon = <FileText className="w-5 h-5" />,
|
icon = <FileText className="w-5 h-5" />,
|
||||||
defaultCategory = 'PATCH',
|
defaultCategory = 'PATCH',
|
||||||
categories = [
|
categories = [
|
||||||
@@ -192,7 +204,7 @@ export function BoardList({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const isNew = editFormData.idx === 0;
|
const isNew = editFormData.idx === 0;
|
||||||
|
|
||||||
if (isNew) {
|
if (isNew) {
|
||||||
// 신규 등록
|
// 신규 등록
|
||||||
const response = await comms.addBoard(
|
const response = await comms.addBoard(
|
||||||
@@ -202,7 +214,7 @@ export function BoardList({
|
|||||||
editFormData.title || '',
|
editFormData.title || '',
|
||||||
editFormData.contents || ''
|
editFormData.contents || ''
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.Success) {
|
if (response.Success) {
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
setEditFormData(null);
|
setEditFormData(null);
|
||||||
@@ -219,7 +231,7 @@ export function BoardList({
|
|||||||
editFormData.title || '',
|
editFormData.title || '',
|
||||||
editFormData.contents || ''
|
editFormData.contents || ''
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.Success) {
|
if (response.Success) {
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
setEditFormData(null);
|
setEditFormData(null);
|
||||||
@@ -241,7 +253,7 @@ export function BoardList({
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await comms.deleteBoard(editFormData.idx);
|
const response = await comms.deleteBoard(editFormData.idx);
|
||||||
|
|
||||||
if (response.Success) {
|
if (response.Success) {
|
||||||
setShowEditModal(false);
|
setShowEditModal(false);
|
||||||
setEditFormData(null);
|
setEditFormData(null);
|
||||||
@@ -284,7 +296,7 @@ export function BoardList({
|
|||||||
const getCategoryColor = (cate: string) => {
|
const getCategoryColor = (cate: string) => {
|
||||||
const category = categories.find(c => c.value.toUpperCase() === cate.toUpperCase());
|
const category = categories.find(c => c.value.toUpperCase() === cate.toUpperCase());
|
||||||
if (!category) return 'bg-gray-500/20 text-gray-400';
|
if (!category) return 'bg-gray-500/20 text-gray-400';
|
||||||
|
|
||||||
switch (category.color) {
|
switch (category.color) {
|
||||||
case 'lime': return 'bg-lime-500/20 text-lime-400';
|
case 'lime': return 'bg-lime-500/20 text-lime-400';
|
||||||
case 'red': return 'bg-red-500/20 text-red-400';
|
case 'red': return 'bg-red-500/20 text-red-400';
|
||||||
@@ -296,133 +308,180 @@ export function BoardList({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-fade-in">
|
<div className="space-y-6 animate-fade-in">
|
||||||
{/* 검색 필터 */}
|
|
||||||
<div className="glass-effect rounded-2xl p-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex items-center gap-2 flex-1">
|
|
||||||
<label className="text-white/70 text-sm font-medium whitespace-nowrap">검색어</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={searchKey}
|
|
||||||
onChange={(e) => setSearchKey(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
|
||||||
placeholder="제목, 내용, 작성자 등"
|
|
||||||
className="flex-1 h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleSearch}
|
|
||||||
disabled={loading}
|
|
||||||
className="h-10 bg-primary-500 hover:bg-primary-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Search className="w-4 h-4 mr-2" />
|
|
||||||
)}
|
|
||||||
조회
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setEditFormData({
|
|
||||||
idx: 0,
|
|
||||||
bidx: bidx,
|
|
||||||
gcode: '',
|
|
||||||
header: '',
|
|
||||||
cate: defaultCategory,
|
|
||||||
title: '',
|
|
||||||
contents: '',
|
|
||||||
file: '',
|
|
||||||
guid: '',
|
|
||||||
url: '',
|
|
||||||
wuid: '',
|
|
||||||
wuid_name: '',
|
|
||||||
wdate: null,
|
|
||||||
project: '',
|
|
||||||
pidx: 0,
|
|
||||||
close: false,
|
|
||||||
remark: ''
|
|
||||||
});
|
|
||||||
setShowEditModal(true);
|
|
||||||
}}
|
|
||||||
disabled={!(userLevel >= 9 || userId === '395552')}
|
|
||||||
className="h-10 bg-green-500 hover:bg-green-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
추가
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 게시판 목록 */}
|
{/* 게시판 목록 */}
|
||||||
<div className="glass-effect rounded-2xl overflow-hidden">
|
<div className="glass-effect rounded-3xl overflow-hidden shadow-2xl border border-white/10">
|
||||||
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
|
<div className="px-6 py-4 border-b border-white/10 flex flex-col md:flex-row items-center justify-between gap-4">
|
||||||
<h3 className="text-lg font-semibold text-white flex items-center">
|
<div className="flex items-center gap-3">
|
||||||
{icon}
|
<div className="p-2 bg-primary-500/20 rounded-lg">
|
||||||
<span className="ml-2">{title}</span>
|
{icon}
|
||||||
</h3>
|
</div>
|
||||||
<span className="text-white/60 text-sm">{boardList.length}건</span>
|
<h3 className="text-lg font-bold text-white tracking-tight">{title}</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* 검색창 */}
|
||||||
|
<div className="relative group w-48 md:w-64">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-white/40 group-focus-within:text-primary-400 transition-colors" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchKey}
|
||||||
|
onChange={(e) => setSearchKey(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
|
placeholder="검색..."
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-xl pl-9 pr-8 py-1.5 text-xs text-white placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all backdrop-blur-sm"
|
||||||
|
/>
|
||||||
|
{searchKey && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSearchKey('');
|
||||||
|
loadData();
|
||||||
|
}}
|
||||||
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-white/20 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 개수 */}
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-3 py-1.5 rounded-xl border border-white/10 h-[38px]">
|
||||||
|
<span className="text-primary-400 font-bold text-sm">{boardList.length}</span>
|
||||||
|
<span className="text-white/40 text-[10px] uppercase">건</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 새로고침 */}
|
||||||
|
<button
|
||||||
|
onClick={loadData}
|
||||||
|
disabled={loading}
|
||||||
|
className="p-2 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl text-white/70 hover:text-white transition-all disabled:opacity-50"
|
||||||
|
title="새로고침"
|
||||||
|
>
|
||||||
|
<RefreshCw className={clsx("w-4 h-4", loading && "animate-spin")} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 추가 버튼 */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditFormData({
|
||||||
|
idx: 0,
|
||||||
|
bidx: bidx,
|
||||||
|
gcode: '',
|
||||||
|
header: '',
|
||||||
|
cate: defaultCategory,
|
||||||
|
title: '',
|
||||||
|
contents: '',
|
||||||
|
file: '',
|
||||||
|
guid: '',
|
||||||
|
url: '',
|
||||||
|
wuid: '',
|
||||||
|
wuid_name: '',
|
||||||
|
wdate: null,
|
||||||
|
project: '',
|
||||||
|
pidx: 0,
|
||||||
|
close: false,
|
||||||
|
remark: ''
|
||||||
|
});
|
||||||
|
setShowEditModal(true);
|
||||||
|
}}
|
||||||
|
disabled={!(userLevel >= 9 || userId === '395552')}
|
||||||
|
className="p-2 bg-success-500 hover:bg-success-600 border border-white/20 rounded-xl text-white transition-all shadow-lg shadow-success-500/20 active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||||
|
title="추가"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="divide-y divide-white/10 max-h-[calc(100vh-300px)] overflow-y-auto">
|
<div className="divide-y divide-white/5 max-h-[calc(100vh-280px)] overflow-y-auto custom-scrollbar">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="px-6 py-8 text-center">
|
<div className="px-6 py-12 text-center">
|
||||||
<div className="flex items-center justify-center">
|
<RefreshCw className="w-10 h-10 mx-auto mb-4 animate-spin text-primary-500/50" />
|
||||||
<RefreshCw className="w-5 h-5 mr-2 animate-spin text-white/50" />
|
<p className="text-white/50 font-medium text-sm">데이터를 실시간으로 동기화 중...</p>
|
||||||
<span className="text-white/50">데이터를 불러오는 중...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : boardList.length === 0 ? (
|
) : boardList.length === 0 ? (
|
||||||
<div className="px-6 py-8 text-center">
|
<div className="px-6 py-20 text-center">
|
||||||
<FileText className="w-12 h-12 mx-auto mb-3 text-white/30" />
|
<div className="relative inline-block mb-4">
|
||||||
<p className="text-white/50">조회된 데이터가 없습니다.</p>
|
<FileText className="w-16 h-16 mx-auto text-white/10" />
|
||||||
|
</div>
|
||||||
|
<p className="text-white/30 font-medium">조회된 내역이 없습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
boardList.map((item) => (
|
boardList.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.idx}
|
key={item.idx}
|
||||||
className="px-6 py-3 hover:bg-white/5 transition-colors cursor-pointer"
|
className="group px-6 py-4 hover:bg-white/[0.03] transition-all cursor-pointer relative"
|
||||||
onClick={() => handleRowClick(item)}
|
onClick={() => handleRowClick(item)}
|
||||||
style={{ paddingLeft: `${24 + (item.depth || 0) * 24}px` }}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex items-center gap-2 flex-shrink-0">
|
{/* 카테고리/상태 */}
|
||||||
|
<div className="flex items-center gap-2 shrink-0 min-w-[120px]">
|
||||||
{item.depth && item.depth > 0 && (
|
{item.depth && item.depth > 0 && (
|
||||||
<span className="text-white/40 text-xs mr-1">↳</span>
|
<div className="ml-2 mr-1">
|
||||||
|
<ChevronRight className="w-4 h-4 text-white/20" />
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
{item.cate && (
|
{item.cate && (
|
||||||
<span className={`px-2 py-0.5 text-xs rounded whitespace-nowrap ${getCategoryColor(item.cate)}`}>
|
<span className={clsx(
|
||||||
|
"px-2.5 py-1 text-xs font-bold rounded-md tracking-wider uppercase shadow-sm",
|
||||||
|
getCategoryColor(item.cate)
|
||||||
|
)}>
|
||||||
{item.cate}
|
{item.cate}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{item.header && (
|
{item.header && (
|
||||||
<span className="px-2 py-0.5 bg-primary-500/20 text-primary-400 text-xs rounded whitespace-nowrap">
|
<span className="px-2.5 py-1 bg-primary-500/10 text-primary-400 text-xs font-bold rounded-md border border-primary-500/20 whitespace-nowrap">
|
||||||
{item.header}
|
{item.header}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center text-white/60 text-xs flex-shrink-0 mr-3">
|
|
||||||
<Calendar className="w-3 h-3 mr-1" />
|
{/* 제목 섹션 */}
|
||||||
{formatDate(item.wdate)}
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h4 className="text-sm font-bold text-white group-hover:text-primary-400 transition-colors truncate">
|
||||||
|
{item.title || '(댓글)'}
|
||||||
|
</h4>
|
||||||
|
<div className="flex items-center gap-2 shrink-0">
|
||||||
|
{isNew(item.wdate) && (
|
||||||
|
<span className="px-1.5 py-0.5 bg-danger-500 text-white text-[10px] font-bold rounded-sm animate-pulse">
|
||||||
|
NEW
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{(item.reply_count ?? 0) > 0 && (
|
||||||
|
<div className="flex items-center gap-1 px-2 py-0.5 bg-white/5 rounded-full border border-white/10 group-hover:border-primary-500/30 transition-colors">
|
||||||
|
<MessageSquare className="w-3 h-3 text-primary-400" />
|
||||||
|
<span className="text-xs font-bold text-primary-400">{item.reply_count}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h4 className="text-white font-medium flex-1 min-w-0 flex items-center gap-2">
|
|
||||||
<span className="truncate">{item.title || '(댓글)'}</span>
|
{/* 정보 섹션 */}
|
||||||
{isNew(item.wdate) && (
|
<div className="flex items-center gap-8 shrink-0">
|
||||||
<span className="px-1.5 py-0.5 bg-yellow-500 text-white text-[10px] rounded font-bold animate-pulse flex-shrink-0">
|
<div className="flex items-center gap-6">
|
||||||
NEW
|
<div className="w-24 flex items-center gap-2 justify-end">
|
||||||
</span>
|
<div className="w-6 h-6 rounded-full bg-white/5 flex items-center justify-center border border-white/10">
|
||||||
)}
|
<User className="w-3.5 h-3.5 text-white/40" />
|
||||||
{(item.reply_count ?? 0) > 0 && (
|
</div>
|
||||||
<span className="px-1.5 py-0.5 bg-blue-500/20 text-blue-400 text-[10px] rounded flex-shrink-0">
|
<span className="text-sm font-medium text-white/70 truncate max-w-[80px]">
|
||||||
💬 {item.reply_count}
|
{item.wuid_name || item.wuid}
|
||||||
</span>
|
</span>
|
||||||
)}
|
</div>
|
||||||
</h4>
|
<div className="w-28 text-right flex items-center gap-2 justify-end">
|
||||||
<div className="flex items-center text-white/60 text-xs flex-shrink-0">
|
<Calendar className="w-3.5 h-3.5 text-white/30" />
|
||||||
<User className="w-3 h-3 mr-1" />
|
<span className="text-sm text-white/50 font-mono tracking-tight">
|
||||||
{item.wuid_name || item.wuid}
|
{formatDate(item.wdate)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 호버 시 나타나는 화살표 */}
|
||||||
|
<div className="absolute right-4 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-all transform translate-x-2 group-hover:translate-x-0 hidden md:block">
|
||||||
|
<ChevronRight className="w-5 h-5 text-primary-500/40" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -433,87 +492,100 @@ export function BoardList({
|
|||||||
|
|
||||||
{/* 상세 모달 */}
|
{/* 상세 모달 */}
|
||||||
{showModal && selectedItem && (
|
{showModal && selectedItem && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md animate-fade-in">
|
||||||
<div className="bg-gray-900 rounded-2xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden border border-white/10">
|
<div className="bg-[#1a1b2e]/90 rounded-3xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden border border-white/10 flex flex-col backdrop-blur-xl">
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
|
{/* 헤더 */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center justify-between px-8 py-6 border-b border-white/10 bg-white/5">
|
||||||
{selectedItem.header && (
|
<div className="flex items-center gap-3">
|
||||||
<span className="px-2 py-1 bg-primary-500/20 text-primary-400 text-sm rounded">
|
<div className="flex items-center gap-2">
|
||||||
{selectedItem.header}
|
{selectedItem.cate && (
|
||||||
</span>
|
<span className={clsx(
|
||||||
)}
|
"px-2.5 py-1 text-[10px] font-bold rounded-md tracking-wider uppercase",
|
||||||
{selectedItem.cate && (
|
getCategoryColor(selectedItem.cate)
|
||||||
<span className={`px-2 py-1 text-sm rounded ${getCategoryColor(selectedItem.cate)}`}>
|
)}>
|
||||||
{selectedItem.cate}
|
{selectedItem.cate}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<h2 className="text-xl font-bold text-white ml-2">{selectedItem.title}</h2>
|
{selectedItem.header && (
|
||||||
|
<span className="px-2.5 py-1 bg-primary-500/10 text-primary-400 text-[10px] font-bold rounded-md border border-primary-500/20 whitespace-nowrap">
|
||||||
|
{selectedItem.header}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold text-white ml-2 tracking-tight">{selectedItem.title}</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowModal(false)}
|
onClick={() => setShowModal(false)}
|
||||||
className="text-white/50 hover:text-white transition-colors"
|
className="p-2 hover:bg-white/10 rounded-full text-white/40 hover:text-white transition-all transform hover:rotate-90"
|
||||||
>
|
>
|
||||||
<span className="text-2xl">×</span>
|
<X className="w-6 h-6" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-6 py-4 border-b border-white/10 flex items-center gap-4 text-sm text-white/60">
|
{/* 정보바 */}
|
||||||
<div className="flex items-center">
|
<div className="px-8 py-3 border-b border-white/5 bg-white/[0.02] flex items-center gap-6 text-xs text-white/50 font-medium">
|
||||||
<User className="w-4 h-4 mr-1" />
|
<div className="flex items-center gap-2">
|
||||||
{selectedItem.wuid_name || selectedItem.wuid}
|
<div className="w-5 h-5 rounded-full bg-white/5 flex items-center justify-center border border-white/10">
|
||||||
|
<User className="w-3 h-3 text-white/40" />
|
||||||
|
</div>
|
||||||
|
<span className="text-white/70">{selectedItem.wuid_name || selectedItem.wuid}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center">
|
<div className="flex items-center gap-2 border-l border-white/10 pl-6">
|
||||||
<Calendar className="w-4 h-4 mr-1" />
|
<Calendar className="w-3.5 h-3.5" />
|
||||||
{formatDate(selectedItem.wdate)}
|
<span>{formatDate(selectedItem.wdate)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-y-auto max-h-[calc(90vh-400px)] p-6">
|
{/* 본문 */}
|
||||||
|
<div className="overflow-y-auto flex-1 p-8 space-y-8 custom-scrollbar">
|
||||||
{selectedItem.contents && selectedItem.contents.trim() && (
|
{selectedItem.contents && selectedItem.contents.trim() && (
|
||||||
<div className="prose prose-invert max-w-none mb-8">
|
<div className="prose prose-invert max-w-none">
|
||||||
<div className="text-white whitespace-pre-wrap">{selectedItem.contents}</div>
|
<div className="text-white/90 leading-relaxed whitespace-pre-wrap text-[15px] font-medium opacity-90">{selectedItem.contents}</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 답글 목록 */}
|
{/* 답글 목록 */}
|
||||||
{replyPosts.length > 0 && (
|
{replyPosts.length > 0 && (
|
||||||
<div className={selectedItem.contents && selectedItem.contents.trim() ? "border-t border-white/10 pt-6 mb-6" : "mb-6"}>
|
<div className="space-y-4">
|
||||||
<h3 className="text-lg font-semibold text-white mb-4">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
답글 {replyPosts.length}개
|
<div className="w-1 h-5 bg-success-500 rounded-full shadow-[0_0_10px_rgba(34,197,94,0.5)]"></div>
|
||||||
</h3>
|
<h3 className="text-sm font-bold text-white tracking-wide uppercase opacity-70">
|
||||||
|
답글 <span className="text-success-400">{replyPosts.length}</span>
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{replyPosts.map((replyPost) => (
|
{replyPosts.map((replyPost) => (
|
||||||
<div
|
<div
|
||||||
key={replyPost.idx}
|
key={replyPost.idx}
|
||||||
className="bg-white/5 hover:bg-white/10 rounded-lg p-4 cursor-pointer transition-colors"
|
className="group bg-white/5 hover:bg-white/10 border border-white/5 hover:border-white/10 rounded-2xl p-5 cursor-pointer transition-all active:scale-[0.98]"
|
||||||
onClick={() => handleRowClick(replyPost)}
|
onClick={() => handleRowClick(replyPost)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<h4 className="text-white font-medium flex items-center gap-2">
|
<h4 className="text-sm font-bold text-white flex items-center gap-3">
|
||||||
{replyPost.depth && replyPost.depth > 0 && (
|
{replyPost.depth && replyPost.depth > 0 && (
|
||||||
<span className="text-white/40 text-sm">↳</span>
|
<ChevronRight className="w-4 h-4 text-white/20" />
|
||||||
)}
|
)}
|
||||||
<span>{replyPost.title}</span>
|
<span className="group-hover:text-primary-400 transition-colors">{replyPost.title}</span>
|
||||||
{isNew(replyPost.wdate) && (
|
{isNew(replyPost.wdate) && (
|
||||||
<span className="px-1.5 py-0.5 bg-yellow-500 text-white text-[10px] rounded font-bold">
|
<span className="px-1.5 py-0.5 bg-danger-500 text-white text-[9px] font-bold rounded-sm">
|
||||||
NEW
|
NEW
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</h4>
|
</h4>
|
||||||
<div className="flex items-center gap-3 text-xs text-white/60">
|
<div className="flex items-center gap-4 text-[10px] text-white/40 font-bold uppercase tracking-wider">
|
||||||
<div className="flex items-center">
|
<span className="flex items-center gap-1.5">
|
||||||
<User className="w-3 h-3 mr-1" />
|
<User className="w-3 h-3" />
|
||||||
{replyPost.wuid_name || replyPost.wuid}
|
{replyPost.wuid_name || replyPost.wuid}
|
||||||
</div>
|
</span>
|
||||||
<div className="flex items-center">
|
<span className="flex items-center gap-1.5">
|
||||||
<Calendar className="w-3 h-3 mr-1" />
|
<Calendar className="w-3 h-3" />
|
||||||
{formatDate(replyPost.wdate)}
|
{formatDate(replyPost.wdate)}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{replyPost.contents && (
|
{replyPost.contents && (
|
||||||
<div className="text-white/60 text-sm line-clamp-2">
|
<div className="text-white/50 text-xs line-clamp-2 leading-relaxed italic">
|
||||||
{replyPost.contents}
|
{replyPost.contents}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@@ -523,39 +595,48 @@ export function BoardList({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 댓글 목록 */}
|
{/* 댓글 섹션 */}
|
||||||
<div className={(selectedItem.contents && selectedItem.contents.trim()) || replyPosts.length > 0 ? "border-t border-white/10 pt-6" : ""}>
|
<div className="space-y-6">
|
||||||
<h3 className="text-lg font-semibold text-white mb-4">
|
<div className="flex items-center gap-3 mb-2">
|
||||||
댓글 {replies.length}개
|
<div className="w-1 h-5 bg-primary-500 rounded-full shadow-[0_0_10px_rgba(59,130,246,0.5)]"></div>
|
||||||
</h3>
|
<h3 className="text-sm font-bold text-white tracking-wide uppercase opacity-70">
|
||||||
|
댓글 <span className="text-primary-400">{replies.length}</span>
|
||||||
<div className="space-y-4 mb-6">
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
{replies.map((reply) => (
|
{replies.map((reply) => (
|
||||||
<div key={reply.idx} className="bg-white/5 rounded-lg p-4">
|
<div key={reply.idx} className="bg-white/5 border border-white/5 rounded-2xl p-5 group hover:bg-white/[0.07] transition-all">
|
||||||
<div className="flex items-center gap-2 mb-2">
|
<div className="flex items-center justify-between mb-3">
|
||||||
<User className="w-4 h-4 text-white/60" />
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm text-white/80">{reply.wuid_name || reply.wuid}</span>
|
<div className="w-7 h-7 rounded-full bg-primary-500/10 flex items-center justify-center border border-primary-500/20">
|
||||||
<span className="text-xs text-white/50">{formatDate(reply.wdate)}</span>
|
<User className="w-3.5 h-3.5 text-primary-400" />
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-col">
|
||||||
|
<span className="text-[11px] font-bold text-white/80">{reply.wuid_name || reply.wuid}</span>
|
||||||
|
<span className="text-[9px] text-white/30 font-bold tracking-wider">{formatDate(reply.wdate)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="text-white/70 text-sm whitespace-pre-wrap">{reply.contents}</div>
|
<div className="text-white/70 text-[13px] leading-relaxed whitespace-pre-wrap pl-[36px]">{reply.contents}</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 댓글 입력 */}
|
{/* 댓글 입력 */}
|
||||||
<div className="bg-white/5 rounded-lg p-4">
|
<div className="bg-white/[0.03] border border-white/10 rounded-2xl p-5 focus-within:border-primary-500/50 transition-all">
|
||||||
<textarea
|
<textarea
|
||||||
value={commentText}
|
value={commentText}
|
||||||
onChange={(e) => setCommentText(e.target.value)}
|
onChange={(e) => setCommentText(e.target.value)}
|
||||||
placeholder="댓글을 입력하세요..."
|
placeholder="공유하고 싶은 의견을 입력하세요..."
|
||||||
rows={3}
|
rows={3}
|
||||||
className="w-full bg-white/10 border border-white/30 rounded-lg px-3 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 resize-none"
|
className="w-full bg-transparent border-none text-white text-sm placeholder-white/20 focus:ring-0 resize-none font-medium"
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-end mt-2">
|
<div className="flex justify-end mt-3 border-t border-white/5 pt-3">
|
||||||
<button
|
<button
|
||||||
onClick={handleAddComment}
|
onClick={handleAddComment}
|
||||||
disabled={!commentText.trim()}
|
disabled={!commentText.trim()}
|
||||||
className="px-4 py-2 bg-primary-500 hover:bg-primary-600 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg transition-colors"
|
className="px-6 py-2 bg-primary-500 hover:bg-primary-600 disabled:opacity-30 disabled:cursor-not-allowed text-white text-xs font-bold rounded-xl transition-all shadow-lg shadow-primary-500/20 active:scale-95"
|
||||||
>
|
>
|
||||||
댓글 등록
|
댓글 등록
|
||||||
</button>
|
</button>
|
||||||
@@ -564,23 +645,25 @@ export function BoardList({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-t border-white/10 bg-white/5">
|
{/* 하단 버튼 바 */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center justify-between px-8 py-6 border-t border-white/10 bg-white/5">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowReplyModal(true)}
|
onClick={() => setShowReplyModal(true)}
|
||||||
className="px-4 py-2 rounded-lg bg-green-500 hover:bg-green-600 text-white transition-colors"
|
className="px-5 py-2.5 rounded-xl bg-success-500 hover:bg-success-600 text-white text-sm font-bold transition-all shadow-lg shadow-success-500/20 active:scale-95 flex items-center gap-2"
|
||||||
>
|
>
|
||||||
|
<MessageSquare className="w-4 h-4" />
|
||||||
답글 달기
|
답글 달기
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
{(userLevel >= 9 || userId === '395552') && (
|
{(userLevel >= 9 || userId === '395552') && (
|
||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
onClick={handleEditClick}
|
onClick={handleEditClick}
|
||||||
className="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white transition-colors flex items-center"
|
className="px-5 py-2.5 rounded-xl bg-primary-500 hover:bg-primary-600 text-white text-sm font-bold transition-all shadow-lg shadow-primary-500/20 active:scale-95 flex items-center gap-2"
|
||||||
>
|
>
|
||||||
<Edit3 className="w-4 h-4 mr-2" />
|
<Edit3 className="w-4 h-4" />
|
||||||
편집
|
편집
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@@ -600,7 +683,7 @@ export function BoardList({
|
|||||||
alert('삭제 중 오류가 발생했습니다.');
|
alert('삭제 중 오류가 발생했습니다.');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
className="px-4 py-2 rounded-lg bg-red-500 hover:bg-red-600 text-white transition-colors"
|
className="px-5 py-2.5 rounded-xl bg-danger-500/10 hover:bg-danger-500 text-danger-500 hover:text-white border border-danger-500/30 hover:border-danger-500 text-sm font-bold transition-all active:scale-95"
|
||||||
>
|
>
|
||||||
삭제
|
삭제
|
||||||
</button>
|
</button>
|
||||||
@@ -608,7 +691,7 @@ export function BoardList({
|
|||||||
)}
|
)}
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowModal(false)}
|
onClick={() => setShowModal(false)}
|
||||||
className="px-4 py-2 rounded-lg bg-white/10 hover:bg-white/20 text-white transition-colors"
|
className="px-5 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-white/70 hover:text-white text-sm font-bold transition-all active:scale-95"
|
||||||
>
|
>
|
||||||
닫기
|
닫기
|
||||||
</button>
|
</button>
|
||||||
@@ -620,85 +703,87 @@ export function BoardList({
|
|||||||
|
|
||||||
{/* 편집 모달 */}
|
{/* 편집 모달 */}
|
||||||
{showEditModal && editFormData && (
|
{showEditModal && editFormData && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md animate-fade-in">
|
||||||
<div className="bg-gray-900 rounded-2xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden border border-white/10">
|
<div className="bg-[#1a1b2e]/90 rounded-3xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden border border-white/10 flex flex-col backdrop-blur-xl">
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
|
<div className="flex items-center justify-between px-8 py-6 border-b border-white/10 bg-white/5">
|
||||||
<h2 className="text-xl font-bold text-white flex items-center">
|
<h2 className="text-xl font-bold text-white flex items-center tracking-tight">
|
||||||
<Edit3 className="w-5 h-5 mr-2" />
|
<div className="p-2 bg-primary-500/20 rounded-lg mr-3">
|
||||||
|
<Edit3 className="w-5 h-5 text-primary-400" />
|
||||||
|
</div>
|
||||||
{editFormData.idx === 0 ? `${title} 등록` : `${title} 편집`}
|
{editFormData.idx === 0 ? `${title} 등록` : `${title} 편집`}
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowEditModal(false)}
|
onClick={() => setShowEditModal(false)}
|
||||||
className="text-white/50 hover:text-white transition-colors"
|
className="p-2 hover:bg-white/10 rounded-full text-white/40 hover:text-white transition-all transform hover:rotate-90"
|
||||||
>
|
>
|
||||||
<span className="text-2xl">×</span>
|
<X className="w-6 h-6" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-y-auto max-h-[calc(90vh-180px)] p-6 space-y-4">
|
<div className="overflow-y-auto flex-1 p-8 space-y-6 custom-scrollbar">
|
||||||
<div className="flex items-center gap-3">
|
<div className="grid grid-cols-1 md:grid-cols-4 gap-6">
|
||||||
<div className="w-32">
|
<div className="md:col-span-1">
|
||||||
<label className="block text-white/70 text-xs font-medium mb-1">카테고리</label>
|
<label className="block text-white/50 text-[11px] font-bold uppercase tracking-wider mb-2">카테고리</label>
|
||||||
<select
|
<select
|
||||||
value={editFormData.cate || defaultCategory}
|
value={editFormData.cate || defaultCategory}
|
||||||
onChange={(e) => setEditFormData({ ...editFormData, cate: e.target.value })}
|
onChange={(e) => setEditFormData({ ...editFormData, cate: e.target.value })}
|
||||||
className="w-full h-9 bg-white/10 border border-white/30 rounded-lg px-2 text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-400"
|
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-white text-sm font-medium focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all appearance-none cursor-pointer"
|
||||||
>
|
>
|
||||||
{categories.map((cat) => (
|
{categories.map((cat) => (
|
||||||
<option key={cat.value} value={cat.value} className="bg-gray-800">
|
<option key={cat.value} value={cat.value} className="bg-[#1a1b2e] text-white">
|
||||||
{cat.label}
|
{cat.label}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1">
|
<div className="md:col-span-3">
|
||||||
<label className="block text-white/70 text-xs font-medium mb-1">제목</label>
|
<label className="block text-white/50 text-[11px] font-bold uppercase tracking-wider mb-2">제목</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={editFormData.title || ''}
|
value={editFormData.title || ''}
|
||||||
onChange={(e) => setEditFormData({ ...editFormData, title: e.target.value })}
|
onChange={(e) => setEditFormData({ ...editFormData, title: e.target.value })}
|
||||||
className="w-full h-9 bg-white/10 border border-white/30 rounded-lg px-3 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400"
|
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-white text-sm font-medium placeholder-white/10 focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all"
|
||||||
placeholder="제목"
|
placeholder="내용을 요약할 제목을 입력하세요"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-white/70 text-sm font-medium mb-2">내용</label>
|
<label className="block text-white/50 text-[11px] font-bold uppercase tracking-wider mb-2">상세 정보</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={editFormData.contents || ''}
|
value={editFormData.contents || ''}
|
||||||
onChange={(e) => setEditFormData({ ...editFormData, contents: e.target.value })}
|
onChange={(e) => setEditFormData({ ...editFormData, contents: e.target.value })}
|
||||||
rows={15}
|
rows={15}
|
||||||
className="w-full bg-white/10 border border-white/30 rounded-lg px-3 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 resize-none"
|
className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-4 text-white text-sm font-medium placeholder-white/10 focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all resize-none leading-relaxed custom-scrollbar"
|
||||||
placeholder="내용을 입력하세요..."
|
placeholder="패치 내역 또는 게시글의 상세 내용을 자유롭게 작성하세요..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-t border-white/10 bg-white/5">
|
<div className="flex items-center justify-between px-8 py-6 border-t border-white/10 bg-white/5">
|
||||||
<div>
|
<div>
|
||||||
{editFormData && editFormData.idx > 0 && (
|
{editFormData && editFormData.idx > 0 && (
|
||||||
<button
|
<button
|
||||||
onClick={handleDelete}
|
onClick={handleDelete}
|
||||||
className="px-4 py-2 rounded-lg bg-red-500 hover:bg-red-600 text-white transition-colors"
|
className="px-6 py-2.5 rounded-xl bg-danger-500/10 hover:bg-danger-500 text-danger-500 hover:text-white border border-danger-500/30 hover:border-danger-500 text-sm font-bold transition-all active:scale-95"
|
||||||
>
|
>
|
||||||
삭제
|
게시글 삭제
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowEditModal(false)}
|
onClick={() => setShowEditModal(false)}
|
||||||
className="px-4 py-2 rounded-lg bg-white/10 hover:bg-white/20 text-white transition-colors"
|
className="px-6 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-white/70 hover:text-white text-sm font-bold transition-all active:scale-95"
|
||||||
>
|
>
|
||||||
취소
|
취소
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleEditSave}
|
onClick={handleEditSave}
|
||||||
className="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white transition-colors"
|
className="px-8 py-2.5 rounded-xl bg-primary-500 hover:bg-primary-600 text-white text-sm font-bold transition-all shadow-lg shadow-primary-500/20 active:scale-95"
|
||||||
>
|
>
|
||||||
저장
|
저장하기
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -708,60 +793,62 @@ export function BoardList({
|
|||||||
|
|
||||||
{/* 답글 달기 모달 */}
|
{/* 답글 달기 모달 */}
|
||||||
{showReplyModal && selectedItem && (
|
{showReplyModal && selectedItem && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md animate-fade-in">
|
||||||
<div className="bg-gray-900 rounded-2xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden border border-white/10">
|
<div className="bg-[#1a1b2e]/90 rounded-3xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden border border-white/10 flex flex-col backdrop-blur-xl">
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
|
<div className="flex items-center justify-between px-8 py-6 border-b border-white/10 bg-white/5">
|
||||||
<h2 className="text-xl font-bold text-white flex items-center">
|
<h2 className="text-xl font-bold text-white flex items-center tracking-tight">
|
||||||
<Edit3 className="w-5 h-5 mr-2" />
|
<div className="p-2 bg-success-500/20 rounded-lg mr-3">
|
||||||
|
<MessageSquare className="w-5 h-5 text-success-400" />
|
||||||
|
</div>
|
||||||
답글 작성
|
답글 작성
|
||||||
</h2>
|
</h2>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowReplyModal(false)}
|
onClick={() => setShowReplyModal(false)}
|
||||||
className="text-white/50 hover:text-white transition-colors"
|
className="p-2 hover:bg-white/10 rounded-full text-white/40 hover:text-white transition-all transform hover:rotate-90"
|
||||||
>
|
>
|
||||||
<span className="text-2xl">×</span>
|
<X className="w-6 h-6" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-y-auto max-h-[calc(90vh-180px)] p-6 space-y-4">
|
<div className="overflow-y-auto flex-1 p-8 space-y-6 custom-scrollbar">
|
||||||
<div className="bg-white/5 rounded-lg p-4 mb-4">
|
<div className="bg-white/5 border border-white/5 rounded-2xl p-6">
|
||||||
<div className="text-sm text-white/60 mb-2">원글</div>
|
<div className="text-[10px] font-bold text-white/30 uppercase tracking-widest mb-1.5">원본 게시글</div>
|
||||||
<div className="text-white font-medium">{selectedItem.title}</div>
|
<div className="text-white font-semibold text-base">{selectedItem.title}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-white/70 text-sm font-medium mb-2">답글 제목</label>
|
<label className="block text-white/50 text-[11px] font-bold uppercase tracking-wider mb-2">답글 제목</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={replyFormData.title}
|
value={replyFormData.title}
|
||||||
onChange={(e) => setReplyFormData({ ...replyFormData, title: e.target.value })}
|
onChange={(e) => setReplyFormData({ ...replyFormData, title: e.target.value })}
|
||||||
className="w-full h-10 bg-white/10 border border-white/30 rounded-lg px-3 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400"
|
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-white text-sm font-medium placeholder-white/10 focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all"
|
||||||
placeholder="답글 제목을 입력하세요"
|
placeholder="답글의 제목을 입력하세요"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-white/70 text-sm font-medium mb-2">답글 내용</label>
|
<label className="block text-white/50 text-[11px] font-bold uppercase tracking-wider mb-2">답글 내용</label>
|
||||||
<textarea
|
<textarea
|
||||||
value={replyFormData.contents}
|
value={replyFormData.contents}
|
||||||
onChange={(e) => setReplyFormData({ ...replyFormData, contents: e.target.value })}
|
onChange={(e) => setReplyFormData({ ...replyFormData, contents: e.target.value })}
|
||||||
rows={15}
|
rows={12}
|
||||||
className="w-full bg-white/10 border border-white/30 rounded-lg px-3 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 resize-none"
|
className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-4 text-white text-sm font-medium placeholder-white/10 focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all resize-none leading-relaxed custom-scrollbar"
|
||||||
placeholder="답글 내용을 입력하세요..."
|
placeholder="답글 내용을 상세히 작성하세요..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-white/10 bg-white/5">
|
<div className="flex items-center justify-end gap-3 px-8 py-6 border-t border-white/10 bg-white/5">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowReplyModal(false)}
|
onClick={() => setShowReplyModal(false)}
|
||||||
className="px-4 py-2 rounded-lg bg-white/10 hover:bg-white/20 text-white transition-colors"
|
className="px-6 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-white/70 hover:text-white text-sm font-bold transition-all active:scale-95"
|
||||||
>
|
>
|
||||||
취소
|
취소
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleAddReply}
|
onClick={handleAddReply}
|
||||||
className="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white transition-colors"
|
className="px-8 py-2.5 rounded-xl bg-primary-500 hover:bg-primary-600 text-white text-sm font-bold transition-all shadow-lg shadow-primary-500/20 active:scale-95"
|
||||||
>
|
>
|
||||||
답글 등록
|
답글 등록
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -187,7 +187,7 @@ export function Dashboard() {
|
|||||||
// 최근 15일간 업무일지 미등록(8시간 미만) 확인
|
// 최근 15일간 업무일지 미등록(8시간 미만) 확인
|
||||||
if (jobreportHistoryResponse.Success && jobreportHistoryResponse.Data) {
|
if (jobreportHistoryResponse.Success && jobreportHistoryResponse.Data) {
|
||||||
const dailyWork: { [key: string]: number } = {};
|
const dailyWork: { [key: string]: number } = {};
|
||||||
|
|
||||||
// 날짜별 시간 합계 계산
|
// 날짜별 시간 합계 계산
|
||||||
jobreportHistoryResponse.Data.forEach((item: JobReportItem) => {
|
jobreportHistoryResponse.Data.forEach((item: JobReportItem) => {
|
||||||
if (item.pdate) {
|
if (item.pdate) {
|
||||||
@@ -197,22 +197,22 @@ export function Dashboard() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const insufficientDays: { date: string; hrs: number }[] = [];
|
const insufficientDays: { date: string; hrs: number }[] = [];
|
||||||
|
|
||||||
// 어제부터 15일 전까지 확인 (오늘은 제외)
|
// 어제부터 15일 전까지 확인 (오늘은 제외)
|
||||||
for (let i = 1; i <= 15; i++) {
|
for (let i = 1; i <= 15; i++) {
|
||||||
const d = new Date(now);
|
const d = new Date(now);
|
||||||
d.setDate(now.getDate() - i);
|
d.setDate(now.getDate() - i);
|
||||||
const dStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
const dStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
|
||||||
|
|
||||||
// 주말(토:6, 일:0) 제외
|
|
||||||
if (d.getDay() === 0 || d.getDay() === 6) continue;
|
|
||||||
|
|
||||||
const hrs = dailyWork[dStr] || 0;
|
// 주말(토:6, 일:0) 제외
|
||||||
if (hrs < 8) {
|
if (d.getDay() === 0 || d.getDay() === 6) continue;
|
||||||
insufficientDays.push({ date: dStr, hrs });
|
|
||||||
}
|
const hrs = dailyWork[dStr] || 0;
|
||||||
|
if (hrs < 8) {
|
||||||
|
insufficientDays.push({ date: dStr, hrs });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setUnregisteredJobReportCount(insufficientDays.length);
|
setUnregisteredJobReportCount(insufficientDays.length);
|
||||||
setUnregisteredJobReportDays(insufficientDays);
|
setUnregisteredJobReportDays(insufficientDays);
|
||||||
}
|
}
|
||||||
@@ -516,24 +516,24 @@ export function Dashboard() {
|
|||||||
try {
|
try {
|
||||||
const response = editingNote
|
const response = editingNote
|
||||||
? await comms.editNote(
|
? await comms.editNote(
|
||||||
editingNote.idx,
|
editingNote.idx,
|
||||||
formData.pdate,
|
formData.pdate,
|
||||||
formData.title,
|
formData.title,
|
||||||
formData.uid,
|
formData.uid,
|
||||||
formData.description,
|
formData.description,
|
||||||
'',
|
'',
|
||||||
formData.share,
|
formData.share,
|
||||||
formData.guid
|
formData.guid
|
||||||
)
|
)
|
||||||
: await comms.addNote(
|
: await comms.addNote(
|
||||||
formData.pdate,
|
formData.pdate,
|
||||||
formData.title,
|
formData.title,
|
||||||
formData.uid,
|
formData.uid,
|
||||||
formData.description,
|
formData.description,
|
||||||
'',
|
'',
|
||||||
formData.share,
|
formData.share,
|
||||||
formData.guid
|
formData.guid
|
||||||
);
|
);
|
||||||
|
|
||||||
if (response.Success) {
|
if (response.Success) {
|
||||||
setShowNoteEditModal(false);
|
setShowNoteEditModal(false);
|
||||||
@@ -569,147 +569,147 @@ export function Dashboard() {
|
|||||||
|
|
||||||
{/* 통계 카드 */}
|
{/* 통계 카드 */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-5 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-5 gap-6">
|
||||||
<StatCard
|
<StatCard
|
||||||
title="구매요청 (NR)"
|
title="구매요청 (NR)"
|
||||||
value={purchaseNR}
|
value={purchaseNR}
|
||||||
icon={<ShoppingCart className="w-6 h-6 text-primary-400" />}
|
icon={<ShoppingCart className="w-6 h-6 text-primary-400" />}
|
||||||
color="text-primary-400"
|
color="text-primary-400"
|
||||||
onClick={loadNRList}
|
onClick={loadNRList}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title="구매요청 (CR)"
|
title="구매요청 (CR)"
|
||||||
value={purchaseCR}
|
value={purchaseCR}
|
||||||
icon={<FileCheck className="w-6 h-6 text-success-400" />}
|
icon={<FileCheck className="w-6 h-6 text-success-400" />}
|
||||||
color="text-success-400"
|
color="text-success-400"
|
||||||
onClick={loadCRList}
|
onClick={loadCRList}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title="미완료 할일"
|
title="미완료 할일"
|
||||||
value={todoCount}
|
value={todoCount}
|
||||||
icon={<ClipboardList className="w-6 h-6 text-warning-400" />}
|
icon={<ClipboardList className="w-6 h-6 text-warning-400" />}
|
||||||
color="text-warning-400"
|
color="text-warning-400"
|
||||||
onClick={() => navigate('/todo')}
|
onClick={() => navigate('/todo')}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title="업무일지 미등록"
|
title="업무일지 미등록"
|
||||||
value={`${unregisteredJobReportCount}건`}
|
value={`${unregisteredJobReportCount}건`}
|
||||||
icon={<AlertTriangle className="w-6 h-6 text-danger-400" />}
|
icon={<AlertTriangle className="w-6 h-6 text-danger-400" />}
|
||||||
color="text-danger-400"
|
color="text-danger-400"
|
||||||
onClick={() => setShowUnregisteredModal(true)}
|
onClick={() => setShowUnregisteredModal(true)}
|
||||||
/>
|
/>
|
||||||
<StatCard
|
<StatCard
|
||||||
title="금일 업무일지"
|
title="금일 업무일지"
|
||||||
value={`${todayWorkHrs}시간`}
|
value={`${todayWorkHrs}시간`}
|
||||||
icon={<Clock className="w-6 h-6 text-cyan-400" />}
|
icon={<Clock className="w-6 h-6 text-cyan-400" />}
|
||||||
color="text-cyan-400"
|
color="text-cyan-400"
|
||||||
onClick={() => navigate('/jobreport')}
|
onClick={() => navigate('/jobreport')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 할일 목록 */}
|
{/* 할일 목록 */}
|
||||||
<div className="glass-effect rounded-2xl overflow-hidden">
|
<div className="glass-effect rounded-2xl overflow-hidden">
|
||||||
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
|
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
|
||||||
<h3 className="text-lg font-semibold text-white flex items-center">
|
<h3 className="text-lg font-semibold text-white flex items-center">
|
||||||
<AlertTriangle className="w-5 h-5 mr-2 text-warning-400" />
|
<AlertTriangle className="w-5 h-5 mr-2 text-warning-400" />
|
||||||
할일
|
할일
|
||||||
</h3>
|
</h3>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleTodoAdd}
|
onClick={handleTodoAdd}
|
||||||
className="p-1.5 rounded-lg bg-primary-500/20 text-primary-400 hover:bg-primary-500/30 transition-colors"
|
className="p-1.5 rounded-lg bg-primary-500/20 text-primary-400 hover:bg-primary-500/30 transition-colors"
|
||||||
title="할일 추가"
|
title="할일 추가"
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => navigate('/todo')}
|
|
||||||
className="p-1.5 rounded-lg bg-white/10 text-white/70 hover:bg-white/20 transition-colors"
|
|
||||||
title="전체보기"
|
|
||||||
>
|
|
||||||
<List className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="divide-y divide-white/10">
|
|
||||||
{urgentTodos.length > 0 ? (
|
|
||||||
urgentTodos.map((todo) => (
|
|
||||||
<div
|
|
||||||
key={todo.idx}
|
|
||||||
className="px-6 py-4 hover:bg-white/5 transition-colors cursor-pointer"
|
|
||||||
onClick={() => handleTodoEdit(todo)}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between">
|
<Plus className="w-4 h-4" />
|
||||||
<div className="flex items-center space-x-4 flex-1 min-w-0">
|
</button>
|
||||||
{todo.flag && (
|
<button
|
||||||
<Flag className="w-4 h-4 text-warning-400 flex-shrink-0" />
|
onClick={() => navigate('/todo')}
|
||||||
)}
|
className="p-1.5 rounded-lg bg-white/10 text-white/70 hover:bg-white/20 transition-colors"
|
||||||
<div className="flex-1 min-w-0">
|
title="전체보기"
|
||||||
<p className="text-white font-medium">
|
>
|
||||||
{todo.request && (
|
<List className="w-4 h-4" />
|
||||||
<span className="text-xs text-primary-400 mr-2">
|
</button>
|
||||||
({todo.request})
|
</div>
|
||||||
</span>
|
</div>
|
||||||
)}
|
|
||||||
{todo.title || '제목 없음'}
|
<div className="divide-y divide-white/10">
|
||||||
</p>
|
{urgentTodos.length > 0 ? (
|
||||||
<p className="text-white/60 text-sm line-clamp-1 mt-1">
|
urgentTodos.map((todo) => (
|
||||||
{todo.remark}
|
<div
|
||||||
</p>
|
key={todo.idx}
|
||||||
|
className="px-6 py-4 hover:bg-white/5 transition-colors cursor-pointer"
|
||||||
|
onClick={() => handleTodoEdit(todo)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-4 flex-1 min-w-0">
|
||||||
|
{todo.flag && (
|
||||||
|
<Flag className="w-4 h-4 text-warning-400 flex-shrink-0" />
|
||||||
|
)}
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-white font-medium">
|
||||||
|
{todo.request && (
|
||||||
|
<span className="text-xs text-primary-400 mr-2">
|
||||||
|
({todo.request})
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{todo.title || '제목 없음'}
|
||||||
|
</p>
|
||||||
|
<p className="text-white/60 text-sm line-clamp-1 mt-1">
|
||||||
|
{todo.remark}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-3 flex-shrink-0 ml-4">
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getPriorityClass(todo.seqno)}`}>
|
||||||
|
{getPriorityText(todo.seqno)}
|
||||||
|
</span>
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusClass(todo.status)}`}>
|
||||||
|
{getStatusText(todo.status)}
|
||||||
|
</span>
|
||||||
|
{todo.expire && (
|
||||||
|
<span className={`text-xs ${new Date(todo.expire) < new Date() ? 'text-danger-400' : 'text-white/60'}`}>
|
||||||
|
{new Date(todo.expire).toLocaleDateString('ko-KR')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-3 flex-shrink-0 ml-4">
|
|
||||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getPriorityClass(todo.seqno)}`}>
|
|
||||||
{getPriorityText(todo.seqno)}
|
|
||||||
</span>
|
|
||||||
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusClass(todo.status)}`}>
|
|
||||||
{getStatusText(todo.status)}
|
|
||||||
</span>
|
|
||||||
{todo.expire && (
|
|
||||||
<span className={`text-xs ${new Date(todo.expire) < new Date() ? 'text-danger-400' : 'text-white/60'}`}>
|
|
||||||
{new Date(todo.expire).toLocaleDateString('ko-KR')}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<div className="px-6 py-8 text-center text-white/50">
|
||||||
|
<CheckCircle className="w-12 h-12 mx-auto mb-3 text-success-400/50" />
|
||||||
|
<p>할일이 없습니다</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{allUrgentTodos.length > 6 && (
|
||||||
|
<div className="px-6 py-3 border-t border-white/10 flex items-center justify-between">
|
||||||
|
<span className="text-xs text-white/50">
|
||||||
|
{(todoPage - 1) * 6 + 1}-{Math.min(todoPage * 6, allUrgentTodos.length)} / {allUrgentTodos.length}건
|
||||||
|
</span>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setTodoPage(p => Math.max(1, p - 1))}
|
||||||
|
disabled={todoPage === 1}
|
||||||
|
className="px-2 py-1 rounded bg-white/10 hover:bg-white/20 text-white/70 disabled:opacity-30 disabled:cursor-not-allowed transition-colors text-xs"
|
||||||
|
>
|
||||||
|
이전
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setTodoPage(p => Math.min(Math.ceil(allUrgentTodos.length / 6), p + 1))}
|
||||||
|
disabled={todoPage >= Math.ceil(allUrgentTodos.length / 6)}
|
||||||
|
className="px-2 py-1 rounded bg-white/10 hover:bg-white/20 text-white/70 disabled:opacity-30 disabled:cursor-not-allowed transition-colors text-xs"
|
||||||
|
>
|
||||||
|
다음
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
))
|
|
||||||
) : (
|
|
||||||
<div className="px-6 py-8 text-center text-white/50">
|
|
||||||
<CheckCircle className="w-12 h-12 mx-auto mb-3 text-success-400/50" />
|
|
||||||
<p>할일이 없습니다</p>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{allUrgentTodos.length > 6 && (
|
|
||||||
<div className="px-6 py-3 border-t border-white/10 flex items-center justify-between">
|
|
||||||
<span className="text-xs text-white/50">
|
|
||||||
{(todoPage - 1) * 6 + 1}-{Math.min(todoPage * 6, allUrgentTodos.length)} / {allUrgentTodos.length}건
|
|
||||||
</span>
|
|
||||||
<div className="flex gap-1">
|
|
||||||
<button
|
|
||||||
onClick={() => setTodoPage(p => Math.max(1, p - 1))}
|
|
||||||
disabled={todoPage === 1}
|
|
||||||
className="px-2 py-1 rounded bg-white/10 hover:bg-white/20 text-white/70 disabled:opacity-30 disabled:cursor-not-allowed transition-colors text-xs"
|
|
||||||
>
|
|
||||||
이전
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setTodoPage(p => Math.min(Math.ceil(allUrgentTodos.length / 6), p + 1))}
|
|
||||||
disabled={todoPage >= Math.ceil(allUrgentTodos.length / 6)}
|
|
||||||
className="px-2 py-1 rounded bg-white/10 hover:bg-white/20 text-white/70 disabled:opacity-30 disabled:cursor-not-allowed transition-colors text-xs"
|
|
||||||
>
|
|
||||||
다음
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 업무일지 미등록 상세 모달 */}
|
{/* 업무일지 미등록 상세 모달 */}
|
||||||
{showUnregisteredModal && (
|
{showUnregisteredModal && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
|
||||||
<div className="bg-slate-900 border border-white/10 rounded-2xl w-full max-w-md shadow-2xl overflow-hidden animate-scale-in">
|
<div className="bg-bg-paper border border-white/10 rounded-2xl w-full max-w-md shadow-2xl overflow-hidden animate-scale-in">
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 bg-white/5">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 bg-white/5">
|
||||||
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
|
||||||
<AlertTriangle className="w-5 h-5 text-danger-400" />
|
<AlertTriangle className="w-5 h-5 text-danger-400" />
|
||||||
@@ -722,12 +722,12 @@ export function Dashboard() {
|
|||||||
<X className="w-5 h-5" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="p-6 max-h-[60vh] overflow-y-auto">
|
<div className="p-6 max-h-[60vh] overflow-y-auto">
|
||||||
<p className="text-white/70 text-sm mb-4">
|
<p className="text-white/70 text-sm mb-4">
|
||||||
최근 15일(평일 기준) 중 8시간 미만 등록된 날짜입니다.
|
최근 15일(평일 기준) 중 8시간 미만 등록된 날짜입니다.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{unregisteredJobReportDays.length === 0 ? (
|
{unregisteredJobReportDays.length === 0 ? (
|
||||||
<div className="text-center py-8 text-white/50">
|
<div className="text-center py-8 text-white/50">
|
||||||
미등록 내역이 없습니다.
|
미등록 내역이 없습니다.
|
||||||
@@ -753,12 +753,12 @@ export function Dashboard() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-6 py-4 border-t border-white/10 bg-white/5 flex justify-end">
|
<div className="px-6 py-4 border-t border-white/10 bg-white/5 flex justify-end">
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShowUnregisteredModal(false);
|
setShowUnregisteredModal(false);
|
||||||
navigate('/jobreport');
|
navigate('/jobreport');
|
||||||
}}
|
}}
|
||||||
className="px-4 py-2 bg-primary-500 hover:bg-primary-600 text-white rounded-lg transition-colors text-sm font-medium"
|
className="px-4 py-2 bg-primary-500 hover:bg-primary-600 text-white rounded-lg transition-colors text-sm font-medium"
|
||||||
>
|
>
|
||||||
@@ -897,11 +897,10 @@ export function Dashboard() {
|
|||||||
key={option.value}
|
key={option.value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTodoFormData(prev => ({ ...prev, status: option.value as TodoStatus }))}
|
onClick={() => setTodoFormData(prev => ({ ...prev, status: option.value as TodoStatus }))}
|
||||||
className={`px-3 py-1 rounded-lg text-xs font-medium border transition-all ${
|
className={`px-3 py-1 rounded-lg text-xs font-medium border transition-all ${todoFormData.status === option.value
|
||||||
todoFormData.status === option.value
|
|
||||||
? getStatusClass(option.value)
|
? getStatusClass(option.value)
|
||||||
: 'bg-white/10 text-white/50 border-white/20 hover:bg-white/20'
|
: 'bg-white/10 text-white/50 border-white/20 hover:bg-white/20'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</button>
|
</button>
|
||||||
@@ -1058,11 +1057,10 @@ export function Dashboard() {
|
|||||||
key={option.value}
|
key={option.value}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setTodoFormData(prev => ({ ...prev, status: option.value as TodoStatus }))}
|
onClick={() => setTodoFormData(prev => ({ ...prev, status: option.value as TodoStatus }))}
|
||||||
className={`px-3 py-1 rounded-lg text-xs font-medium border transition-all ${
|
className={`px-3 py-1 rounded-lg text-xs font-medium border transition-all ${todoFormData.status === option.value
|
||||||
todoFormData.status === option.value
|
|
||||||
? getStatusClass(option.value)
|
? getStatusClass(option.value)
|
||||||
: 'bg-white/10 text-white/50 border-white/20 hover:bg-white/20'
|
: 'bg-white/10 text-white/50 border-white/20 hover:bg-white/20'
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{option.label}
|
{option.label}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -2,18 +2,17 @@ import { useState, useEffect, useCallback } from 'react';
|
|||||||
import {
|
import {
|
||||||
Calendar,
|
Calendar,
|
||||||
Search,
|
Search,
|
||||||
User,
|
Filter,
|
||||||
|
XCircle,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
Plus,
|
Plus,
|
||||||
CheckCircle,
|
|
||||||
AlertCircle,
|
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { comms } from '../communication';
|
import { comms } from '../communication';
|
||||||
import { HolidayRequest, HolidayRequestSummary } from '../types';
|
import { HolidayRequest, HolidayRequestSummary, GroupUser } from '../types';
|
||||||
import { HolidayRequestDialog } from '../components/holiday/HolidayRequestDialog';
|
import { HolidayRequestDialog } from '../components/holiday/HolidayRequestDialog';
|
||||||
import { DevelopmentNotice } from '@/components/common/DevelopmentNotice';
|
import { DevelopmentNotice } from '@/components/DevelopmentNotice';
|
||||||
|
import { DateRangePicker } from '@/components/DateRangePicker';
|
||||||
|
import { UserSelector } from '@/components/UserSelector';
|
||||||
|
|
||||||
export default function HolidayRequestPage() {
|
export default function HolidayRequestPage() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -30,13 +29,14 @@ export default function HolidayRequestPage() {
|
|||||||
// 필터 상태
|
// 필터 상태
|
||||||
const [startDate, setStartDate] = useState('');
|
const [startDate, setStartDate] = useState('');
|
||||||
const [endDate, setEndDate] = useState('');
|
const [endDate, setEndDate] = useState('');
|
||||||
|
const [filterText, setFilterText] = useState('');
|
||||||
const [selectedUserId, setSelectedUserId] = useState('%');
|
const [selectedUserId, setSelectedUserId] = useState('%');
|
||||||
const [userLevel, setUserLevel] = useState(0);
|
const [userLevel, setUserLevel] = useState(0);
|
||||||
const [currentUserId, setCurrentUserId] = useState('');
|
const [currentUserId, setCurrentUserId] = useState('');
|
||||||
const [currentUserName, setCurrentUserName] = useState('');
|
const [currentUserName, setCurrentUserName] = useState('');
|
||||||
|
|
||||||
// 사용자 목록
|
// 사용자 목록
|
||||||
const [users, setUsers] = useState<Array<{ id: string, name: string }>>([]);
|
const [users, setUsers] = useState<GroupUser[]>([]);
|
||||||
|
|
||||||
// Dialog State
|
// Dialog State
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
@@ -80,30 +80,34 @@ export default function HolidayRequestPage() {
|
|||||||
const loginStatus = await comms.checkLoginStatus();
|
const loginStatus = await comms.checkLoginStatus();
|
||||||
if (loginStatus.Success && loginStatus.IsLoggedIn && loginStatus.User) {
|
if (loginStatus.Success && loginStatus.IsLoggedIn && loginStatus.User) {
|
||||||
const user = loginStatus.User as { Level?: number; Id?: string; NameK?: string; Name?: string };
|
const user = loginStatus.User as { Level?: number; Id?: string; NameK?: string; Name?: string };
|
||||||
setCurrentUserId(user.Id || '');
|
const userId = user.Id || '';
|
||||||
setCurrentUserName(user.NameK || user.Name || '');
|
const level = user.Level || 0;
|
||||||
setUserLevel(user.Level || 0);
|
|
||||||
|
|
||||||
// 사용자 목록 로드
|
setCurrentUserId(userId);
|
||||||
loadUsers(user.Level || 0);
|
setCurrentUserName(user.NameK || user.Name || '');
|
||||||
|
setUserLevel(level);
|
||||||
|
|
||||||
|
// 기본 선택: 본인
|
||||||
|
setSelectedUserId(userId);
|
||||||
|
|
||||||
|
// 레벨 5 이상인 경우 사용자 목록 로드 (전체 조회 가능)
|
||||||
|
if (level >= 5) {
|
||||||
|
loadUsers();
|
||||||
|
} else {
|
||||||
|
// 권한이 없으면 본인만 선택 가능 (UI 콤보박스 비활성화)
|
||||||
|
setUsers([{ id: userId, name: user.NameK || user.Name || userId } as GroupUser]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load user info:', error);
|
console.error('Failed to load user info:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadUsers = async (level: number) => {
|
const loadUsers = async () => {
|
||||||
try {
|
try {
|
||||||
// 레벨 5 이상만 사용자 목록 조회 가능
|
const userList = await comms.getUserList('');
|
||||||
if (level >= 5) {
|
if (userList && userList.length > 0) {
|
||||||
const userList = await comms.getUserList('');
|
setUsers(userList);
|
||||||
if (userList && userList.length > 0) {
|
|
||||||
const mappedUsers = userList.map((u: any) => ({
|
|
||||||
id: u.id || u.Id,
|
|
||||||
name: u.name || u.NameK || u.id
|
|
||||||
}));
|
|
||||||
setUsers([{ id: '%', name: '전체' }, ...mappedUsers]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load users:', error);
|
console.error('Failed to load users:', error);
|
||||||
@@ -154,19 +158,7 @@ export default function HolidayRequestPage() {
|
|||||||
}
|
}
|
||||||
}, [startDate, endDate, selectedUserId, userLevel, currentUserId]);
|
}, [startDate, endDate, selectedUserId, userLevel, currentUserId]);
|
||||||
|
|
||||||
// 월 이동
|
|
||||||
const moveMonth = (offset: number) => {
|
|
||||||
const current = new Date(startDate);
|
|
||||||
current.setMonth(current.getMonth() + offset);
|
|
||||||
const year = current.getFullYear();
|
|
||||||
const month = current.getMonth();
|
|
||||||
|
|
||||||
const newStart = new Date(year, month, 1);
|
|
||||||
const newEnd = new Date(year, month + 1, 0);
|
|
||||||
|
|
||||||
setStartDate(formatDate(newStart));
|
|
||||||
setEndDate(formatDate(newEnd));
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCategoryName = (cate: string) => {
|
const getCategoryName = (cate: string) => {
|
||||||
const categories: { [key: string]: string } = {
|
const categories: { [key: string]: string } = {
|
||||||
@@ -208,233 +200,227 @@ export default function HolidayRequestPage() {
|
|||||||
return startDate > today;
|
return startDate > today;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const filteredRequests = requests.filter(req => {
|
||||||
|
if (!filterText) return true;
|
||||||
|
const searchLower = filterText.toLowerCase();
|
||||||
|
return (
|
||||||
|
(req.name || '').toLowerCase().includes(searchLower) ||
|
||||||
|
(req.uid || '').toLowerCase().includes(searchLower) ||
|
||||||
|
(req.HolyReason || '').toLowerCase().includes(searchLower) ||
|
||||||
|
(req.HolyLocation || '').toLowerCase().includes(searchLower) ||
|
||||||
|
getCategoryName(req.cate).toLowerCase().includes(searchLower)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-fade-in">
|
<div className="space-y-6 animate-fade-in">
|
||||||
<div className="max-w-[1920px] mx-auto space-y-6">
|
<div className="max-w-[1920px] mx-auto space-y-6">
|
||||||
<DevelopmentNotice />
|
<DevelopmentNotice />
|
||||||
{/* 상단 컨트롤 바 */}
|
{/* 상단 컨트롤 바 */}
|
||||||
<div className="glass-effect rounded-2xl p-6">
|
<div className="glass-effect rounded-2xl p-6 relative z-20">
|
||||||
<div className="flex flex-col space-y-4">
|
<div className="flex flex-col space-y-4">
|
||||||
|
|
||||||
<div className="flex flex-col md:flex-row gap-4 items-end md:items-center justify-between">
|
<div className="flex flex-col md:flex-row gap-4 items-end md:items-center justify-between">
|
||||||
{/* Date Move & Pick */}
|
{/* Date Move & Pick */}
|
||||||
<div className="flex items-center gap-2 w-full md:w-auto">
|
<div className="flex items-center gap-4 w-full md:w-auto flex-wrap">
|
||||||
<button
|
<DateRangePicker
|
||||||
onClick={() => moveMonth(-1)}
|
startDate={startDate}
|
||||||
className="p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors"
|
endDate={endDate}
|
||||||
title="이전 달"
|
onChange={(start, end) => {
|
||||||
>
|
setStartDate(start);
|
||||||
<ChevronLeft className="w-5 h-5" />
|
setEndDate(end);
|
||||||
</button>
|
}}
|
||||||
|
align="vertical"
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="w-full md:w-64">
|
||||||
<input
|
<UserSelector
|
||||||
type="date"
|
users={users}
|
||||||
value={startDate}
|
selectedIds={selectedUserId === '%' ? [] : [selectedUserId]}
|
||||||
onChange={(e) => setStartDate(e.target.value)}
|
onChange={(ids) => setSelectedUserId(ids[0] || '%')}
|
||||||
className="bg-white/20 border border-white/30 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 text-sm"
|
placeholder="신청자 선택"
|
||||||
/>
|
className="w-full"
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={endDate}
|
|
||||||
onChange={(e) => setEndDate(e.target.value)}
|
|
||||||
className="bg-white/20 border border-white/30 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 text-sm"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<div className="h-10 flex items-end">
|
||||||
onClick={() => moveMonth(1)}
|
<button
|
||||||
className="p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors"
|
onClick={loadData}
|
||||||
title="다음 달"
|
disabled={loading}
|
||||||
>
|
className="flex-1 md:flex-none bg-white/10 hover:bg-white/20 text-white px-4 py-2 rounded-lg transition-colors flex items-center justify-center disabled:opacity-50 h-[60px]"
|
||||||
<ChevronRight className="w-5 h-5" />
|
>
|
||||||
</button>
|
{loading ? <RefreshCw className="w-4 h-4 mr-2 animate-spin" /> : <Search className="w-4 h-4 mr-2" />}
|
||||||
|
조회
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* User Select (Manager) */}
|
|
||||||
{userLevel >= 5 && (
|
|
||||||
<div className="w-full md:w-64">
|
|
||||||
<div className="relative">
|
|
||||||
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-white/50" />
|
|
||||||
<select
|
|
||||||
value={selectedUserId}
|
|
||||||
onChange={(e) => setSelectedUserId(e.target.value)}
|
|
||||||
className="w-full bg-white/20 border border-white/30 rounded-lg pl-10 pr-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 appearance-none"
|
|
||||||
>
|
|
||||||
{users.map(user => (
|
|
||||||
<option key={user.id} value={user.id} className="bg-[#1e1e2e]">{user.name}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Buttons */}
|
|
||||||
<div className="flex gap-2 w-full md:w-auto">
|
<div className="flex gap-2 w-full md:w-auto">
|
||||||
{/* Refresh / Search */}
|
|
||||||
<button
|
|
||||||
onClick={loadData}
|
|
||||||
disabled={loading}
|
|
||||||
className="flex-1 md:flex-none bg-white/10 hover:bg-white/20 text-white px-4 py-2 rounded-lg transition-colors flex items-center justify-center disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{loading ? <RefreshCw className="w-4 h-4 mr-2 animate-spin" /> : <Search className="w-4 h-4 mr-2" />}
|
|
||||||
조회
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{/* Add Request */}
|
{/* Add Request */}
|
||||||
<button
|
<button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSelectedRequest(null);
|
setSelectedRequest(null);
|
||||||
setIsDialogOpen(true);
|
setIsDialogOpen(true);
|
||||||
}}
|
}}
|
||||||
className="flex-1 md:flex-none bg-primary-500 hover:bg-primary-600 text-white px-4 py-2 rounded-lg transition-colors flex items-center justify-center"
|
className="flex-1 md:flex-none bg-primary-500 hover:bg-primary-600 text-white px-4 py-2 rounded-lg transition-colors flex items-center justify-center font-bold"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-5 h-5 mr-1" />
|
||||||
신청
|
휴가 신청
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="border-t border-white/10 my-4"></div>
|
{/* List Table */}
|
||||||
|
<div className="glass-effect rounded-2xl overflow-hidden shadow-2xl transition-all duration-300">
|
||||||
|
<div className="px-6 py-4 flex flex-col md:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-primary-500/20 rounded-lg">
|
||||||
|
<Calendar className="w-5 h-5 text-primary-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold text-[var(--text-primary)] tracking-tight">
|
||||||
|
신청 내역
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Summary Stats Cards (Merged) */}
|
<div className="flex items-center gap-4 w-full md:w-auto">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
{/* 검색 필터 */}
|
||||||
|
<div className="relative flex-1 md:w-64 group">
|
||||||
<StatCard
|
<Filter className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-[var(--text-secondary)] group-focus-within:text-primary-400 transition-colors" />
|
||||||
title="휴가 예정"
|
<input
|
||||||
value={`${scheduledStats.count}건 (${scheduledStats.days}일)`}
|
type="text"
|
||||||
icon={<Calendar className="w-6 h-6 text-blue-400" />}
|
value={filterText}
|
||||||
color={scheduledStats.count > 0 ? "text-blue-400" : "text-white/50"}
|
onChange={(e) => setFilterText(e.target.value)}
|
||||||
/>
|
placeholder="검색..."
|
||||||
<StatCard
|
className="w-full bg-[var(--bg-paper)] border border-[var(--border-color)] rounded-xl pl-10 pr-10 py-2 text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary-500/50 transition-all text-sm placeholder-[var(--text-muted)]"
|
||||||
title="휴가 잔량 (일)"
|
/>
|
||||||
value={`${balance.days.toFixed(1)}일`}
|
{filterText && (
|
||||||
icon={<RefreshCw className="w-6 h-6 text-green-400" />}
|
<button
|
||||||
color="text-green-400"
|
onClick={() => setFilterText('')}
|
||||||
/>
|
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
<StatCard
|
>
|
||||||
title="휴가 잔량 (시간)"
|
<XCircle className="w-4 h-4" />
|
||||||
value={`${balance.times.toFixed(1)}시간`}
|
</button>
|
||||||
icon={<RefreshCw className="w-6 h-6 text-purple-400" />}
|
)}
|
||||||
color="text-purple-400"
|
</div>
|
||||||
/>
|
<div className="flex items-center gap-1 bg-[var(--bg-paper)] px-3 py-1.5 rounded-lg border border-[var(--border-color)]">
|
||||||
|
<span className="text-[var(--text-primary)] font-bold text-sm">{filteredRequests.length}</span>
|
||||||
|
<span className="text-[var(--text-secondary)] text-xs">건</span>
|
||||||
|
{requests.length !== filteredRequests.length && <span className="text-[var(--text-muted)] text-xs ml-1">(전체 {requests.length})</span>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* List Table */}
|
<div className="overflow-x-auto">
|
||||||
<div className="glass-effect rounded-2xl overflow-hidden">
|
<table className="w-full">
|
||||||
<div className="px-6 py-4 border-b border-white/10 flex justify-between items-center">
|
<thead className="bg-[var(--bg-paper)]/50">
|
||||||
<h3 className="text-lg font-semibold text-white">신청 내역</h3>
|
<tr>
|
||||||
<span className="text-white/50 text-sm">
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--text-secondary)] uppercase w-[50px]">No</th>
|
||||||
총 {requests.length}건
|
{/* <th className="px-4 py-3 text-left text-xs font-medium text-[var(--text-secondary)] uppercase">부서</th> <- Removed */}
|
||||||
</span>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--text-secondary)] uppercase w-[100px]">신청자</th>
|
||||||
</div>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--text-secondary)] uppercase w-[80px]">종류</th>
|
||||||
|
<th className="px-4 py-3 text-center text-xs font-medium text-[var(--text-secondary)] uppercase w-[80px]">상태</th>
|
||||||
<div className="overflow-x-auto">
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--text-secondary)] uppercase w-[140px]">시작일</th>
|
||||||
<table className="w-full">
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--text-secondary)] uppercase w-[140px]">종료일</th>
|
||||||
<thead className="bg-white/10">
|
<th className="px-4 py-3 text-center text-xs font-medium text-[var(--text-secondary)] uppercase w-[60px]">일수</th>
|
||||||
|
<th className="px-4 py-3 text-center text-xs font-medium text-[var(--text-secondary)] uppercase w-[60px]">시간</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--text-secondary)] uppercase w-[100px]">행선지</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--text-secondary)] uppercase">사유</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-[var(--border-color)]">
|
||||||
|
{loading ? (
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase w-[50px]">No</th>
|
<td colSpan={10} className="px-4 py-8 text-center bg-transparent">
|
||||||
{/* <th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">부서</th> <- Removed */}
|
<div className="flex items-center justify-center">
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase w-[100px]">신청자</th>
|
<RefreshCw className="w-5 h-5 mr-2 animate-spin text-[var(--text-secondary)]" />
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase w-[80px]">종류</th>
|
<span className="text-[var(--text-secondary)]">데이터를 불러오는 중...</span>
|
||||||
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-[80px]">상태</th>
|
</div>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase w-[140px]">시작일</th>
|
</td>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase w-[140px]">종료일</th>
|
|
||||||
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-[60px]">일수</th>
|
|
||||||
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-[60px]">시간</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase w-[100px]">행선지</th>
|
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">사유</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
) : filteredRequests.length === 0 ? (
|
||||||
<tbody className="divide-y divide-white/10">
|
<tr>
|
||||||
{loading ? (
|
<td colSpan={10} className="px-4 py-8 text-center text-[var(--text-secondary)] bg-transparent">
|
||||||
<tr>
|
{filterText ? '검색 결과가 없습니다.' : '조회된 데이터가 없습니다.'}
|
||||||
<td colSpan={10} className="px-4 py-8 text-center bg-transparent">
|
</td>
|
||||||
<div className="flex items-center justify-center">
|
</tr>
|
||||||
<RefreshCw className="w-5 h-5 mr-2 animate-spin text-white/50" />
|
) : (
|
||||||
<span className="text-white/50">데이터를 불러오는 중...</span>
|
filteredRequests.map((req, index) => {
|
||||||
</div>
|
// 미래 휴가 배경색 처리
|
||||||
</td>
|
const isFutureRequest = isFuture(req.sdate);
|
||||||
</tr>
|
|
||||||
) : requests.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={9} className="px-4 py-8 text-center text-white/50 bg-transparent">
|
|
||||||
조회된 데이터가 없습니다.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
requests.map((req, index) => {
|
|
||||||
// 미래 휴가 배경색 처리
|
|
||||||
const isFutureRequest = isFuture(req.sdate);
|
|
||||||
const rowClass = isFutureRequest
|
|
||||||
? "bg-blue-500/10 hover:bg-blue-500/20 transition-colors cursor-pointer"
|
|
||||||
: "hover:bg-white/5 transition-colors cursor-pointer";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
key={req.idx}
|
key={req.idx}
|
||||||
className={rowClass}
|
onClick={() => {
|
||||||
onClick={() => {
|
// 승인되지 않았거나(1이 아님), 본인이거나, 관리자(Lev >=5)인 경우만 상세 보기 가능
|
||||||
|
if (req.conf !== 1 || req.uid === currentUserId || userLevel >= 5) {
|
||||||
setSelectedRequest(req);
|
setSelectedRequest(req);
|
||||||
setIsDialogOpen(true);
|
setIsDialogOpen(true);
|
||||||
}}
|
}
|
||||||
>
|
}}
|
||||||
<td className="px-4 py-3 text-white/50 text-sm">{index + 1}</td>
|
className={`
|
||||||
{/* <td className="px-4 py-3 text-white/70 text-sm">{req.dept || '-'}</td> <- Removed */}
|
${index % 2 === 0 ? 'bg-[var(--bg-paper)]/30' : ''}
|
||||||
<td className="px-4 py-3 text-white text-sm font-medium">{req.name || '-'}</td>
|
hover:bg-[var(--bg-paper)]/50 transition-colors cursor-pointer
|
||||||
<td className="px-4 py-3 text-sm">
|
${(req.conf === 1 && req.uid !== currentUserId && userLevel < 5) ? 'opacity-50 grayscale cursor-not-allowed' : ''}
|
||||||
<span className={`px-2 py-1 rounded text-xs ${req.cate === '1' ? 'bg-primary-500/20 text-primary-300' : // 연차
|
`}
|
||||||
req.cate === '2' ? 'bg-blue-500/20 text-blue-300' : // 반차
|
>
|
||||||
req.cate === '5' ? 'bg-yellow-500/20 text-yellow-300' : // 외출
|
<td className="px-4 py-3 text-[var(--text-secondary)] text-sm">{index + 1}</td>
|
||||||
'bg-white/10 text-white/70'
|
<td className="px-4 py-3 text-[var(--text-primary)] text-sm font-medium">{req.name || '-'}</td>
|
||||||
}`}>
|
<td className="px-4 py-3 text-sm">
|
||||||
{getCategoryName(req.cate)}
|
<span className={`px-2 py-1 rounded text-xs ${req.cate === '1' ? 'bg-primary-500/20 text-primary-300' : // 연차
|
||||||
</span>
|
req.cate === '2' ? 'bg-blue-500/20 text-blue-300' : // 반차
|
||||||
</td>
|
req.cate === '5' ? 'bg-green-500/20 text-green-300' : // 외출 (Green으로 변경 요청 있었음)
|
||||||
<td className="px-4 py-3 text-center">
|
'bg-[var(--bg-paper)] text-[var(--text-secondary)]'
|
||||||
<span className={`px-2 py-1 rounded text-xs font-semibold ${req.conf === 1
|
}`}>
|
||||||
? 'bg-success-500/20 text-success-300'
|
{getCategoryName(req.cate)}
|
||||||
: 'bg-danger-500/20 text-danger-300'
|
</span>
|
||||||
}`}>
|
</td>
|
||||||
{getConfirmStatusText(req.conf)}
|
<td className="px-4 py-3 text-center">
|
||||||
</span>
|
<span className={`px-2 py-1 rounded text-xs font-semibold ${req.conf === 1
|
||||||
</td>
|
? 'bg-success-500/20 text-success-300'
|
||||||
<td className="px-4 py-3 text-white text-sm">
|
: 'bg-danger-500/20 text-danger-300'
|
||||||
{formatDateShort(req.sdate)}
|
}`}>
|
||||||
{isFutureRequest && <span className="ml-2 text-[10px] bg-blue-500 text-white px-1.5 py-0.5 rounded-full">예정</span>}
|
{getConfirmStatusText(req.conf)}
|
||||||
</td>
|
</span>
|
||||||
<td className="px-4 py-3 text-white text-sm">{formatDateShort(req.edate)}</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-center text-white text-sm">{req.HolyDays || 0}</td>
|
<td className="px-4 py-3 text-[var(--text-primary)] text-sm">
|
||||||
<td className="px-4 py-3 text-center text-white text-sm">{req.HolyTimes || 0}</td>
|
{formatDateShort(req.sdate)}
|
||||||
<td className="px-4 py-3 text-white text-sm max-w-[150px] truncate">{req.HolyLocation || '-'}</td>
|
{isFutureRequest && <span className="ml-2 text-[10px] bg-blue-500 text-white px-1.5 py-0.5 rounded-full">예정</span>}
|
||||||
<td className="px-4 py-3 text-white/70 text-sm max-w-xs truncate" title={req.HolyReason || ''}>
|
</td>
|
||||||
{req.HolyReason || '-'}
|
<td className="px-4 py-3 text-[var(--text-primary)] text-sm">{formatDateShort(req.edate)}</td>
|
||||||
</td>
|
<td className="px-4 py-3 text-center text-[var(--text-primary)] text-sm">{req.HolyDays || 0}</td>
|
||||||
</tr>
|
<td className="px-4 py-3 text-center text-[var(--text-primary)] text-sm">{req.HolyTimes || 0}</td>
|
||||||
);
|
<td className="px-4 py-3 text-[var(--text-primary)] text-sm max-w-[150px] truncate">{req.HolyLocation || '-'}</td>
|
||||||
})
|
<td className="px-4 py-3 text-[var(--text-secondary)] text-sm max-w-xs truncate" title={req.HolyReason || ''}>
|
||||||
)}
|
{req.HolyReason || '-'}
|
||||||
</tbody>
|
</td>
|
||||||
</table>
|
</tr>
|
||||||
</div>
|
);
|
||||||
|
})
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 다이얼로그 */}
|
{/* 다이얼로그 */}
|
||||||
<HolidayRequestDialog
|
<HolidayRequestDialog
|
||||||
isOpen={isDialogOpen}
|
isOpen={isDialogOpen}
|
||||||
onClose={() => setIsDialogOpen(false)}
|
onClose={() => setIsDialogOpen(false)}
|
||||||
onSave={() => {
|
onSave={() => {
|
||||||
setIsDialogOpen(false);
|
setIsDialogOpen(false);
|
||||||
loadData();
|
loadData();
|
||||||
}}
|
}}
|
||||||
initialData={selectedRequest}
|
initialData={selectedRequest}
|
||||||
currentUserName={currentUserName}
|
currentUserName={currentUserName}
|
||||||
currentUserId={currentUserId}
|
currentUserId={currentUserId}
|
||||||
userLevel={userLevel}
|
userLevel={userLevel}
|
||||||
/>
|
/>
|
||||||
</div > </div >
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,16 +9,19 @@ import {
|
|||||||
Calendar,
|
Calendar,
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
X,
|
X,
|
||||||
|
XCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { comms } from '@/communication';
|
import { comms } from '@/communication';
|
||||||
import { JobReportItem, JobReportUser } from '@/types';
|
import { JobReportItem, GroupUser } from '@/types';
|
||||||
import { JobreportEditModal, JobreportFormData, initialFormData } from '@/components/jobreport/JobreportEditModal';
|
import { JobreportEditModal, JobreportFormData, initialFormData } from '@/components/jobreport/JobreportEditModal';
|
||||||
import { JobReportDayDialog } from '@/components/jobreport/JobReportDayDialog';
|
import { JobReportDayDialog } from '@/components/jobreport/JobReportDayDialog';
|
||||||
import { JobreportTypeModal } from '@/components/jobreport/JobreportTypeModal';
|
import { JobreportTypeModal } from '@/components/jobreport/JobreportTypeModal';
|
||||||
|
import { DateRangePicker } from '@/components/DateRangePicker';
|
||||||
|
import { UserSelector } from '@/components/UserSelector';
|
||||||
|
|
||||||
export function Jobreport() {
|
export function Jobreport() {
|
||||||
const [jobreportList, setJobreportList] = useState<JobReportItem[]>([]);
|
const [jobreportList, setJobreportList] = useState<JobReportItem[]>([]);
|
||||||
const [users, setUsers] = useState<JobReportUser[]>([]);
|
const [users, setUsers] = useState<GroupUser[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [processing, setProcessing] = useState(false);
|
const [processing, setProcessing] = useState(false);
|
||||||
|
|
||||||
@@ -26,6 +29,7 @@ export function Jobreport() {
|
|||||||
const [startDate, setStartDate] = useState('');
|
const [startDate, setStartDate] = useState('');
|
||||||
const [endDate, setEndDate] = useState('');
|
const [endDate, setEndDate] = useState('');
|
||||||
const [selectedUser, setSelectedUser] = useState('');
|
const [selectedUser, setSelectedUser] = useState('');
|
||||||
|
const [loginUserId, setLoginUserId] = useState('');
|
||||||
const [searchKey, setSearchKey] = useState('');
|
const [searchKey, setSearchKey] = useState('');
|
||||||
|
|
||||||
// 모달 상태
|
// 모달 상태
|
||||||
@@ -149,6 +153,7 @@ export function Jobreport() {
|
|||||||
if (loginStatus.Success && loginStatus.IsLoggedIn && loginStatus.User) {
|
if (loginStatus.Success && loginStatus.IsLoggedIn && loginStatus.User) {
|
||||||
userId = loginStatus.User.Id;
|
userId = loginStatus.User.Id;
|
||||||
setSelectedUser(userId);
|
setSelectedUser(userId);
|
||||||
|
setLoginUserId(userId);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('로그인 정보 로드 오류:', error);
|
console.error('로그인 정보 로드 오류:', error);
|
||||||
@@ -191,8 +196,30 @@ export function Jobreport() {
|
|||||||
// 사용자 목록 로드
|
// 사용자 목록 로드
|
||||||
const loadUsers = async () => {
|
const loadUsers = async () => {
|
||||||
try {
|
try {
|
||||||
const result = await comms.getJobReportUsers();
|
const result = await comms.getUserList('');
|
||||||
setUsers(result || []);
|
if (result) {
|
||||||
|
const today = new Date().toISOString().split('T')[0];
|
||||||
|
const includeResigned = true; // 퇴사자 포함 여부 (조회용이므로 포함)
|
||||||
|
const minLevel = 1; // 최소 레벨
|
||||||
|
|
||||||
|
const filtered = result.filter(u => {
|
||||||
|
// 1. 레벨 체크
|
||||||
|
if ((u.level || 0) < minLevel) return false;
|
||||||
|
|
||||||
|
// 2. 업무일지 사용여부 체크 (목록에 표시하되 토마토색으로 구분하기 위해 필터 해제)
|
||||||
|
if (!u.useJobReport) return false;
|
||||||
|
|
||||||
|
// 3. 퇴사자 체크
|
||||||
|
if (!includeResigned && u.outdate && u.outdate < today) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
setUsers(filtered);
|
||||||
|
} else {
|
||||||
|
setUsers([]);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('사용자 목록 로드 오류:', error);
|
console.error('사용자 목록 로드 오류:', error);
|
||||||
}
|
}
|
||||||
@@ -238,6 +265,12 @@ export function Jobreport() {
|
|||||||
|
|
||||||
// 새 업무일지 추가 모달
|
// 새 업무일지 추가 모달
|
||||||
const openAddModal = () => {
|
const openAddModal = () => {
|
||||||
|
// 본인이 아닌 경우 추가 불가
|
||||||
|
if (selectedUser !== loginUserId) {
|
||||||
|
alert('다른 사용자의 업무일지는 등록할 수 없습니다.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setEditingItem(null);
|
setEditingItem(null);
|
||||||
setFormData({
|
setFormData({
|
||||||
...initialFormData,
|
...initialFormData,
|
||||||
@@ -436,166 +469,78 @@ export function Jobreport() {
|
|||||||
handleSearch();
|
handleSearch();
|
||||||
};
|
};
|
||||||
|
|
||||||
// 빠른 날짜 선택 함수들
|
|
||||||
const setToday = () => {
|
|
||||||
const today = new Date();
|
|
||||||
setStartDate(formatDateLocal(today));
|
|
||||||
};
|
|
||||||
|
|
||||||
const setThisMonth = () => {
|
|
||||||
const now = new Date();
|
|
||||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
|
|
||||||
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0);
|
|
||||||
setStartDate(formatDateLocal(startOfMonth));
|
|
||||||
setEndDate(formatDateLocal(endOfMonth));
|
|
||||||
};
|
|
||||||
|
|
||||||
const setYesterday = () => {
|
|
||||||
const yesterday = new Date();
|
|
||||||
yesterday.setDate(yesterday.getDate() - 1);
|
|
||||||
setStartDate(formatDateLocal(yesterday));
|
|
||||||
};
|
|
||||||
|
|
||||||
const setLastMonth = () => {
|
|
||||||
const now = new Date();
|
|
||||||
const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
|
|
||||||
const lastMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0);
|
|
||||||
setStartDate(formatDateLocal(lastMonthStart));
|
|
||||||
setEndDate(formatDateLocal(lastMonthEnd));
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-fade-in">
|
<div className="space-y-6 animate-fade-in">
|
||||||
{/* 검색 필터 */}
|
{/* 검색 필터 */}
|
||||||
<div className="glass-effect rounded-2xl p-6">
|
<div className="glass-effect rounded-2xl p-6 relative z-20">
|
||||||
<div className="flex gap-6">
|
<div className="flex gap-6">
|
||||||
{/* 좌측: 필터 영역 */}
|
{/* 좌측: 필터 영역 */}
|
||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<div className="flex items-start gap-3">
|
<div className="flex items-stretch gap-4">
|
||||||
{/* 빠른 날짜 선택 버튼 - 2x2 그리드 */}
|
{/* 1. 기간 선택 (Vertical) */}
|
||||||
<div className="grid grid-cols-2 gap-2">
|
<div className="flex-shrink-0">
|
||||||
<button
|
<DateRangePicker
|
||||||
onClick={setToday}
|
startDate={startDate}
|
||||||
className="h-10 bg-white/10 hover:bg-white/20 text-white text-xs px-2 rounded-lg transition-colors whitespace-nowrap"
|
endDate={endDate}
|
||||||
title="오늘 날짜로 설정"
|
onChange={(s, e) => {
|
||||||
>
|
setStartDate(s);
|
||||||
오늘
|
setEndDate(e);
|
||||||
</button>
|
}}
|
||||||
<button
|
align="vertical"
|
||||||
onClick={setYesterday}
|
/>
|
||||||
className="h-10 bg-white/10 hover:bg-white/20 text-white text-xs px-2 rounded-lg transition-colors whitespace-nowrap"
|
|
||||||
title="어제 날짜로 설정"
|
|
||||||
>
|
|
||||||
어제
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={setThisMonth}
|
|
||||||
className="h-10 bg-white/10 hover:bg-white/20 text-white text-xs px-2 rounded-lg transition-colors whitespace-nowrap"
|
|
||||||
title="이번 달 1일부터 말일까지"
|
|
||||||
>
|
|
||||||
이번달
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={setLastMonth}
|
|
||||||
className="h-10 bg-white/10 hover:bg-white/20 text-white text-xs px-2 rounded-lg transition-colors whitespace-nowrap"
|
|
||||||
title="저번달 1일부터 말일까지"
|
|
||||||
>
|
|
||||||
저번달
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 필터 입력 영역: 2행 2열 */}
|
{/* 2. 입력 필드 그룹 (담당자, 검색어) - 2행 */}
|
||||||
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
<div className="flex flex-col justify-between gap-2">
|
||||||
{/* 1행: 시작일, 담당자 */}
|
{/* 담당자 */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-white/70 text-sm font-medium whitespace-nowrap w-12">시작일</label>
|
<UserSelector
|
||||||
<input
|
users={users.map(u => ({
|
||||||
type="date"
|
id: u.id,
|
||||||
value={startDate}
|
name: u.name,
|
||||||
onChange={(e) => setStartDate(e.target.value)}
|
process: u.processs,
|
||||||
className="w-36 h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
|
level: u.level,
|
||||||
/>
|
useJobReport: u.useJobReport,
|
||||||
</div>
|
outdate: u.outdate
|
||||||
<div className="flex items-center gap-2">
|
}))}
|
||||||
<label className="text-white/70 text-sm font-medium whitespace-nowrap w-12">담당자</label>
|
includeResigned={true}
|
||||||
<select
|
onlyJobReportUsers={true}
|
||||||
value={selectedUser}
|
selectedIds={selectedUser ? [selectedUser] : []}
|
||||||
onChange={(e) => setSelectedUser(e.target.value)}
|
onChange={(ids) => setSelectedUser(ids[0] || '')}
|
||||||
className="w-44 h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
|
className="w-48"
|
||||||
>
|
placeholder="전체"
|
||||||
<option value="" className="bg-gray-800">전체</option>
|
|
||||||
{users.map((user) => (
|
|
||||||
<option key={user.id} value={user.id} className="bg-gray-800">
|
|
||||||
{user.name}({user.id})
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{/* 2행: 종료일, 검색어 */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label className="text-white/70 text-sm font-medium whitespace-nowrap w-12">종료일</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={endDate}
|
|
||||||
onChange={(e) => setEndDate(e.target.value)}
|
|
||||||
className="w-36 h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label className="text-white/70 text-sm font-medium whitespace-nowrap w-12">검색어</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={searchKey}
|
|
||||||
onChange={(e) => setSearchKey(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSearchWithReset()}
|
|
||||||
placeholder="프로젝트, 내용 등"
|
|
||||||
className="w-44 h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 버튼 영역: 우측 수직 배치 */}
|
{/* 3. 버튼 그룹 (높이 채우기) */}
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={handleSearchWithReset}
|
onClick={handleSearchWithReset}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="h-10 bg-primary-500 hover:bg-primary-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center disabled:opacity-50"
|
className="h-full min-h-[5.5rem] bg-primary-500 hover:bg-primary-600 border border-white/20 text-white px-6 rounded-lg transition-colors flex flex-col items-center justify-center gap-1 disabled:opacity-50 whitespace-nowrap"
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
<RefreshCw className="w-5 h-5 animate-spin" />
|
||||||
) : (
|
) : (
|
||||||
<Search className="w-4 h-4 mr-2" />
|
<Search className="w-5 h-5" />
|
||||||
)}
|
)}
|
||||||
조회
|
<span className="text-sm">조회</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={openAddModal}
|
onClick={openAddModal}
|
||||||
className="h-10 bg-success-500 hover:bg-success-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center"
|
className="h-full min-h-[5.5rem] bg-success-500 hover:bg-success-600 border border-white/20 text-white px-6 rounded-lg transition-colors flex flex-col items-center justify-center gap-1 whitespace-nowrap"
|
||||||
>
|
>
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<Plus className="w-5 h-5" />
|
||||||
추가
|
<span className="text-sm">추가</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 중앙: 집계 메뉴 */}
|
|
||||||
<div className="flex-shrink-0 flex flex-col gap-3 justify-center">
|
|
||||||
<button
|
|
||||||
onClick={() => setShowDayReportModal(true)}
|
|
||||||
className="h-10 bg-indigo-500 hover:bg-indigo-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center whitespace-nowrap"
|
|
||||||
>
|
|
||||||
<Calendar className="w-4 h-4 mr-2" />
|
|
||||||
일별 집계
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setShowTypeReportModal(true)}
|
|
||||||
className="h-10 bg-purple-500 hover:bg-purple-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center whitespace-nowrap"
|
|
||||||
>
|
|
||||||
<FileText className="w-4 h-4 mr-2" />
|
|
||||||
업무형태별 집계
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 미등록 업무일지 카드 */}
|
{/* 미등록 업무일지 카드 */}
|
||||||
<div className="flex-shrink-0 w-40">
|
<div className="flex-shrink-0 w-40">
|
||||||
@@ -633,13 +578,43 @@ export function Jobreport() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 데이터 테이블 */}
|
{/* 데이터 테이블 */}
|
||||||
<div className="glass-effect rounded-2xl overflow-hidden">
|
<div className="glass-effect rounded-2xl overflow-hidden shadow-2xl transition-all duration-300">
|
||||||
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
|
<div className="px-6 py-4 flex flex-col md:flex-row items-center justify-between gap-4">
|
||||||
<h3 className="text-lg font-semibold text-white flex items-center">
|
<div className="flex items-center gap-3">
|
||||||
<FileText className="w-5 h-5 mr-2" />
|
<div className="p-2 bg-primary-500/20 rounded-lg">
|
||||||
업무일지 목록
|
<FileText className="w-5 h-5 text-primary-400" />
|
||||||
</h3>
|
</div>
|
||||||
<span className="text-white/60 text-sm">{jobreportList.length}건</span>
|
<h3 className="text-lg font-bold text-[var(--text-primary)] tracking-tight">
|
||||||
|
업무일지 목록
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 w-full md:w-auto">
|
||||||
|
{/* 검색 필터 */}
|
||||||
|
<div className="relative flex-1 md:w-80 group">
|
||||||
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-[var(--text-secondary)] group-focus-within:text-primary-400 transition-colors" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchKey}
|
||||||
|
onChange={(e) => setSearchKey(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSearchWithReset()}
|
||||||
|
placeholder="검색..."
|
||||||
|
className="w-full bg-[var(--bg-paper)] border border-[var(--border-color)] rounded-xl pl-10 pr-10 py-2 text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary-500/50 transition-all text-sm placeholder-[var(--text-muted)]"
|
||||||
|
/>
|
||||||
|
{searchKey && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchKey('')}
|
||||||
|
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
|
>
|
||||||
|
<XCircle className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 bg-[var(--bg-paper)] px-3 py-1.5 rounded-lg border border-[var(--border-color)]">
|
||||||
|
<span className="text-[var(--text-primary)] font-bold text-sm">{jobreportList.length}</span>
|
||||||
|
<span className="text-[var(--text-secondary)] text-xs">건</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
@@ -793,6 +768,28 @@ export function Jobreport() {
|
|||||||
initialMonth={startDate.substring(0, 7)}
|
initialMonth={startDate.substring(0, 7)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 리스트가 하단 패널에 가려지지 않도록 빈 공간 추가 */}
|
||||||
|
<div className="h-24"></div>
|
||||||
|
|
||||||
|
{/* 하단 고정 상태바 (출력물 메뉴) */}
|
||||||
|
<div className="fixed bottom-12 left-0 right-0 z-40 bg-black/30 backdrop-blur-xl border-t border-white/10 h-14 flex items-center justify-center gap-4 shadow-2xl animate-slide-up">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowDayReportModal(true)}
|
||||||
|
className="h-9 bg-indigo-500 hover:bg-indigo-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center whitespace-nowrap text-sm shadow-lg border border-white/10"
|
||||||
|
>
|
||||||
|
<Calendar className="w-4 h-4 mr-2" />
|
||||||
|
일별 집계
|
||||||
|
</button>
|
||||||
|
<div className="w-px h-5 bg-white/20"></div>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowTypeReportModal(true)}
|
||||||
|
className="h-9 bg-purple-500 hover:bg-purple-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center whitespace-nowrap text-sm shadow-lg border border-white/10"
|
||||||
|
>
|
||||||
|
<FileText className="w-4 h-4 mr-2" />
|
||||||
|
업무형태별 집계
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 업무형태별 집계 모달 */}
|
{/* 업무형태별 집계 모달 */}
|
||||||
<JobreportTypeModal
|
<JobreportTypeModal
|
||||||
isOpen={showTypeReportModal}
|
isOpen={showTypeReportModal}
|
||||||
|
|||||||
@@ -10,21 +10,19 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
Edit,
|
Edit,
|
||||||
Copy,
|
Copy,
|
||||||
User,
|
|
||||||
ChevronLeft,
|
|
||||||
ChevronRight,
|
|
||||||
Filter
|
Filter
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import { DateRangePicker } from '@/components/DateRangePicker';
|
||||||
|
import { UserSelector } from '@/components/UserSelector';
|
||||||
import { comms } from '@/communication';
|
import { comms } from '@/communication';
|
||||||
import { KuntaeModel, HolydayPermission, HolydayUser, HolydayBalance } from '@/types';
|
import { KuntaeModel, HolydayPermission, GroupUser, HolydayBalance } from '@/types';
|
||||||
import { KuntaeEditModal, KuntaeFormData } from '@/components/kuntae/KuntaeEditModal';
|
import { KuntaeEditModal, KuntaeFormData } from '@/components/kuntae/KuntaeEditModal';
|
||||||
import { DevelopmentNotice } from '@/components/common/DevelopmentNotice';
|
import { DevelopmentNotice } from '@/components/DevelopmentNotice';
|
||||||
|
|
||||||
export function Kuntae() {
|
export function Kuntae() {
|
||||||
const [kuntaeList, setKuntaeList] = useState<KuntaeModel[]>([]);
|
const [kuntaeList, setKuntaeList] = useState<KuntaeModel[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [_processing, setProcessing] = useState(false);
|
const [processing, setProcessing] = useState(false);
|
||||||
|
|
||||||
// 검색 조건
|
// 검색 조건
|
||||||
const [startDate, setStartDate] = useState('');
|
const [startDate, setStartDate] = useState('');
|
||||||
const [endDate, setEndDate] = useState('');
|
const [endDate, setEndDate] = useState('');
|
||||||
@@ -33,7 +31,7 @@ export function Kuntae() {
|
|||||||
|
|
||||||
// 권한 및 사용자 목록
|
// 권한 및 사용자 목록
|
||||||
const [permission, setPermission] = useState<HolydayPermission | null>(null);
|
const [permission, setPermission] = useState<HolydayPermission | null>(null);
|
||||||
const [userList, setUserList] = useState<HolydayUser[]>([]);
|
const [userList, setUserList] = useState<GroupUser[]>([]);
|
||||||
|
|
||||||
// 모달 상태
|
// 모달 상태
|
||||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
@@ -56,13 +54,20 @@ export function Kuntae() {
|
|||||||
|
|
||||||
setStartDate(sd);
|
setStartDate(sd);
|
||||||
setEndDate(ed);
|
setEndDate(ed);
|
||||||
|
console.log("init");
|
||||||
// 권한 조회
|
// 권한 조회
|
||||||
try {
|
try {
|
||||||
const permResponse = await comms.getHolydayPermission();
|
const permResponse = await comms.getHolydayPermission();
|
||||||
if (permResponse.Success && permResponse.Data) {
|
console.log('permResponse', permResponse);
|
||||||
|
if (permResponse.Success) {
|
||||||
// @ts-ignore - API 응답 타입 불일치 해결
|
// @ts-ignore - API 응답 타입 불일치 해결
|
||||||
setPermission(permResponse.Data);
|
const permData = permResponse as any;
|
||||||
|
setPermission(permData);
|
||||||
|
|
||||||
|
// 기본값: 로그인된 사용자
|
||||||
|
if (permData.CurrentUserId) {
|
||||||
|
setSelectedUser(permData.CurrentUserId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('권한 조회 오류:', error);
|
console.error('권한 조회 오류:', error);
|
||||||
@@ -71,30 +76,47 @@ export function Kuntae() {
|
|||||||
init();
|
init();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 사용자 목록 로드 (기간 변경 시)
|
// 사용자 목록 로드 (컴포넌트 마운트 시 한 번만 로드하면 됨, 또는 기간과 무관하게 전체 로드 후 필터)
|
||||||
|
// 여기서는 getUserList를 사용하므로 startDate/endDate 의존성 제거
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const loadUsers = async () => {
|
const loadUsers = async () => {
|
||||||
if (!startDate || !endDate) return;
|
|
||||||
try {
|
try {
|
||||||
const response = await comms.getHolydayUserList(startDate, endDate);
|
const response = await comms.getUserList('');
|
||||||
// @ts-ignore - API 응답이 배열로 옴
|
if (response) {
|
||||||
if (Array.isArray(response)) {
|
const today = new Date().toISOString().split('T')[0];
|
||||||
setUserList(response);
|
const filtered = response.filter(u => {
|
||||||
} else if (response.Success && response.Data) {
|
// 1. 레벨 체크 (1 이상)
|
||||||
// @ts-ignore
|
if ((u.level || 0) < 1) return false;
|
||||||
setUserList(response.Data);
|
// 2. 퇴사자 제외
|
||||||
|
if (u.outdate && u.outdate < today) return false;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
setUserList(filtered);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('사용자 목록 로드 오류:', error);
|
console.error('사용자 목록 로드 오류:', error);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
loadUsers();
|
loadUsers();
|
||||||
}, [startDate, endDate]);
|
}, []);
|
||||||
|
|
||||||
// 데이터 및 잔량 로드
|
// 데이터 및 잔량 로드
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
if (!startDate || !endDate) return;
|
if (!startDate || !endDate) return;
|
||||||
|
|
||||||
|
// 권한 체크: 관리자(Level 5 이상)가 아닌 경우, 본인 외 조회 불가
|
||||||
|
// 초기 로드 시점 등 permission이 아직 없을 수 있으므로 체크
|
||||||
|
if (permission) {
|
||||||
|
const isManager = (permission.Level || 0) >= 5;
|
||||||
|
const currentUserId = permission.CurrentUserId;
|
||||||
|
|
||||||
|
if (!isManager && selectedUser !== currentUserId) {
|
||||||
|
alert('조회 권한이 없습니다. 본인 데이터로 조회합니다.');
|
||||||
|
setSelectedUser(currentUserId);
|
||||||
|
return; // setSelectedUser가 useEffect를 트리거하여 다시 loadData 실행됨
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
// 1. 목록 조회
|
// 1. 목록 조회
|
||||||
@@ -144,17 +166,7 @@ export function Kuntae() {
|
|||||||
);
|
);
|
||||||
}, [kuntaeList, filterText]);
|
}, [kuntaeList, filterText]);
|
||||||
|
|
||||||
// 월 이동
|
|
||||||
const moveMonth = (offset: number) => {
|
|
||||||
const current = new Date(startDate);
|
|
||||||
current.setMonth(current.getMonth() + offset);
|
|
||||||
|
|
||||||
const startOfMonth = new Date(current.getFullYear(), current.getMonth(), 1);
|
|
||||||
const endOfMonth = new Date(current.getFullYear(), current.getMonth() + 1, 0);
|
|
||||||
|
|
||||||
setStartDate(startOfMonth.toISOString().split('T')[0]);
|
|
||||||
setEndDate(endOfMonth.toISOString().split('T')[0]);
|
|
||||||
};
|
|
||||||
|
|
||||||
// 삭제
|
// 삭제
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (id: number) => {
|
||||||
@@ -239,63 +251,84 @@ export function Kuntae() {
|
|||||||
<DevelopmentNotice />
|
<DevelopmentNotice />
|
||||||
|
|
||||||
{/* 상단 컨트롤 바 */}
|
{/* 상단 컨트롤 바 */}
|
||||||
<div className="glass-effect rounded-2xl p-6">
|
<div className="glass-effect rounded-2xl p-6 relative z-20">
|
||||||
<div className="flex flex-col space-y-4">
|
<div className="flex flex-col space-y-4">
|
||||||
|
|
||||||
{/* 1행: 날짜, 사용자, 조회/등록 */}
|
{/* 1행: 날짜, 사용자, 조회/등록 */}
|
||||||
<div className="flex flex-col md:flex-row gap-4 items-end md:items-center justify-between">
|
<div className="flex flex-col md:flex-row gap-4 items-end md:items-center justify-between">
|
||||||
{/* 날짜 선택 및 월 이동 */}
|
{/* 날짜 선택 */}
|
||||||
<div className="flex items-center gap-2 w-full md:w-auto">
|
<div className="flex items-center gap-2 w-full md:w-auto">
|
||||||
<button
|
<DateRangePicker
|
||||||
onClick={() => moveMonth(-1)}
|
startDate={startDate}
|
||||||
className="p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors"
|
endDate={endDate}
|
||||||
>
|
onChange={(s, e) => {
|
||||||
<ChevronLeft className="w-5 h-5" />
|
setStartDate(s);
|
||||||
</button>
|
setEndDate(e);
|
||||||
|
}}
|
||||||
<div className="grid grid-cols-2 gap-2">
|
align="vertical"
|
||||||
<input
|
/>
|
||||||
type="date"
|
|
||||||
value={startDate}
|
|
||||||
onChange={(e) => setStartDate(e.target.value)}
|
|
||||||
className="bg-white/20 border border-white/30 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 text-sm"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={endDate}
|
|
||||||
onChange={(e) => setEndDate(e.target.value)}
|
|
||||||
className="bg-white/20 border border-white/30 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 text-sm"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={() => moveMonth(1)}
|
|
||||||
className="p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors"
|
|
||||||
>
|
|
||||||
<ChevronRight className="w-5 h-5" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 사용자 선택 (관리자용) */}
|
{/* 사용자 선택 */}
|
||||||
{permission?.CanManage && (
|
<div className="w-full md:w-64">
|
||||||
<div className="w-full md:w-64">
|
<UserSelector
|
||||||
<div className="relative">
|
users={userList.map(u => ({
|
||||||
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-white/50" />
|
id: u.id,
|
||||||
<select
|
name: u.name,
|
||||||
value={selectedUser}
|
level: u.level,
|
||||||
onChange={(e) => setSelectedUser(e.target.value)}
|
outdate: u.outdate
|
||||||
className="w-full bg-white/20 border border-white/30 rounded-lg pl-10 pr-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 appearance-none"
|
}))}
|
||||||
>
|
selectedIds={selectedUser ? [selectedUser] : []}
|
||||||
<option value="%" className="bg-[#1e1e2e]">전체 사용자</option>
|
onChange={(ids) => setSelectedUser(ids[0] || '')}
|
||||||
{userList.map(user => (
|
placeholder="사용자 선택"
|
||||||
<option key={user.uid} value={user.uid} className="bg-[#1e1e2e]">
|
className="w-full"
|
||||||
{user.UserName} ({user.uid})
|
/>
|
||||||
</option>
|
</div>
|
||||||
))}
|
|
||||||
</select>
|
{/* 잔량 정보 (Compact) */}
|
||||||
</div>
|
<div className="flex items-center gap-2 overflow-x-auto no-scrollbar hidden md:flex">
|
||||||
</div>
|
{balances.length > 0 ? (
|
||||||
)}
|
balances.map((bal, idx) => {
|
||||||
|
const remainDays = bal.TotalGenDays - bal.TotalUseDays;
|
||||||
|
const remainHours = bal.TotalGenHours - bal.TotalUseHours;
|
||||||
|
|
||||||
|
let icon = <Clock className="w-4 h-4" />;
|
||||||
|
let color = "text-white";
|
||||||
|
if (bal.cate === '연차') {
|
||||||
|
icon = <Calendar className="w-4 h-4 text-primary-400" />;
|
||||||
|
color = "text-primary-400";
|
||||||
|
} else if (bal.cate === '대체') {
|
||||||
|
icon = <RefreshCw className="w-4 h-4 text-success-400" />;
|
||||||
|
color = "text-success-400";
|
||||||
|
} else if (bal.cate === '휴가') {
|
||||||
|
icon = <CheckCircle className="w-4 h-4 text-warning-400" />;
|
||||||
|
color = "text-warning-400";
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={idx} className="flex items-center gap-2 bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 whitespace-nowrap" title={`발생: ${bal.TotalGenDays} / 사용: ${bal.TotalUseDays}`}>
|
||||||
|
{icon}
|
||||||
|
<span className="text-sm text-white/70">{bal.cate}</span>
|
||||||
|
<span className={`text-sm font-bold ${color}`}>
|
||||||
|
{remainDays}일
|
||||||
|
{remainHours > 0 && <span className="ml-1 text-xs opacity-70">({remainHours}h)</span>}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 whitespace-nowrap">
|
||||||
|
<Calendar className="w-4 h-4 text-white/30" />
|
||||||
|
<span className="text-sm text-white/50">연차 -</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 whitespace-nowrap">
|
||||||
|
<RefreshCw className="w-4 h-4 text-white/30" />
|
||||||
|
<span className="text-sm text-white/50">대체 -</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 조회 및 등록 버튼 */}
|
{/* 조회 및 등록 버튼 */}
|
||||||
<div className="flex gap-2 w-full md:w-auto">
|
<div className="flex gap-2 w-full md:w-auto">
|
||||||
@@ -317,136 +350,107 @@ export function Kuntae() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 2행: 검색 필터 */}
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="relative flex-1 max-w-md">
|
|
||||||
<Filter className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-white/50" />
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
{/* 데이터 테이블 */}
|
||||||
|
<div className="glass-effect rounded-2xl overflow-hidden shadow-2xl transition-all duration-300">
|
||||||
|
<div className="px-6 py-4 flex flex-col md:flex-row items-center justify-between gap-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="p-2 bg-primary-500/20 rounded-lg">
|
||||||
|
<Clock className="w-5 h-5 text-primary-400" />
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg font-bold text-[var(--text-primary)] tracking-tight">
|
||||||
|
근태 상세 내역
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-4 w-full md:w-auto">
|
||||||
|
{/* 검색 필터 */}
|
||||||
|
<div className="relative flex-1 md:w-64 group">
|
||||||
|
<Filter className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-[var(--text-secondary)] group-focus-within:text-primary-400 transition-colors" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={filterText}
|
value={filterText}
|
||||||
onChange={(e) => setFilterText(e.target.value)}
|
onChange={(e) => setFilterText(e.target.value)}
|
||||||
placeholder="구분, 내용, 성명으로 검색..."
|
placeholder="검색..."
|
||||||
className="w-full bg-white/10 border border-white/20 rounded-lg pl-10 pr-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 text-sm placeholder-white/30"
|
className="w-full bg-[var(--bg-paper)] border border-[var(--border-color)] rounded-xl pl-10 pr-10 py-2 text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary-500/50 transition-all text-sm placeholder-[var(--text-muted)]"
|
||||||
/>
|
/>
|
||||||
{filterText && (
|
{filterText && (
|
||||||
<button
|
<button
|
||||||
onClick={() => setFilterText('')}
|
onClick={() => setFilterText('')}
|
||||||
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-white/50 hover:text-white"
|
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
>
|
>
|
||||||
<XCircle className="w-4 h-4" />
|
<XCircle className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-1 bg-[var(--bg-paper)] px-3 py-1.5 rounded-lg border border-[var(--border-color)]">
|
||||||
|
<span className="text-[var(--text-primary)] font-bold text-sm">{filteredList.length}</span>
|
||||||
|
<span className="text-[var(--text-secondary)] text-xs">건</span>
|
||||||
|
{kuntaeList.length !== filteredList.length && <span className="text-[var(--text-muted)] text-xs ml-1">(전체 {kuntaeList.length})</span>}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 통계 카드 (잔량 정보) */}
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
|
|
||||||
{balances.length > 0 ? (
|
|
||||||
balances.map((bal, idx) => {
|
|
||||||
// 잔량 계산
|
|
||||||
const remainDays = bal.TotalGenDays - bal.TotalUseDays;
|
|
||||||
const remainHours = bal.TotalGenHours - bal.TotalUseHours;
|
|
||||||
|
|
||||||
// 아이콘 및 색상 결정
|
|
||||||
let icon = <Clock className="w-6 h-6" />;
|
|
||||||
let color = "text-white";
|
|
||||||
|
|
||||||
if (bal.cate === '연차') {
|
|
||||||
icon = <Calendar className="w-6 h-6 text-primary-400" />;
|
|
||||||
color = "text-primary-400";
|
|
||||||
} else if (bal.cate === '대체') {
|
|
||||||
icon = <RefreshCw className="w-6 h-6 text-success-400" />;
|
|
||||||
color = "text-success-400";
|
|
||||||
} else if (bal.cate === '휴가') {
|
|
||||||
icon = <CheckCircle className="w-6 h-6 text-warning-400" />;
|
|
||||||
color = "text-warning-400";
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StatCard
|
|
||||||
key={idx}
|
|
||||||
title={`${bal.cate} 잔량`}
|
|
||||||
value={`${remainDays}일 ${remainHours > 0 ? `(${remainHours}h)` : ''}`}
|
|
||||||
subValue={`발생: ${bal.TotalGenDays} / 사용: ${bal.TotalUseDays}`}
|
|
||||||
icon={icon}
|
|
||||||
color={color}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
// 데이터 없을 때 기본 카드 표시
|
|
||||||
<>
|
|
||||||
<StatCard title="연차 잔량" value="-" icon={<Calendar className="w-6 h-6 text-white/30" />} color="text-white/30" />
|
|
||||||
<StatCard title="대체 잔량" value="-" icon={<RefreshCw className="w-6 h-6 text-white/30" />} color="text-white/30" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 데이터 테이블 */}
|
|
||||||
<div className="glass-effect rounded-2xl overflow-hidden">
|
|
||||||
<div className="px-6 py-4 border-b border-white/10 flex justify-between items-center">
|
|
||||||
<h3 className="text-lg font-semibold text-white">근태 상세 내역</h3>
|
|
||||||
<span className="text-white/50 text-sm">
|
|
||||||
총 {filteredList.length}건
|
|
||||||
{kuntaeList.length !== filteredList.length && ` (전체 ${kuntaeList.length}건 중)`}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<thead className="bg-white/10">
|
<thead className="bg-[var(--bg-paper)]/50">
|
||||||
<tr>
|
<tr>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">구분</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--text-secondary)] uppercase w-[80px]">구분</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">시작일</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--text-secondary)] uppercase">시작일</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">종료일</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--text-secondary)] uppercase">종료일</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">사용(일)</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--text-secondary)] uppercase">사용(일)</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">사용(시간)</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--text-secondary)] uppercase">사용(시간)</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">내용</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--text-secondary)] uppercase">내용</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">성명</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--text-secondary)] uppercase">성명</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">소스</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--text-secondary)] uppercase">소스</th>
|
||||||
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">작업</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--text-secondary)] uppercase">작업</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody className="divide-y divide-white/10">
|
<tbody className="divide-y divide-[var(--border-color)]">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={9} className="px-4 py-8 text-center">
|
<td colSpan={9} className="px-4 py-8 text-center">
|
||||||
<div className="flex items-center justify-center">
|
<div className="flex items-center justify-center">
|
||||||
<RefreshCw className="w-5 h-5 mr-2 animate-spin text-white/50" />
|
<RefreshCw className="w-5 h-5 mr-2 animate-spin text-[var(--text-secondary)]" />
|
||||||
<span className="text-white/50">데이터를 불러오는 중...</span>
|
<span className="text-[var(--text-secondary)]">데이터를 불러오는 중...</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : filteredList.length === 0 ? (
|
) : filteredList.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={9} className="px-4 py-8 text-center text-white/50">
|
<td colSpan={9} className="px-4 py-8 text-center text-[var(--text-secondary)]">
|
||||||
{filterText ? '검색 결과가 없습니다.' : '조회된 데이터가 없습니다.'}
|
{filterText ? '검색 결과가 없습니다.' : '조회된 데이터가 없습니다.'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
filteredList.map((item) => (
|
filteredList.map((item) => (
|
||||||
<tr key={item.idx} className={`hover:bg-white/5 transition-colors ${item.extidx ? 'bg-black/20' : ''}`}>
|
<tr key={item.idx} className={`hover:bg-[var(--bg-paper)]/30 transition-colors ${item.extidx ? 'bg-black/20' : ''}`}>
|
||||||
<td className="px-4 py-3 text-white text-sm">
|
<td className="px-4 py-3 text-[var(--text-primary)] text-sm">
|
||||||
<span className={`px-2 py-1 rounded text-xs ${item.cate === '연차' ? 'bg-primary-500/20 text-primary-300' :
|
<span className={`px-2 py-1 rounded text-xs ${item.cate === '연차' ? 'bg-primary-500/20 text-primary-300' :
|
||||||
item.cate === '대체' ? 'bg-success-500/20 text-success-300' :
|
item.cate === '대체' ? 'bg-success-500/20 text-success-300' :
|
||||||
'bg-white/10 text-white/70'
|
'bg-[var(--bg-paper)] text-[var(--text-secondary)]'
|
||||||
}`}>
|
}`}>
|
||||||
{item.cate || '-'}
|
{item.cate || '-'}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-white text-sm">{formatDate(item.sdate)}</td>
|
<td className="px-4 py-3 text-[var(--text-primary)] text-sm">{formatDate(item.sdate)}</td>
|
||||||
<td className="px-4 py-3 text-white text-sm">{formatDate(item.edate)}</td>
|
<td className="px-4 py-3 text-[var(--text-primary)] text-sm">{formatDate(item.edate)}</td>
|
||||||
<td className="px-4 py-3 text-white text-sm">{item.term > 0 ? item.term : '-'}</td>
|
<td className="px-4 py-3 text-[var(--text-primary)] text-sm">{item.term > 0 ? item.term : '-'}</td>
|
||||||
<td className="px-4 py-3 text-white text-sm">{item.crtime > 0 ? item.crtime : '-'}</td>
|
<td className="px-4 py-3 text-[var(--text-primary)] text-sm">{item.crtime > 0 ? item.crtime : '-'}</td>
|
||||||
<td className="px-4 py-3 text-white/80 text-sm max-w-xs truncate" title={item.contents}>
|
<td className="px-4 py-3 text-[var(--text-primary)]/80 text-sm max-w-xs truncate" title={item.contents}>
|
||||||
{item.contents || '-'}
|
{item.contents || '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-white text-sm">
|
<td className="px-4 py-3 text-[var(--text-primary)] text-sm">
|
||||||
{item.UserName || item.uid}
|
{item.UserName || item.uid}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-white/50 text-xs">
|
<td className="px-4 py-3 text-[var(--text-secondary)] text-xs">
|
||||||
{item.extcate ? `${item.extcate}` : '-'}
|
{item.extcate ? `${item.extcate}` : '-'}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3 text-sm">
|
<td className="px-4 py-3 text-sm">
|
||||||
@@ -468,11 +472,11 @@ export function Kuntae() {
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => handleDelete(item.idx)}
|
onClick={() => handleDelete(item.idx)}
|
||||||
className={`text-danger-400 hover:text-danger-300 transition-colors ${item.extidx ? 'opacity-50 cursor-not-allowed' : ''}`}
|
className={`text-danger-400 hover:text-danger-300 transition-colors ${item.extidx || processing ? 'opacity-50 cursor-not-allowed' : ''}`}
|
||||||
title={item.extidx ? "외부 연동 데이터는 삭제할 수 없습니다" : "삭제"}
|
title={item.extidx ? "외부 연동 데이터는 삭제할 수 없습니다" : "삭제"}
|
||||||
disabled={!!item.extidx}
|
disabled={!!item.extidx || processing}
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
{processing ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -496,28 +500,4 @@ export function Kuntae() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 통계 카드 컴포넌트
|
|
||||||
interface StatCardProps {
|
|
||||||
title: string;
|
|
||||||
value: string | number;
|
|
||||||
subValue?: string;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
color: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function StatCard({ title, value, subValue, icon, color }: StatCardProps) {
|
|
||||||
return (
|
|
||||||
<div className="glass-effect rounded-xl p-4 card-hover">
|
|
||||||
<div className="flex items-center">
|
|
||||||
<div className={`p-3 rounded-lg ${color.replace('text-', 'bg-').replace('-400', '-500/20')}`}>
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
<div className="ml-4">
|
|
||||||
<p className="text-sm font-medium text-white/70">{title}</p>
|
|
||||||
<p className={`text-xl font-bold ${color}`}>{value}</p>
|
|
||||||
{subValue && <p className="text-xs text-white/40 mt-1">{subValue}</p>}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Mail, Search, RefreshCw, Calendar, ChevronLeft, ChevronRight } from 'lucide-react';
|
import { Mail, Search, RefreshCw, Calendar, ChevronLeft, ChevronRight, X } from 'lucide-react';
|
||||||
import { comms } from '@/communication';
|
import { comms } from '@/communication';
|
||||||
import { MailItem, UserInfo } from '@/types';
|
import { MailItem, UserInfo } from '@/types';
|
||||||
import { MailTestDialog } from '@/components/mail/MailTestDialog';
|
import { MailTestDialog } from '@/components/mail/MailTestDialog';
|
||||||
|
import { DateRangePicker } from '@/components/DateRangePicker';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
|
||||||
export function MailList() {
|
export function MailList() {
|
||||||
const [mailList, setMailList] = useState<MailItem[]>([]);
|
const [mailList, setMailList] = useState<MailItem[]>([]);
|
||||||
@@ -42,10 +44,10 @@ export function MailList() {
|
|||||||
|
|
||||||
const start = formatDateLocal(tenDaysAgo);
|
const start = formatDateLocal(tenDaysAgo);
|
||||||
const end = formatDateLocal(now);
|
const end = formatDateLocal(now);
|
||||||
|
|
||||||
setStartDate(start);
|
setStartDate(start);
|
||||||
setEndDate(end);
|
setEndDate(end);
|
||||||
|
|
||||||
// 날짜 설정 후 바로 데이터 로드
|
// 날짜 설정 후 바로 데이터 로드
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
loadDataWithDates(start, end);
|
loadDataWithDates(start, end);
|
||||||
@@ -119,117 +121,137 @@ export function MailList() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-fade-in">
|
<div className="space-y-6 animate-fade-in">
|
||||||
{/* 검색 필터 */}
|
|
||||||
<div className="glass-effect rounded-2xl p-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label className="text-white/70 text-sm font-medium whitespace-nowrap">기간</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={startDate}
|
|
||||||
onChange={(e) => setStartDate(e.target.value)}
|
|
||||||
className="w-36 h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
|
|
||||||
/>
|
|
||||||
<span className="text-white/70">~</span>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={endDate}
|
|
||||||
onChange={(e) => setEndDate(e.target.value)}
|
|
||||||
className="w-36 h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2 flex-1">
|
|
||||||
<label className="text-white/70 text-sm font-medium whitespace-nowrap">검색어</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={searchKey}
|
|
||||||
onChange={(e) => setSearchKey(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
|
||||||
placeholder="제목, 발신자, 수신자 등"
|
|
||||||
className="flex-1 h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleSearch}
|
|
||||||
disabled={loading}
|
|
||||||
className="h-10 bg-primary-500 hover:bg-primary-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Search className="w-4 h-4 mr-2" />
|
|
||||||
)}
|
|
||||||
조회
|
|
||||||
</button>
|
|
||||||
|
|
||||||
{currentUser && currentUser.Level >= 9 && (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowTestDialog(true)}
|
|
||||||
className="h-10 bg-green-500 hover:bg-green-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<Mail className="w-4 h-4 mr-2" />
|
|
||||||
테스트
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 메일 내역 목록 */}
|
{/* 메일 내역 목록 */}
|
||||||
<div className="glass-effect rounded-2xl overflow-hidden">
|
<div className="glass-effect rounded-3xl overflow-hidden shadow-2xl border border-white/10">
|
||||||
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
|
<div className="px-6 py-4 border-b border-white/10 flex flex-col xl:flex-row items-center justify-between gap-4 bg-white/5">
|
||||||
<h3 className="text-lg font-semibold text-white flex items-center">
|
<div className="flex items-center gap-3">
|
||||||
<Mail className="w-5 h-5 mr-2" />
|
<div className="p-2 bg-primary-500/20 rounded-lg">
|
||||||
메일 발신 내역
|
<Mail className="w-5 h-5 text-primary-400" />
|
||||||
</h3>
|
</div>
|
||||||
<span className="text-white/60 text-sm">{mailList.length}건</span>
|
<h3 className="text-lg font-bold text-white tracking-tight">메일 발신 내역</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
{/* 공용 날짜 컨트롤 */}
|
||||||
|
<DateRangePicker
|
||||||
|
startDate={startDate}
|
||||||
|
endDate={endDate}
|
||||||
|
onChange={(start, end) => {
|
||||||
|
setStartDate(start);
|
||||||
|
setEndDate(end);
|
||||||
|
loadDataWithDates(start, end);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 검색창 */}
|
||||||
|
<div className="relative group w-48 md:w-64">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-white/40 group-focus-within:text-primary-400 transition-colors" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchKey}
|
||||||
|
onChange={(e) => setSearchKey(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
|
||||||
|
placeholder="검색어..."
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-xl pl-9 pr-8 py-1.5 text-xs text-white placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all backdrop-blur-sm"
|
||||||
|
/>
|
||||||
|
{searchKey && (
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSearchKey('');
|
||||||
|
loadData();
|
||||||
|
}}
|
||||||
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-white/20 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<X className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 개수 */}
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-3 py-1.5 rounded-xl border border-white/10 h-[38px]">
|
||||||
|
<span className="text-primary-400 font-bold text-sm">{mailList.length}</span>
|
||||||
|
<span className="text-white/40 text-[10px] uppercase">건</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 새로고침 */}
|
||||||
|
<button
|
||||||
|
onClick={handleSearch}
|
||||||
|
disabled={loading}
|
||||||
|
className="p-2 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl text-white/70 hover:text-white transition-all disabled:opacity-50"
|
||||||
|
title="새로고침"
|
||||||
|
>
|
||||||
|
<RefreshCw className={clsx("w-4 h-4", loading && "animate-spin")} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 테스트 버튼 */}
|
||||||
|
{currentUser && currentUser.Level >= 9 && (
|
||||||
|
<button
|
||||||
|
onClick={() => setShowTestDialog(true)}
|
||||||
|
className="px-4 py-1.5 bg-success-500 hover:bg-success-600 border border-white/20 rounded-xl text-white text-xs font-bold transition-all shadow-lg shadow-success-500/20 active:scale-95 flex items-center gap-2"
|
||||||
|
title="메일 발송 테스트"
|
||||||
|
>
|
||||||
|
<Mail className="w-3.5 h-3.5" />
|
||||||
|
테스트
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="divide-y divide-white/10 max-h-[calc(100vh-380px)] overflow-y-auto">
|
<div className="divide-y divide-white/5 max-h-[calc(100vh-280px)] overflow-y-auto custom-scrollbar">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="px-6 py-8 text-center">
|
<div className="px-6 py-12 text-center">
|
||||||
<div className="flex items-center justify-center">
|
<RefreshCw className="w-10 h-10 mx-auto mb-4 animate-spin text-primary-500/50" />
|
||||||
<RefreshCw className="w-5 h-5 mr-2 animate-spin text-white/50" />
|
<p className="text-white/50 font-medium text-sm">데이터를 실시간으로 동기화 중...</p>
|
||||||
<span className="text-white/50">데이터를 불러오는 중...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : mailList.length === 0 ? (
|
) : mailList.length === 0 ? (
|
||||||
<div className="px-6 py-8 text-center">
|
<div className="px-6 py-20 text-center">
|
||||||
<Mail className="w-12 h-12 mx-auto mb-3 text-white/30" />
|
<div className="relative inline-block mb-4">
|
||||||
<p className="text-white/50">조회된 데이터가 없습니다.</p>
|
<Mail className="w-16 h-16 mx-auto text-white/10" />
|
||||||
|
</div>
|
||||||
|
<p className="text-white/30 font-medium">조회된 메일 내역이 없습니다.</p>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
paginatedList.map((item) => (
|
paginatedList.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.idx}
|
key={item.idx}
|
||||||
className={`px-6 py-4 transition-colors ${currentUser && currentUser.Level >= 9 ? 'hover:bg-white/5 cursor-pointer' : 'cursor-default'}`}
|
className={clsx(
|
||||||
|
"group px-6 py-3.5 transition-all relative border-b border-white/[0.02]",
|
||||||
|
currentUser && currentUser.Level >= 9 ? 'hover:bg-white/[0.03] cursor-pointer' : 'cursor-default'
|
||||||
|
)}
|
||||||
onClick={() => handleRowClick(item)}
|
onClick={() => handleRowClick(item)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center justify-between gap-6">
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<h4 className="text-sm font-bold text-white group-hover:text-primary-400 transition-colors mb-2 truncate">
|
||||||
|
{item.subject}
|
||||||
|
</h4>
|
||||||
|
<div className="flex items-center gap-4 text-xs font-medium uppercase tracking-tight">
|
||||||
{item.cate && (
|
{item.cate && (
|
||||||
<span className="px-2 py-0.5 bg-primary-500/20 text-primary-400 text-xs rounded">
|
<span className="px-1.5 py-0.5 bg-primary-500/10 text-primary-400 font-bold rounded border border-primary-500/20 mr-1">
|
||||||
{item.cate}
|
{item.cate}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
{item.project && (
|
{item.project && (
|
||||||
<span className="px-2 py-0.5 bg-white/10 text-white/70 text-xs rounded">
|
<span className="px-1.5 py-0.5 bg-white/5 text-white/40 font-bold rounded border border-white/10 mr-1">
|
||||||
{item.project}
|
{item.project}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
<div className="flex items-center gap-1.5 text-white/30">
|
||||||
<h4 className="text-white font-medium mb-1">{item.subject}</h4>
|
<span className="text-white/20 italic font-bold">FROM:</span>
|
||||||
<div className="flex items-center gap-4 text-white/60 text-sm">
|
<span className="text-white/60 truncate max-w-[180px]">{item.fromlist}</span>
|
||||||
<div>발신: {item.fromlist}</div>
|
</div>
|
||||||
<div>수신: {item.tolist}</div>
|
<div className="flex items-center gap-1.5 text-white/30">
|
||||||
|
<span className="text-white/20 italic font-bold">TO:</span>
|
||||||
|
<span className="text-white/60 truncate max-w-[180px]">{item.tolist}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-end gap-1 flex-shrink-0">
|
<div className="flex flex-col items-end gap-2 shrink-0">
|
||||||
<div className="flex items-center text-white/60 text-xs">
|
<div className="flex items-center gap-2 px-3 py-1.5 bg-white/5 rounded-lg border border-white/5">
|
||||||
<Calendar className="w-3 h-3 mr-1" />
|
<Calendar className="w-3.5 h-3.5 text-white/30" />
|
||||||
{formatDate(item.wdate)}
|
<span className="text-sm text-white/50 font-mono">{formatDate(item.wdate)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -264,63 +286,70 @@ export function MailList() {
|
|||||||
|
|
||||||
{/* 상세 모달 */}
|
{/* 상세 모달 */}
|
||||||
{showModal && selectedItem && (
|
{showModal && selectedItem && (
|
||||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md animate-fade-in">
|
||||||
<div className="bg-gray-900 rounded-2xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden border border-white/10">
|
<div className="bg-[#1a1b2e]/90 rounded-3xl shadow-2xl w-full max-w-5xl max-h-[90vh] overflow-hidden border border-white/10 flex flex-col backdrop-blur-xl">
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
|
{/* 헤더 */}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center justify-between px-8 py-6 border-b border-white/10 bg-white/5">
|
||||||
{selectedItem.cate && (
|
<div className="flex items-center gap-4">
|
||||||
<span className="px-2 py-1 bg-primary-500/20 text-primary-400 text-sm rounded">
|
<div className="flex items-center gap-2">
|
||||||
{selectedItem.cate}
|
{selectedItem.cate && (
|
||||||
</span>
|
<span className="px-2.5 py-1 bg-primary-500/10 text-primary-400 text-[10px] font-bold rounded-md border border-primary-500/20 uppercase tracking-wider">
|
||||||
)}
|
{selectedItem.cate}
|
||||||
<h2 className="text-xl font-bold text-white ml-2">{selectedItem.subject}</h2>
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h2 className="text-xl font-bold text-white tracking-tight">{selectedItem.subject}</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowModal(false)}
|
onClick={() => setShowModal(false)}
|
||||||
className="text-white/50 hover:text-white transition-colors"
|
className="p-2 hover:bg-white/10 rounded-full text-white/40 hover:text-white transition-all transform hover:rotate-90"
|
||||||
>
|
>
|
||||||
<span className="text-2xl">×</span>
|
<X className="w-6 h-6" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-6 py-4 border-b border-white/10 space-y-2 text-sm">
|
{/* 메타 정보 */}
|
||||||
<div className="flex items-start gap-2 text-white/70">
|
<div className="px-8 py-6 border-b border-white/5 bg-white/[0.02] space-y-3">
|
||||||
<span className="font-medium w-16">발신:</span>
|
<div className="flex items-start gap-4">
|
||||||
<span className="text-white">{selectedItem.fromlist}</span>
|
<span className="text-[10px] font-bold text-white/20 uppercase tracking-widest w-20 pt-1">발신자</span>
|
||||||
|
<span className="text-sm text-white/80 font-medium">{selectedItem.fromlist}</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-start gap-2 text-white/70">
|
<div className="flex items-start gap-4 border-t border-white/5 pt-3">
|
||||||
<span className="font-medium w-16">수신:</span>
|
<span className="text-[10px] font-bold text-white/20 uppercase tracking-widest w-20 pt-1">수신자</span>
|
||||||
<span className="text-white">{selectedItem.tolist}</span>
|
<span className="text-sm text-white/80 font-medium">{selectedItem.tolist}</span>
|
||||||
</div>
|
</div>
|
||||||
{selectedItem.cclist && (
|
{selectedItem.cclist && (
|
||||||
<div className="flex items-start gap-2 text-white/70">
|
<div className="flex items-start gap-4 border-t border-white/5 pt-3">
|
||||||
<span className="font-medium w-16">참조:</span>
|
<span className="text-[10px] font-bold text-white/20 uppercase tracking-widest w-20 pt-1">참조</span>
|
||||||
<span className="text-white">{selectedItem.cclist}</span>
|
<span className="text-sm text-white/80 font-medium">{selectedItem.cclist}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{selectedItem.bcclist && (
|
{selectedItem.bcclist && (
|
||||||
<div className="flex items-start gap-2 text-white/70">
|
<div className="flex items-start gap-4 border-t border-white/5 pt-3">
|
||||||
<span className="font-medium w-16">숨은참조:</span>
|
<span className="text-[10px] font-bold text-white/20 uppercase tracking-widest w-20 pt-1">숨은참조</span>
|
||||||
<span className="text-white">{selectedItem.bcclist}</span>
|
<span className="text-sm text-white/80 font-medium">{selectedItem.bcclist}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2 text-white/60">
|
<div className="flex items-center gap-2 pt-3 text-[10px] font-bold text-white/30 uppercase tracking-widest border-t border-white/5">
|
||||||
<Calendar className="w-4 h-4" />
|
<Calendar className="w-3.5 h-3.5" />
|
||||||
{formatDate(selectedItem.wdate)}
|
<span>발송 일시:</span>
|
||||||
|
<span className="text-white/50">{formatDate(selectedItem.wdate)}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="overflow-y-auto max-h-[calc(90vh-280px)] p-6">
|
{/* 본문 */}
|
||||||
<div
|
<div className="flex-1 overflow-y-auto p-8 bg-white/5 custom-scrollbar">
|
||||||
className="prose prose-invert max-w-none"
|
<div
|
||||||
|
className="prose prose-invert max-w-none text-white/90 leading-relaxed text-[15px]"
|
||||||
dangerouslySetInnerHTML={{ __html: selectedItem.htmlbody }}
|
dangerouslySetInnerHTML={{ __html: selectedItem.htmlbody }}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center justify-end px-6 py-4 border-t border-white/10 bg-white/5">
|
{/* 푸터 */}
|
||||||
|
<div className="flex items-center justify-end px-8 py-6 border-t border-white/10 bg-white/5">
|
||||||
<button
|
<button
|
||||||
onClick={() => setShowModal(false)}
|
onClick={() => setShowModal(false)}
|
||||||
className="px-4 py-2 rounded-lg bg-white/10 hover:bg-white/20 text-white transition-colors"
|
className="px-6 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-white/70 hover:text-white text-sm font-bold transition-all active:scale-95"
|
||||||
>
|
>
|
||||||
닫기
|
닫기
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -8,22 +8,19 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
Share2,
|
Share2,
|
||||||
Lock,
|
Lock,
|
||||||
|
XCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { comms } from '@/communication';
|
import { comms } from '@/communication';
|
||||||
import { NoteItem } from '@/types';
|
import { NoteItem } from '@/types';
|
||||||
import { NoteEditModal } from '@/components/note/NoteEditModal';
|
import { NoteEditModal } from '@/components/note/NoteEditModal';
|
||||||
import { NoteViewModal } from '@/components/note/NoteViewModal';
|
import { NoteViewModal } from '@/components/note/NoteViewModal';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
|
||||||
export function Note() {
|
export function Note() {
|
||||||
const [noteList, setNoteList] = useState<NoteItem[]>([]);
|
const [noteList, setNoteList] = useState<NoteItem[]>([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [processing, setProcessing] = useState(false);
|
const [processing, setProcessing] = useState(false);
|
||||||
|
|
||||||
// 검색 조건
|
|
||||||
const [startDate, setStartDate] = useState('');
|
|
||||||
const [endDate, setEndDate] = useState('');
|
|
||||||
const [searchKey, setSearchKey] = useState('');
|
const [searchKey, setSearchKey] = useState('');
|
||||||
|
|
||||||
// 모달 상태
|
// 모달 상태
|
||||||
const [showModal, setShowModal] = useState(false);
|
const [showModal, setShowModal] = useState(false);
|
||||||
const [showViewModal, setShowViewModal] = useState(false);
|
const [showViewModal, setShowViewModal] = useState(false);
|
||||||
@@ -32,53 +29,26 @@ export function Note() {
|
|||||||
|
|
||||||
// 페이징 상태
|
// 페이징 상태
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const pageSize = 10;
|
const pageSize = 100;
|
||||||
|
|
||||||
// 날짜 포맷 헬퍼 함수 (로컬 시간 기준)
|
// 날짜 포맷 헬퍼 함수 (로컬 시간 기준)
|
||||||
const formatDateLocal = (date: Date) => {
|
const formatDateLocal = (date: Date) => {
|
||||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 초기화 완료 플래그
|
|
||||||
const [initialized, setInitialized] = useState(false);
|
|
||||||
|
|
||||||
// 날짜 초기화
|
|
||||||
useEffect(() => {
|
|
||||||
const now = new Date();
|
|
||||||
// 2000년부터 현재까지 데이터 조회
|
|
||||||
const startOfPeriod = new Date(2000, 0, 1);
|
|
||||||
|
|
||||||
const sd = formatDateLocal(startOfPeriod);
|
|
||||||
const ed = formatDateLocal(now);
|
|
||||||
|
|
||||||
setStartDate(sd);
|
|
||||||
setEndDate(ed);
|
|
||||||
|
|
||||||
// 초기화 완료 표시
|
|
||||||
setInitialized(true);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// 초기화 완료 후 조회 실행 (최초 1회만)
|
|
||||||
useEffect(() => {
|
|
||||||
if (initialized && startDate && endDate) {
|
|
||||||
handleSearch();
|
|
||||||
}
|
|
||||||
}, [initialized]);
|
|
||||||
|
|
||||||
// 데이터 로드
|
// 데이터 로드
|
||||||
const loadData = useCallback(async () => {
|
const loadData = useCallback(async () => {
|
||||||
if (!startDate || !endDate) return;
|
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
console.log('메모장 조회 요청:', { startDate, endDate });
|
const startOfPeriod = '2000-01-01';
|
||||||
const response = await comms.getNoteList(startDate, endDate, '');
|
const today = formatDateLocal(new Date());
|
||||||
|
|
||||||
|
console.log('메모장 전수 조회 요청');
|
||||||
|
const response = await comms.getNoteList(startOfPeriod, today, '');
|
||||||
console.log('메모장 조회 응답:', response);
|
console.log('메모장 조회 응답:', response);
|
||||||
if (response.Success && response.Data) {
|
if (response.Success && response.Data) {
|
||||||
console.log('메모장 데이터 개수:', response.Data.length);
|
|
||||||
setNoteList(response.Data);
|
setNoteList(response.Data);
|
||||||
} else {
|
} else {
|
||||||
console.log('메모장 조회 실패 또는 데이터 없음:', response);
|
|
||||||
setNoteList([]);
|
setNoteList([]);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -87,16 +57,12 @@ export function Note() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [startDate, endDate]);
|
}, []);
|
||||||
|
|
||||||
// 검색
|
// 초기화 완료 후 조회 실행 (최초 1회만)
|
||||||
const handleSearch = async () => {
|
useEffect(() => {
|
||||||
if (new Date(startDate) > new Date(endDate)) {
|
loadData();
|
||||||
alert('시작일은 종료일보다 늦을 수 없습니다.');
|
}, [loadData]);
|
||||||
return;
|
|
||||||
}
|
|
||||||
await loadData();
|
|
||||||
};
|
|
||||||
|
|
||||||
// 새 메모 추가 모달
|
// 새 메모 추가 모달
|
||||||
const openAddModal = () => {
|
const openAddModal = () => {
|
||||||
@@ -225,6 +191,7 @@ export function Note() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
||||||
// 필터링된 목록 (검색어 적용)
|
// 필터링된 목록 (검색어 적용)
|
||||||
const filteredList = noteList.filter(item => {
|
const filteredList = noteList.filter(item => {
|
||||||
if (!searchKey.trim()) return true;
|
if (!searchKey.trim()) return true;
|
||||||
@@ -243,121 +210,157 @@ export function Note() {
|
|||||||
currentPage * pageSize
|
currentPage * pageSize
|
||||||
);
|
);
|
||||||
|
|
||||||
// 검색 시 페이지 초기화
|
|
||||||
const handleSearchWithReset = () => {
|
|
||||||
setCurrentPage(1);
|
|
||||||
handleSearch();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-fade-in">
|
<div className="space-y-6 animate-fade-in pb-4">
|
||||||
{/* 검색 필터 */}
|
|
||||||
<div className="glass-effect rounded-2xl p-6">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label className="text-white/70 text-sm font-medium whitespace-nowrap">기간</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={startDate}
|
|
||||||
onChange={(e) => setStartDate(e.target.value)}
|
|
||||||
className="w-36 h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
|
|
||||||
/>
|
|
||||||
<span className="text-white/70">~</span>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={endDate}
|
|
||||||
onChange={(e) => setEndDate(e.target.value)}
|
|
||||||
className="w-36 h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label className="text-white/70 text-sm font-medium whitespace-nowrap">검색어</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={searchKey}
|
|
||||||
onChange={(e) => setSearchKey(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === 'Enter' && handleSearchWithReset()}
|
|
||||||
placeholder="제목, 내용, 작성자 등"
|
|
||||||
className="w-60 h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={handleSearchWithReset}
|
|
||||||
disabled={loading}
|
|
||||||
className="h-10 bg-primary-500 hover:bg-primary-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{loading ? (
|
|
||||||
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Search className="w-4 h-4 mr-2" />
|
|
||||||
)}
|
|
||||||
조회
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<button
|
|
||||||
onClick={openAddModal}
|
|
||||||
className="h-10 bg-success-500 hover:bg-success-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
새 메모
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 메모 리스트 */}
|
{/* 메모 리스트 */}
|
||||||
<div className="glass-effect rounded-2xl overflow-hidden">
|
<div className="glass-effect rounded-3xl overflow-hidden shadow-2xl border border-white/10">
|
||||||
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
|
<div className="px-6 py-4 border-b border-white/10 flex flex-col md:flex-row items-center justify-between gap-4">
|
||||||
<h3 className="text-lg font-semibold text-white flex items-center">
|
<div className="flex items-center gap-3">
|
||||||
<FileText className="w-5 h-5 mr-2" />
|
<div className="p-2 bg-primary-500/20 rounded-lg">
|
||||||
메모장 목록
|
<FileText className="w-5 h-5 text-primary-400" />
|
||||||
</h3>
|
</div>
|
||||||
<span className="text-white/60 text-sm">{filteredList.length}건</span>
|
<h3 className="text-lg font-bold text-white tracking-tight">메모장 목록</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{/* 검색창 */}
|
||||||
|
<div className="relative group w-48 md:w-64">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-white/40 group-focus-within:text-primary-400 transition-colors" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={searchKey}
|
||||||
|
onChange={(e) => {
|
||||||
|
setSearchKey(e.target.value);
|
||||||
|
setCurrentPage(1); // 검색 시 1페이지로 이동
|
||||||
|
}}
|
||||||
|
placeholder="검색..."
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-xl pl-9 pr-8 py-1.5 text-xs text-white placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all backdrop-blur-sm"
|
||||||
|
/>
|
||||||
|
{searchKey && (
|
||||||
|
<button
|
||||||
|
onClick={() => setSearchKey('')}
|
||||||
|
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-white/20 hover:text-white transition-colors"
|
||||||
|
>
|
||||||
|
<XCircle className="w-3.5 h-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 개수 */}
|
||||||
|
<div className="flex items-center gap-2 bg-white/5 px-3 py-1.5 rounded-xl border border-white/10 h-[38px]">
|
||||||
|
<span className="text-primary-400 font-bold text-sm">{filteredList.length}</span>
|
||||||
|
<span className="text-white/40 text-[10px] uppercase">건</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 새로고침 */}
|
||||||
|
<button
|
||||||
|
onClick={loadData}
|
||||||
|
disabled={loading}
|
||||||
|
className="p-2 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl text-white/70 hover:text-white transition-all disabled:opacity-50"
|
||||||
|
title="새로고침"
|
||||||
|
>
|
||||||
|
<RefreshCw className={clsx("w-4 h-4", loading && "animate-spin")} />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{/* 새 메모 작성 */}
|
||||||
|
<button
|
||||||
|
onClick={openAddModal}
|
||||||
|
className="p-2 bg-success-500 hover:bg-success-600 border border-white/20 rounded-xl text-white transition-all shadow-lg shadow-success-500/20 active:scale-95"
|
||||||
|
title="새 메모 작성"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="divide-y divide-white/10 max-h-[calc(100vh-300px)] overflow-y-auto">
|
{/* 컬럼 헤더 (업무일지 디자인 통일) */}
|
||||||
|
<div className="bg-white/10 px-6 py-3 border-b border-white/5 flex items-center gap-4">
|
||||||
|
<div className="w-8 text-center text-xs font-medium text-white/70 uppercase">상태</div>
|
||||||
|
<div className="flex-1 text-xs font-medium text-white/70 uppercase">제목</div>
|
||||||
|
<div className="flex items-center gap-6 shrink-0">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div className="w-20 text-right text-xs font-medium text-white/70 uppercase">작성자</div>
|
||||||
|
<div className="w-24 text-center text-xs font-medium text-white/70 uppercase">작성일</div>
|
||||||
|
<div className="w-16 text-right text-xs font-medium text-white/70 uppercase">조회수</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-[88px]"></div> {/* 액션 버튼 공간 */}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-white/5 max-h-[calc(100vh-280px)] overflow-y-auto custom-scrollbar">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="px-6 py-8 text-center">
|
<div className="px-6 py-12 text-center">
|
||||||
<div className="flex items-center justify-center">
|
<RefreshCw className="w-10 h-10 mx-auto mb-4 animate-spin text-primary-500/50" />
|
||||||
<RefreshCw className="w-5 h-5 mr-2 animate-spin text-white/50" />
|
<p className="text-white/50 font-medium text-sm">데이터를 실시간으로 동기화 중...</p>
|
||||||
<span className="text-white/50">데이터를 불러오는 중...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : filteredList.length === 0 ? (
|
) : filteredList.length === 0 ? (
|
||||||
<div className="px-6 py-8 text-center">
|
<div className="px-6 py-20 text-center">
|
||||||
<FileText className="w-12 h-12 mx-auto mb-3 text-white/30" />
|
<div className="relative inline-block mb-4">
|
||||||
<p className="text-white/50">조회된 데이터가 없습니다.</p>
|
<FileText className="w-16 h-16 mx-auto text-white/10" />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<Search className="w-6 h-6 text-primary-500/20" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p className="text-white/30 text-base">조회된 메모가 없습니다</p>
|
||||||
|
<button
|
||||||
|
onClick={openAddModal}
|
||||||
|
className="mt-4 text-primary-400 hover:text-primary-300 text-sm font-medium underline underline-offset-4"
|
||||||
|
>
|
||||||
|
첫 번째 메모를 작성해보세요
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
paginatedList.map((item) => (
|
paginatedList.map((item) => (
|
||||||
<div
|
<div
|
||||||
key={item.idx}
|
key={item.idx}
|
||||||
className="px-6 py-3 hover:bg-white/5 transition-colors cursor-pointer group"
|
className="px-6 py-2.5 hover:bg-white/[0.03] transition-all cursor-pointer group relative"
|
||||||
onClick={() => handleNoteClick(item)}
|
onClick={() => handleNoteClick(item)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-between gap-4">
|
<div className="flex items-center gap-4">
|
||||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
{/* 공유/잠금 아이콘 - 컬럼 너비 8에 맞춤 */}
|
||||||
|
<div className={clsx(
|
||||||
|
"w-8 h-8 rounded-lg flex items-center justify-center shrink-0 transition-all group-hover:scale-110",
|
||||||
|
item.share ? "bg-success-500/20 text-success-400 shadow-[0_0_10px_rgba(34,197,94,0.1)]" : "bg-blue-500/20 text-blue-400 shadow-[0_0_10px_rgba(59,130,246,0.1)]"
|
||||||
|
)}>
|
||||||
{item.share ? (
|
{item.share ? (
|
||||||
<Share2 className="w-4 h-4 text-green-400 flex-shrink-0" />
|
<Share2 className="w-4 h-4" />
|
||||||
) : (
|
) : (
|
||||||
<Lock className="w-4 h-4 text-blue-400 flex-shrink-0" />
|
<Lock className="w-4 h-4" />
|
||||||
)}
|
)}
|
||||||
<p className="text-white text-sm font-medium truncate flex-1">
|
|
||||||
{(item.title || '제목 없음').length > 15 ? `${(item.title || '제목 없음').substring(0, 15)}...` : (item.title || '제목 없음')}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-4 flex-shrink-0">
|
|
||||||
<span className="text-white/60 text-xs">{item.uid || '-'}</span>
|
{/* 제목 - flex-1 로 확장 */}
|
||||||
<span className="text-white/60 text-xs">{formatDate(item.pdate)}</span>
|
<div className="flex-1 min-w-0">
|
||||||
<span className="text-white/50 text-xs">조회 {item.viewcount || 0}</span>
|
<h4 className="text-[var(--text-primary)] font-medium group-hover:text-primary-300 transition-colors truncate text-sm">
|
||||||
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
{item.title || '제목 없음'}
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 작성자, 일자, 조회수 - 헤더 컬럼과 동일 간격 배치 */}
|
||||||
|
<div className="flex items-center gap-6 shrink-0">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-white/60 text-sm w-20 truncate text-right">
|
||||||
|
{item.uid || 'ADMIN'}
|
||||||
|
</span>
|
||||||
|
<span className="text-white/50 text-sm w-24 text-center">
|
||||||
|
{formatDate(item.pdate)}
|
||||||
|
</span>
|
||||||
|
<span className="text-white/30 text-xs w-16 text-right font-medium">
|
||||||
|
{item.viewcount || 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-all translate-x-2 group-hover:translate-x-0 w-[88px] justify-end">
|
||||||
<button
|
<button
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
openEditModal(item);
|
openEditModal(item);
|
||||||
}}
|
}}
|
||||||
className="text-white/40 hover:text-primary-400 transition-colors"
|
className="p-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-primary-400 transition-all border border-white/10"
|
||||||
title="편집"
|
title="편집"
|
||||||
>
|
>
|
||||||
<Edit className="w-4 h-4" />
|
<Edit className="w-4 h-4" />
|
||||||
@@ -367,7 +370,7 @@ export function Note() {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleDelete(item.idx);
|
handleDelete(item.idx);
|
||||||
}}
|
}}
|
||||||
className="text-white/40 hover:text-red-400 transition-colors"
|
className="p-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-danger-400 transition-all border border-white/10"
|
||||||
title="삭제"
|
title="삭제"
|
||||||
>
|
>
|
||||||
<Trash2 className="w-4 h-4" />
|
<Trash2 className="w-4 h-4" />
|
||||||
@@ -382,39 +385,42 @@ export function Note() {
|
|||||||
|
|
||||||
{/* 페이징 */}
|
{/* 페이징 */}
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="px-6 py-4 border-t border-white/10 flex items-center justify-between">
|
<div className="px-6 py-4 border-t border-white/10 flex items-center justify-between bg-white/[0.02]">
|
||||||
<div className="text-white/50 text-sm">
|
<div className="text-white/40 text-xs font-medium">
|
||||||
총 {filteredList.length}건 중 {(currentPage - 1) * pageSize + 1}-{Math.min(currentPage * pageSize, filteredList.length)}건
|
총 <span className="text-white">{filteredList.length}</span>건 중
|
||||||
|
<span className="text-white ml-2">{(currentPage - 1) * pageSize + 1}-{Math.min(currentPage * pageSize, filteredList.length)}</span>건 표시
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage(1)}
|
onClick={() => setCurrentPage(1)}
|
||||||
disabled={currentPage === 1}
|
disabled={currentPage === 1}
|
||||||
className="px-3 py-1 rounded bg-white/10 text-white/70 hover:bg-white/20 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
className="w-8 h-8 flex items-center justify-center rounded-lg bg-white/5 text-white/70 hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed transition-all border border-white/10 text-xs"
|
||||||
>
|
>
|
||||||
«
|
«
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||||
disabled={currentPage === 1}
|
disabled={currentPage === 1}
|
||||||
className="px-3 py-1 rounded bg-white/10 text-white/70 hover:bg-white/20 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
className="w-8 h-8 flex items-center justify-center rounded-lg bg-white/5 text-white/70 hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed transition-all border border-white/10 text-xs"
|
||||||
>
|
>
|
||||||
‹
|
‹
|
||||||
</button>
|
</button>
|
||||||
<span className="text-white/70 px-3">
|
<div className="flex items-center bg-white/5 px-3 h-8 rounded-lg border border-white/10 text-xs font-bold">
|
||||||
{currentPage} / {totalPages}
|
<span className="text-primary-400">{currentPage}</span>
|
||||||
</span>
|
<span className="text-white/30 mx-1.5">/</span>
|
||||||
|
<span className="text-white/70">{totalPages}</span>
|
||||||
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||||
disabled={currentPage === totalPages}
|
disabled={currentPage === totalPages}
|
||||||
className="px-3 py-1 rounded bg-white/10 text-white/70 hover:bg-white/20 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
className="w-8 h-8 flex items-center justify-center rounded-lg bg-white/5 text-white/70 hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed transition-all border border-white/10 text-xs"
|
||||||
>
|
>
|
||||||
›
|
›
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCurrentPage(totalPages)}
|
onClick={() => setCurrentPage(totalPages)}
|
||||||
disabled={currentPage === totalPages}
|
disabled={currentPage === totalPages}
|
||||||
className="px-3 py-1 rounded bg-white/10 text-white/70 hover:bg-white/20 disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
|
className="w-8 h-8 flex items-center justify-center rounded-lg bg-white/5 text-white/70 hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed transition-all border border-white/10 text-xs"
|
||||||
>
|
>
|
||||||
»
|
»
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -11,11 +11,12 @@ import {
|
|||||||
ClipboardList,
|
ClipboardList,
|
||||||
Mail,
|
Mail,
|
||||||
Edit2,
|
Edit2,
|
||||||
|
XCircle,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { comms } from '@/communication';
|
import { comms } from '@/communication';
|
||||||
import { ProjectListItem, ProjectListResponse } from '@/types';
|
import { ProjectListItem, ProjectListResponse } from '@/types';
|
||||||
import { ProjectDetailDialog } from '@/components/project';
|
import { ProjectDetailDialog } from '@/components/project';
|
||||||
import { DevelopmentNotice } from '@/components/common/DevelopmentNotice';
|
import { DevelopmentNotice } from '@/components/DevelopmentNotice';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
// 상태별 색상 매핑
|
// 상태별 색상 매핑
|
||||||
@@ -302,11 +303,6 @@ export function Project() {
|
|||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<div className="glass-effect rounded-xl p-4">
|
<div className="glass-effect rounded-xl p-4">
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<FolderKanban className="w-6 h-6 text-primary-400" />
|
|
||||||
<h1 className="text-xl font-bold text-white">프로젝트 목록</h1>
|
|
||||||
<span className="text-white/50 text-sm">({filteredProjects.length}건)</span>
|
|
||||||
</div>
|
|
||||||
<button
|
<button
|
||||||
onClick={loadProjects}
|
onClick={loadProjects}
|
||||||
disabled={loading}
|
disabled={loading}
|
||||||
@@ -414,234 +410,267 @@ export function Project() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 메인 콘텐츠 */}
|
{/* 메인 콘텐츠 */}
|
||||||
<div className="glass-effect rounded-xl overflow-hidden">
|
<div className="glass-effect rounded-2xl overflow-hidden shadow-2xl transition-all duration-300">
|
||||||
<div className="overflow-x-auto">
|
<div className="px-6 py-4 flex flex-col md:flex-row items-center justify-between gap-4">
|
||||||
<table className="w-full text-sm">
|
<div className="flex items-center gap-3">
|
||||||
<thead className="bg-white/5 sticky top-0">
|
<div className="p-2 bg-primary-500/20 rounded-lg">
|
||||||
<tr className="text-white/60 text-left">
|
<FolderKanban className="w-5 h-5 text-primary-400" />
|
||||||
<th className="px-3 py-2 w-16">상태</th>
|
</div>
|
||||||
<th className="px-3 py-2">프로젝트명</th>
|
<h3 className="text-lg font-bold text-[var(--text-primary)] tracking-tight">
|
||||||
<th className="px-3 py-2 w-20">챔피언</th>
|
프로젝트 목록
|
||||||
<th className="px-3 py-2 w-28">요청자</th>
|
</h3>
|
||||||
<th className="px-3 py-2 w-20 text-center">진행률</th>
|
</div>
|
||||||
<th className="px-3 py-2 w-24">시작</th>
|
|
||||||
<th className="px-3 py-2 w-24">만료/완료</th>
|
|
||||||
<th className="px-3 py-2 w-10"></th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody className="divide-y divide-white/5">
|
|
||||||
{loading ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={8} className="px-3 py-8 text-center text-white/50">
|
|
||||||
<RefreshCw className="w-6 h-6 animate-spin mx-auto mb-2" />
|
|
||||||
로딩중...
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : paginatedProjects.length === 0 ? (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={8} className="px-3 py-8 text-center text-white/50">
|
|
||||||
프로젝트가 없습니다.
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
) : (
|
|
||||||
paginatedProjects.map((project) => {
|
|
||||||
const statusColor = statusColors[project.status] || { text: 'text-white', bg: 'bg-white/10' };
|
|
||||||
const isExpanded = expandedProject === project.idx;
|
|
||||||
|
|
||||||
return (
|
<div className="flex items-center gap-4 w-full md:w-auto">
|
||||||
<>
|
{/* 검색 필터 */}
|
||||||
<tr
|
<div className="relative flex-1 md:w-80 group">
|
||||||
key={project.idx}
|
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-[var(--text-secondary)] group-focus-within:text-primary-400 transition-colors" />
|
||||||
className={clsx(
|
<input
|
||||||
'border-b border-white/10 cursor-pointer hover:bg-white/5',
|
type="text"
|
||||||
isExpanded && 'bg-primary-900/30'
|
value={searchKey}
|
||||||
)}
|
onChange={(e) => setSearchKey(e.target.value)}
|
||||||
onClick={() => toggleHistory(project.idx)}
|
placeholder="검색..."
|
||||||
>
|
className="w-full bg-[var(--bg-paper)] border border-[var(--border-color)] rounded-xl pl-10 pr-10 py-2 text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary-500/50 transition-all text-sm placeholder-[var(--text-muted)]"
|
||||||
<td className="px-3 py-2">
|
/>
|
||||||
<span className={`px-2 py-0.5 rounded text-xs ${statusColor.bg} ${statusColor.text}`}>
|
{searchKey && (
|
||||||
{project.status}
|
<button
|
||||||
</span>
|
onClick={() => setSearchKey('')}
|
||||||
</td>
|
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
|
||||||
<td className={`px-3 py-2 ${statusColor.text}`}>
|
>
|
||||||
<div className="truncate max-w-xs" title={project.name}>
|
<XCircle className="w-4 h-4" />
|
||||||
<div className="flex items-center gap-2">
|
</button>
|
||||||
<button
|
)}
|
||||||
onClick={e => {
|
</div>
|
||||||
e.stopPropagation();
|
<div className="flex items-center gap-1 bg-[var(--bg-paper)] px-3 py-1.5 rounded-lg border border-[var(--border-color)]">
|
||||||
handleSelectProject(project);
|
<span className="text-[var(--text-primary)] font-bold text-sm">{filteredProjects.length}</span>
|
||||||
}}
|
<span className="text-[var(--text-secondary)] text-xs">건</span>
|
||||||
className="text-primary-300 hover:text-primary-200 transition-colors"
|
</div>
|
||||||
title="편집"
|
</div>
|
||||||
>
|
</div>
|
||||||
<Edit2 className="w-4 h-4" />
|
<table className="w-full text-sm">
|
||||||
</button>
|
<thead className="bg-white/5 sticky top-0">
|
||||||
<span className="font-regular text-white/90">{project.name}</span>
|
<tr className="text-white/60 text-left">
|
||||||
</div>
|
<th className="px-3 py-2 w-16">상태</th>
|
||||||
</div>
|
<th className="px-3 py-2">프로젝트명</th>
|
||||||
</td>
|
<th className="px-3 py-2 w-20">챔피언</th>
|
||||||
<td className="px-3 py-2 text-white/70">{project.name_champion || project.userManager}</td>
|
<th className="px-3 py-2 w-28">요청자</th>
|
||||||
<td className="px-3 py-2 text-white/70 text-xs">
|
<th className="px-3 py-2 w-20 text-center">진행률</th>
|
||||||
<div>{project.ReqLine}</div>
|
<th className="px-3 py-2 w-24">시작</th>
|
||||||
<div className="text-white/50">{project.reqstaff}</div>
|
<th className="px-3 py-2 w-24">만료/완료</th>
|
||||||
</td>
|
<th className="px-3 py-2 w-10"></th>
|
||||||
<td className="px-3 py-2">
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-white/5">
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-3 py-8 text-center text-white/50">
|
||||||
|
<RefreshCw className="w-6 h-6 animate-spin mx-auto mb-2" />
|
||||||
|
로딩중...
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : paginatedProjects.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={8} className="px-3 py-8 text-center text-white/50">
|
||||||
|
프로젝트가 없습니다.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
paginatedProjects.map((project) => {
|
||||||
|
const statusColor = statusColors[project.status] || { text: 'text-white', bg: 'bg-white/10' };
|
||||||
|
const isExpanded = expandedProject === project.idx;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<tr
|
||||||
|
key={project.idx}
|
||||||
|
className={clsx(
|
||||||
|
'border-b border-white/10 cursor-pointer hover:bg-white/5',
|
||||||
|
isExpanded && 'bg-primary-900/30'
|
||||||
|
)}
|
||||||
|
onClick={() => toggleHistory(project.idx)}
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<span className={`px-2 py-0.5 rounded text-xs ${statusColor.bg} ${statusColor.text}`}>
|
||||||
|
{project.status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className={`px-3 py-2 ${statusColor.text}`}>
|
||||||
|
<div className="truncate max-w-xs" title={project.name}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<div className="flex-1 h-2 bg-white/10 rounded-full overflow-hidden">
|
<button
|
||||||
<div
|
onClick={e => {
|
||||||
className="h-full bg-primary-500 transition-all"
|
e.stopPropagation();
|
||||||
style={{ width: `${project.progress || 0}%` }}
|
handleSelectProject(project);
|
||||||
/>
|
}}
|
||||||
</div>
|
className="text-primary-300 hover:text-primary-200 transition-colors"
|
||||||
<span className="text-xs text-white/50">{project.progress || 0}%</span>
|
title="편집"
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2 text-white/50">{formatDate(project.sdate)}</td>
|
|
||||||
<td className="px-3 py-2 text-white/50 text-xs">
|
|
||||||
<div>{formatDate(project.ddate)}</div>
|
|
||||||
<div className="text-white/40">{formatDate(project.edate)}</div>
|
|
||||||
</td>
|
|
||||||
<td className="px-3 py-2">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
{project.jasmin && project.jasmin > 0 && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
openJasmin(project.jasmin);
|
|
||||||
}}
|
|
||||||
className="text-primary-400 hover:text-primary-300"
|
|
||||||
title="자스민 열기"
|
|
||||||
>
|
|
||||||
<ExternalLink className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
{(userLevel >= 9 || userCode === '395552') && (
|
|
||||||
<button
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
const w = window as any;
|
|
||||||
if (w.CefSharp) {
|
|
||||||
w.CefSharp.BindObjectAsync('bridge').then(() => {
|
|
||||||
w.bridge?.OpenMailHistory();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="text-cyan-400 hover:text-cyan-300"
|
|
||||||
title="메일내역"
|
|
||||||
>
|
|
||||||
<Mail className="w-4 h-4" />
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<a
|
|
||||||
href={`#/partlist?idx=${project.idx}&name=${encodeURIComponent(project.name)}`}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
className="text-amber-400 hover:text-amber-300"
|
|
||||||
title="파트리스트"
|
|
||||||
>
|
>
|
||||||
<ClipboardList className="w-4 h-4" />
|
<Edit2 className="w-4 h-4" />
|
||||||
</a>
|
</button>
|
||||||
|
<span className="font-regular text-white/90">{project.name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-white/70">{project.name_champion || project.userManager}</td>
|
||||||
|
<td className="px-3 py-2 text-white/70 text-xs">
|
||||||
|
<div>{project.ReqLine}</div>
|
||||||
|
<div className="text-white/50">{project.reqstaff}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex-1 h-2 bg-white/10 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary-500 transition-all"
|
||||||
|
style={{ width: `${project.progress || 0}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-white/50">{project.progress || 0}%</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-white/50">{formatDate(project.sdate)}</td>
|
||||||
|
<td className="px-3 py-2 text-white/50 text-xs">
|
||||||
|
<div>{formatDate(project.ddate)}</div>
|
||||||
|
<div className="text-white/40">{formatDate(project.edate)}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{project.jasmin && project.jasmin > 0 && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openJasmin(project.jasmin);
|
||||||
|
}}
|
||||||
|
className="text-primary-400 hover:text-primary-300"
|
||||||
|
title="자스민 열기"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{(userLevel >= 9 || userCode === '395552') && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
const w = window as any;
|
||||||
|
if (w.CefSharp) {
|
||||||
|
w.CefSharp.BindObjectAsync('bridge').then(() => {
|
||||||
|
w.bridge?.OpenMailHistory();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="text-cyan-400 hover:text-cyan-300"
|
||||||
|
title="메일내역"
|
||||||
|
>
|
||||||
|
<Mail className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<a
|
||||||
|
href={`#/partlist?idx=${project.idx}&name=${encodeURIComponent(project.name)}`}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
className="text-amber-400 hover:text-amber-300"
|
||||||
|
title="파트리스트"
|
||||||
|
>
|
||||||
|
<ClipboardList className="w-4 h-4" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{isExpanded && (
|
||||||
|
<tr key={`history-${project.idx}`}>
|
||||||
|
<td colSpan={8} className="px-3 py-2 bg-primary-950/50">
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<div className="text-sm font-semibold text-primary-300">주간 업무 내용</div>
|
||||||
|
<button
|
||||||
|
onClick={() => startAddHistory(project.idx)}
|
||||||
|
className="text-xs px-3 py-1 bg-primary-500/20 hover:bg-primary-500/30 text-primary-400 rounded transition-colors"
|
||||||
|
>
|
||||||
|
+ 새 내용 등록
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{loadingHistory ? (
|
||||||
|
<div className="text-white/50 text-sm">로딩 중...</div>
|
||||||
|
) : editingHistory ? (
|
||||||
|
<div className="bg-white/10 rounded p-3 space-y-3">
|
||||||
|
<div className="flex gap-4 text-xs text-white/60">
|
||||||
|
<span className="text-primary-400 font-semibold">{formatDate(editingHistory.pdate)}</span>
|
||||||
|
<span>진행률: {editingHistory.progress || 0}%</span>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
value={editRemark}
|
||||||
|
onChange={(e) => setEditRemark(e.target.value)}
|
||||||
|
className="w-full h-32 px-3 py-2 bg-white/5 border border-white/10 rounded text-white text-sm resize-none"
|
||||||
|
placeholder="업무 내용을 입력하세요..."
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2 justify-end">
|
||||||
|
<button
|
||||||
|
onClick={cancelEdit}
|
||||||
|
className="px-3 py-1 bg-white/5 hover:bg-white/10 text-white/70 rounded text-sm transition-colors"
|
||||||
|
>
|
||||||
|
취소
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={saveHistory}
|
||||||
|
className="px-3 py-1 bg-primary-500/20 hover:bg-primary-500/30 text-primary-400 rounded text-sm transition-colors"
|
||||||
|
>
|
||||||
|
저장
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : projectHistory.length > 0 ? (
|
||||||
|
<div
|
||||||
|
className="bg-white/5 rounded p-3 border-l-2 border-primary-500 cursor-pointer hover:bg-white/10 transition-colors"
|
||||||
|
onClick={() => startEditHistory(projectHistory[0])}
|
||||||
|
>
|
||||||
|
<div className="flex gap-4 mb-2 text-xs">
|
||||||
|
<span className="text-primary-400 font-semibold">{formatDate(projectHistory[0].pdate)}</span>
|
||||||
|
<span className="text-white/60">진행률: {projectHistory[0].progress || 0}%</span>
|
||||||
|
<span className="text-white/40">{projectHistory[0].wname || ''}</span>
|
||||||
|
</div>
|
||||||
|
{projectHistory[0].remark ? (
|
||||||
|
<div className="text-sm text-white/80 whitespace-pre-wrap">{projectHistory[0].remark}</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-white/40 italic">내용이 비어있습니다. 클릭하여 입력하세요.</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-white/50 text-sm text-center py-4">
|
||||||
|
업무 내용이 없습니다. 새 내용을 등록하세요.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
{isExpanded && (
|
)}
|
||||||
<tr key={`history-${project.idx}`}>
|
</>
|
||||||
<td colSpan={8} className="px-3 py-2 bg-primary-950/50">
|
);
|
||||||
<div className="p-4">
|
})
|
||||||
<div className="flex items-center justify-between mb-3">
|
)}
|
||||||
<div className="text-sm font-semibold text-primary-300">주간 업무 내용</div>
|
</tbody>
|
||||||
<button
|
</table>
|
||||||
onClick={() => startAddHistory(project.idx)}
|
|
||||||
className="text-xs px-3 py-1 bg-primary-500/20 hover:bg-primary-500/30 text-primary-400 rounded transition-colors"
|
|
||||||
>
|
|
||||||
+ 새 내용 등록
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{loadingHistory ? (
|
|
||||||
<div className="text-white/50 text-sm">로딩 중...</div>
|
|
||||||
) : editingHistory ? (
|
|
||||||
<div className="bg-white/10 rounded p-3 space-y-3">
|
|
||||||
<div className="flex gap-4 text-xs text-white/60">
|
|
||||||
<span className="text-primary-400 font-semibold">{formatDate(editingHistory.pdate)}</span>
|
|
||||||
<span>진행률: {editingHistory.progress || 0}%</span>
|
|
||||||
</div>
|
|
||||||
<textarea
|
|
||||||
value={editRemark}
|
|
||||||
onChange={(e) => setEditRemark(e.target.value)}
|
|
||||||
className="w-full h-32 px-3 py-2 bg-white/5 border border-white/10 rounded text-white text-sm resize-none"
|
|
||||||
placeholder="업무 내용을 입력하세요..."
|
|
||||||
/>
|
|
||||||
<div className="flex gap-2 justify-end">
|
|
||||||
<button
|
|
||||||
onClick={cancelEdit}
|
|
||||||
className="px-3 py-1 bg-white/5 hover:bg-white/10 text-white/70 rounded text-sm transition-colors"
|
|
||||||
>
|
|
||||||
취소
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={saveHistory}
|
|
||||||
className="px-3 py-1 bg-primary-500/20 hover:bg-primary-500/30 text-primary-400 rounded text-sm transition-colors"
|
|
||||||
>
|
|
||||||
저장
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : projectHistory.length > 0 ? (
|
|
||||||
<div
|
|
||||||
className="bg-white/5 rounded p-3 border-l-2 border-primary-500 cursor-pointer hover:bg-white/10 transition-colors"
|
|
||||||
onClick={() => startEditHistory(projectHistory[0])}
|
|
||||||
>
|
|
||||||
<div className="flex gap-4 mb-2 text-xs">
|
|
||||||
<span className="text-primary-400 font-semibold">{formatDate(projectHistory[0].pdate)}</span>
|
|
||||||
<span className="text-white/60">진행률: {projectHistory[0].progress || 0}%</span>
|
|
||||||
<span className="text-white/40">{projectHistory[0].wname || ''}</span>
|
|
||||||
</div>
|
|
||||||
{projectHistory[0].remark ? (
|
|
||||||
<div className="text-sm text-white/80 whitespace-pre-wrap">{projectHistory[0].remark}</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-sm text-white/40 italic">내용이 비어있습니다. 클릭하여 입력하세요.</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-white/50 text-sm text-center py-4">
|
|
||||||
업무 내용이 없습니다. 새 내용을 등록하세요.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
})
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 페이징 */}
|
|
||||||
{totalPages > 1 && (
|
|
||||||
<div className="flex items-center justify-center gap-2 p-3 border-t border-white/10">
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
|
||||||
disabled={currentPage === 1}
|
|
||||||
className="p-1 rounded hover:bg-white/10 disabled:opacity-30"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="w-5 h-5 text-white/70" />
|
|
||||||
</button>
|
|
||||||
<span className="text-white/70 text-sm">
|
|
||||||
{currentPage} / {totalPages}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
|
||||||
disabled={currentPage === totalPages}
|
|
||||||
className="p-1 rounded hover:bg-white/10 disabled:opacity-30"
|
|
||||||
>
|
|
||||||
<ChevronRight className="w-5 h-5 text-white/70" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 페이징 */}
|
||||||
|
{totalPages > 1 && (
|
||||||
|
<div className="flex items-center justify-center gap-2 p-3 border-t border-white/10">
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={currentPage === 1}
|
||||||
|
className="p-1 rounded hover:bg-white/10 disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="w-5 h-5 text-white/70" />
|
||||||
|
</button>
|
||||||
|
<span className="text-white/70 text-sm">
|
||||||
|
{currentPage} / {totalPages}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={currentPage === totalPages}
|
||||||
|
className="p-1 rounded hover:bg-white/10 disabled:opacity-30"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-5 h-5 text-white/70" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{/* 프로젝트 상세 다이얼로그 */}
|
{/* 프로젝트 상세 다이얼로그 */}
|
||||||
{showDetailDialog && selectedProject && (
|
{showDetailDialog && selectedProject && (
|
||||||
<ProjectDetailDialog
|
<ProjectDetailDialog
|
||||||
@@ -649,8 +678,6 @@ export function Project() {
|
|||||||
onClose={handleCloseDialog}
|
onClose={handleCloseDialog}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,15 +2,20 @@ import { useState, useEffect, useCallback } from 'react';
|
|||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Edit2,
|
Edit2,
|
||||||
|
Edit3,
|
||||||
Trash2,
|
Trash2,
|
||||||
Flag,
|
Flag,
|
||||||
Zap,
|
Zap,
|
||||||
CheckCircle,
|
CheckCircle,
|
||||||
X,
|
X,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
RefreshCw,
|
||||||
|
Calendar,
|
||||||
|
Check,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { comms } from '@/communication';
|
import { comms } from '@/communication';
|
||||||
import { TodoModel, TodoStatus, TodoPriority } from '@/types';
|
import { TodoModel, TodoStatus, TodoPriority } from '@/types';
|
||||||
|
import { clsx } from 'clsx';
|
||||||
|
|
||||||
// 상태/중요도 유틸리티 함수들
|
// 상태/중요도 유틸리티 함수들
|
||||||
const getStatusText = (status: string): string => {
|
const getStatusText = (status: string): string => {
|
||||||
@@ -26,12 +31,12 @@ const getStatusText = (status: string): string => {
|
|||||||
|
|
||||||
const getStatusClass = (status: string): string => {
|
const getStatusClass = (status: string): string => {
|
||||||
switch (status) {
|
switch (status) {
|
||||||
case '0': return 'bg-gray-500/20 text-gray-300 border-gray-500/30';
|
case '0': return 'bg-white/5 text-white/40 border-white/10';
|
||||||
case '1': return 'bg-primary-500/20 text-primary-300 border-primary-500/30';
|
case '1': return 'bg-primary-500/10 text-primary-400 border-primary-500/20';
|
||||||
case '2': return 'bg-danger-500/20 text-danger-300 border-danger-500/30';
|
case '2': return 'bg-danger-500/10 text-danger-400 border-danger-500/20';
|
||||||
case '3': return 'bg-warning-500/20 text-warning-300 border-warning-500/30';
|
case '3': return 'bg-warning-500/10 text-warning-400 border-warning-500/20';
|
||||||
case '5': return 'bg-success-500/20 text-success-300 border-success-500/30';
|
case '5': return 'bg-success-500/10 text-success-400 border-success-500/20';
|
||||||
default: return 'bg-white/10 text-white/50 border-white/20';
|
default: return 'bg-white/5 text-white/30 border-white/5';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -47,11 +52,11 @@ const getPriorityText = (seqno: number): string => {
|
|||||||
|
|
||||||
const getPriorityClass = (seqno: number): string => {
|
const getPriorityClass = (seqno: number): string => {
|
||||||
switch (seqno) {
|
switch (seqno) {
|
||||||
case -1: return 'bg-white/5 text-white/40';
|
case -1: return 'text-white/20';
|
||||||
case 1: return 'bg-primary-500/20 text-primary-300';
|
case 1: return 'text-primary-400 font-bold';
|
||||||
case 2: return 'bg-warning-500/20 text-warning-300';
|
case 2: return 'text-warning-400 font-bold';
|
||||||
case 3: return 'bg-danger-500/20 text-danger-300';
|
case 3: return 'text-danger-400 font-bold';
|
||||||
default: return 'bg-white/10 text-white/50';
|
default: return 'text-white/40';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -265,111 +270,93 @@ export function Todo() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-fade-in">
|
<div className="space-y-6 animate-fade-in pb-10">
|
||||||
{/* 헤더 */}
|
{/* 할일 요약 & 컨트롤 */}
|
||||||
<div className="glass-effect rounded-2xl overflow-hidden">
|
<div className="glass-effect rounded-3xl overflow-hidden shadow-2xl border border-white/10">
|
||||||
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
|
<div className="px-6 py-4 border-b border-white/10 flex flex-col md:flex-row items-center justify-between gap-4 bg-white/5">
|
||||||
<h2 className="text-xl font-semibold text-white flex items-center">
|
<div className="flex items-center gap-3">
|
||||||
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<div className="p-2 bg-primary-500/20 rounded-lg">
|
||||||
<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" />
|
<CheckCircle className="w-5 h-5 text-primary-400" />
|
||||||
</svg>
|
</div>
|
||||||
내 할일 목록
|
<h3 className="text-lg font-bold text-white tracking-tight">내 할일 목록</h3>
|
||||||
</h2>
|
</div>
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setFormData(initialFormData);
|
|
||||||
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"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4 mr-1" />
|
|
||||||
새 할일 추가
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 탭 메뉴 */}
|
<div className="flex items-center gap-3">
|
||||||
<div className="px-6 py-2 border-b border-white/10">
|
{/* 개수 표시 */}
|
||||||
<div className="flex space-x-1 bg-white/5 rounded-lg p-1">
|
<div className="flex items-center gap-2 bg-white/5 px-3 py-1.5 rounded-xl border border-white/10 h-[38px]">
|
||||||
|
<span className="text-primary-400 font-bold text-sm tracking-tighter">{todos.length}</span>
|
||||||
|
<span className="text-white/40 text-[10px] uppercase font-bold">Total</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 새로고침 */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('active')}
|
onClick={loadTodos}
|
||||||
className={`flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 ${
|
disabled={loading}
|
||||||
activeTab === 'active'
|
className="p-2 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl text-white/70 hover:text-white transition-all active:scale-95 disabled:opacity-50"
|
||||||
? 'text-white bg-white/20 shadow-sm'
|
title="새로고침"
|
||||||
: 'text-white/60 hover:text-white hover:bg-white/10'
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center space-x-2">
|
<RefreshCw className={clsx("w-4 h-4", loading && "animate-spin")} />
|
||||||
<Zap className="w-4 h-4" />
|
|
||||||
<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>
|
||||||
|
|
||||||
|
{/* 추가 버튼 */}
|
||||||
<button
|
<button
|
||||||
onClick={() => setActiveTab('hold')}
|
onClick={() => {
|
||||||
className={`flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 ${
|
setFormData(initialFormData);
|
||||||
activeTab === 'hold'
|
setShowAddModal(true);
|
||||||
? 'text-white bg-white/20 shadow-sm'
|
}}
|
||||||
: 'text-white/60 hover:text-white hover:bg-white/10'
|
className="p-2 bg-primary-500 hover:bg-primary-600 border border-white/20 rounded-xl text-white transition-all shadow-lg shadow-primary-500/20 active:scale-95 flex items-center justify-center"
|
||||||
}`}
|
title="새 할일 추가"
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center space-x-2">
|
<Plus className="w-5 h-5" />
|
||||||
<span>보류</span>
|
|
||||||
<span className="px-2 py-0.5 text-xs bg-warning-500/30 text-warning-200 rounded-full">
|
|
||||||
{holdTodos.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('completed')}
|
|
||||||
className={`flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 ${
|
|
||||||
activeTab === '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">
|
|
||||||
<CheckCircle className="w-4 h-4" />
|
|
||||||
<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>
|
|
||||||
<button
|
|
||||||
onClick={() => setActiveTab('cancelled')}
|
|
||||||
className={`flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 ${
|
|
||||||
activeTab === 'cancelled'
|
|
||||||
? '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">
|
|
||||||
<X className="w-4 h-4" />
|
|
||||||
<span>취소</span>
|
|
||||||
<span className="px-2 py-0.5 text-xs bg-danger-500/30 text-danger-200 rounded-full">
|
|
||||||
{cancelledTodos.length}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 할일 테이블 */}
|
{/* 탭 메뉴 */}
|
||||||
<div className="overflow-x-auto">
|
<div className="px-6 py-2 bg-white/[0.02]">
|
||||||
<table className="w-full">
|
<div className="flex space-x-1 p-1">
|
||||||
<thead className="bg-white/10">
|
{[
|
||||||
<tr>
|
{ id: 'active', label: '진행중', icon: Zap, count: activeTodos.length, color: 'primary' },
|
||||||
{activeTab === 'active' && (
|
{ id: 'hold', label: '보류', icon: Loader2, count: holdTodos.length, color: 'warning' },
|
||||||
<th className="px-2 py-4 text-center text-xs font-medium text-white/70 uppercase tracking-wider w-10 border-r border-white/10"></th>
|
{ id: 'completed', label: '완료', icon: CheckCircle, count: completedTodos.length, color: 'success' },
|
||||||
|
{ id: 'cancelled', label: '취소', icon: X, count: cancelledTodos.length, color: 'danger' },
|
||||||
|
].map((tab) => (
|
||||||
|
<button
|
||||||
|
key={tab.id}
|
||||||
|
onClick={() => setActiveTab(tab.id as any)}
|
||||||
|
className={clsx(
|
||||||
|
"flex-1 px-4 py-2.5 text-xs font-bold rounded-xl transition-all duration-300 flex items-center justify-center gap-2 border",
|
||||||
|
activeTab === tab.id
|
||||||
|
? `text-white bg-${tab.color}-500/20 border-${tab.color}-500/30 shadow-lg shadow-${tab.color}-500/10`
|
||||||
|
: "text-white/30 border-transparent hover:text-white/60 hover:bg-white/5"
|
||||||
)}
|
)}
|
||||||
<th className="px-3 py-4 text-center text-xs font-medium text-white/70 uppercase tracking-wider w-16 border-r border-white/10">상태</th>
|
>
|
||||||
<th className="px-3 py-4 text-center text-xs font-medium text-white/70 uppercase tracking-wider w-16 border-r border-white/10">플래그</th>
|
<tab.icon className={clsx("w-3.5 h-3.5", activeTab === tab.id ? `text-${tab.color}-400` : "opacity-50")} />
|
||||||
<th className="px-4 py-4 text-center text-xs font-medium text-white/70 uppercase tracking-wider border-r border-white/10">제목</th>
|
<span>{tab.label}</span>
|
||||||
<th className="px-3 py-4 text-center text-xs font-medium text-white/70 uppercase tracking-wider w-24 border-r border-white/10">요청자</th>
|
<span className={clsx(
|
||||||
<th className="px-3 py-4 text-center text-xs font-medium text-white/70 uppercase tracking-wider w-20 border-r border-white/10">중요도</th>
|
"px-1.5 py-0.5 rounded-md text-[10px] min-w-[1.5rem]",
|
||||||
<th className="px-3 py-4 text-center text-xs font-medium text-white/70 uppercase tracking-wider w-24">
|
activeTab === tab.id ? `bg-${tab.color}-500/20 text-${tab.color}-200` : "bg-white/5 text-white/20"
|
||||||
|
)}>
|
||||||
|
{tab.count}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 할일 테이블 */}
|
||||||
|
<div className="overflow-x-auto custom-scrollbar max-h-[calc(100vh-320px)] overflow-y-auto">
|
||||||
|
<table className="w-full border-collapse">
|
||||||
|
<thead className="sticky top-0 z-10 bg-white/[0.05] backdrop-blur-md">
|
||||||
|
<tr className="border-b border-white/10">
|
||||||
|
{activeTab === 'active' && (
|
||||||
|
<th className="px-3 py-3.5 text-center text-[11px] font-bold text-white/30 uppercase tracking-widest w-12"></th>
|
||||||
|
)}
|
||||||
|
<th className="px-4 py-3.5 text-center text-[11px] font-bold text-white/30 uppercase tracking-widest w-20">상태</th>
|
||||||
|
<th className="px-6 py-3.5 text-left text-[11px] font-bold text-white/30 uppercase tracking-widest">할일 개요</th>
|
||||||
|
<th className="px-4 py-3.5 text-center text-[11px] font-bold text-white/30 uppercase tracking-widest w-28">요청자</th>
|
||||||
|
<th className="px-4 py-3.5 text-center text-[11px] font-bold text-white/30 uppercase tracking-widest w-20">우선순위</th>
|
||||||
|
<th className="px-4 py-3.5 text-center text-[11px] font-bold text-white/30 uppercase tracking-widest w-28">
|
||||||
{activeTab === 'completed' ? '완료일' : '만료일'}
|
{activeTab === 'completed' ? '완료일' : '만료일'}
|
||||||
</th>
|
</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -387,7 +374,7 @@ export function Todo() {
|
|||||||
))}
|
))}
|
||||||
{(activeTab === 'active' ? activeTodos : activeTab === 'hold' ? holdTodos : activeTab === 'completed' ? completedTodos : cancelledTodos).length === 0 && (
|
{(activeTab === 'active' ? activeTodos : activeTab === 'hold' ? holdTodos : activeTab === 'completed' ? completedTodos : cancelledTodos).length === 0 && (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={activeTab === 'active' ? 7 : 6} className="px-6 py-8 text-center text-white/50">
|
<td colSpan={activeTab === 'active' ? 6 : 5} className="px-6 py-8 text-center text-white/50">
|
||||||
{activeTab === 'active' ? '진행중인 할일이 없습니다' : activeTab === 'hold' ? '보류된 할일이 없습니다' : activeTab === 'completed' ? '완료된 할일이 없습니다' : '취소된 할일이 없습니다'}
|
{activeTab === 'active' ? '진행중인 할일이 없습니다' : activeTab === 'hold' ? '보류된 할일이 없습니다' : activeTab === 'completed' ? '완료된 할일이 없습니다' : '취소된 할일이 없습니다'}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -470,45 +457,61 @@ function TodoRow({ todo, showOkdate, showCompleteButton = true, onEdit, onComple
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<tr
|
<tr
|
||||||
className="hover:bg-white/5 transition-colors cursor-pointer"
|
className="group hover:bg-white/[0.03] transition-all cursor-pointer border-b border-white/[0.02]"
|
||||||
onClick={onEdit}
|
onClick={onEdit}
|
||||||
>
|
>
|
||||||
{showCompleteButton && (
|
{showCompleteButton && (
|
||||||
<td className="px-2 py-4 text-center border-r border-white/10">
|
<td className="px-3 py-3 text-center">
|
||||||
<button
|
<button
|
||||||
onClick={handleComplete}
|
onClick={handleComplete}
|
||||||
className="p-1.5 bg-success-500/20 hover:bg-success-500/40 text-success-300 rounded-full transition-colors"
|
className="p-1.5 bg-success-500/10 hover:bg-success-500/20 text-success-400 rounded-lg transition-all border border-success-500/20 active:scale-90"
|
||||||
title="완료 처리"
|
title="완료 처리"
|
||||||
>
|
>
|
||||||
<CheckCircle className="w-4 h-4" />
|
<CheckCircle className="w-3.5 h-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
)}
|
)}
|
||||||
<td className="px-3 py-4 text-center whitespace-nowrap border-r border-white/10">
|
<td className="px-4 py-3 text-center whitespace-nowrap">
|
||||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${getStatusClass(todo.status)}`}>
|
<span className={clsx(
|
||||||
|
"inline-flex items-center px-2 py-0.5 rounded text-xs font-bold border uppercase tracking-widest",
|
||||||
|
getStatusClass(todo.status)
|
||||||
|
)}>
|
||||||
{getStatusText(todo.status)}
|
{getStatusText(todo.status)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-4 text-center whitespace-nowrap border-r border-white/10">
|
<td className="px-6 py-3 text-left">
|
||||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${
|
<div className="flex items-center gap-2">
|
||||||
todo.flag ? 'bg-warning-500/20 text-warning-300' : 'bg-white/10 text-white/50'
|
{todo.flag && (
|
||||||
}`}>
|
<Flag className="w-3.5 h-3.5 text-warning-400 fill-warning-400/20 shrink-0" />
|
||||||
{todo.flag ? <Flag className="w-3 h-3 mr-1" /> : null}
|
)}
|
||||||
{todo.flag ? '고정' : '일반'}
|
<span className="text-sm font-bold text-white group-hover:text-primary-400 transition-colors truncate">
|
||||||
</span>
|
{todo.title || '제목 없음'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-4 text-left text-white border-r border-white/10">{todo.title || '제목 없음'}</td>
|
<td className="px-4 py-3 text-center text-sm font-medium text-white/70">{todo.request || '-'}</td>
|
||||||
<td className="px-3 py-4 text-center text-white/80 border-r border-white/10">{todo.request || '-'}</td>
|
<td className="px-4 py-3 text-center whitespace-nowrap">
|
||||||
<td className="px-3 py-4 text-center whitespace-nowrap border-r border-white/10">
|
<span className={clsx(
|
||||||
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${getPriorityClass(todo.seqno)}`}>
|
"inline-flex items-center px-2 py-0.5 rounded text-xs font-bold uppercase tracking-widest",
|
||||||
|
getPriorityClass(todo.seqno)
|
||||||
|
)}>
|
||||||
|
<Zap className={clsx("w-3.5 h-3.5 mr-1", todo.seqno > 0 ? "fill-current" : "opacity-20")} />
|
||||||
{getPriorityText(todo.seqno)}
|
{getPriorityText(todo.seqno)}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className={`px-3 py-4 text-center whitespace-nowrap ${showOkdate ? 'text-success-400' : (isExpired ? 'text-danger-400' : 'text-white/80')}`}>
|
<td className="px-4 py-3 text-center whitespace-nowrap">
|
||||||
{showOkdate
|
<div className={clsx(
|
||||||
? (todo.okdate ? new Date(todo.okdate).toLocaleDateString('ko-KR', { year: '2-digit', month: '2-digit', day: '2-digit' }) : '-')
|
"inline-flex items-center gap-2 px-3 py-1 bg-white/5 rounded-lg border border-white/5",
|
||||||
: (todo.expire ? new Date(todo.expire).toLocaleDateString('ko-KR', { year: '2-digit', month: '2-digit', day: '2-digit' }) : '-')
|
showOkdate ? 'text-success-400' : (isExpired ? 'text-danger-400' : 'text-white/40')
|
||||||
}
|
)}>
|
||||||
|
<Calendar className="w-3.5 h-3.5 opacity-30" />
|
||||||
|
<span className="text-sm font-mono font-medium">
|
||||||
|
{showOkdate
|
||||||
|
? (todo.okdate ? new Date(todo.okdate).toLocaleDateString('ko-KR', { year: '2-digit', month: '2-digit', day: '2-digit' }) : '-')
|
||||||
|
: (todo.expire ? new Date(todo.expire).toLocaleDateString('ko-KR', { year: '2-digit', month: '2-digit', day: '2-digit' }) : '-')
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
);
|
);
|
||||||
@@ -551,159 +554,192 @@ function TodoModal({
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50" onClick={onClose}>
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md animate-fade-in" onClick={onClose}>
|
||||||
<div className="flex items-center justify-center min-h-screen p-4">
|
<div className="bg-[#1a1b2e]/90 rounded-3xl shadow-2xl w-full max-w-2xl overflow-hidden border border-white/10 flex flex-col backdrop-blur-xl" onClick={(e) => e.stopPropagation()}>
|
||||||
<div
|
{/* 헤더 */}
|
||||||
className="glass-effect rounded-2xl w-full max-w-2xl animate-slide-up"
|
<div className="flex items-center justify-between px-8 py-6 border-b border-white/10 bg-white/5">
|
||||||
onClick={(e) => e.stopPropagation()}
|
<div className="flex items-center gap-4">
|
||||||
>
|
<div className="p-2 bg-primary-500/20 rounded-lg">
|
||||||
{/* 헤더 */}
|
{isEdit ? <Edit3 className="w-5 h-5 text-primary-400" /> : <Plus className="w-5 h-5 text-primary-400" />}
|
||||||
<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">
|
|
||||||
<Plus className="w-5 h-5 mr-2" />
|
|
||||||
{title}
|
|
||||||
</h2>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
{isEdit && onComplete && currentStatus !== '5' && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onComplete}
|
|
||||||
disabled={processing}
|
|
||||||
className="bg-success-500 hover:bg-success-600 text-white px-3 py-1.5 rounded-lg transition-colors flex items-center disabled:opacity-50 text-sm"
|
|
||||||
>
|
|
||||||
<CheckCircle className="w-4 h-4 mr-1" />
|
|
||||||
완료 처리
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
<button onClick={onClose} className="text-white/70 hover:text-white transition-colors">
|
|
||||||
<X className="w-6 h-6" />
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<h2 className="text-xl font-bold text-white tracking-tight">{title}</h2>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{isEdit && onComplete && currentStatus !== '5' && (
|
||||||
|
<button
|
||||||
|
onClick={onComplete}
|
||||||
|
disabled={processing}
|
||||||
|
className="px-4 py-1.5 bg-success-500 hover:bg-success-600 border border-white/20 rounded-xl text-white text-xs font-bold transition-all shadow-lg shadow-success-500/20 active:scale-95 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Check className="w-3.5 h-3.5" />
|
||||||
|
완료 처리
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
className="p-2 hover:bg-white/10 rounded-full text-white/40 hover:text-white transition-all transform hover:rotate-90"
|
||||||
|
>
|
||||||
|
<X className="w-6 h-6" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 내용 */}
|
{/* 내 */}
|
||||||
<div className="p-6 space-y-4">
|
<div className="p-8 space-y-6 overflow-y-auto max-h-[70vh] custom-scrollbar">
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<label className="block text-white/70 text-sm font-medium mb-2">제목 (선택사항)</label>
|
<label className="text-[10px] font-bold text-white/20 uppercase tracking-widest ml-1">제목 (선택사항)</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={formData.title}
|
value={formData.title}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
|
onChange={(e) => setFormData(prev => ({ ...prev, 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"
|
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm text-white placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all"
|
||||||
placeholder="할일 제목을 입력하세요"
|
placeholder="제목입력..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div className="space-y-2">
|
||||||
<label className="block text-white/70 text-sm font-medium mb-2">만료일 (선택사항)</label>
|
<label className="text-[10px] font-bold text-white/20 uppercase tracking-widest ml-1">만료일 (선택사항)</label>
|
||||||
|
<div className="relative">
|
||||||
|
<Calendar className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-white/20 pointer-events-none" />
|
||||||
<input
|
<input
|
||||||
type="date"
|
type="date"
|
||||||
value={formData.expire}
|
value={formData.expire}
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, expire: e.target.value }))}
|
onChange={(e) => setFormData(prev => ({ ...prev, 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"
|
className="w-full bg-white/5 border border-white/10 rounded-xl pl-12 pr-4 py-3 text-sm text-white focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all [color-scheme:dark]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-white/70 text-sm font-medium mb-2">내용 *</label>
|
|
||||||
<textarea
|
|
||||||
value={formData.remark}
|
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, 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
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-white/70 text-sm font-medium mb-2">요청자</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={formData.request}
|
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, 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">
|
|
||||||
{statusOptions.map((option) => (
|
|
||||||
<button
|
|
||||||
key={option.value}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setFormData(prev => ({ ...prev, status: option.value }))}
|
|
||||||
className={`px-3 py-1 rounded-lg text-xs font-medium border transition-all ${
|
|
||||||
formData.status === option.value
|
|
||||||
? getStatusClass(option.value)
|
|
||||||
: 'bg-white/10 text-white/50 border-white/20 hover:bg-white/20'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{option.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-white/70 text-sm font-medium mb-2">중요도</label>
|
|
||||||
<select
|
|
||||||
value={formData.seqno}
|
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, seqno: parseInt(e.target.value) as TodoPriority }))}
|
|
||||||
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={3}>긴급</option>
|
|
||||||
<option value={2}>매우 중요</option>
|
|
||||||
<option value={1}>중요</option>
|
|
||||||
<option value={0}>보통</option>
|
|
||||||
<option value={-1}>낮음</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-end">
|
|
||||||
<label className="flex items-center text-white/70 text-sm font-medium cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={formData.flag}
|
|
||||||
onChange={(e) => setFormData(prev => ({ ...prev, 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="space-y-2">
|
||||||
<div className="px-6 py-4 border-t border-white/10 flex justify-end">
|
<label className="text-[10px] font-bold text-white/20 uppercase tracking-widest ml-1">내용 *</label>
|
||||||
<div className="flex space-x-3">
|
<textarea
|
||||||
|
value={formData.remark}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, remark: e.target.value }))}
|
||||||
|
rows={4}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm text-white placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all resize-none"
|
||||||
|
placeholder="내용을 입력하세요..."
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-[10px] font-bold text-white/20 uppercase tracking-widest ml-1">업무 요청자</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.request}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, request: e.target.value }))}
|
||||||
|
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm text-white placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all font-medium"
|
||||||
|
placeholder="요청자 성명..."
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 pt-2">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<label className="text-[10px] font-bold text-white/20 uppercase tracking-widest ml-1">진행 상태</label>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{statusOptions.map((option) => (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormData(prev => ({ ...prev, status: option.value }))}
|
||||||
|
className={clsx(
|
||||||
|
"px-3 py-1.5 rounded-lg text-[10px] font-bold border transition-all uppercase tracking-widest",
|
||||||
|
formData.status === option.value
|
||||||
|
? getStatusClass(option.value)
|
||||||
|
: "bg-white/5 text-white/20 border-white/5 hover:bg-white/10"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{option.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<label className="text-[10px] font-bold text-white/20 uppercase tracking-widest ml-1">우선순위 & FLAG</label>
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer group">
|
||||||
|
<div className={clsx(
|
||||||
|
"w-8 h-4 rounded-full relative transition-all duration-300 border",
|
||||||
|
formData.flag ? "bg-warning-500/40 border-warning-500/50" : "bg-white/10 border-white/10"
|
||||||
|
)}>
|
||||||
|
<div className={clsx(
|
||||||
|
"absolute top-0.5 w-2.5 h-2.5 rounded-full bg-white transition-all duration-300 shadow-sm",
|
||||||
|
formData.flag ? "left-4.5 bg-warning-400" : "left-0.5 opacity-30"
|
||||||
|
)} />
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
className="hidden"
|
||||||
|
checked={formData.flag}
|
||||||
|
onChange={(e) => setFormData(prev => ({ ...prev, flag: e.target.checked }))}
|
||||||
|
/>
|
||||||
|
<span className={clsx(
|
||||||
|
"text-[10px] font-bold uppercase tracking-widest",
|
||||||
|
formData.flag ? "text-warning-400" : "text-white/20"
|
||||||
|
)}>고정</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1.5">
|
||||||
|
{[
|
||||||
|
{ value: 3, label: 'URG', color: 'danger' },
|
||||||
|
{ value: 2, label: 'HIGH', color: 'warning' },
|
||||||
|
{ value: 1, label: 'MID', color: 'primary' },
|
||||||
|
{ value: 0, label: 'LOW', color: 'white' },
|
||||||
|
{ value: -1, label: 'MINI', color: 'white' },
|
||||||
|
].map((p) => (
|
||||||
|
<button
|
||||||
|
key={p.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setFormData(prev => ({ ...prev, seqno: p.value as TodoPriority }))}
|
||||||
|
className={clsx(
|
||||||
|
"flex-1 py-2 rounded-lg text-[9px] font-extrabold border transition-all tracking-tighter",
|
||||||
|
formData.seqno === p.value
|
||||||
|
? `bg-${p.color}-500/20 text-${p.color === 'white' ? 'white' : p.color + '-400'} border-${p.color === 'white' ? 'white/20' : p.color + '-500/30'}`
|
||||||
|
: "bg-white/5 text-white/20 border-white/5 hover:bg-white/10"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{p.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 푸터 */}
|
||||||
|
<div className="px-8 py-6 border-t border-white/10 bg-white/5 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
{isEdit && onDelete && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onSubmit}
|
onClick={onDelete}
|
||||||
disabled={processing}
|
disabled={processing}
|
||||||
className="bg-primary-500 hover:bg-primary-600 text-white px-6 py-2 rounded-lg transition-colors flex items-center disabled:opacity-50"
|
className="px-5 py-2.5 rounded-xl bg-danger-500/10 hover:bg-danger-500/20 border border-danger-500/20 text-danger-400 text-sm font-bold transition-all active:scale-95 flex items-center gap-2"
|
||||||
>
|
>
|
||||||
{processing ? (
|
<Trash2 className="w-4 h-4" />
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
삭제하기
|
||||||
) : (
|
|
||||||
<Edit2 className="w-4 h-4 mr-2" />
|
|
||||||
)}
|
|
||||||
{submitText}
|
|
||||||
</button>
|
</button>
|
||||||
{isEdit && onDelete && (
|
)}
|
||||||
<button
|
</div>
|
||||||
type="button"
|
<div className="flex items-center gap-3">
|
||||||
onClick={onDelete}
|
<button
|
||||||
disabled={processing}
|
type="button"
|
||||||
className="bg-danger-500 hover:bg-danger-600 text-white px-4 py-2 rounded-lg transition-colors flex items-center disabled:opacity-50"
|
onClick={onClose}
|
||||||
>
|
className="px-6 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-white/70 hover:text-white text-sm font-bold transition-all active:scale-95"
|
||||||
<Trash2 className="w-4 h-4 mr-2" />
|
>
|
||||||
삭제
|
취소
|
||||||
</button>
|
</button>
|
||||||
)}
|
<button
|
||||||
</div>
|
type="button"
|
||||||
|
onClick={onSubmit}
|
||||||
|
disabled={processing}
|
||||||
|
className="px-8 py-2.5 bg-primary-500 hover:bg-primary-600 border border-white/20 rounded-xl text-white text-sm font-bold transition-all shadow-lg shadow-primary-500/20 active:scale-95 flex items-center gap-2"
|
||||||
|
>
|
||||||
|
{processing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Edit2 className="w-4 h-4" />}
|
||||||
|
{submitText} 완료
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -176,6 +176,7 @@ export interface JobReportItem {
|
|||||||
export interface JobReportUser {
|
export interface JobReportUser {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
process?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 로그인 관련 타입
|
// 로그인 관련 타입
|
||||||
@@ -492,6 +493,10 @@ export interface MachineBridgeInterface {
|
|||||||
// HolidayRequest API (휴가/외출 신청)
|
// HolidayRequest API (휴가/외출 신청)
|
||||||
HolidayRequest_GetList(startDate: string, endDate: string, userId: string, userLevel: number): Promise<string>;
|
HolidayRequest_GetList(startDate: string, endDate: string, userId: string, userLevel: number): Promise<string>;
|
||||||
HolidayRequest_Save(idx: number, uid: string, cate: string, sdate: string, edate: string, remark: string, response: string, conf: number, holyReason: string, holyBackup: string, holyLocation: string, holyDays: number, holyTimes: number, stime: string, etime: string): Promise<string>;
|
HolidayRequest_Save(idx: number, uid: string, cate: string, sdate: string, edate: string, remark: string, response: string, conf: number, holyReason: string, holyBackup: string, holyLocation: string, holyDays: number, holyTimes: number, stime: string, etime: string): Promise<string>;
|
||||||
|
|
||||||
|
// Settings API
|
||||||
|
GetSettings(): Promise<string>;
|
||||||
|
SaveSettings(jsonSettings: string): Promise<string>;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 사용자 권한 정보 타입
|
// 사용자 권한 정보 타입
|
||||||
@@ -808,6 +813,27 @@ export interface ProjectListResponse {
|
|||||||
CurrentUser?: string;
|
CurrentUser?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 설정(Settings.cs) 관련 타입
|
||||||
|
export interface SettingsModel {
|
||||||
|
Disable8HourOver: boolean;
|
||||||
|
startForm: number; // enum (0:없음, 1:NR구매, 2:프로젝트, 3:업무일지, 4:재고, 5:재고현황, 6:근태, 7:품목)
|
||||||
|
DupWindow: boolean;
|
||||||
|
Language: string;
|
||||||
|
FullScreen: boolean;
|
||||||
|
Showbuyerror: boolean;
|
||||||
|
NotShowJobreportPRewView: boolean;
|
||||||
|
Barcode: string;
|
||||||
|
CamIndex: number;
|
||||||
|
HideToolbar: number; // enum (0:Left, 1:Right, 2:Top, 3:Bottom, 4:Hide)
|
||||||
|
Theme: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SettingsResponse {
|
||||||
|
Success: boolean;
|
||||||
|
Message?: string;
|
||||||
|
Data?: SettingsModel;
|
||||||
|
}
|
||||||
|
|
||||||
// 프로젝트 히스토리 타입
|
// 프로젝트 히스토리 타입
|
||||||
export interface ProjectHistory {
|
export interface ProjectHistory {
|
||||||
idx: number;
|
idx: number;
|
||||||
|
|||||||
@@ -7,18 +7,32 @@ export default {
|
|||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
|
// Semantic Colors (Mapped to CSS Variables)
|
||||||
primary: {
|
primary: {
|
||||||
50: '#eff6ff',
|
DEFAULT: 'rgb(var(--color-primary) / <alpha-value>)',
|
||||||
100: '#dbeafe',
|
400: 'rgb(var(--color-primary-light) / <alpha-value>)',
|
||||||
200: '#bfdbfe',
|
500: 'rgb(var(--color-primary) / <alpha-value>)',
|
||||||
300: '#93c5fd',
|
600: 'rgb(var(--color-primary-dark) / <alpha-value>)',
|
||||||
400: '#60a5fa',
|
|
||||||
500: '#3b82f6',
|
|
||||||
600: '#2563eb',
|
|
||||||
700: '#1d4ed8',
|
|
||||||
800: '#1e40af',
|
|
||||||
900: '#1e3a8a',
|
|
||||||
},
|
},
|
||||||
|
bg: {
|
||||||
|
main: 'var(--bg-main)',
|
||||||
|
paper: 'var(--bg-paper)',
|
||||||
|
surface: 'rgba(255, 255, 255, 0.1)', // fallback or common
|
||||||
|
},
|
||||||
|
text: {
|
||||||
|
primary: 'var(--text-primary)',
|
||||||
|
secondary: 'var(--text-secondary)',
|
||||||
|
muted: 'var(--text-muted)',
|
||||||
|
},
|
||||||
|
border: {
|
||||||
|
DEFAULT: 'var(--border-color)',
|
||||||
|
base: 'var(--border-base)',
|
||||||
|
},
|
||||||
|
accent: {
|
||||||
|
DEFAULT: 'rgb(var(--color-accent) / <alpha-value>)',
|
||||||
|
},
|
||||||
|
|
||||||
|
// Legacy Support (Optional - keep generic scales if needed for specific overrides)
|
||||||
success: {
|
success: {
|
||||||
50: '#f0fdf4',
|
50: '#f0fdf4',
|
||||||
100: '#dcfce7',
|
100: '#dcfce7',
|
||||||
|
|||||||
Reference in New Issue
Block a user