diff --git a/Project/frontend/src/App.tsx b/Project/frontend/src/App.tsx index 245a26c..8606ce0 100644 --- a/Project/frontend/src/App.tsx +++ b/Project/frontend/src/App.tsx @@ -12,6 +12,7 @@ import HolidayRequest from '@/pages/HolidayRequest'; import { comms } from '@/communication'; import { UserInfo } from '@/types'; import { Loader2 } from 'lucide-react'; +import { ThemeProvider } from '@/context/ThemeContext'; export default function App() { const [isConnected, setIsConnected] = useState(false); @@ -68,7 +69,7 @@ export default function App() { // 로그인 상태 체크 중 if (isLoggedIn === null) { return ( -
+

로그인 상태 확인 중...

@@ -84,40 +85,42 @@ export default function App() { // 로그인 됨 → 메인 앱 표시 return ( - - - }> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - - - {/* Tailwind Breakpoint Indicator - 개발용 */} -
- XS - SM - MD - LG - XL - 2XL -
-
+ + + + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + {/* Tailwind Breakpoint Indicator - 개발용 */} +
+ XS + SM + MD + LG + XL + 2XL +
+
+
); } diff --git a/Project/frontend/src/communication.ts b/Project/frontend/src/communication.ts index d059721..a458b32 100644 --- a/Project/frontend/src/communication.ts +++ b/Project/frontend/src/communication.ts @@ -45,10 +45,11 @@ import type { LicenseItem, PartListItem, HolidayRequest, + SettingsModel, } from '@/types'; // WebView2 환경 감지 -const isWebView = typeof window !== 'undefined' && +const isWebView = typeof window !== 'undefined' && window.chrome?.webview?.hostObjects !== undefined; const machine: MachineBridgeInterface | null = isWebView @@ -152,7 +153,7 @@ class CommunicationLayer { if (msg.requestId && msg.requestId !== requestId) { return; } - + clearTimeout(timeoutId); this.listeners = this.listeners.filter(cb => cb !== handler); resolve(msg.data as T); @@ -802,6 +803,32 @@ class CommunicationLayer { } } + // ===== Settings API ===== + + public async getSettings(): Promise> { + if (isWebView && machine) { + const result = await machine.GetSettings(); + // MachineBridge returns JSON string of the object directly + // WebSocket wrap it in { type, data } + // This helper handles webview vs websocket differences usually... + // Wait, MachineBridge.Settings.cs returns JsonConvert.SerializeObject(Pub.setting). + // So result is string "{"Disable8HourOver": ...}" + return { Success: true, Data: JSON.parse(result) }; + } else { + return this.wsRequest>('GET_SETTINGS', 'SETTINGS_DATA'); + } + } + + public async saveSettings(settings: Partial): Promise { + if (isWebView && machine) { + const result = await machine.SaveSettings(JSON.stringify(settings)); + return JSON.parse(result); + } else { + return this.wsRequest('SAVE_SETTINGS', 'SETTINGS_SAVED', { settings }); + } + } + + public async getJobReportDetail(idx: number): Promise> { if (isWebView && machine) { const result = await machine.Jobreport_GetDetail(idx); diff --git a/Project/frontend/src/components/DateRangePicker.tsx b/Project/frontend/src/components/DateRangePicker.tsx new file mode 100644 index 0000000..74bb6a5 --- /dev/null +++ b/Project/frontend/src/components/DateRangePicker.tsx @@ -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(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(
); + } + + 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( + + ); + } + + return ( +
+ {/* Header */} +
+ + + {year}년 {month + 1}월 + + +
+ {/* Days Header */} +
+ {['일', '월', '화', '수', '목', '금', '토'].map(day => ( +
+ {day} +
+ ))} +
+ {/* Days Grid */} +
+ {days} +
+
+ ); + }; + + 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 ( +
+ {/* Trigger */} +
+ {align === 'horizontal' ? ( + <> + + + {startDate} ~ {endDate} + + + ) : ( + <> +
+ 시작 + {startDate} +
+
+ 종료 + {endDate} +
+ + )} +
+ + {/* Popover */} + {isOpen && ( +
+ + {/* Presets Grid */} +
+ + + + + +
+ +
+ + {/* Dual Calendars Container */} +
+ {renderCalendarGrid(leftViewDate, setLeftViewDate, 'start')} + + {/* Mobile/Desktop separator or spacer */} +
+ + {renderCalendarGrid(rightViewDate, setRightViewDate, 'end')} +
+ +
+ + {/* Action Buttons */} +
+ + +
+
+ )} +
+ ); +} diff --git a/Project/frontend/src/components/DevelopmentNotice.tsx b/Project/frontend/src/components/DevelopmentNotice.tsx new file mode 100644 index 0000000..3fec636 --- /dev/null +++ b/Project/frontend/src/components/DevelopmentNotice.tsx @@ -0,0 +1,13 @@ + + +export function DevelopmentNotice() { + return ( +
+ + + Development Mode: 현재 개발중인 페이지입니다. 데이터가 정확하지 않을 수 있으며 오류가 발생할 수 있습니다. + + +
+ ); +} diff --git a/Project/frontend/src/components/UserSelector.tsx b/Project/frontend/src/components/UserSelector.tsx new file mode 100644 index 0000000..44b5ce4 --- /dev/null +++ b/Project/frontend/src/components/UserSelector.tsx @@ -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(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 ( +
+ {/* Trigger Button */} + + + {/* Popover */} + {isOpen && ( +
+
+ {/* Search Input */} +
+ + 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 + /> +
+ + {/* User List */} +
+ {filteredAndSortedUsers.length === 0 ? ( +
+ 검색 결과가 없습니다. +
+ ) : ( + filteredAndSortedUsers.map(user => { + const isSelected = selectedIds.includes(user.id); + return ( + + ); + }) + )} +
+
+
+ )} +
+ ); +} diff --git a/Project/frontend/src/components/common/DevelopmentNotice.tsx b/Project/frontend/src/components/common/DevelopmentNotice.tsx deleted file mode 100644 index 99a08b5..0000000 --- a/Project/frontend/src/components/common/DevelopmentNotice.tsx +++ /dev/null @@ -1,12 +0,0 @@ - - -export function DevelopmentNotice() { - return ( -
- - ⚠️ - 현재 개발중인 페이지입니다. 데이터가 정확하지 않을 수 있습니다. - -
- ); -} diff --git a/Project/frontend/src/components/holiday/HolidayRequestDialog.tsx b/Project/frontend/src/components/holiday/HolidayRequestDialog.tsx index d4a18a7..1d56e02 100644 --- a/Project/frontend/src/components/holiday/HolidayRequestDialog.tsx +++ b/Project/frontend/src/components/holiday/HolidayRequestDialog.tsx @@ -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>([]); - const [adminComments, setAdminComments] = useState([]); // Code 54 + // Form State const [formData, setFormData] = useState({ @@ -356,14 +356,14 @@ export function HolidayRequestDialog({ return (
-
- {/* Header */} -
-

- +
+ {/* Header - Lively Gradient */} +
+

+ {title}

-
@@ -378,10 +378,10 @@ export function HolidayRequestDialog({ {/* Left Column: Inputs */}
{/* Request Type */} -
+