feat: apply dark glassmorphism theme to License list and JobReport daily summary dialog

This commit is contained in:
backuppc
2025-12-30 14:16:46 +09:00
parent d11a86b58d
commit 959b1b685d
32 changed files with 3720 additions and 2162 deletions

View File

@@ -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>
); );
} }

View File

@@ -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);

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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>
);

View File

@@ -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>

View File

@@ -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>

View File

@@ -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} />

View 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)} />
</>
);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 */}

View 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
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -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>

View 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;
}

View File

@@ -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);
} }

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
); );
} }

View File

@@ -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}

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
); );
} }

View File

@@ -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>

View File

@@ -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;

View File

@@ -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',