feat: apply dark glassmorphism theme to License list and JobReport daily summary dialog
This commit is contained in:
277
Project/frontend/src/components/DateRangePicker.tsx
Normal file
277
Project/frontend/src/components/DateRangePicker.tsx
Normal file
@@ -0,0 +1,277 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
interface DateRangePickerProps {
|
||||
startDate: string; // YYYY-MM-DD
|
||||
endDate: string; // YYYY-MM-DD
|
||||
onChange: (startDate: string, endDate: string) => void;
|
||||
align?: 'horizontal' | 'vertical';
|
||||
}
|
||||
|
||||
export function DateRangePicker({ startDate, endDate, onChange, align = 'horizontal' }: DateRangePickerProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Internal state for delayed update
|
||||
const [localStart, setLocalStart] = useState(startDate);
|
||||
const [localEnd, setLocalEnd] = useState(endDate);
|
||||
|
||||
// View states for the two calendars
|
||||
const [leftViewDate, setLeftViewDate] = useState(new Date());
|
||||
const [rightViewDate, setRightViewDate] = useState(new Date());
|
||||
|
||||
// Sync props to internal state and init view dates on open
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setLocalStart(startDate);
|
||||
setLocalEnd(endDate);
|
||||
|
||||
const sDate = startDate ? new Date(startDate) : new Date();
|
||||
const eDate = endDate ? new Date(endDate) : new Date();
|
||||
|
||||
setLeftViewDate(new Date(sDate.getFullYear(), sDate.getMonth(), 1));
|
||||
setRightViewDate(new Date(eDate.getFullYear(), eDate.getMonth(), 1));
|
||||
}
|
||||
}, [isOpen, startDate, endDate]);
|
||||
|
||||
// Close on click outside
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const formatDate = (date: Date) => {
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
};
|
||||
|
||||
const setPreset = (type: 'today' | 'tomorrow' | 'thisWeek' | 'thisMonth' | 'lastMonth') => {
|
||||
const today = new Date();
|
||||
let newStart = today;
|
||||
let newEnd = today;
|
||||
|
||||
switch (type) {
|
||||
case 'today':
|
||||
break;
|
||||
case 'tomorrow':
|
||||
const tomorrow = new Date(today);
|
||||
tomorrow.setDate(today.getDate() + 1);
|
||||
newStart = tomorrow;
|
||||
newEnd = tomorrow;
|
||||
break;
|
||||
case 'thisWeek':
|
||||
const day = today.getDay();
|
||||
const diff = today.getDate() - day; // Sunday start
|
||||
newStart = new Date(today.setDate(diff));
|
||||
newEnd = new Date(today.setDate(diff + 6));
|
||||
break;
|
||||
case 'thisMonth':
|
||||
newStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
||||
newEnd = new Date(today.getFullYear(), today.getMonth() + 1, 0);
|
||||
break;
|
||||
case 'lastMonth':
|
||||
newStart = new Date(today.getFullYear(), today.getMonth() - 1, 1);
|
||||
newEnd = new Date(today.getFullYear(), today.getMonth(), 0);
|
||||
break;
|
||||
}
|
||||
|
||||
const sStr = formatDate(newStart);
|
||||
const eStr = formatDate(newEnd);
|
||||
setLocalStart(sStr);
|
||||
setLocalEnd(eStr);
|
||||
|
||||
// Update views to match selection
|
||||
setLeftViewDate(new Date(newStart.getFullYear(), newStart.getMonth(), 1));
|
||||
setRightViewDate(new Date(newEnd.getFullYear(), newEnd.getMonth(), 1));
|
||||
};
|
||||
|
||||
const handleConfirm = () => {
|
||||
onChange(localStart, localEnd);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const toggleOpen = () => setIsOpen(!isOpen);
|
||||
|
||||
// --- Calendar Logic ---
|
||||
const getDaysInMonth = (year: number, month: number) => new Date(year, month + 1, 0).getDate();
|
||||
const getFirstDayOfMonth = (year: number, month: number) => new Date(year, month, 1).getDay();
|
||||
|
||||
const renderCalendarGrid = (viewDate: Date, setViewDate: (d: Date) => void, type: 'start' | 'end') => {
|
||||
const year = viewDate.getFullYear();
|
||||
const month = viewDate.getMonth();
|
||||
const daysInMonth = getDaysInMonth(year, month);
|
||||
const firstDay = getFirstDayOfMonth(year, month);
|
||||
|
||||
const days = [];
|
||||
for (let i = 0; i < firstDay; i++) {
|
||||
days.push(<div key={`empty-${i}`} className="h-8 w-8" />);
|
||||
}
|
||||
|
||||
for (let day = 1; day <= daysInMonth; day++) {
|
||||
const current = new Date(year, month, day);
|
||||
const dateStr = formatDate(current);
|
||||
|
||||
// Only highlight the date corresponding to the specific calendar type
|
||||
const isSelected = type === 'start'
|
||||
? localStart === dateStr
|
||||
: localEnd === dateStr;
|
||||
|
||||
days.push(
|
||||
<button
|
||||
key={day}
|
||||
onClick={() => handleDateClick(dateStr, type)}
|
||||
className={clsx(
|
||||
"h-8 w-8 rounded-full flex items-center justify-center text-xs transition-all relative",
|
||||
// Selected
|
||||
isSelected ? "bg-blue-600 text-white font-bold hover:bg-blue-500 z-10 ring-2 ring-blue-400/50" : "text-white/80 hover:bg-white/10"
|
||||
)}
|
||||
>
|
||||
{day}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-[240px]">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-2 px-2">
|
||||
<button onClick={() => {
|
||||
const d = new Date(viewDate);
|
||||
d.setMonth(d.getMonth() - 1);
|
||||
setViewDate(d);
|
||||
}} className="p-1 hover:bg-white/10 rounded text-white/70">
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</button>
|
||||
<span className="text-sm font-semibold text-white">
|
||||
{year}년 {month + 1}월
|
||||
</span>
|
||||
<button onClick={() => {
|
||||
const d = new Date(viewDate);
|
||||
d.setMonth(d.getMonth() + 1);
|
||||
setViewDate(d);
|
||||
}} className="p-1 hover:bg-white/10 rounded text-white/70">
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
{/* Days Header */}
|
||||
<div className="grid grid-cols-7 gap-1 text-center mb-1">
|
||||
{['일', '월', '화', '수', '목', '금', '토'].map(day => (
|
||||
<div key={day} className="text-[10px] text-white/40 font-medium py-1">
|
||||
{day}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{/* Days Grid */}
|
||||
<div className="grid grid-cols-7 gap-1">
|
||||
{days}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const handleDateClick = (dateStr: string, type: 'start' | 'end') => {
|
||||
if (type === 'start') {
|
||||
setLocalStart(dateStr);
|
||||
// Validation: Start cannot be after End
|
||||
if (localEnd && new Date(dateStr) > new Date(localEnd)) {
|
||||
setLocalEnd(dateStr);
|
||||
}
|
||||
} else {
|
||||
setLocalEnd(dateStr);
|
||||
// Validation: End cannot be before Start
|
||||
if (localStart && new Date(dateStr) < new Date(localStart)) {
|
||||
setLocalStart(dateStr);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative inline-block" ref={containerRef}>
|
||||
{/* Trigger */}
|
||||
<div
|
||||
onClick={toggleOpen}
|
||||
className={clsx(
|
||||
"bg-white/5 border border-white/10 rounded-lg px-3 py-2 cursor-pointer hover:bg-white/10 transition-colors w-fit",
|
||||
align === 'vertical' ? 'flex flex-col items-start gap-1' : 'flex items-center gap-2'
|
||||
)}
|
||||
>
|
||||
{align === 'horizontal' ? (
|
||||
<>
|
||||
<CalendarIcon className="w-4 h-4 text-white/50" />
|
||||
<span className="text-white text-sm font-medium">
|
||||
{startDate} <span className="text-white/50 mx-1">~</span> {endDate}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white/50 text-xs">시작</span>
|
||||
<span className="text-white text-sm font-medium tracking-wider">{startDate}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-white/50 text-xs">종료</span>
|
||||
<span className="text-white text-sm font-medium tracking-wider">{endDate}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Popover */}
|
||||
{isOpen && (
|
||||
<div className="absolute top-full left-0 mt-2 z-[9999] bg-[#1e1e2e] border border-white/10 rounded-xl shadow-xl p-4 w-auto backdrop-blur-md">
|
||||
|
||||
{/* Presets Grid */}
|
||||
<div className="flex flex-wrap gap-2 mb-4 justify-center">
|
||||
<button onClick={() => setPreset('today')} className="px-3 py-1.5 bg-white/5 hover:bg-white/10 rounded-full text-xs text-white/80 border border-white/5 transition-colors">오늘</button>
|
||||
<button onClick={() => setPreset('tomorrow')} className="px-3 py-1.5 bg-white/5 hover:bg-white/10 rounded-full text-xs text-white/80 border border-white/5 transition-colors">내일</button>
|
||||
<button onClick={() => setPreset('thisWeek')} className="px-3 py-1.5 bg-white/5 hover:bg-white/10 rounded-full text-xs text-white/80 border border-white/5 transition-colors">이번주</button>
|
||||
<button onClick={() => setPreset('thisMonth')} className="px-3 py-1.5 bg-white/5 hover:bg-white/10 rounded-full text-xs text-white/80 border border-white/5 transition-colors">이번달</button>
|
||||
<button onClick={() => setPreset('lastMonth')} className="px-3 py-1.5 bg-white/5 hover:bg-white/10 rounded-full text-xs text-white/80 border border-white/5 transition-colors">저번달</button>
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-white/10 mb-4" />
|
||||
|
||||
{/* Dual Calendars Container */}
|
||||
<div className="flex flex-col md:flex-row gap-6">
|
||||
{renderCalendarGrid(leftViewDate, setLeftViewDate, 'start')}
|
||||
|
||||
{/* Mobile/Desktop separator or spacer */}
|
||||
<div className="hidden md:block w-px bg-white/10 self-stretch my-2"></div>
|
||||
|
||||
{renderCalendarGrid(rightViewDate, setRightViewDate, 'end')}
|
||||
</div>
|
||||
|
||||
<div className="h-px bg-white/10 my-4" />
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={handleCancel}
|
||||
className="px-4 py-2 rounded-lg text-sm text-white/70 hover:bg-white/10 transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
className="px-4 py-2 rounded-lg text-sm bg-blue-600 hover:bg-blue-500 text-white font-medium transition-colors"
|
||||
>
|
||||
확인
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
Project/frontend/src/components/DevelopmentNotice.tsx
Normal file
13
Project/frontend/src/components/DevelopmentNotice.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
|
||||
|
||||
export function DevelopmentNotice() {
|
||||
return (
|
||||
<div className="bg-gradient-to-r from-[#ff1493] via-[#ff69b4] to-[#ff1493] border-2 border-white/50 rounded-xl p-4 flex items-center justify-center shadow-[0_0_25px_rgba(255,20,147,0.6)] backdrop-blur-md mb-6">
|
||||
<span className="text-white font-black flex items-center gap-3 drop-shadow-[0_2px_4px_rgba(0,0,0,0.3)] tracking-tight">
|
||||
<span className="text-2xl animate-bounce">✨</span>
|
||||
<span className="uppercase italic">Development Mode:</span> 현재 개발중인 페이지입니다. 데이터가 정확하지 않을 수 있으며 오류가 발생할 수 있습니다.
|
||||
<span className="text-2xl animate-bounce" style={{ animationDelay: '0.2s' }}>✨</span>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
213
Project/frontend/src/components/UserSelector.tsx
Normal file
213
Project/frontend/src/components/UserSelector.tsx
Normal file
@@ -0,0 +1,213 @@
|
||||
import { useState, useRef, useEffect, useMemo } from 'react';
|
||||
import { Search, Check, ChevronDown, User } from 'lucide-react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
export interface UserInfo {
|
||||
id: string;
|
||||
name: string;
|
||||
process?: string;
|
||||
level?: number;
|
||||
useJobReport?: boolean;
|
||||
outdate?: string | null;
|
||||
}
|
||||
|
||||
interface UserSelectorProps {
|
||||
users: UserInfo[];
|
||||
selectedIds: string[]; // Always array for internal consistency, or handle single/multi logic
|
||||
onChange: (ids: string[]) => void;
|
||||
multiSelect?: boolean;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
// Filter Options
|
||||
minLevel?: number;
|
||||
onlyJobReportUsers?: boolean;
|
||||
includeResigned?: boolean;
|
||||
}
|
||||
|
||||
export function UserSelector({
|
||||
users,
|
||||
selectedIds,
|
||||
onChange,
|
||||
multiSelect = false,
|
||||
placeholder = '담당자 선택',
|
||||
className
|
||||
}: UserSelectorProps) {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Filter and Sort users
|
||||
const filteredAndSortedUsers = useMemo(() => {
|
||||
let result = users;
|
||||
|
||||
// Filter
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
result = result.filter(user =>
|
||||
user.name.toLowerCase().includes(query) ||
|
||||
user.id.toLowerCase().includes(query)
|
||||
);
|
||||
}
|
||||
|
||||
// Sort: Process -> Name
|
||||
return result.sort((a, b) => {
|
||||
// Compare Process
|
||||
const processA = a.process || '';
|
||||
const processB = b.process || '';
|
||||
if (processA !== processB) {
|
||||
if (processA < processB) return -1;
|
||||
if (processA > processB) return 1;
|
||||
}
|
||||
|
||||
// Compare Name
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
}, [users, searchQuery]);
|
||||
|
||||
// Handle outside click
|
||||
useEffect(() => {
|
||||
function handleClickOutside(event: MouseEvent) {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
}
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}, []);
|
||||
|
||||
const handleSelect = (userId: string) => {
|
||||
if (multiSelect) {
|
||||
if (selectedIds.includes(userId)) {
|
||||
onChange(selectedIds.filter(id => id !== userId));
|
||||
} else {
|
||||
onChange([...selectedIds, userId]);
|
||||
}
|
||||
} else {
|
||||
// Single select: toggle or set
|
||||
if (selectedIds.includes(userId)) {
|
||||
onChange([]); // Deselect if clicked again? Or just prevent? Usually deselect is allowed.
|
||||
} else {
|
||||
onChange([userId]);
|
||||
setIsOpen(false); // Close on selection for single mode
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const selectedUserList = useMemo(() => users.filter(u => selectedIds.includes(u.id)), [users, selectedIds]);
|
||||
|
||||
const getDisplayText = () => {
|
||||
if (selectedIds.length === 0) return placeholder;
|
||||
|
||||
if (selectedUserList.length === 0) {
|
||||
return selectedIds[0];
|
||||
}
|
||||
|
||||
if (selectedUserList.length === 1) {
|
||||
return `${selectedUserList[0].name}(${selectedUserList[0].id})`;
|
||||
}
|
||||
return `${selectedUserList[0].name} 외 ${selectedUserList.length - 1}명`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={clsx("relative z-30", className)} ref={containerRef}>
|
||||
{/* Trigger Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className={clsx(
|
||||
"flex items-center justify-between w-full h-[60px] px-3 bg-white/5 border border-white/10 rounded-lg text-white transition-colors focus:outline-none focus:ring-2 focus:ring-primary-400 hover:bg-white/10",
|
||||
isOpen && "ring-2 ring-primary-400"
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 overflow-hidden text-left">
|
||||
<User className="w-5 h-5 text-white/50 flex-shrink-0" />
|
||||
<div className="flex flex-col justify-center gap-0.5">
|
||||
{selectedUserList.length === 1 ? (
|
||||
<>
|
||||
<div className="text-sm font-medium leading-none flex items-center">
|
||||
{selectedUserList[0].name}
|
||||
<span className="text-white/40 text-xs ml-1 font-normal">({selectedUserList[0].id})</span>
|
||||
</div>
|
||||
{selectedUserList[0].process ? (
|
||||
<div className="text-xs text-white/60 leading-none">
|
||||
{selectedUserList[0].process}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-xs text-white/30 leading-none">
|
||||
-
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="truncate text-sm">
|
||||
{getDisplayText()}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<ChevronDown className="w-4 h-4 text-white/50 ml-2 flex-shrink-0" />
|
||||
</button>
|
||||
|
||||
{/* Popover */}
|
||||
{isOpen && (
|
||||
<div className="absolute top-full mt-2 w-[360px] bg-[#1e293b] border border-white/10 rounded-xl shadow-2xl overflow-hidden animate-fade-in-up z-50">
|
||||
<div className="p-2 space-y-2">
|
||||
{/* Search Input */}
|
||||
<div className="relative">
|
||||
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-white/50" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="사용자 검색"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full h-8 bg-black/20 border border-white/10 rounded pl-8 pr-2 text-xs text-white focus:outline-none focus:border-primary-500 placeholder-white/30"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* User List */}
|
||||
<div className="max-h-60 overflow-y-auto space-y-1 scrollbar-thin scrollbar-thumb-white/10 scrollbar-track-transparent pr-1">
|
||||
{filteredAndSortedUsers.length === 0 ? (
|
||||
<div className="text-center text-white/40 text-xs py-4">
|
||||
검색 결과가 없습니다.
|
||||
</div>
|
||||
) : (
|
||||
filteredAndSortedUsers.map(user => {
|
||||
const isSelected = selectedIds.includes(user.id);
|
||||
return (
|
||||
<button
|
||||
key={user.id}
|
||||
onClick={() => handleSelect(user.id)}
|
||||
className={clsx(
|
||||
"w-full flex items-center gap-2 px-2 py-1.5 rounded text-left transition-colors text-sm",
|
||||
isSelected ? "bg-primary-500/20 text-primary-200" : "text-white/80 hover:bg-white/10"
|
||||
)}
|
||||
>
|
||||
<div className={clsx(
|
||||
"w-4 h-4 rounded border flex items-center justify-center transition-colors flex-shrink-0",
|
||||
isSelected ? "bg-primary-500 border-primary-500" : "border-white/30"
|
||||
)}>
|
||||
{isSelected && <Check className="w-3 h-3 text-white" />}
|
||||
</div>
|
||||
<div className="flex flex-col min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{user.process && (
|
||||
<span className="text-[10px] text-primary-300 bg-primary-500/10 px-1 rounded flex-shrink-0">
|
||||
{user.process}
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs font-medium truncate">{user.name}</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-white/50">{user.id}</span>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
|
||||
|
||||
export function DevelopmentNotice() {
|
||||
return (
|
||||
<div className="bg-yellow-500/10 border border-yellow-500/20 rounded-lg p-4 flex items-center justify-center animate-pulse">
|
||||
<span className="text-yellow-400 font-medium flex items-center gap-2">
|
||||
<span className="text-xl">⚠️</span>
|
||||
현재 개발중인 페이지입니다. 데이터가 정확하지 않을 수 있습니다.
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X, Save, Calendar, Clock, MapPin, User, FileText, AlertCircle } from 'lucide-react';
|
||||
import { comms } from '../../communication';
|
||||
import { DevelopmentNotice } from '../common/DevelopmentNotice';
|
||||
import { DevelopmentNotice } from "../DevelopmentNotice";
|
||||
import { HolidayRequest, CommonCode } from '@/types';
|
||||
|
||||
interface HolidayRequestDialogProps {
|
||||
@@ -36,7 +36,7 @@ export function HolidayRequestDialog({
|
||||
backup: []
|
||||
});
|
||||
const [users, setUsers] = useState<Array<{ id: string; name: string }>>([]);
|
||||
const [adminComments, setAdminComments] = useState<CommonCode[]>([]); // Code 54
|
||||
|
||||
|
||||
// Form State
|
||||
const [formData, setFormData] = useState<HolidayRequest>({
|
||||
@@ -356,14 +356,14 @@ export function HolidayRequestDialog({
|
||||
|
||||
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="bg-[#1e1e2e] rounded-2xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto border border-white/10">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 bg-white/5">
|
||||
<h2 className="text-xl font-bold text-white flex items-center">
|
||||
<Calendar className="w-5 h-5 mr-2 text-primary-400" />
|
||||
<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 - Lively Gradient */}
|
||||
<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 drop-shadow-md">
|
||||
<Calendar className="w-6 h-6 mr-2 text-white animate-pulse" />
|
||||
{title}
|
||||
</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" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -378,10 +378,10 @@ export function HolidayRequestDialog({
|
||||
{/* Left Column: Inputs */}
|
||||
<div className="space-y-6">
|
||||
{/* 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'}`}>
|
||||
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${requestType === 'day' ? 'border-green-400' : 'border-white/30'}`}>
|
||||
{requestType === 'day' && <div className="w-2 h-2 rounded-full bg-green-400" />}
|
||||
<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-primary-500" />}
|
||||
</div>
|
||||
<input
|
||||
type="radio"
|
||||
@@ -391,11 +391,11 @@ export function HolidayRequestDialog({
|
||||
className="hidden"
|
||||
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 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'}`}>
|
||||
{requestType === 'time' && <div className="w-2 h-2 rounded-full bg-green-400" />}
|
||||
<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-primary-500" />}
|
||||
</div>
|
||||
<input
|
||||
type="radio"
|
||||
@@ -405,11 +405,11 @@ export function HolidayRequestDialog({
|
||||
className="hidden"
|
||||
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 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'}`}>
|
||||
{requestType === 'out' && <div className="w-2 h-2 rounded-full bg-green-400" />}
|
||||
<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-primary-500" />}
|
||||
</div>
|
||||
<input
|
||||
type="radio"
|
||||
@@ -419,24 +419,24 @@ export function HolidayRequestDialog({
|
||||
className="hidden"
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
<span className="font-medium text-white/90">외출</span>
|
||||
<span className="font-bold text-primary-400">외출</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* User Selection (Admin only) */}
|
||||
{userLevel >= 5 && (
|
||||
<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" /> 신청자
|
||||
</label>
|
||||
<select
|
||||
value={formData.uid}
|
||||
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}
|
||||
>
|
||||
{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>
|
||||
</div>
|
||||
@@ -445,26 +445,26 @@ export function HolidayRequestDialog({
|
||||
{/* Date & Time */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<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" /> 시작일
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.sdate}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
<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" /> 종료일
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.edate}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
@@ -475,21 +475,21 @@ export function HolidayRequestDialog({
|
||||
{/* Category & Reason */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<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" /> 구분
|
||||
</label>
|
||||
{requestType === 'day' ? (
|
||||
<select
|
||||
value={formData.cate}
|
||||
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}
|
||||
>
|
||||
{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 key = code.code || (code as any).Code || (code as any).key || (code as any).Key || val;
|
||||
return (
|
||||
<option key={key} value={val} className="bg-[#1e1e2e]">
|
||||
<option key={key} value={val} className="bg-bg-paper">
|
||||
{val}
|
||||
</option>
|
||||
);
|
||||
@@ -500,12 +500,12 @@ export function HolidayRequestDialog({
|
||||
type="text"
|
||||
value={formData.cate}
|
||||
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 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" /> 사유
|
||||
</label>
|
||||
<input
|
||||
@@ -513,7 +513,7 @@ export function HolidayRequestDialog({
|
||||
list="reason-list"
|
||||
value={formData.HolyReason || ''}
|
||||
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="입력 또는 선택"
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
@@ -532,7 +532,7 @@ export function HolidayRequestDialog({
|
||||
{/* Location & Backup */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<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" /> 행선지
|
||||
</label>
|
||||
<input
|
||||
@@ -540,7 +540,7 @@ export function HolidayRequestDialog({
|
||||
list="location-list"
|
||||
value={formData.HolyLocation || ''}
|
||||
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="입력 또는 선택"
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
@@ -555,7 +555,7 @@ export function HolidayRequestDialog({
|
||||
</datalist>
|
||||
</div>
|
||||
<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" /> 업무대행
|
||||
</label>
|
||||
<input
|
||||
@@ -563,7 +563,7 @@ export function HolidayRequestDialog({
|
||||
list="backup-list"
|
||||
value={formData.HolyBackup || ''}
|
||||
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="입력 또는 선택"
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
@@ -582,24 +582,24 @@ export function HolidayRequestDialog({
|
||||
{/* Days & Times (Manual Override) */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<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
|
||||
type="number"
|
||||
step="0.5"
|
||||
value={formData.HolyDays}
|
||||
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'}
|
||||
/>
|
||||
</div>
|
||||
<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
|
||||
type="number"
|
||||
step="0.5"
|
||||
value={formData.HolyTimes}
|
||||
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'}
|
||||
/>
|
||||
</div>
|
||||
@@ -609,53 +609,51 @@ export function HolidayRequestDialog({
|
||||
{requestType === 'out' && (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<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" /> 시작 시간
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
value={formData.stime}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
<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" /> 종료 시간
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
value={formData.etime}
|
||||
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}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
{/* Right Column: Remark */}
|
||||
<div className="md:col-span-1 h-full">
|
||||
<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
|
||||
value={formData.Remark}
|
||||
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="비고 사항을 입력하세요..."
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
|
||||
{/* 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>
|
||||
<div className="flex gap-4">
|
||||
<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" />}
|
||||
</div>
|
||||
<input
|
||||
@@ -666,10 +664,10 @@ export function HolidayRequestDialog({
|
||||
className="hidden"
|
||||
disabled={userLevel < 5}
|
||||
/>
|
||||
<span className="text-white/70">미승인</span>
|
||||
<span className="text-primary-200">미승인</span>
|
||||
</label>
|
||||
<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" />}
|
||||
</div>
|
||||
<input
|
||||
@@ -680,10 +678,10 @@ export function HolidayRequestDialog({
|
||||
className="hidden"
|
||||
disabled={userLevel < 5}
|
||||
/>
|
||||
<span className="text-white/70">승인</span>
|
||||
<span className="text-primary-200">승인</span>
|
||||
</label>
|
||||
<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" />}
|
||||
</div>
|
||||
<input
|
||||
@@ -694,24 +692,18 @@ export function HolidayRequestDialog({
|
||||
className="hidden"
|
||||
disabled={userLevel < 5}
|
||||
/>
|
||||
<span className="text-white/70">반려</span>
|
||||
<span className="text-primary-200">반려</span>
|
||||
</label>
|
||||
</div>
|
||||
<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
|
||||
type="text"
|
||||
list="adminCommentsList"
|
||||
value={formData.Response}
|
||||
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}
|
||||
/>
|
||||
<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>
|
||||
@@ -719,10 +711,10 @@ export function HolidayRequestDialog({
|
||||
</div>
|
||||
|
||||
{/* 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
|
||||
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>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 { 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) => {
|
||||
if (!data) return 'text-gray-400';
|
||||
if (!data) return 'text-white/20';
|
||||
|
||||
if (data.jobtype === '휴가') return 'text-red-500 font-medium';
|
||||
if (isHoliday) return 'text-green-500 font-medium';
|
||||
if (data.jobtype === '휴가') return 'text-danger-400 font-black bg-danger-500/10';
|
||||
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-red-500';
|
||||
if (data.ot > 0) return 'text-purple-500 font-medium';
|
||||
if (data.hrs > 8) return 'text-primary-400 font-bold underline underline-offset-2';
|
||||
if (data.hrs < 8) return 'text-danger-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)
|
||||
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 => {
|
||||
const cells = dayColumns.map(col => {
|
||||
userRows.forEach((row: UserRow) => {
|
||||
const cells = dayColumns.map((col: DayColumn) => {
|
||||
const data = row.dailyData.get(col.day);
|
||||
return formatCellContent(data, col.isHoliday);
|
||||
});
|
||||
@@ -222,125 +223,207 @@ export function JobReportDayDialog({ isOpen, onClose, initialMonth }: JobReportD
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<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="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-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="flex items-center gap-4">
|
||||
<h2 className="text-2xl font-bold text-white">일별 근무시간 집계</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<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-6">
|
||||
<div className="flex items-center gap-4">
|
||||
<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
|
||||
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>
|
||||
<span className="text-lg font-medium text-white min-w-[100px] text-center">
|
||||
{currentMonth}
|
||||
</span>
|
||||
<div className="px-4 flex items-center gap-2">
|
||||
<Calendar className="w-4 h-4 text-primary-400" />
|
||||
<span className="text-sm font-bold text-white font-mono min-w-[80px] text-center italic tracking-wider">
|
||||
{currentMonth}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
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" />
|
||||
내보내기
|
||||
<span>CSV 내보내기</span>
|
||||
</button>
|
||||
<button
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-white/50">데이터를 불러오는 중...</div>
|
||||
</div>
|
||||
) : (
|
||||
<table className="w-full border-collapse">
|
||||
<thead className="sticky top-0 bg-gray-800 z-10">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-xs font-medium text-white/70 uppercase border border-white/10 bg-gray-800">
|
||||
사원명
|
||||
</th>
|
||||
{dayColumns.map(col => (
|
||||
<th
|
||||
key={col.day}
|
||||
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' :
|
||||
col.dayOfWeek === '일' ? 'bg-red-900/30 text-red-400' :
|
||||
col.dayOfWeek === '토' ? 'bg-blue-900/30 text-blue-400' :
|
||||
'bg-gray-800 text-white/70'
|
||||
}`}
|
||||
title={col.holidayMemo}
|
||||
>
|
||||
{col.day}<br />({col.dayOfWeek})
|
||||
</th>
|
||||
))}
|
||||
<th className="px-3 py-2 text-center text-xs font-medium text-white/70 uppercase border border-white/10 bg-gray-800">
|
||||
합계
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{userRows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={dayColumns.length + 2} className="px-4 py-8 text-center text-white/50">
|
||||
조회된 데이터가 없습니다.
|
||||
</td>
|
||||
</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>
|
||||
{/* 테이블 컨텐츠 */}
|
||||
<div className="flex-1 overflow-hidden flex flex-col p-6 bg-white/[0.02]">
|
||||
<div className="flex-1 glass-effect rounded-2xl border border-white/10 flex flex-col overflow-hidden shadow-2xl">
|
||||
{loading ? (
|
||||
<div className="flex-1 flex flex-col items-center justify-center">
|
||||
<RefreshCw className="w-12 h-12 text-primary-500/30 animate-spin mb-4" />
|
||||
<p className="text-white/40 font-medium">데이터를 분석하고 집계하는 중...</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex-1 overflow-auto custom-scrollbar">
|
||||
<table className="w-full border-separate border-spacing-0">
|
||||
<thead className="sticky top-0 z-20">
|
||||
<tr>
|
||||
<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>
|
||||
{dayColumns.map(col => (
|
||||
<th
|
||||
key={col.day}
|
||||
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]",
|
||||
col.isHoliday ? 'text-danger-400 bg-danger-500/10' :
|
||||
col.dayOfWeek === '일' ? 'text-danger-400' :
|
||||
col.dayOfWeek === '토' ? 'text-primary-400' :
|
||||
'text-white/40'
|
||||
)}
|
||||
title={col.holidayMemo}
|
||||
>
|
||||
<div className="flex flex-col items-center">
|
||||
<span className="text-sm font-mono leading-none">{col.day}</span>
|
||||
<span className="mt-1 opacity-60 leading-none">{col.dayOfWeek}</span>
|
||||
</div>
|
||||
</th>
|
||||
))}
|
||||
<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)
|
||||
</th>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</thead>
|
||||
<tbody className="divide-y divide-white/[0.03]">
|
||||
{userRows.length === 0 ? (
|
||||
<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 className="px-6 py-4 border-t border-white/10 bg-gray-800/50">
|
||||
<div className="flex flex-wrap gap-4 text-xs text-white/70">
|
||||
<div><span className="text-gray-400">--</span> : 자료없음</div>
|
||||
<div><span className="text-red-500">휴가</span> : 휴가</div>
|
||||
<div><span className="text-green-500">*8+2</span> : 휴일근무</div>
|
||||
<div><span className="text-blue-500">9+0</span> : 8시간 초과</div>
|
||||
<div><span className="text-red-500">7+0</span> : 8시간 미만</div>
|
||||
<div><span className="text-purple-500">8+2</span> : 8시간+OT</div>
|
||||
{/* 하단 범례 (Legend) */}
|
||||
<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">
|
||||
<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 className="flex items-center gap-6 text-[11px] font-bold whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-5 h-5 rounded-md bg-white/5 flex items-center justify-center text-gray-500 font-mono italic">--</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/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>
|
||||
);
|
||||
}
|
||||
|
||||
const RefreshCw = ({ className }: { className?: string }) => (
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24" height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
className={className}
|
||||
>
|
||||
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
|
||||
<path d="M21 3v5h-5" />
|
||||
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
|
||||
<path d="M3 21v-5h5" />
|
||||
</svg>
|
||||
);
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
|
||||
import { X, RefreshCw } from 'lucide-react';
|
||||
import { comms } from '@/communication';
|
||||
import { JobReportTypeItem } from '@/types';
|
||||
import { DevelopmentNotice } from '@/components/DevelopmentNotice';
|
||||
|
||||
interface JobreportTypeModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -65,6 +66,7 @@ export function JobreportTypeModal({
|
||||
|
||||
{/* 컨텐츠 */}
|
||||
<div className="p-6">
|
||||
<DevelopmentNotice />
|
||||
<div className="mb-4 text-white/70 text-sm">
|
||||
기간: {startDate} ~ {endDate}
|
||||
</div>
|
||||
|
||||
@@ -116,7 +116,7 @@ export function KuntaeEditModal({ isOpen, onClose, onSave, initialData, mode }:
|
||||
|
||||
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="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">
|
||||
<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"
|
||||
>
|
||||
{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>
|
||||
</div>
|
||||
|
||||
@@ -10,7 +10,7 @@ interface LayoutProps {
|
||||
|
||||
export function Layout({ isConnected, user }: LayoutProps) {
|
||||
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">
|
||||
{/* Top Navigation Header */}
|
||||
<Header isConnected={isConnected} />
|
||||
|
||||
21
Project/frontend/src/components/layout/SettingsButton.tsx
Normal file
21
Project/frontend/src/components/layout/SettingsButton.tsx
Normal file
@@ -0,0 +1,21 @@
|
||||
import { useState } from 'react';
|
||||
import { Settings } from 'lucide-react';
|
||||
import { SettingsDialog } from '../settings/SettingsDialog';
|
||||
|
||||
export function SettingsButton() {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
onClick={() => setIsOpen(true)}
|
||||
className="flex items-center justify-center p-2 rounded-lg text-white/70 hover:text-white hover:bg-white/10 transition-colors"
|
||||
title="설정"
|
||||
>
|
||||
<Settings className="w-4 h-4" />
|
||||
</button>
|
||||
|
||||
<SettingsDialog isOpen={isOpen} onClose={() => setIsOpen(false)} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Clock, Wifi, WifiOff } from 'lucide-react';
|
||||
import { UserInfoButton } from './UserInfoButton';
|
||||
import { SettingsButton } from './SettingsButton';
|
||||
import { comms } from '@/communication';
|
||||
|
||||
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">
|
||||
{/* Left: User Info */}
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* Settings Button */}
|
||||
<SettingsButton />
|
||||
<UserInfoButton userName={userName} userDept={userDept} />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { X, Save, Trash2 } from 'lucide-react';
|
||||
import { X, Save, Trash2, ShieldCheck, Info, Package, Truck, User, Calendar, FileText, CheckCircle, XCircle } from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import { useState, useEffect } from 'react';
|
||||
import type { LicenseItem } from '@/types';
|
||||
|
||||
@@ -86,65 +87,88 @@ export function LicenseEditDialog({ item, isOpen, onClose, onSave, onDelete }: L
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" 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="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-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 */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-white/10">
|
||||
<h2 className="text-xl font-semibold text-white">
|
||||
{formData.idx ? '라이선스 수정' : '라이선스 추가'}
|
||||
</h2>
|
||||
<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="p-2.5 bg-primary-500/20 rounded-xl">
|
||||
<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
|
||||
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" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<h3 className="text-sm font-semibold text-white/90 flex items-center space-x-2 border-b border-white/10 pb-2">
|
||||
<span>기본 정보</span>
|
||||
<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">
|
||||
<Info className="w-4 h-4" />
|
||||
기본 정보
|
||||
</h3>
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
<div className="col-span-1 flex items-center">
|
||||
<label className="flex items-center space-x-2 cursor-pointer">
|
||||
<div className="grid grid-cols-12 gap-5">
|
||||
<div className="col-span-12 md:col-span-2">
|
||||
<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
|
||||
type="checkbox"
|
||||
checked={formData.expire || false}
|
||||
onChange={(e) => setFormData({ ...formData, expire: e.target.checked })}
|
||||
className="w-4 h-4"
|
||||
type="text"
|
||||
value={formData.name || ''}
|
||||
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||
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>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-5">
|
||||
<label className="block text-sm text-white/70 mb-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>
|
||||
<div className="col-span-12 md:col-span-2">
|
||||
<label className="block text-xs font-bold text-white/30 uppercase tracking-widest mb-2 px-1">버전</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.version || ''}
|
||||
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 className="col-span-3">
|
||||
<label className="block text-sm text-white/70 mb-1">자재번호</label>
|
||||
<div className="col-span-12 md:col-span-3">
|
||||
<label className="block text-xs font-bold text-white/30 uppercase tracking-widest mb-2 px-1">자재번호</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.meterialNo || ''}
|
||||
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>
|
||||
@@ -152,26 +176,27 @@ export function LicenseEditDialog({ item, isOpen, onClose, onSave, onDelete }: L
|
||||
|
||||
{/* 공급 정보 */}
|
||||
<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">
|
||||
<span>공급 정보</span>
|
||||
<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">
|
||||
<Truck className="w-4 h-4" />
|
||||
공급 정보
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
<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
|
||||
type="text"
|
||||
value={formData.supply || ''}
|
||||
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>
|
||||
<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.manu || ''}
|
||||
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>
|
||||
@@ -179,35 +204,36 @@ export function LicenseEditDialog({ item, isOpen, onClose, onSave, onDelete }: L
|
||||
|
||||
{/* 사용 정보 */}
|
||||
<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">
|
||||
<span>사용 정보</span>
|
||||
<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">
|
||||
<User className="w-4 h-4" />
|
||||
사용 정보
|
||||
</h3>
|
||||
<div className="grid grid-cols-12 gap-4">
|
||||
<div className="col-span-2">
|
||||
<label className="block text-sm text-white/70 mb-1">수량</label>
|
||||
<div className="grid grid-cols-12 gap-5">
|
||||
<div className="col-span-12 md:col-span-2">
|
||||
<label className="block text-xs font-bold text-white/30 uppercase tracking-widest mb-2 px-1">수량</label>
|
||||
<input
|
||||
type="number"
|
||||
value={formData.qty || 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 className="col-span-4">
|
||||
<label className="block text-sm text-white/70 mb-1">사용자</label>
|
||||
<div className="col-span-12 md:col-span-4">
|
||||
<label className="block text-xs font-bold text-white/30 uppercase tracking-widest mb-2 px-1">사용자</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.uids || ''}
|
||||
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 className="col-span-6">
|
||||
<label className="block text-sm text-white/70 mb-1">S/N</label>
|
||||
<div className="col-span-12 md:col-span-6">
|
||||
<label className="block text-xs font-bold text-white/30 uppercase tracking-widest mb-2 px-1">S/N</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.serialNo || ''}
|
||||
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>
|
||||
@@ -215,74 +241,82 @@ export function LicenseEditDialog({ item, isOpen, onClose, onSave, onDelete }: L
|
||||
|
||||
{/* 기간 정보 */}
|
||||
<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">
|
||||
<span>기간 정보</span>
|
||||
<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">
|
||||
<Calendar className="w-4 h-4" />
|
||||
기간 정보
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-2 gap-5">
|
||||
<div>
|
||||
<label className="block text-sm text-white/70 mb-1">시작일</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.sdate || ''}
|
||||
onChange={(e) => setFormData({ ...formData, sdate: 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"
|
||||
/>
|
||||
<label className="block text-xs font-bold text-white/30 uppercase tracking-widest mb-2 px-1">시작일</label>
|
||||
<div className="relative group">
|
||||
<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" />
|
||||
<input
|
||||
type="date"
|
||||
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>
|
||||
<label className="block text-sm text-white/70 mb-1">종료일</label>
|
||||
<input
|
||||
type="date"
|
||||
value={formData.edate || ''}
|
||||
onChange={(e) => setFormData({ ...formData, edate: 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"
|
||||
/>
|
||||
<label className="block text-xs font-bold text-white/30 uppercase tracking-widest mb-2 px-1">종료일</label>
|
||||
<div className="relative group">
|
||||
<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" />
|
||||
<input
|
||||
type="date"
|
||||
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 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">
|
||||
<span>비고</span>
|
||||
<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">
|
||||
<FileText className="w-4 h-4" />
|
||||
비고
|
||||
</h3>
|
||||
<textarea
|
||||
value={formData.remark || ''}
|
||||
onChange={(e) => setFormData({ ...formData, remark: e.target.value })}
|
||||
rows={3}
|
||||
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"
|
||||
rows={4}
|
||||
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="추가 메모를 입력하세요..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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>
|
||||
{formData.idx && onDelete && (
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
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>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
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
|
||||
onClick={handleSave}
|
||||
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>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -4,12 +4,14 @@ import {
|
||||
FolderOpen,
|
||||
Download,
|
||||
Search,
|
||||
X,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
ShieldCheck,
|
||||
RefreshCw,
|
||||
} from 'lucide-react';
|
||||
import { clsx } from 'clsx';
|
||||
import { comms } from '@/communication';
|
||||
import { LicenseEditDialog } from './LicenseEditDialog';
|
||||
import type { LicenseItem } from '@/types';
|
||||
@@ -229,149 +231,215 @@ export function LicenseList() {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold text-white">라이선스 관리</h1>
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex flex-col h-full animate-fade-in bg-white/[0.02]">
|
||||
{/* 리스트 헤더 */}
|
||||
<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">
|
||||
<div className="flex items-center gap-4">
|
||||
<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
|
||||
onClick={handleAdd}
|
||||
onClick={loadData}
|
||||
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" />
|
||||
<span>추가</span>
|
||||
<RefreshCw className={clsx("w-4 h-4", loading && "animate-spin")} />
|
||||
</button>
|
||||
|
||||
{/* CSV */}
|
||||
<button
|
||||
onClick={handleExportCSV}
|
||||
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" />
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search */}
|
||||
<div className="flex items-center space-x-2">
|
||||
<div className="flex-1 relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/50" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchText}
|
||||
onChange={(e) => setSearchText(e.target.value)}
|
||||
placeholder="검색 (제품명, 버전, 공급업체, 제조사, S/N, 자재번호, 비고)"
|
||||
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"
|
||||
/>
|
||||
{searchText && (
|
||||
<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 className="flex-1 overflow-hidden flex flex-col p-6">
|
||||
{/* 테이블 메인 섹션 */}
|
||||
<div className="flex-1 glass-effect rounded-2xl border border-white/10 flex flex-col overflow-hidden shadow-2xl">
|
||||
{/* 컬럼 헤더 */}
|
||||
<div className="bg-white/10 px-6 py-3 border-b border-white/5 flex items-center gap-4 sticky top-0 z-10">
|
||||
<div className="w-12 text-center text-xs font-medium text-white/70 uppercase">상태</div>
|
||||
<div className="flex-1 flex items-center gap-4">
|
||||
<div className="w-1/4 text-xs font-medium text-white/70 uppercase">제품명</div>
|
||||
<div className="w-1/4 text-xs font-medium text-white/70 uppercase">버전</div>
|
||||
<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>
|
||||
<div className="flex-1 text-xs font-medium text-white/70 uppercase">S/N</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>
|
||||
|
||||
{/* Edit Dialog */}
|
||||
|
||||
290
Project/frontend/src/components/settings/SettingsDialog.tsx
Normal file
290
Project/frontend/src/components/settings/SettingsDialog.tsx
Normal file
@@ -0,0 +1,290 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X, Settings as SettingsIcon, Monitor, Palette, Save } from 'lucide-react';
|
||||
import { useTheme } from '@/context/ThemeContext';
|
||||
import { comms } from '@/communication';
|
||||
import { SettingsModel } from '@/types';
|
||||
|
||||
|
||||
interface SettingsDialogProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
|
||||
const { theme, setTheme } = useTheme();
|
||||
const [activeTab, setActiveTab] = useState<'general' | 'theme'>('theme');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [settings, setSettings] = useState<SettingsModel | null>(null);
|
||||
|
||||
// 설정 로드
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
loadSettings();
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
const loadSettings = async () => {
|
||||
setLoading(true);
|
||||
console.log('Settings Load Start: comms.getSettings() call');
|
||||
try {
|
||||
const response = await comms.getSettings();
|
||||
console.log('Settings response:', response);
|
||||
// 백엔드에서 객체를 직접 반환함 (ApiResponse 래퍼 없음)
|
||||
// Settings response: {Xml: {...}, Theme: "...", ...}
|
||||
if (response) {
|
||||
// @ts-ignore
|
||||
const settingsData = response as SettingsModel;
|
||||
setSettings(settingsData);
|
||||
|
||||
// DB에 저장된 테마가 있다면 현재 테마와 동기화
|
||||
if (settingsData.Theme && settingsData.Theme !== theme) {
|
||||
// DB 테마가 현재 로컬 테마와 다르다면?
|
||||
// 일단 UI 상의 선택 상태는 DB 값으로 설정.
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('설정 로드 실패:', error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!settings) return;
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
// 1. 백엔드 저장
|
||||
const response = await comms.saveSettings(settings);
|
||||
|
||||
if (response.Success) {
|
||||
// 2. 테마 변경 적용 (Context 업데이트)
|
||||
if (settings.Theme && settings.Theme !== theme) {
|
||||
setTheme(settings.Theme as any);
|
||||
}
|
||||
alert('설정이 저장되었습니다.');
|
||||
onClose();
|
||||
} else {
|
||||
alert('저장 실패: ' + (response.Message || '알 수 없는 오류'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('설정 저장 실패:', error);
|
||||
alert('저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleThemeSelect = (selectedTheme: string) => {
|
||||
if (settings) {
|
||||
setSettings({ ...settings, Theme: selectedTheme });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCheckboxChange = (field: keyof SettingsModel, checked: boolean) => {
|
||||
if (settings) {
|
||||
setSettings({ ...settings, [field]: checked });
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/50 backdrop-blur-sm">
|
||||
<div className="glass-effect rounded-2xl w-full max-w-2xl animate-slide-up mx-4 overflow-hidden flex flex-col max-h-[80vh]">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between bg-white/5">
|
||||
<h2 className="text-xl font-bold text-white flex items-center">
|
||||
<SettingsIcon className="w-5 h-5 mr-2 text-primary-400" />
|
||||
환경 설정
|
||||
</h2>
|
||||
<button onClick={onClose} className="text-white/70 hover:text-white transition-colors">
|
||||
<X className="w-6 h-6" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
{/* Sidebar Tabs */}
|
||||
<div className="w-48 border-r border-white/10 bg-black/20 p-4 space-y-2">
|
||||
<button
|
||||
onClick={() => setActiveTab('general')}
|
||||
className={`w-full text-left px-4 py-3 rounded-lg flex items-center space-x-3 transition-colors ${activeTab === 'general' ? 'bg-primary-500/20 text-primary-300' : 'text-white/70 hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<Monitor className="w-4 h-4" />
|
||||
<span>일반</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('theme')}
|
||||
className={`w-full text-left px-4 py-3 rounded-lg flex items-center space-x-3 transition-colors ${activeTab === 'theme' ? 'bg-primary-500/20 text-primary-300' : 'text-white/70 hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<Palette className="w-4 h-4" />
|
||||
<span>테마</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 p-6 overflow-y-auto custom-scrollbar bg-black/10">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-full text-white/50">
|
||||
<span className="animate-spin mr-2">⏳</span> 설정을 불러오는 중...
|
||||
</div>
|
||||
) : !settings ? (
|
||||
<div className="text-center text-red-400">설정 데이터를 불러오지 못했습니다.</div>
|
||||
) : (
|
||||
<>
|
||||
{activeTab === 'general' && (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-white/90 mb-4">기본 설정</h3>
|
||||
|
||||
<label className="flex items-center space-x-3 cursor-pointer group">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={settings.FullScreen}
|
||||
onChange={(e) => handleCheckboxChange('FullScreen', e.target.checked)}
|
||||
/>
|
||||
<div className="w-10 h-6 bg-white/20 rounded-full peer peer-checked:bg-primary-500 peer-focus:ring-2 peer-focus:ring-primary-500/50 transition-all"></div>
|
||||
<div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full transition-all peer-checked:translate-x-4"></div>
|
||||
</div>
|
||||
<span className="text-white/80 group-hover:text-white transition-colors">전체 화면으로 시작</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center space-x-3 cursor-pointer group">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={settings.DupWindow}
|
||||
onChange={(e) => handleCheckboxChange('DupWindow', e.target.checked)}
|
||||
/>
|
||||
<div className="w-10 h-6 bg-white/20 rounded-full peer peer-checked:bg-primary-500 peer-focus:ring-2 peer-focus:ring-primary-500/50 transition-all"></div>
|
||||
<div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full transition-all peer-checked:translate-x-4"></div>
|
||||
</div>
|
||||
<span className="text-white/80 group-hover:text-white transition-colors">창 중복 실행 허용</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center space-x-3 cursor-pointer group">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={settings.Disable8HourOver}
|
||||
onChange={(e) => handleCheckboxChange('Disable8HourOver', e.target.checked)}
|
||||
/>
|
||||
<div className="w-10 h-6 bg-white/20 rounded-full peer peer-checked:bg-primary-500 peer-focus:ring-2 peer-focus:ring-primary-500/50 transition-all"></div>
|
||||
<div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full transition-all peer-checked:translate-x-4"></div>
|
||||
</div>
|
||||
<span className="text-white/80 group-hover:text-white transition-colors">8시간 초과 근무 경고 비활성화</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'theme' && (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold text-white/90">테마 선택</h3>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{/* Dark Theme */}
|
||||
<button
|
||||
onClick={() => handleThemeSelect('dark')}
|
||||
className={`relative group p-4 rounded-xl border-2 transition-all duration-300 text-left ${settings.Theme === 'dark' || (!settings.Theme && theme === 'dark')
|
||||
? 'border-primary-500 bg-primary-500/10'
|
||||
: 'border-white/10 hover:border-white/30 hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-white font-medium">Dark (기본)</span>
|
||||
{settings.Theme === 'dark' && <div className="w-2 h-2 rounded-full bg-primary-500"></div>}
|
||||
</div>
|
||||
<div className="h-16 rounded-lg bg-[#1e1e2e] border border-white/10 flex overflow-hidden">
|
||||
<div className="w-1/4 bg-[#181825] border-r border-white/5"></div>
|
||||
<div className="flex-1 p-2">
|
||||
<div className="h-2 w-2/3 bg-white/10 rounded mb-2"></div>
|
||||
<div className="h-8 w-full bg-[#1e1e2e] border border-primary-500/30 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* PSH_PINK Theme */}
|
||||
<button
|
||||
onClick={() => handleThemeSelect('PSH_PINK')}
|
||||
className={`relative group p-4 rounded-xl border-2 transition-all duration-300 text-left ${settings.Theme === 'PSH_PINK'
|
||||
? 'border-[#FF00FF] bg-[#FF00FF]/10'
|
||||
: 'border-white/10 hover:border-white/30 hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-white font-medium">PSH Pink</span>
|
||||
{settings.Theme === 'PSH_PINK' && <div className="w-2 h-2 rounded-full bg-[#FF00FF]"></div>}
|
||||
</div>
|
||||
<div className="h-16 rounded-lg bg-[#2D0A1E] border border-[#FF66FF]/30 flex overflow-hidden">
|
||||
<div className="w-1/4 bg-[#1A0512] border-r border-[#FF66FF]/20"></div>
|
||||
<div className="flex-1 p-2">
|
||||
<div className="h-2 w-2/3 bg-[#FF66FF]/20 rounded mb-2"></div>
|
||||
<div className="h-8 w-full bg-[#3D0D28] border border-[#FF00FF] rounded relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-[#FF00FF]/10 to-transparent"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* JW_SKY Theme */}
|
||||
<button
|
||||
onClick={() => handleThemeSelect('JW_SKY')}
|
||||
className={`relative group p-4 rounded-xl border-2 transition-all duration-300 text-left ${settings.Theme === 'JW_SKY'
|
||||
? 'border-sky-400 bg-sky-400/10'
|
||||
: 'border-white/10 hover:border-white/30 hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-white font-medium">JW Sky</span>
|
||||
{settings.Theme === 'JW_SKY' && <div className="w-2 h-2 rounded-full bg-sky-400"></div>}
|
||||
</div>
|
||||
<div className="h-16 rounded-lg bg-[#0F172A] border border-sky-400/30 flex overflow-hidden">
|
||||
<div className="w-1/4 bg-[#020617] border-r border-sky-400/20"></div>
|
||||
<div className="flex-1 p-2">
|
||||
<div className="h-2 w-2/3 bg-sky-400/20 rounded mb-2"></div>
|
||||
<div className="h-8 w-full bg-[#1E293B] border border-sky-400 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="px-6 py-4 border-t border-white/10 bg-white/5 flex justify-end space-x-3">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 rounded-lg text-white/70 hover:text-white hover:bg-white/5 transition-colors"
|
||||
>
|
||||
취소
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || loading}
|
||||
className="px-6 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white shadow-lg shadow-primary-500/30 transition-all flex items-center disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saving ? (
|
||||
<span className="animate-spin mr-2">⏳</span>
|
||||
) : (
|
||||
<Save className="w-4 h-4 mr-2" />
|
||||
)}
|
||||
저장
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
65
Project/frontend/src/components/settings/SettingsGeneral.tsx
Normal file
65
Project/frontend/src/components/settings/SettingsGeneral.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { SettingsModel } from '@/types';
|
||||
|
||||
interface SettingsGeneralProps {
|
||||
settings: SettingsModel;
|
||||
onChange: (field: keyof SettingsModel, checked: boolean) => void;
|
||||
}
|
||||
|
||||
export function SettingsGeneral({ settings, onChange }: SettingsGeneralProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="bg-white/5 p-4 rounded-lg overflow-auto max-h-96">
|
||||
<h3 className="text-white/90 font-medium mb-2">반환된 설정 데이터 (Debug)</h3>
|
||||
<pre className="text-xs text-white/70 whitespace-pre-wrap font-mono">
|
||||
{JSON.stringify(settings, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<h3 className="text-lg font-semibold text-white/90 mb-4">기본 설정</h3>
|
||||
|
||||
<label className="flex items-center space-x-3 cursor-pointer group">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={settings.FullScreen}
|
||||
onChange={(e) => onChange('FullScreen', e.target.checked)}
|
||||
/>
|
||||
<div className="w-10 h-6 bg-white/20 rounded-full peer peer-checked:bg-primary-500 peer-focus:ring-2 peer-focus:ring-primary-500/50 transition-all"></div>
|
||||
<div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full transition-all peer-checked:translate-x-4"></div>
|
||||
</div>
|
||||
<span className="text-white/80 group-hover:text-white transition-colors">전체 화면으로 시작</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center space-x-3 cursor-pointer group">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={settings.DupWindow}
|
||||
onChange={(e) => onChange('DupWindow', e.target.checked)}
|
||||
/>
|
||||
<div className="w-10 h-6 bg-white/20 rounded-full peer peer-checked:bg-primary-500 peer-focus:ring-2 peer-focus:ring-primary-500/50 transition-all"></div>
|
||||
<div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full transition-all peer-checked:translate-x-4"></div>
|
||||
</div>
|
||||
<span className="text-white/80 group-hover:text-white transition-colors">창 중복 실행 허용</span>
|
||||
</label>
|
||||
|
||||
<label className="flex items-center space-x-3 cursor-pointer group">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="sr-only peer"
|
||||
checked={settings.Disable8HourOver}
|
||||
onChange={(e) => onChange('Disable8HourOver', e.target.checked)}
|
||||
/>
|
||||
<div className="w-10 h-6 bg-white/20 rounded-full peer peer-checked:bg-primary-500 peer-focus:ring-2 peer-focus:ring-primary-500/50 transition-all"></div>
|
||||
<div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full transition-all peer-checked:translate-x-4"></div>
|
||||
</div>
|
||||
<span className="text-white/80 group-hover:text-white transition-colors">8시간 초과 근무 경고 비활성화</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
82
Project/frontend/src/components/settings/SettingsTheme.tsx
Normal file
82
Project/frontend/src/components/settings/SettingsTheme.tsx
Normal file
@@ -0,0 +1,82 @@
|
||||
import { SettingsModel } from '@/types';
|
||||
import { Theme } from '@/context/ThemeContext';
|
||||
|
||||
interface SettingsThemeProps {
|
||||
settings: SettingsModel;
|
||||
currentTheme: Theme;
|
||||
onSelect: (theme: string) => void;
|
||||
}
|
||||
|
||||
export function SettingsTheme({ settings, currentTheme, onSelect }: SettingsThemeProps) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h3 className="text-lg font-semibold text-white/90">테마 선택</h3>
|
||||
<div className="grid grid-cols-1 gap-4">
|
||||
{/* Dark Theme */}
|
||||
<button
|
||||
onClick={() => onSelect('dark')}
|
||||
className={`relative group p-4 rounded-xl border-2 transition-all duration-300 text-left ${settings.Theme === 'dark' || (!settings.Theme && currentTheme === 'dark')
|
||||
? 'border-primary-500 bg-primary-500/10'
|
||||
: 'border-white/10 hover:border-white/30 hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-white font-medium">Dark (기본)</span>
|
||||
{(settings.Theme === 'dark') && <div className="w-2 h-2 rounded-full bg-primary-500"></div>}
|
||||
</div>
|
||||
<div className="h-16 rounded-lg bg-[#1e1e2e] border border-white/10 flex overflow-hidden">
|
||||
<div className="w-1/4 bg-[#181825] border-r border-white/5"></div>
|
||||
<div className="flex-1 p-2">
|
||||
<div className="h-2 w-2/3 bg-white/10 rounded mb-2"></div>
|
||||
<div className="h-8 w-full bg-[#1e1e2e] border border-primary-500/30 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* PSH_PINK Theme */}
|
||||
<button
|
||||
onClick={() => onSelect('PSH_PINK')}
|
||||
className={`relative group p-4 rounded-xl border-2 transition-all duration-300 text-left ${settings.Theme === 'PSH_PINK'
|
||||
? 'border-[#FF00FF] bg-[#FF00FF]/10'
|
||||
: 'border-white/10 hover:border-white/30 hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-white font-medium">PSH Pink</span>
|
||||
{settings.Theme === 'PSH_PINK' && <div className="w-2 h-2 rounded-full bg-[#FF00FF]"></div>}
|
||||
</div>
|
||||
<div className="h-16 rounded-lg bg-[#2D0A1E] border border-[#FF66FF]/30 flex overflow-hidden">
|
||||
<div className="w-1/4 bg-[#1A0512] border-r border-[#FF66FF]/20"></div>
|
||||
<div className="flex-1 p-2">
|
||||
<div className="h-2 w-2/3 bg-[#FF66FF]/20 rounded mb-2"></div>
|
||||
<div className="h-8 w-full bg-[#3D0D28] border border-[#FF00FF] rounded relative overflow-hidden">
|
||||
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-[#FF00FF]/10 to-transparent"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* JW_SKY Theme */}
|
||||
<button
|
||||
onClick={() => onSelect('JW_SKY')}
|
||||
className={`relative group p-4 rounded-xl border-2 transition-all duration-300 text-left ${settings.Theme === 'JW_SKY'
|
||||
? 'border-sky-400 bg-sky-400/10'
|
||||
: 'border-white/10 hover:border-white/30 hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-white font-medium">JW Sky</span>
|
||||
{settings.Theme === 'JW_SKY' && <div className="w-2 h-2 rounded-full bg-sky-400"></div>}
|
||||
</div>
|
||||
<div className="h-16 rounded-lg bg-[#0F172A] border border-sky-400/30 flex overflow-hidden">
|
||||
<div className="w-1/4 bg-[#020617] border-r border-sky-400/20"></div>
|
||||
<div className="flex-1 p-2">
|
||||
<div className="h-2 w-2/3 bg-sky-400/20 rounded mb-2"></div>
|
||||
<div className="h-8 w-full bg-[#1E293B] border border-sky-400 rounded"></div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { 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 { comms } from '@/communication';
|
||||
import { UserInfoDetail } from '@/types';
|
||||
import { useTheme, Theme } from '@/context/ThemeContext';
|
||||
|
||||
interface UserInfoDialogProps {
|
||||
isOpen: boolean;
|
||||
@@ -125,6 +126,7 @@ export function UserInfoDialog({ isOpen, onClose, userId, onSave }: UserInfoDial
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showPasswordDialog, setShowPasswordDialog] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const { theme, setTheme } = useTheme();
|
||||
|
||||
const [formData, setFormData] = useState<UserInfoDetail>({
|
||||
Id: '',
|
||||
@@ -221,6 +223,10 @@ export function UserInfoDialog({ isOpen, onClose, userId, onSave }: UserInfoDial
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
};
|
||||
|
||||
const handleThemeChange = (newTheme: Theme) => {
|
||||
setTheme(newTheme);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
@@ -249,6 +255,52 @@ export function UserInfoDialog({ isOpen, onClose, userId, onSave }: UserInfoDial
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user