Compare commits

..

6 Commits

57 changed files with 6375 additions and 3732 deletions

View File

@@ -374,6 +374,7 @@
<Compile Include="Web\MachineBridge\MachineBridge.HolidayRequest.cs" /> <Compile Include="Web\MachineBridge\MachineBridge.HolidayRequest.cs" />
<Compile Include="Web\MachineBridge\MachineBridge.Login.cs" /> <Compile Include="Web\MachineBridge\MachineBridge.Login.cs" />
<Compile Include="Web\MachineBridge\MachineBridge.Dashboard.cs" /> <Compile Include="Web\MachineBridge\MachineBridge.Dashboard.cs" />
<Compile Include="Web\MachineBridge\MachineBridge.Settings.cs" />
<Compile Include="Web\MachineBridge\MachineBridge.Todo.cs" /> <Compile Include="Web\MachineBridge\MachineBridge.Todo.cs" />
<Compile Include="Web\MachineBridge\MachineBridge.Common.cs" /> <Compile Include="Web\MachineBridge\MachineBridge.Common.cs" />
<Compile Include="Web\MachineBridge\MachineBridge.Jobreport.cs" /> <Compile Include="Web\MachineBridge\MachineBridge.Jobreport.cs" />

View File

@@ -83,6 +83,9 @@ namespace Project
[DisplayName("Tool Bar")] [DisplayName("Tool Bar")]
public eToolPosition HideToolbar { get; set; } public eToolPosition HideToolbar { get; set; }
[DisplayName("테마")]
public string Theme { get; set; }
public Setting() : this(Util.CurrentPath + "setting.xml") { } public Setting() : this(Util.CurrentPath + "setting.xml") { }

View File

@@ -0,0 +1,58 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Newtonsoft.Json;
using Project;
namespace Project.Web
{
public partial class MachineBridge
{
public string GetSettings()
{
try
{
return JsonConvert.SerializeObject(Pub.setting);
}
catch (Exception ex)
{
return JsonConvert.SerializeObject(new { Error = ex.Message });
}
}
public string SaveSettings(string jsonSettings)
{
try
{
if (string.IsNullOrEmpty(jsonSettings))
{
return JsonConvert.SerializeObject(new { Success = false, Message = "Empty settings data" });
}
var dict = JsonConvert.DeserializeObject<Dictionary<string, object>>(jsonSettings);
// Update properties using reflection or manual mapping
// Since Setting class inherits arUtil.Setting and might have complex types, manual mapping for known properties or generic reflection is better.
// For now, let's target 'Theme' specifically as requested, and generic handling for others if possible?
// Actually, Pub.setting is an instance. We can try to deserialize INTO it, or update properties.
if (dict.ContainsKey("Theme"))
{
Pub.setting.Theme = dict["Theme"]?.ToString();
}
// Add other setting properties here as needed in the future
// or implement a generic property updater
Pub.setting.Save(); // Save to XML
return JsonConvert.SerializeObject(new { Success = true });
}
catch (Exception ex)
{
return JsonConvert.SerializeObject(new { Success = false, Message = ex.Message });
}
}
}
}

View File

@@ -124,14 +124,23 @@ namespace Project.Web
{ {
try try
{ {
var result = await socket.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None); List<byte> messageBytes = new List<byte>();
if (result.MessageType == WebSocketMessageType.Close) WebSocketReceiveResult result;
do
{ {
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing", CancellationToken.None); result = await socket.ReceiveAsync(new ArraySegment<byte>(buffer), CancellationToken.None);
if (result.MessageType == WebSocketMessageType.Close)
{
await socket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Closing", CancellationToken.None);
return;
}
messageBytes.AddRange(new ArraySegment<byte>(buffer, 0, result.Count));
} }
else if (result.MessageType == WebSocketMessageType.Text) while (!result.EndOfMessage);
if (result.MessageType == WebSocketMessageType.Text)
{ {
string msg = Encoding.UTF8.GetString(buffer, 0, result.Count); string msg = Encoding.UTF8.GetString(messageBytes.ToArray());
await HandleMessage(msg, socket); await HandleMessage(msg, socket);
} }
} }
@@ -551,7 +560,7 @@ namespace Project.Web
case "USERLIST_GET_LIST": case "USERLIST_GET_LIST":
{ {
string process = json.process ?? "%"; string process = json.process ?? string.Empty;
string result = _bridge.UserList_GetList(process); string result = _bridge.UserList_GetList(process);
var response = new { type = "USERLIST_LIST_DATA", data = JsonConvert.DeserializeObject(result) }; var response = new { type = "USERLIST_LIST_DATA", data = JsonConvert.DeserializeObject(result) };
await Send(socket, JsonConvert.SerializeObject(response)); await Send(socket, JsonConvert.SerializeObject(response));
@@ -725,6 +734,25 @@ namespace Project.Web
} }
break; break;
// ===== Settings API =====
case "GET_SETTINGS":
{
string result = _bridge.GetSettings();
var response = new { type = "SETTINGS_DATA", data = JsonConvert.DeserializeObject(result) };
await Send(socket, JsonConvert.SerializeObject(response));
}
break;
case "SAVE_SETTINGS":
{
string settingsData = JsonConvert.SerializeObject(json.settings);
string result = _bridge.SaveSettings(settingsData);
var response = new { type = "SETTINGS_SAVED", data = JsonConvert.DeserializeObject(result) };
await Send(socket, JsonConvert.SerializeObject(response));
}
break;
// ===== Kuntae API ===== // ===== Kuntae API =====
case "GET_KUNTAE_LIST": case "GET_KUNTAE_LIST":
{ {

View File

@@ -12,6 +12,7 @@ import HolidayRequest from '@/pages/HolidayRequest';
import { comms } from '@/communication'; import { comms } from '@/communication';
import { UserInfo } from '@/types'; import { UserInfo } from '@/types';
import { Loader2 } from 'lucide-react'; import { Loader2 } from 'lucide-react';
import { ThemeProvider } from '@/context/ThemeContext';
export default function App() { export default function App() {
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
@@ -68,7 +69,7 @@ export default function App() {
// 로그인 상태 체크 중 // 로그인 상태 체크 중
if (isLoggedIn === null) { if (isLoggedIn === null) {
return ( return (
<div className="min-h-screen bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900 flex items-center justify-center"> <div className="min-h-screen gradient-bg flex items-center justify-center">
<div className="text-center"> <div className="text-center">
<Loader2 className="w-10 h-10 text-white animate-spin mx-auto mb-4" /> <Loader2 className="w-10 h-10 text-white animate-spin mx-auto mb-4" />
<p className="text-white/70"> ...</p> <p className="text-white/70"> ...</p>
@@ -84,40 +85,42 @@ export default function App() {
// 로그인 됨 → 메인 앱 표시 // 로그인 됨 → 메인 앱 표시
return ( return (
<HashRouter> <ThemeProvider>
<Routes> <HashRouter>
<Route element={<Layout isConnected={isConnected} user={user} />}> <Routes>
<Route path="/" element={<Dashboard />} /> <Route element={<Layout isConnected={isConnected} user={user} />}>
<Route path="/dashboard" element={<Dashboard />} /> <Route path="/" element={<Dashboard />} />
<Route path="/todo" element={<Todo />} /> <Route path="/dashboard" element={<Dashboard />} />
<Route path="/kuntae" element={<Kuntae />} /> <Route path="/todo" element={<Todo />} />
<Route path="/holiday-request" element={<HolidayRequest />} /> <Route path="/kuntae" element={<Kuntae />} />
<Route path="/jobreport" element={<Jobreport />} /> <Route path="/holiday-request" element={<HolidayRequest />} />
<Route path="/project" element={<Project />} /> <Route path="/jobreport" element={<Jobreport />} />
<Route path="/common" element={<CommonCodePage />} /> <Route path="/project" element={<Project />} />
<Route path="/items" element={<ItemsPage />} /> <Route path="/common" element={<CommonCodePage />} />
<Route path="/customs" element={<Customs />} /> <Route path="/items" element={<ItemsPage />} />
<Route path="/user/list" element={<UserListPage />} /> <Route path="/customs" element={<Customs />} />
<Route path="/user/auth" element={<UserAuthPage />} /> <Route path="/user/list" element={<UserListPage />} />
<Route path="/monthly-work" element={<MonthlyWorkPage />} /> <Route path="/user/auth" element={<UserAuthPage />} />
<Route path="/mail-form" element={<MailFormPage />} /> <Route path="/monthly-work" element={<MonthlyWorkPage />} />
<Route path="/note" element={<Note />} /> <Route path="/mail-form" element={<MailFormPage />} />
<Route path="/patch-list" element={<PatchList />} /> <Route path="/note" element={<Note />} />
<Route path="/bug-report" element={<BugReport />} /> <Route path="/patch-list" element={<PatchList />} />
<Route path="/mail-list" element={<MailList />} /> <Route path="/bug-report" element={<BugReport />} />
<Route path="/license" element={<LicenseList />} /> <Route path="/mail-list" element={<MailList />} />
<Route path="/partlist" element={<PartList />} /> <Route path="/license" element={<LicenseList />} />
</Route> <Route path="/partlist" element={<PartList />} />
</Routes> </Route>
{/* Tailwind Breakpoint Indicator - 개발용 */} </Routes>
<div className="fixed bottom-2 right-2 z-50 bg-black/80 text-white text-xs px-2 py-1 rounded font-mono"> {/* Tailwind Breakpoint Indicator - 개발용 */}
<span className="sm:hidden">XS</span> <div className="fixed bottom-2 right-2 z-50 bg-black/80 text-white text-xs px-2 py-1 rounded font-mono">
<span className="hidden sm:inline md:hidden">SM</span> <span className="sm:hidden">XS</span>
<span className="hidden md:inline lg:hidden">MD</span> <span className="hidden sm:inline md:hidden">SM</span>
<span className="hidden lg:inline xl:hidden">LG</span> <span className="hidden md:inline lg:hidden">MD</span>
<span className="hidden xl:inline 2xl:hidden">XL</span> <span className="hidden lg:inline xl:hidden">LG</span>
<span className="hidden 2xl:inline">2XL</span> <span className="hidden xl:inline 2xl:hidden">XL</span>
</div> <span className="hidden 2xl:inline">2XL</span>
</HashRouter> </div>
</HashRouter>
</ThemeProvider>
); );
} }

View File

@@ -45,10 +45,11 @@ import type {
LicenseItem, LicenseItem,
PartListItem, PartListItem,
HolidayRequest, HolidayRequest,
SettingsModel,
} from '@/types'; } from '@/types';
// WebView2 환경 감지 // WebView2 환경 감지
const isWebView = typeof window !== 'undefined' && const isWebView = typeof window !== 'undefined' &&
window.chrome?.webview?.hostObjects !== undefined; window.chrome?.webview?.hostObjects !== undefined;
const machine: MachineBridgeInterface | null = isWebView const machine: MachineBridgeInterface | null = isWebView
@@ -152,7 +153,7 @@ class CommunicationLayer {
if (msg.requestId && msg.requestId !== requestId) { if (msg.requestId && msg.requestId !== requestId) {
return; return;
} }
clearTimeout(timeoutId); clearTimeout(timeoutId);
this.listeners = this.listeners.filter(cb => cb !== handler); this.listeners = this.listeners.filter(cb => cb !== handler);
resolve(msg.data as T); resolve(msg.data as T);
@@ -802,6 +803,32 @@ class CommunicationLayer {
} }
} }
// ===== Settings API =====
public async getSettings(): Promise<ApiResponse<SettingsModel>> {
if (isWebView && machine) {
const result = await machine.GetSettings();
// MachineBridge returns JSON string of the object directly
// WebSocket wrap it in { type, data }
// This helper handles webview vs websocket differences usually...
// Wait, MachineBridge.Settings.cs returns JsonConvert.SerializeObject(Pub.setting).
// So result is string "{"Disable8HourOver": ...}"
return { Success: true, Data: JSON.parse(result) };
} else {
return this.wsRequest<ApiResponse<SettingsModel>>('GET_SETTINGS', 'SETTINGS_DATA');
}
}
public async saveSettings(settings: Partial<SettingsModel>): Promise<ApiResponse> {
if (isWebView && machine) {
const result = await machine.SaveSettings(JSON.stringify(settings));
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse>('SAVE_SETTINGS', 'SETTINGS_SAVED', { settings });
}
}
public async getJobReportDetail(idx: number): Promise<ApiResponse<JobReportItem>> { public async getJobReportDetail(idx: number): Promise<ApiResponse<JobReportItem>> {
if (isWebView && machine) { if (isWebView && machine) {
const result = await machine.Jobreport_GetDetail(idx); const result = await machine.Jobreport_GetDetail(idx);
@@ -1505,6 +1532,49 @@ class CommunicationLayer {
} }
} }
/**
* 업체정보 추가
*/
public async addCustoms(item: Omit<CustomItem, 'idx' | 'wuid' | 'wdate' | 'gcode'>): Promise<ApiResponse> {
if (isWebView && machine) {
// @ts-ignore - Assuming Customs_Add exists on the native side if needed
const result = await machine.Customs_Add(
item.grp, item.name, item.owner, item.ownertel, item.address, item.tel, item.fax, item.email, item.memo, item.uptae, item.staff, item.stafftel, item.name2
);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse>('CUSTOMS_ADD', 'CUSTOMS_ADD_RESULT', { ...item });
}
}
/**
* 업체정보 수정
*/
public async updateCustoms(item: Omit<CustomItem, 'wuid' | 'wdate' | 'gcode'>): Promise<ApiResponse> {
if (isWebView && machine) {
// @ts-ignore - Assuming Customs_Update exists on the native side if needed
const result = await machine.Customs_Update(
item.idx, item.grp, item.name, item.owner, item.ownertel, item.address, item.tel, item.fax, item.email, item.memo, item.uptae, item.staff, item.stafftel, item.name2
);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse>('CUSTOMS_UPDATE', 'CUSTOMS_UPDATE_RESULT', { ...item });
}
}
/**
* 업체정보 삭제
*/
public async deleteCustoms(idx: number): Promise<ApiResponse> {
if (isWebView && machine) {
// @ts-ignore - Assuming Customs_Delete exists on the native side if needed
const result = await machine.Customs_Delete(idx);
return JSON.parse(result);
} else {
return this.wsRequest<ApiResponse>('CUSTOMS_DELETE', 'CUSTOMS_DELETE_RESULT', { idx });
}
}
/** /**
* 라이선스 목록 조회 * 라이선스 목록 조회
* @returns ApiResponse<LicenseItem[]> * @returns ApiResponse<LicenseItem[]>

View File

@@ -0,0 +1,277 @@
import { useState, useRef, useEffect } from 'react';
import { ChevronLeft, ChevronRight, Calendar as CalendarIcon } from 'lucide-react';
import clsx from 'clsx';
interface DateRangePickerProps {
startDate: string; // YYYY-MM-DD
endDate: string; // YYYY-MM-DD
onChange: (startDate: string, endDate: string) => void;
align?: 'horizontal' | 'vertical';
}
export function DateRangePicker({ startDate, endDate, onChange, align = 'horizontal' }: DateRangePickerProps) {
const [isOpen, setIsOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Internal state for delayed update
const [localStart, setLocalStart] = useState(startDate);
const [localEnd, setLocalEnd] = useState(endDate);
// View states for the two calendars
const [leftViewDate, setLeftViewDate] = useState(new Date());
const [rightViewDate, setRightViewDate] = useState(new Date());
// Sync props to internal state and init view dates on open
useEffect(() => {
if (isOpen) {
setLocalStart(startDate);
setLocalEnd(endDate);
const sDate = startDate ? new Date(startDate) : new Date();
const eDate = endDate ? new Date(endDate) : new Date();
setLeftViewDate(new Date(sDate.getFullYear(), sDate.getMonth(), 1));
setRightViewDate(new Date(eDate.getFullYear(), eDate.getMonth(), 1));
}
}, [isOpen, startDate, endDate]);
// Close on click outside
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const formatDate = (date: Date) => {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
};
const setPreset = (type: 'today' | 'tomorrow' | 'thisWeek' | 'thisMonth' | 'lastMonth') => {
const today = new Date();
let newStart = today;
let newEnd = today;
switch (type) {
case 'today':
break;
case 'tomorrow':
const tomorrow = new Date(today);
tomorrow.setDate(today.getDate() + 1);
newStart = tomorrow;
newEnd = tomorrow;
break;
case 'thisWeek':
const day = today.getDay();
const diff = today.getDate() - day; // Sunday start
newStart = new Date(today.setDate(diff));
newEnd = new Date(today.setDate(diff + 6));
break;
case 'thisMonth':
newStart = new Date(today.getFullYear(), today.getMonth(), 1);
newEnd = new Date(today.getFullYear(), today.getMonth() + 1, 0);
break;
case 'lastMonth':
newStart = new Date(today.getFullYear(), today.getMonth() - 1, 1);
newEnd = new Date(today.getFullYear(), today.getMonth(), 0);
break;
}
const sStr = formatDate(newStart);
const eStr = formatDate(newEnd);
setLocalStart(sStr);
setLocalEnd(eStr);
// Update views to match selection
setLeftViewDate(new Date(newStart.getFullYear(), newStart.getMonth(), 1));
setRightViewDate(new Date(newEnd.getFullYear(), newEnd.getMonth(), 1));
};
const handleConfirm = () => {
onChange(localStart, localEnd);
setIsOpen(false);
};
const handleCancel = () => {
setIsOpen(false);
};
const toggleOpen = () => setIsOpen(!isOpen);
// --- Calendar Logic ---
const getDaysInMonth = (year: number, month: number) => new Date(year, month + 1, 0).getDate();
const getFirstDayOfMonth = (year: number, month: number) => new Date(year, month, 1).getDay();
const renderCalendarGrid = (viewDate: Date, setViewDate: (d: Date) => void, type: 'start' | 'end') => {
const year = viewDate.getFullYear();
const month = viewDate.getMonth();
const daysInMonth = getDaysInMonth(year, month);
const firstDay = getFirstDayOfMonth(year, month);
const days = [];
for (let i = 0; i < firstDay; i++) {
days.push(<div key={`empty-${i}`} className="h-8 w-8" />);
}
for (let day = 1; day <= daysInMonth; day++) {
const current = new Date(year, month, day);
const dateStr = formatDate(current);
// Only highlight the date corresponding to the specific calendar type
const isSelected = type === 'start'
? localStart === dateStr
: localEnd === dateStr;
days.push(
<button
key={day}
onClick={() => handleDateClick(dateStr, type)}
className={clsx(
"h-8 w-8 rounded-full flex items-center justify-center text-xs transition-all relative",
// Selected
isSelected ? "bg-blue-600 text-white font-bold hover:bg-blue-500 z-10 ring-2 ring-blue-400/50" : "text-white/80 hover:bg-white/10"
)}
>
{day}
</button>
);
}
return (
<div className="w-[240px]">
{/* Header */}
<div className="flex items-center justify-between mb-2 px-2">
<button onClick={() => {
const d = new Date(viewDate);
d.setMonth(d.getMonth() - 1);
setViewDate(d);
}} className="p-1 hover:bg-white/10 rounded text-white/70">
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-sm font-semibold text-white">
{year} {month + 1}
</span>
<button onClick={() => {
const d = new Date(viewDate);
d.setMonth(d.getMonth() + 1);
setViewDate(d);
}} className="p-1 hover:bg-white/10 rounded text-white/70">
<ChevronRight className="w-4 h-4" />
</button>
</div>
{/* Days Header */}
<div className="grid grid-cols-7 gap-1 text-center mb-1">
{['일', '월', '화', '수', '목', '금', '토'].map(day => (
<div key={day} className="text-[10px] text-white/40 font-medium py-1">
{day}
</div>
))}
</div>
{/* Days Grid */}
<div className="grid grid-cols-7 gap-1">
{days}
</div>
</div>
);
};
const handleDateClick = (dateStr: string, type: 'start' | 'end') => {
if (type === 'start') {
setLocalStart(dateStr);
// Validation: Start cannot be after End
if (localEnd && new Date(dateStr) > new Date(localEnd)) {
setLocalEnd(dateStr);
}
} else {
setLocalEnd(dateStr);
// Validation: End cannot be before Start
if (localStart && new Date(dateStr) < new Date(localStart)) {
setLocalStart(dateStr);
}
}
};
return (
<div className="relative inline-block" ref={containerRef}>
{/* Trigger */}
<div
onClick={toggleOpen}
className={clsx(
"bg-white/5 border border-white/10 rounded-lg px-3 py-2 cursor-pointer hover:bg-white/10 transition-colors w-fit",
align === 'vertical' ? 'flex flex-col items-start gap-1' : 'flex items-center gap-2'
)}
>
{align === 'horizontal' ? (
<>
<CalendarIcon className="w-4 h-4 text-white/50" />
<span className="text-white text-sm font-medium">
{startDate} <span className="text-white/50 mx-1">~</span> {endDate}
</span>
</>
) : (
<>
<div className="flex items-center gap-2">
<span className="text-white/50 text-xs"></span>
<span className="text-white text-sm font-medium tracking-wider">{startDate}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-white/50 text-xs"></span>
<span className="text-white text-sm font-medium tracking-wider">{endDate}</span>
</div>
</>
)}
</div>
{/* Popover */}
{isOpen && (
<div className="absolute top-full left-0 mt-2 z-[9999] bg-[#1e1e2e] border border-white/10 rounded-xl shadow-xl p-4 w-auto backdrop-blur-md">
{/* Presets Grid */}
<div className="flex flex-wrap gap-2 mb-4 justify-center">
<button onClick={() => setPreset('today')} className="px-3 py-1.5 bg-white/5 hover:bg-white/10 rounded-full text-xs text-white/80 border border-white/5 transition-colors"></button>
<button onClick={() => setPreset('tomorrow')} className="px-3 py-1.5 bg-white/5 hover:bg-white/10 rounded-full text-xs text-white/80 border border-white/5 transition-colors"></button>
<button onClick={() => setPreset('thisWeek')} className="px-3 py-1.5 bg-white/5 hover:bg-white/10 rounded-full text-xs text-white/80 border border-white/5 transition-colors"></button>
<button onClick={() => setPreset('thisMonth')} className="px-3 py-1.5 bg-white/5 hover:bg-white/10 rounded-full text-xs text-white/80 border border-white/5 transition-colors"></button>
<button onClick={() => setPreset('lastMonth')} className="px-3 py-1.5 bg-white/5 hover:bg-white/10 rounded-full text-xs text-white/80 border border-white/5 transition-colors"></button>
</div>
<div className="h-px bg-white/10 mb-4" />
{/* Dual Calendars Container */}
<div className="flex flex-col md:flex-row gap-6">
{renderCalendarGrid(leftViewDate, setLeftViewDate, 'start')}
{/* Mobile/Desktop separator or spacer */}
<div className="hidden md:block w-px bg-white/10 self-stretch my-2"></div>
{renderCalendarGrid(rightViewDate, setRightViewDate, 'end')}
</div>
<div className="h-px bg-white/10 my-4" />
{/* Action Buttons */}
<div className="flex justify-end gap-2">
<button
onClick={handleCancel}
className="px-4 py-2 rounded-lg text-sm text-white/70 hover:bg-white/10 transition-colors"
>
</button>
<button
onClick={handleConfirm}
className="px-4 py-2 rounded-lg text-sm bg-blue-600 hover:bg-blue-500 text-white font-medium transition-colors"
>
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,13 @@
export function DevelopmentNotice() {
return (
<div className="bg-gradient-to-r from-[#ff1493] via-[#ff69b4] to-[#ff1493] border-2 border-white/50 rounded-xl p-4 flex items-center justify-center shadow-[0_0_25px_rgba(255,20,147,0.6)] backdrop-blur-md mb-6">
<span className="text-white font-black flex items-center gap-3 drop-shadow-[0_2px_4px_rgba(0,0,0,0.3)] tracking-tight">
<span className="text-2xl animate-bounce"></span>
<span className="uppercase italic">Development Mode:</span> . .
<span className="text-2xl animate-bounce" style={{ animationDelay: '0.2s' }}></span>
</span>
</div>
);
}

View File

@@ -0,0 +1,213 @@
import { useState, useRef, useEffect, useMemo } from 'react';
import { Search, Check, ChevronDown, User } from 'lucide-react';
import clsx from 'clsx';
export interface UserInfo {
id: string;
name: string;
process?: string;
level?: number;
useJobReport?: boolean;
outdate?: string | null;
}
interface UserSelectorProps {
users: UserInfo[];
selectedIds: string[]; // Always array for internal consistency, or handle single/multi logic
onChange: (ids: string[]) => void;
multiSelect?: boolean;
placeholder?: string;
className?: string;
// Filter Options
minLevel?: number;
onlyJobReportUsers?: boolean;
includeResigned?: boolean;
}
export function UserSelector({
users,
selectedIds,
onChange,
multiSelect = false,
placeholder = '담당자 선택',
className
}: UserSelectorProps) {
const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
const containerRef = useRef<HTMLDivElement>(null);
// Filter and Sort users
const filteredAndSortedUsers = useMemo(() => {
let result = users;
// Filter
if (searchQuery.trim()) {
const query = searchQuery.toLowerCase();
result = result.filter(user =>
user.name.toLowerCase().includes(query) ||
user.id.toLowerCase().includes(query)
);
}
// Sort: Process -> Name
return result.sort((a, b) => {
// Compare Process
const processA = a.process || '';
const processB = b.process || '';
if (processA !== processB) {
if (processA < processB) return -1;
if (processA > processB) return 1;
}
// Compare Name
return a.name.localeCompare(b.name);
});
}, [users, searchQuery]);
// Handle outside click
useEffect(() => {
function handleClickOutside(event: MouseEvent) {
if (containerRef.current && !containerRef.current.contains(event.target as Node)) {
setIsOpen(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return () => document.removeEventListener('mousedown', handleClickOutside);
}, []);
const handleSelect = (userId: string) => {
if (multiSelect) {
if (selectedIds.includes(userId)) {
onChange(selectedIds.filter(id => id !== userId));
} else {
onChange([...selectedIds, userId]);
}
} else {
// Single select: toggle or set
if (selectedIds.includes(userId)) {
onChange([]); // Deselect if clicked again? Or just prevent? Usually deselect is allowed.
} else {
onChange([userId]);
setIsOpen(false); // Close on selection for single mode
}
}
};
const selectedUserList = useMemo(() => users.filter(u => selectedIds.includes(u.id)), [users, selectedIds]);
const getDisplayText = () => {
if (selectedIds.length === 0) return placeholder;
if (selectedUserList.length === 0) {
return selectedIds[0];
}
if (selectedUserList.length === 1) {
return `${selectedUserList[0].name}(${selectedUserList[0].id})`;
}
return `${selectedUserList[0].name}${selectedUserList.length - 1}`;
};
return (
<div className={clsx("relative z-30", className)} ref={containerRef}>
{/* Trigger Button */}
<button
type="button"
onClick={() => setIsOpen(!isOpen)}
className={clsx(
"flex items-center justify-between w-full h-[60px] px-3 bg-white/5 border border-white/10 rounded-lg text-white transition-colors focus:outline-none focus:ring-2 focus:ring-primary-400 hover:bg-white/10",
isOpen && "ring-2 ring-primary-400"
)}
>
<div className="flex items-center gap-3 overflow-hidden text-left">
<User className="w-5 h-5 text-white/50 flex-shrink-0" />
<div className="flex flex-col justify-center gap-0.5">
{selectedUserList.length === 1 ? (
<>
<div className="text-sm font-medium leading-none flex items-center">
{selectedUserList[0].name}
<span className="text-white/40 text-xs ml-1 font-normal">({selectedUserList[0].id})</span>
</div>
{selectedUserList[0].process ? (
<div className="text-xs text-white/60 leading-none">
{selectedUserList[0].process}
</div>
) : (
<div className="text-xs text-white/30 leading-none">
-
</div>
)}
</>
) : (
<span className="truncate text-sm">
{getDisplayText()}
</span>
)}
</div>
</div>
<ChevronDown className="w-4 h-4 text-white/50 ml-2 flex-shrink-0" />
</button>
{/* Popover */}
{isOpen && (
<div className="absolute top-full mt-2 w-[360px] bg-[#1e293b] border border-white/10 rounded-xl shadow-2xl overflow-hidden animate-fade-in-up z-50">
<div className="p-2 space-y-2">
{/* Search Input */}
<div className="relative">
<Search className="absolute left-2 top-1/2 -translate-y-1/2 w-4 h-4 text-white/50" />
<input
type="text"
placeholder="사용자 검색"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full h-8 bg-black/20 border border-white/10 rounded pl-8 pr-2 text-xs text-white focus:outline-none focus:border-primary-500 placeholder-white/30"
autoFocus
/>
</div>
{/* User List */}
<div className="max-h-60 overflow-y-auto space-y-1 scrollbar-thin scrollbar-thumb-white/10 scrollbar-track-transparent pr-1">
{filteredAndSortedUsers.length === 0 ? (
<div className="text-center text-white/40 text-xs py-4">
.
</div>
) : (
filteredAndSortedUsers.map(user => {
const isSelected = selectedIds.includes(user.id);
return (
<button
key={user.id}
onClick={() => handleSelect(user.id)}
className={clsx(
"w-full flex items-center gap-2 px-2 py-1.5 rounded text-left transition-colors text-sm",
isSelected ? "bg-primary-500/20 text-primary-200" : "text-white/80 hover:bg-white/10"
)}
>
<div className={clsx(
"w-4 h-4 rounded border flex items-center justify-center transition-colors flex-shrink-0",
isSelected ? "bg-primary-500 border-primary-500" : "border-white/30"
)}>
{isSelected && <Check className="w-3 h-3 text-white" />}
</div>
<div className="flex flex-col min-w-0">
<div className="flex items-center gap-1.5">
{user.process && (
<span className="text-[10px] text-primary-300 bg-primary-500/10 px-1 rounded flex-shrink-0">
{user.process}
</span>
)}
<span className="text-xs font-medium truncate">{user.name}</span>
</div>
<span className="text-[10px] text-white/50">{user.id}</span>
</div>
</button>
);
})
)}
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -1,12 +0,0 @@
export function DevelopmentNotice() {
return (
<div className="bg-yellow-500/10 border border-yellow-500/20 rounded-lg p-4 flex items-center justify-center animate-pulse">
<span className="text-yellow-400 font-medium flex items-center gap-2">
<span className="text-xl"></span>
. .
</span>
</div>
);
}

View File

@@ -0,0 +1,365 @@
import { useState, useEffect } from 'react';
import {
Building,
User,
Phone,
Mail,
MapPin,
FileText,
X,
Save,
Trash2,
Hash,
Briefcase
} from 'lucide-react';
import { comms } from '@/communication';
import type { CustomItem } from '@/types';
import { DevelopmentNotice } from '../DevelopmentNotice';
interface CustomEditDialogProps {
isOpen: boolean;
onClose: () => void;
onSaved: () => void;
item: CustomItem | null;
}
const initialForm: Omit<CustomItem, 'idx' | 'wuid' | 'wdate' | 'gcode'> = {
grp: '',
name: '',
owner: '',
ownertel: '',
address: '',
tel: '',
fax: '',
email: '',
memo: '',
uptae: '',
staff: '',
stafftel: '',
name2: ''
};
export function CustomEditDialog({ isOpen, onClose, onSaved, item }: CustomEditDialogProps) {
const [formData, setFormData] = useState<Omit<CustomItem, 'idx' | 'wuid' | 'wdate' | 'gcode'>>(initialForm);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (item) {
setFormData({
grp: item.grp || '',
name: item.name || '',
owner: item.owner || '',
ownertel: item.ownertel || '',
address: item.address || '',
tel: item.tel || '',
fax: item.fax || '',
email: item.email || '',
memo: item.memo || '',
uptae: item.uptae || '',
staff: item.staff || '',
stafftel: item.stafftel || '',
name2: item.name2 || ''
});
} else {
setFormData(initialForm);
}
}, [item, isOpen]);
const handleSave = async () => {
if (!formData.name) {
alert('업체명을 입력해주세요.');
return;
}
setLoading(true);
try {
let response;
if (item) {
response = await comms.updateCustoms({ ...formData, idx: item.idx });
} else {
response = await comms.addCustoms(formData);
}
if (response.Success) {
onSaved();
onClose();
} else {
alert(response.Message || '저장에 실패했습니다.');
}
} catch (error) {
console.error('업체정보 저장 오류:', error);
alert('저장 중 오류가 발생했습니다.');
} finally {
setLoading(false);
}
};
const handleDelete = async () => {
if (!item) return;
if (!confirm('정말 삭제하시겠습니까?')) return;
setLoading(true);
try {
const response = await comms.deleteCustoms(item.idx);
if (response.Success) {
onSaved();
onClose();
} else {
alert(response.Message || '삭제에 실패했습니다.');
}
} catch (error) {
console.error('업체정보 삭제 오류:', error);
alert('삭제 중 오류가 발생했습니다.');
} finally {
setLoading(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
{/* 배경 오버레이 */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm animate-fade-in"
onClick={onClose}
/>
{/* 다이얼로그 콘텐트 */}
<div className="dialog-container relative w-full max-w-2xl border border-white/10 rounded-3xl overflow-hidden animate-scale-in transition-all duration-300">
<div className="dialog-header px-6 py-5 border-b border-white/10 flex items-center justify-between bg-white/[0.02]">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary-500/20 rounded-xl text-primary-400">
<Building className="w-5 h-5" />
</div>
<div>
<h3 className="dialog-title tracking-tight">
{item ? '업체 정보 수정' : '새 업체 등록'}
</h3>
<p className="text-white/30 text-[10px] uppercase font-bold tracking-widest mt-0.5">
{item ? 'Edit Company Profile' : 'Register New Company'}
</p>
</div>
</div>
<button
onClick={onClose}
className="p-2 hover:bg-white/10 rounded-xl text-white/40 hover:text-white transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
<div className="p-6 max-h-[70vh] overflow-y-auto custom-scrollbar">
{/* 개발 중 알림 */}
<div className="mb-6">
<DevelopmentNotice />
</div>
<div className="space-y-8">
{/* 기본 정보 */}
<section className="space-y-4">
<div className="flex items-center gap-3 mb-2">
<Hash className="w-4 h-4 text-primary-500" />
<h4 className="text-sm font-bold text-white/70 uppercase tracking-tighter"> </h4>
<div className="flex-1 h-px bg-white/5"></div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1.5 px-3 py-2 bg-white/5 rounded-2xl border border-white/5 focus-within:border-primary-500/30 transition-colors">
<label className="text-[10px] font-bold text-white/30 uppercase pl-1 flex items-center gap-1.5">
<Building className="w-2.5 h-2.5" /> () *
</label>
<input
type="text"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full bg-transparent border-none text-sm text-white focus:outline-none placeholder:text-white/10"
placeholder="한글 업체명"
/>
</div>
<div className="space-y-1.5 px-3 py-2 bg-white/5 rounded-2xl border border-white/5 focus-within:border-primary-500/30 transition-colors">
<label className="text-[10px] font-bold text-white/30 uppercase pl-1 flex items-center gap-1.5">
<Briefcase className="w-2.5 h-2.5" /> (/)
</label>
<input
type="text"
value={formData.name2}
onChange={(e) => setFormData({ ...formData, name2: e.target.value })}
className="w-full bg-transparent border-none text-sm text-white focus:outline-none placeholder:text-white/10"
placeholder="영문 업체명 또는 별칭"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-1.5 px-3 py-2 bg-white/5 rounded-2xl border border-white/5">
<label className="text-[10px] font-bold text-white/30 uppercase pl-1"></label>
<input
type="text"
value={formData.owner}
onChange={(e) => setFormData({ ...formData, owner: e.target.value })}
className="w-full bg-transparent border-none text-sm text-white focus:outline-none placeholder:text-white/10"
placeholder="대표자 성함"
/>
</div>
<div className="space-y-1.5 px-3 py-2 bg-white/5 rounded-2xl border border-white/5">
<label className="text-[10px] font-bold text-white/30 uppercase pl-1"></label>
<input
type="text"
value={formData.grp}
onChange={(e) => setFormData({ ...formData, grp: e.target.value })}
className="w-full bg-transparent border-none text-sm text-white focus:outline-none placeholder:text-white/10"
placeholder="협력사, 고객사 등"
/>
</div>
<div className="space-y-1.5 px-3 py-2 bg-white/5 rounded-2xl border border-white/5">
<label className="text-[10px] font-bold text-white/30 uppercase pl-1"></label>
<input
type="text"
value={formData.uptae}
onChange={(e) => setFormData({ ...formData, uptae: e.target.value })}
className="w-full bg-transparent border-none text-sm text-white focus:outline-none placeholder:text-white/10"
placeholder="IT, 유통 등"
/>
</div>
</div>
</section>
{/* 연락처 정보 */}
<section className="space-y-4">
<div className="flex items-center gap-3 mb-2">
<Phone className="w-4 h-4 text-primary-500" />
<h4 className="text-sm font-bold text-white/70 uppercase tracking-tighter"> </h4>
<div className="flex-1 h-px bg-white/5"></div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1.5 px-3 py-2 bg-white/5 rounded-2xl border border-white/5">
<label className="text-[10px] font-bold text-white/30 uppercase pl-1 flex items-center gap-1.5">
<Phone className="w-2.5 h-2.5" />
</label>
<input
type="text"
value={formData.tel}
onChange={(e) => setFormData({ ...formData, tel: e.target.value })}
className="w-full bg-transparent border-none text-sm text-white focus:outline-none placeholder:text-white/10"
placeholder="00-000-0000"
/>
</div>
<div className="space-y-1.5 px-3 py-2 bg-white/5 rounded-2xl border border-white/5">
<label className="text-[10px] font-bold text-white/30 uppercase pl-1 flex items-center gap-1.5">
<Mail className="w-2.5 h-2.5" />
</label>
<input
type="text"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full bg-transparent border-none text-sm text-white focus:outline-none placeholder:text-white/10"
placeholder="email@company.com"
/>
</div>
</div>
<div className="space-y-1.5 px-3 py-2 bg-white/5 rounded-2xl border border-white/5">
<label className="text-[10px] font-bold text-white/30 uppercase pl-1 flex items-center gap-1.5">
<MapPin className="w-2.5 h-2.5" />
</label>
<input
type="text"
value={formData.address}
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
className="w-full bg-transparent border-none text-sm text-white focus:outline-none placeholder:text-white/10"
placeholder="사업장 소재지 상세 주소"
/>
</div>
</section>
{/* 담당자 정보 */}
<section className="space-y-4">
<div className="flex items-center gap-3 mb-2">
<User className="w-4 h-4 text-primary-500" />
<h4 className="text-sm font-bold text-white/70 uppercase tracking-tighter"> </h4>
<div className="flex-1 h-px bg-white/5"></div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1.5 px-3 py-2 bg-white/5 rounded-2xl border border-white/5">
<label className="text-[10px] font-bold text-white/30 uppercase pl-1"> </label>
<input
type="text"
value={formData.staff}
onChange={(e) => setFormData({ ...formData, staff: e.target.value })}
className="w-full bg-transparent border-none text-sm text-white focus:outline-none placeholder:text-white/10"
placeholder="담당자 이름"
/>
</div>
<div className="space-y-1.5 px-3 py-2 bg-white/5 rounded-2xl border border-white/5">
<label className="text-[10px] font-bold text-white/30 uppercase pl-1"> </label>
<input
type="text"
value={formData.stafftel}
onChange={(e) => setFormData({ ...formData, stafftel: e.target.value })}
className="w-full bg-transparent border-none text-sm text-white focus:outline-none placeholder:text-white/10"
placeholder="010-0000-0000"
/>
</div>
</div>
</section>
{/* 메모 */}
<section className="space-y-4">
<div className="flex items-center gap-3 mb-2">
<FileText className="w-4 h-4 text-primary-500" />
<h4 className="text-sm font-bold text-white/70 uppercase tracking-tighter"> </h4>
<div className="flex-1 h-px bg-white/5"></div>
</div>
<div className="space-y-1.5 px-3 py-2 bg-white/5 rounded-2xl border border-white/5">
<label className="text-[10px] font-bold text-white/30 uppercase pl-1"> </label>
<textarea
rows={4}
value={formData.memo}
onChange={(e) => setFormData({ ...formData, memo: e.target.value })}
className="w-full bg-transparent border-none text-sm text-white focus:outline-none placeholder:text-white/10 resize-none"
placeholder="업체 관련 메모를 자유롭게 입력하세요"
/>
</div>
</section>
</div>
</div>
{/* 푸터 버튼 */}
<div className="dialog-footer px-6 py-5 bg-white/[0.02] border-t border-white/10 flex items-center justify-between">
<div>
{item && (
<button
onClick={handleDelete}
disabled={loading}
className="flex items-center gap-2 px-4 py-2 bg-red-500/10 hover:bg-red-500/20 text-red-400 rounded-xl font-bold text-xs transition-colors border border-red-500/20"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
<div className="flex items-center gap-3">
<button
onClick={onClose}
className="px-5 py-2 text-white/40 hover:text-white font-bold text-xs transition-colors"
>
</button>
<button
onClick={handleSave}
disabled={loading}
className="flex items-center gap-2 px-6 py-2 bg-primary-500 hover:bg-primary-600 text-white rounded-xl font-bold text-xs shadow-lg shadow-primary-500/20 transition-all disabled:opacity-50"
>
<Save className="w-3.5 h-3.5" />
{loading ? '저장 중...' : (item ? '수정 완료' : '업체 등록')}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@@ -140,12 +140,12 @@ export function FavoriteDialog({ isOpen, onClose }: FavoriteDialogProps) {
/> />
{/* Dialog */} {/* Dialog */}
<div className="relative w-full max-w-4xl mx-4 glass-effect-solid rounded-2xl shadow-2xl overflow-hidden animate-fade-in"> <div className="dialog-container relative w-full max-w-4xl mx-4 rounded-2xl overflow-hidden animate-fade-in transition-all duration-300">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10"> <div className="dialog-header flex items-center justify-between px-6 py-4 border-b border-white/10">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Star className="w-5 h-5 text-yellow-400" /> <Star className="w-5 h-5 text-yellow-400" />
<h2 className="text-lg font-semibold text-white"></h2> <h2 className="dialog-title"></h2>
<span className="text-white/50 text-sm">({favorites.length})</span> <span className="text-white/50 text-sm">({favorites.length})</span>
</div> </div>
<button <button
@@ -190,7 +190,7 @@ export function FavoriteDialog({ isOpen, onClose }: FavoriteDialogProps) {
</div> </div>
{/* Footer */} {/* Footer */}
<div className="px-6 py-3 border-t border-white/10 bg-black/20"> <div className="dialog-footer px-6 py-3 border-t border-white/10 bg-black/20">
<p className="text-xs text-white/40 text-center"> <p className="text-xs text-white/40 text-center">
</p> </p>

View File

@@ -1,7 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { X, Save, Calendar, Clock, MapPin, User, FileText, AlertCircle } from 'lucide-react'; import { X, Save, Calendar, Clock, MapPin, User, FileText, AlertCircle } from 'lucide-react';
import { comms } from '../../communication'; import { comms } from '../../communication';
import { DevelopmentNotice } from '../common/DevelopmentNotice'; import { DevelopmentNotice } from "../DevelopmentNotice";
import { HolidayRequest, CommonCode } from '@/types'; import { HolidayRequest, CommonCode } from '@/types';
interface HolidayRequestDialogProps { interface HolidayRequestDialogProps {
@@ -36,7 +36,7 @@ export function HolidayRequestDialog({
backup: [] backup: []
}); });
const [users, setUsers] = useState<Array<{ id: string; name: string }>>([]); const [users, setUsers] = useState<Array<{ id: string; name: string }>>([]);
const [adminComments, setAdminComments] = useState<CommonCode[]>([]); // Code 54
// Form State // Form State
const [formData, setFormData] = useState<HolidayRequest>({ const [formData, setFormData] = useState<HolidayRequest>({
@@ -356,14 +356,14 @@ export function HolidayRequestDialog({
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-fade-in"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4 animate-fade-in">
<div className="bg-[#1e1e2e] rounded-2xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto border border-white/10"> <div className="dialog-container rounded-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto border-2 border-primary transition-all duration-300">
{/* Header */} {/* Header - Lively Gradient */}
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 bg-white/5"> <div className="dialog-header flex items-center justify-between px-6 py-4 bg-gradient-to-r from-primary-500 via-primary-400 to-primary-600">
<h2 className="text-xl font-bold text-white flex items-center"> <h2 className="dialog-title text-white flex items-center drop-shadow-md">
<Calendar className="w-5 h-5 mr-2 text-primary-400" /> <Calendar className="w-6 h-6 mr-2 text-white animate-pulse" />
{title} {title}
</h2> </h2>
<button onClick={onClose} className="text-white/50 hover:text-white transition-colors"> <button onClick={onClose} className="text-white/80 hover:text-white transition-colors bg-white/10 hover:bg-white/20 rounded-full p-1">
<X className="w-5 h-5" /> <X className="w-5 h-5" />
</button> </button>
</div> </div>
@@ -378,10 +378,10 @@ export function HolidayRequestDialog({
{/* Left Column: Inputs */} {/* Left Column: Inputs */}
<div className="space-y-6"> <div className="space-y-6">
{/* Request Type */} {/* Request Type */}
<div className="flex gap-4 p-4 bg-white/5 rounded-lg border border-white/5"> <div className="flex gap-4 p-4 bg-primary-500/10 rounded-lg border-2 border-primary-500/30">
<label className={`flex items-center gap-2 ${(isReadOnly || (formData.idx > 0 && initialData?.cate !== '대체' && initialData?.cate !== '외출')) ? 'cursor-not-allowed' : 'cursor-pointer'}`}> <label className={`flex items-center gap-2 ${(isReadOnly || (formData.idx > 0 && initialData?.cate !== '대체' && initialData?.cate !== '외출')) ? 'cursor-not-allowed' : 'cursor-pointer'}`}>
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${requestType === 'day' ? 'border-green-400' : 'border-white/30'}`}> <div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${requestType === 'day' ? 'border-primary-500' : 'border-primary-500/30'}`}>
{requestType === 'day' && <div className="w-2 h-2 rounded-full bg-green-400" />} {requestType === 'day' && <div className="w-2 h-2 rounded-full bg-primary-500" />}
</div> </div>
<input <input
type="radio" type="radio"
@@ -391,11 +391,11 @@ export function HolidayRequestDialog({
className="hidden" className="hidden"
disabled={isReadOnly || (formData.idx > 0 && initialData?.cate !== '대체' && initialData?.cate !== '외출')} disabled={isReadOnly || (formData.idx > 0 && initialData?.cate !== '대체' && initialData?.cate !== '외출')}
/> />
<span className="font-medium text-white/90"></span> <span className="font-bold text-primary-400"></span>
</label> </label>
<label className={`flex items-center gap-2 ${(isReadOnly || (formData.idx > 0 && initialData?.cate !== '대체')) ? 'cursor-not-allowed' : 'cursor-pointer'}`}> <label className={`flex items-center gap-2 ${(isReadOnly || (formData.idx > 0 && initialData?.cate !== '대체')) ? 'cursor-not-allowed' : 'cursor-pointer'}`}>
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${requestType === 'time' ? 'border-green-400' : 'border-white/30'}`}> <div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${requestType === 'time' ? 'border-primary-500' : 'border-primary-500/30'}`}>
{requestType === 'time' && <div className="w-2 h-2 rounded-full bg-green-400" />} {requestType === 'time' && <div className="w-2 h-2 rounded-full bg-primary-500" />}
</div> </div>
<input <input
type="radio" type="radio"
@@ -405,11 +405,11 @@ export function HolidayRequestDialog({
className="hidden" className="hidden"
disabled={isReadOnly || (formData.idx > 0 && initialData?.cate !== '대체')} disabled={isReadOnly || (formData.idx > 0 && initialData?.cate !== '대체')}
/> />
<span className="font-medium text-white/90"></span> <span className="font-bold text-primary-400"></span>
</label> </label>
<label className={`flex items-center gap-2 ${isReadOnly ? 'cursor-not-allowed' : 'cursor-pointer'}`}> <label className={`flex items-center gap-2 ${isReadOnly ? 'cursor-not-allowed' : 'cursor-pointer'}`}>
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${requestType === 'out' ? 'border-green-400' : 'border-white/30'}`}> <div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${requestType === 'out' ? 'border-primary-500' : 'border-primary-500/30'}`}>
{requestType === 'out' && <div className="w-2 h-2 rounded-full bg-green-400" />} {requestType === 'out' && <div className="w-2 h-2 rounded-full bg-primary-500" />}
</div> </div>
<input <input
type="radio" type="radio"
@@ -419,24 +419,24 @@ export function HolidayRequestDialog({
className="hidden" className="hidden"
disabled={isReadOnly} disabled={isReadOnly}
/> />
<span className="font-medium text-white/90"></span> <span className="font-bold text-primary-400"></span>
</label> </label>
</div> </div>
{/* User Selection (Admin only) */} {/* User Selection (Admin only) */}
{userLevel >= 5 && ( {userLevel >= 5 && (
<div className="grid grid-cols-1 gap-2"> <div className="grid grid-cols-1 gap-2">
<label className="text-sm font-medium text-white/70 flex items-center gap-2"> <label className="text-sm font-bold text-primary-400 flex items-center gap-2">
<User className="w-4 h-4" /> <User className="w-4 h-4" />
</label> </label>
<select <select
value={formData.uid} value={formData.uid}
onChange={(e) => setFormData({ ...formData, uid: e.target.value })} onChange={(e) => setFormData({ ...formData, uid: e.target.value })}
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400" className="w-full px-3 py-2 bg-primary-500/10 border-2 border-primary-500/30 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-500"
disabled={isReadOnly || formData.idx > 0} disabled={isReadOnly || formData.idx > 0}
> >
{users.map(user => ( {users.map(user => (
<option key={user.id} value={user.id} className="bg-[#1e1e2e]">{user.name} ({user.id})</option> <option key={user.id} value={user.id} className="bg-bg-paper">{user.name} ({user.id})</option>
))} ))}
</select> </select>
</div> </div>
@@ -445,26 +445,26 @@ export function HolidayRequestDialog({
{/* Date & Time */} {/* Date & Time */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-white/70 flex items-center gap-2"> <label className="text-sm font-bold text-primary-400 flex items-center gap-2">
<Calendar className="w-4 h-4" /> <Calendar className="w-4 h-4" />
</label> </label>
<input <input
type="date" type="date"
value={formData.sdate} value={formData.sdate}
onChange={(e) => setFormData({ ...formData, sdate: e.target.value })} onChange={(e) => setFormData({ ...formData, sdate: e.target.value })}
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:bg-white/10" className="w-full px-3 py-2 bg-primary-500/10 border-2 border-primary-500/30 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:bg-primary-500/5"
disabled={isReadOnly} disabled={isReadOnly}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-white/70 flex items-center gap-2"> <label className="text-sm font-bold text-primary-400 flex items-center gap-2">
<Calendar className="w-4 h-4" /> <Calendar className="w-4 h-4" />
</label> </label>
<input <input
type="date" type="date"
value={formData.edate} value={formData.edate}
onChange={(e) => setFormData({ ...formData, edate: e.target.value })} onChange={(e) => setFormData({ ...formData, edate: e.target.value })}
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:bg-white/10" className="w-full px-3 py-2 bg-primary-500/10 border-2 border-primary-500/30 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:bg-primary-500/5"
disabled={isReadOnly} disabled={isReadOnly}
/> />
</div> </div>
@@ -475,21 +475,21 @@ export function HolidayRequestDialog({
{/* Category & Reason */} {/* Category & Reason */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-white/70 flex items-center gap-2"> <label className="text-sm font-bold text-primary-400 flex items-center gap-2">
<FileText className="w-4 h-4" /> <FileText className="w-4 h-4" />
</label> </label>
{requestType === 'day' ? ( {requestType === 'day' ? (
<select <select
value={formData.cate} value={formData.cate}
onChange={(e) => setFormData({ ...formData, cate: e.target.value })} onChange={(e) => setFormData({ ...formData, cate: e.target.value })}
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:bg-white/10" className="w-full px-3 py-2 bg-primary-500/10 border-2 border-primary-500/30 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:bg-primary-500/5"
disabled={isReadOnly} disabled={isReadOnly}
> >
{codes.cate.map(code => { {codes.cate.map(code => {
const val = code.memo || (code as any).Memo || code.svalue || (code as any).SValue || (code as any).value || (code as any).Value || (code as any).name || (code as any).Name; const val = code.memo || (code as any).Memo || code.svalue || (code as any).SValue || (code as any).value || (code as any).Value || (code as any).name || (code as any).Name;
const key = code.code || (code as any).Code || (code as any).key || (code as any).Key || val; const key = code.code || (code as any).Code || (code as any).key || (code as any).Key || val;
return ( return (
<option key={key} value={val} className="bg-[#1e1e2e]"> <option key={key} value={val} className="bg-bg-paper">
{val} {val}
</option> </option>
); );
@@ -500,12 +500,12 @@ export function HolidayRequestDialog({
type="text" type="text"
value={formData.cate} value={formData.cate}
readOnly readOnly
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white/50 cursor-not-allowed" className="w-full px-3 py-2 bg-primary-500/10 border-2 border-primary-500/30 rounded-lg text-white/50 cursor-not-allowed"
/> />
)} )}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-white/70 flex items-center gap-2"> <label className="text-sm font-bold text-primary-400 flex items-center gap-2">
<AlertCircle className="w-4 h-4" /> <AlertCircle className="w-4 h-4" />
</label> </label>
<input <input
@@ -513,7 +513,7 @@ export function HolidayRequestDialog({
list="reason-list" list="reason-list"
value={formData.HolyReason || ''} value={formData.HolyReason || ''}
onChange={(e) => setFormData({ ...formData, HolyReason: e.target.value })} onChange={(e) => setFormData({ ...formData, HolyReason: e.target.value })}
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:bg-white/10" className="w-full px-3 py-2 bg-primary-500/10 border-2 border-primary-500/30 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:bg-primary-500/5"
placeholder="입력 또는 선택" placeholder="입력 또는 선택"
disabled={isReadOnly} disabled={isReadOnly}
/> />
@@ -532,7 +532,7 @@ export function HolidayRequestDialog({
{/* Location & Backup */} {/* Location & Backup */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-white/70 flex items-center gap-2"> <label className="text-sm font-bold text-primary-400 flex items-center gap-2">
<MapPin className="w-4 h-4" /> <MapPin className="w-4 h-4" />
</label> </label>
<input <input
@@ -540,7 +540,7 @@ export function HolidayRequestDialog({
list="location-list" list="location-list"
value={formData.HolyLocation || ''} value={formData.HolyLocation || ''}
onChange={(e) => setFormData({ ...formData, HolyLocation: e.target.value })} onChange={(e) => setFormData({ ...formData, HolyLocation: e.target.value })}
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:bg-white/10" className="w-full px-3 py-2 bg-primary-500/10 border-2 border-primary-500/30 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:bg-primary-500/5"
placeholder="입력 또는 선택" placeholder="입력 또는 선택"
disabled={isReadOnly} disabled={isReadOnly}
/> />
@@ -555,7 +555,7 @@ export function HolidayRequestDialog({
</datalist> </datalist>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-white/70 flex items-center gap-2"> <label className="text-sm font-bold text-primary-400 flex items-center gap-2">
<User className="w-4 h-4" /> <User className="w-4 h-4" />
</label> </label>
<input <input
@@ -563,7 +563,7 @@ export function HolidayRequestDialog({
list="backup-list" list="backup-list"
value={formData.HolyBackup || ''} value={formData.HolyBackup || ''}
onChange={(e) => setFormData({ ...formData, HolyBackup: e.target.value })} onChange={(e) => setFormData({ ...formData, HolyBackup: e.target.value })}
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:bg-white/10" className="w-full px-3 py-2 bg-primary-500/10 border-2 border-primary-500/30 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:bg-primary-500/5"
placeholder="입력 또는 선택" placeholder="입력 또는 선택"
disabled={isReadOnly} disabled={isReadOnly}
/> />
@@ -582,24 +582,24 @@ export function HolidayRequestDialog({
{/* Days & Times (Manual Override) */} {/* Days & Times (Manual Override) */}
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-white/70"></label> <label className="text-sm font-bold text-primary-400"></label>
<input <input
type="number" type="number"
step="0.5" step="0.5"
value={formData.HolyDays} value={formData.HolyDays}
onChange={(e) => setFormData({ ...formData, HolyDays: parseFloat(e.target.value) })} onChange={(e) => setFormData({ ...formData, HolyDays: parseFloat(e.target.value) })}
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:bg-white/10 disabled:text-white/30 disabled:cursor-not-allowed" className="w-full px-3 py-2 bg-primary-500/10 border-2 border-primary-500/30 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:bg-primary-500/5 disabled:text-primary-200/30 disabled:cursor-not-allowed"
disabled={isReadOnly || requestType !== 'day'} disabled={isReadOnly || requestType !== 'day'}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-white/70"></label> <label className="text-sm font-bold text-primary-400"></label>
<input <input
type="number" type="number"
step="0.5" step="0.5"
value={formData.HolyTimes} value={formData.HolyTimes}
onChange={(e) => setFormData({ ...formData, HolyTimes: parseFloat(e.target.value) })} onChange={(e) => setFormData({ ...formData, HolyTimes: parseFloat(e.target.value) })}
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:bg-white/10 disabled:text-white/30 disabled:cursor-not-allowed" className="w-full px-3 py-2 bg-primary-500/10 border-2 border-primary-500/30 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:bg-primary-500/5 disabled:text-primary-200/30 disabled:cursor-not-allowed"
disabled={isReadOnly || requestType === 'day'} disabled={isReadOnly || requestType === 'day'}
/> />
</div> </div>
@@ -609,53 +609,51 @@ export function HolidayRequestDialog({
{requestType === 'out' && ( {requestType === 'out' && (
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-4">
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-white/70 flex items-center gap-2"> <label className="text-sm font-bold text-primary-400 flex items-center gap-2">
<Clock className="w-4 h-4" /> <Clock className="w-4 h-4" />
</label> </label>
<input <input
type="time" type="time"
value={formData.stime} value={formData.stime}
onChange={(e) => setFormData({ ...formData, stime: e.target.value })} onChange={(e) => setFormData({ ...formData, stime: e.target.value })}
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:bg-white/10" className="w-full px-3 py-2 bg-primary-500/10 border-2 border-primary-500/30 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:bg-primary-500/5"
disabled={isReadOnly} disabled={isReadOnly}
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-white/70 flex items-center gap-2"> <label className="text-sm font-bold text-primary-400 flex items-center gap-2">
<Clock className="w-4 h-4" /> <Clock className="w-4 h-4" />
</label> </label>
<input <input
type="time" type="time"
value={formData.etime} value={formData.etime}
onChange={(e) => setFormData({ ...formData, etime: e.target.value })} onChange={(e) => setFormData({ ...formData, etime: e.target.value })}
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:bg-white/10" className="w-full px-3 py-2 bg-primary-500/10 border-2 border-primary-500/30 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-500 disabled:bg-primary-500/5"
disabled={isReadOnly} disabled={isReadOnly}
/> />
</div> </div>
</div> </div>
)} )}
</div> </div>
{/* Right Column: Remark */} {/* Right Column: Remark */}
<div className="md:col-span-1 h-full"> <div className="md:col-span-1 h-full">
<div className="flex flex-col h-full space-y-2"> <div className="flex flex-col h-full space-y-2">
<label className="text-sm font-medium text-white/70"></label> <label className="text-sm font-medium text-primary-200"></label>
<textarea <textarea
value={formData.Remark} value={formData.Remark}
onChange={(e) => setFormData({ ...formData, Remark: e.target.value })} onChange={(e) => setFormData({ ...formData, Remark: e.target.value })}
className="w-full flex-1 px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400 resize-none min-h-[200px] disabled:bg-white/10" className="w-full flex-1 px-3 py-2 bg-primary-500/10 border border-primary-500/30 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400 resize-none min-h-[200px] disabled:bg-primary-500/5"
placeholder="비고 사항을 입력하세요..." placeholder="비고 사항을 입력하세요..."
disabled={isReadOnly} disabled={isReadOnly}
/> />
{/* Admin Response & Confirmation (Moved to Right) */} {/* Admin Response & Confirmation (Moved to Right) */}
<div className="p-4 bg-primary-500/10 rounded-lg space-y-4 border border-primary-500/20 mt-4"> <div className="p-4 bg-primary-500/10 rounded-lg space-y-4 border border-primary-500/30 mt-4">
<h3 className="font-semibold text-primary-400"> </h3> <h3 className="font-semibold text-primary-400"> </h3>
<div className="flex gap-4"> <div className="flex gap-4">
<label className={`flex items-center gap-2 ${userLevel < 5 ? 'cursor-not-allowed' : 'cursor-pointer'}`}> <label className={`flex items-center gap-2 ${userLevel < 5 ? 'cursor-not-allowed' : 'cursor-pointer'}`}>
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${formData.conf === 0 ? 'border-primary-400' : 'border-white/30'}`}> <div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${formData.conf === 0 ? 'border-primary-400' : 'border-primary-500/30'}`}>
{formData.conf === 0 && <div className="w-2 h-2 rounded-full bg-primary-400" />} {formData.conf === 0 && <div className="w-2 h-2 rounded-full bg-primary-400" />}
</div> </div>
<input <input
@@ -666,10 +664,10 @@ export function HolidayRequestDialog({
className="hidden" className="hidden"
disabled={userLevel < 5} disabled={userLevel < 5}
/> />
<span className="text-white/70"></span> <span className="text-primary-200"></span>
</label> </label>
<label className={`flex items-center gap-2 ${userLevel < 5 ? 'cursor-not-allowed' : 'cursor-pointer'}`}> <label className={`flex items-center gap-2 ${userLevel < 5 ? 'cursor-not-allowed' : 'cursor-pointer'}`}>
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${formData.conf === 1 ? 'border-green-400' : 'border-white/30'}`}> <div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${formData.conf === 1 ? 'border-green-400' : 'border-primary-500/30'}`}>
{formData.conf === 1 && <div className="w-2 h-2 rounded-full bg-green-400" />} {formData.conf === 1 && <div className="w-2 h-2 rounded-full bg-green-400" />}
</div> </div>
<input <input
@@ -680,10 +678,10 @@ export function HolidayRequestDialog({
className="hidden" className="hidden"
disabled={userLevel < 5} disabled={userLevel < 5}
/> />
<span className="text-white/70"></span> <span className="text-primary-200"></span>
</label> </label>
<label className={`flex items-center gap-2 ${userLevel < 5 ? 'cursor-not-allowed' : 'cursor-pointer'}`}> <label className={`flex items-center gap-2 ${userLevel < 5 ? 'cursor-not-allowed' : 'cursor-pointer'}`}>
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${formData.conf === 2 ? 'border-red-400' : 'border-white/30'}`}> <div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${formData.conf === 2 ? 'border-red-400' : 'border-primary-500/30'}`}>
{formData.conf === 2 && <div className="w-2 h-2 rounded-full bg-red-400" />} {formData.conf === 2 && <div className="w-2 h-2 rounded-full bg-red-400" />}
</div> </div>
<input <input
@@ -694,24 +692,18 @@ export function HolidayRequestDialog({
className="hidden" className="hidden"
disabled={userLevel < 5} disabled={userLevel < 5}
/> />
<span className="text-white/70"></span> <span className="text-primary-200"></span>
</label> </label>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-sm font-medium text-white/70"> </label> <label className="text-sm font-medium text-primary-200"> </label>
<input <input
type="text" type="text"
list="adminCommentsList"
value={formData.Response} value={formData.Response}
onChange={(e) => setFormData({ ...formData, Response: e.target.value })} onChange={(e) => setFormData({ ...formData, Response: e.target.value })}
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:bg-white/10" className="w-full px-3 py-2 bg-primary-500/10 border border-primary-500/30 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:bg-primary-500/5"
disabled={userLevel < 5} disabled={userLevel < 5}
/> />
<datalist id="adminCommentsList">
{adminComments.map((item) => (
<option key={(item as any).code || (item as any).Code} value={(item as any).memo || (item as any).Memo || (item as any).svalue || (item as any).SValue} />
))}
</datalist>
</div> </div>
</div> </div>
</div> </div>
@@ -719,10 +711,10 @@ export function HolidayRequestDialog({
</div> </div>
{/* Footer */} {/* Footer */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-white/10 bg-white/5"> <div className="dialog-footer flex items-center justify-end gap-3 px-6 py-4 border-t border-primary-500/30 bg-primary-500/10">
<button <button
onClick={onClose} onClick={onClose}
className="px-4 py-2 text-white/70 hover:text-white hover:bg-white/10 rounded-lg transition-colors font-medium" className="px-4 py-2 text-primary-200 hover:text-white hover:bg-primary-500/20 rounded-lg transition-colors font-medium"
> >
</button> </button>

View File

@@ -61,13 +61,42 @@ export function ItemEditDialog({ item, isOpen, onClose, onSave, onDelete }: Item
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]); }, [isOpen, onClose]);
// 이미지를 Base64로 변환 // 이미지를 압축하여 Base64로 변환
const convertToBase64 = (file: File): Promise<string> => { const compressImage = (file: File): Promise<string> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file); reader.readAsDataURL(file);
reader.onload = (event) => {
const img = new Image();
img.src = event.target?.result as string;
img.onload = () => {
const canvas = document.createElement('canvas');
const MAX_WIDTH = 640;
const MAX_HEIGHT = 480;
let width = img.width;
let height = img.height;
// 너비가 최대값보다 크면 리사이즈
if (width > MAX_WIDTH) {
height *= MAX_WIDTH / width;
width = MAX_WIDTH;
}
// 높이가 최대값보다 크면 리사이즈 (너비 리사이즈 후에도 높이가 클 수 있음)
if (height > MAX_HEIGHT) {
width *= MAX_HEIGHT / height;
height = MAX_HEIGHT;
}
canvas.width = width;
canvas.height = height;
const ctx = canvas.getContext('2d');
ctx?.drawImage(img, 0, 0, width, height);
resolve(canvas.toDataURL('image/jpeg', 0.8)); // Quality 0.8
};
img.onerror = (error) => reject(error);
};
reader.onerror = (error) => reject(error);
}); });
}; };
@@ -79,7 +108,7 @@ export function ItemEditDialog({ item, isOpen, onClose, onSave, onDelete }: Item
} }
try { try {
const base64 = await convertToBase64(file); const base64 = await compressImage(file);
setImageData(base64); setImageData(base64);
// 기존 품목인 경우 바로 저장 // 기존 품목인 경우 바로 저장
@@ -209,14 +238,30 @@ export function ItemEditDialog({ item, isOpen, onClose, onSave, onDelete }: Item
{/* 다이얼로그 - 이미지 영역 포함해서 더 넓게 */} {/* 다이얼로그 - 이미지 영역 포함해서 더 넓게 */}
<div <div
className="relative bg-slate-800 rounded-xl shadow-2xl w-full max-w-4xl mx-4 border border-white/10" className="dialog-container relative rounded-xl w-full max-w-4xl mx-4 transition-all duration-300"
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
> >
{/* 헤더 */} {/* 헤더 */}
<div className="flex items-center justify-between p-4 border-b border-white/10"> <div className="dialog-header flex items-center justify-between p-4">
<h2 className="text-lg font-semibold text-white"> <div className="flex items-center gap-4">
{isNew ? '품목 추가' : '품목 편집'} <h2 className="dialog-title">
</h2> {isNew ? '품목 추가' : '품목 편집'}
</h2>
<label className="flex items-center gap-2 cursor-pointer group select-none">
<div className={`w-10 h-5 rounded-full relative transition-colors ${editData.disable ? 'bg-red-500' : 'bg-white/20'}`}>
<input
type="checkbox"
checked={editData.disable}
onChange={(e) => setEditData({ ...editData, disable: e.target.checked })}
className="sr-only"
/>
<div className={`absolute left-1 top-1 w-3 h-3 bg-white rounded-full transition-transform ${editData.disable ? 'translate-x-[20px]' : ''}`} />
</div>
<span className={`text-sm transition-colors ${editData.disable ? 'text-red-400 font-medium' : 'text-white/50 group-hover:text-white/70'}`}>
{editData.disable ? '비활성화됨' : '비활성화'}
</span>
</label>
</div>
<button <button
onClick={onClose} onClick={onClose}
className="p-1 hover:bg-white/10 rounded text-white/70 hover:text-white transition-colors" className="p-1 hover:bg-white/10 rounded text-white/70 hover:text-white transition-colors"
@@ -237,7 +282,7 @@ export function ItemEditDialog({ item, isOpen, onClose, onSave, onDelete }: Item
type="text" type="text"
value={editData.sid} value={editData.sid}
onChange={(e) => setEditData({ ...editData, sid: e.target.value })} onChange={(e) => setEditData({ ...editData, sid: e.target.value })}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white" className={`w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg ${editData.disable ? 'text-red-400 font-bold' : 'text-white'}`}
/> />
</div> </div>
@@ -310,7 +355,7 @@ export function ItemEditDialog({ item, isOpen, onClose, onSave, onDelete }: Item
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-3 gap-4">
{/* 공급처 */} {/* 공급처 */}
<div> <div>
<label className="block text-sm font-medium text-white/70 mb-1"></label> <label className="block text-sm font-medium text-white/70 mb-1"></label>
@@ -332,17 +377,17 @@ export function ItemEditDialog({ item, isOpen, onClose, onSave, onDelete }: Item
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white" className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
/> />
</div> </div>
</div>
{/* 보관장소 */} {/* 보관장소 */}
<div> <div>
<label className="block text-sm font-medium text-white/70 mb-1"></label> <label className="block text-sm font-medium text-white/70 mb-1"></label>
<input <input
type="text" type="text"
value={editData.storage} value={editData.storage}
onChange={(e) => setEditData({ ...editData, storage: e.target.value })} onChange={(e) => setEditData({ ...editData, storage: e.target.value })}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white" className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white"
/> />
</div>
</div> </div>
{/* 메모 */} {/* 메모 */}
@@ -356,17 +401,7 @@ export function ItemEditDialog({ item, isOpen, onClose, onSave, onDelete }: Item
/> />
</div> </div>
{/* 비활성화 */}
<div className="flex items-center gap-2">
<input
type="checkbox"
id="disable"
checked={editData.disable}
onChange={(e) => setEditData({ ...editData, disable: e.target.checked })}
className="w-4 h-4 rounded border-white/20 bg-white/10"
/>
<label htmlFor="disable" className="text-sm text-white/70"></label>
</div>
</div> </div>
{/* 오른쪽: 이미지 영역 */} {/* 오른쪽: 이미지 영역 */}
@@ -380,11 +415,10 @@ export function ItemEditDialog({ item, isOpen, onClose, onSave, onDelete }: Item
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDrop={handleDrop} onDrop={handleDrop}
className={`flex-1 min-h-[200px] rounded-lg border-2 border-dashed transition-colors flex items-center justify-center overflow-hidden ${ className={`flex-1 min-h-[200px] rounded-lg border-2 border-dashed transition-colors flex items-center justify-center overflow-hidden ${isDragging
isDragging ? 'border-blue-400 bg-blue-500/20'
? 'border-blue-400 bg-blue-500/20' : 'border-white/20 bg-white/5 hover:border-white/40'
: 'border-white/20 bg-white/5 hover:border-white/40' }`}
}`}
> >
{imageLoading ? ( {imageLoading ? (
<div className="text-center"> <div className="text-center">
@@ -468,7 +502,7 @@ export function ItemEditDialog({ item, isOpen, onClose, onSave, onDelete }: Item
</div> </div>
{/* 푸터 */} {/* 푸터 */}
<div className="flex items-center justify-between p-4 border-t border-white/10"> <div className="dialog-footer flex items-center justify-between p-4">
<div> <div>
{!isNew && ( {!isNew && (
<button <button

View File

@@ -1,5 +1,6 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { X, ChevronLeft, ChevronRight, Download } from 'lucide-react'; import { X, ChevronLeft, ChevronRight, Download, Calendar, Users, Clock } from 'lucide-react';
import { clsx } from 'clsx';
import { comms } from '@/communication'; import { comms } from '@/communication';
import { HolidayItem } from '@/types'; import { HolidayItem } from '@/types';
@@ -177,16 +178,16 @@ export function JobReportDayDialog({ isOpen, onClose, initialMonth }: JobReportD
// 셀 색상 결정 // 셀 색상 결정
const getCellStyle = (data: { hrs: number; ot: number; jobtype: string } | undefined, isHoliday: boolean) => { const getCellStyle = (data: { hrs: number; ot: number; jobtype: string } | undefined, isHoliday: boolean) => {
if (!data) return 'text-gray-400'; if (!data) return 'text-white/20';
if (data.jobtype === '휴가') return 'text-red-500 font-medium'; if (data.jobtype === '휴가') return 'text-danger-400 font-black bg-danger-500/10';
if (isHoliday) return 'text-green-500 font-medium'; if (isHoliday) return 'text-success-400 font-bold bg-success-500/10 underline underline-offset-4';
if (data.hrs > 8) return 'text-blue-500 font-medium'; if (data.hrs > 8) return 'text-primary-400 font-bold underline underline-offset-2';
if (data.hrs < 8) return 'text-red-500'; if (data.hrs < 8) return 'text-danger-500 font-medium';
if (data.ot > 0) return 'text-purple-500 font-medium'; if (data.ot > 0) return 'text-warning-400 font-bold italic';
return 'text-white'; return 'text-white/70';
}; };
// 셀 내용 포맷 // 셀 내용 포맷
@@ -201,10 +202,10 @@ export function JobReportDayDialog({ isOpen, onClose, initialMonth }: JobReportD
// 엑셀 내보내기 (간단한 CSV) // 엑셀 내보내기 (간단한 CSV)
const exportToExcel = () => { const exportToExcel = () => {
let csv = '사원명,' + dayColumns.map(c => `${c.day}(${c.dayOfWeek})`).join(',') + ',합계\n'; let csv = '사원명,' + dayColumns.map((c: DayColumn) => `${c.day}(${c.dayOfWeek})`).join(',') + ',합계\n';
userRows.forEach(row => { userRows.forEach((row: UserRow) => {
const cells = dayColumns.map(col => { const cells = dayColumns.map((col: DayColumn) => {
const data = row.dailyData.get(col.day); const data = row.dailyData.get(col.day);
return formatCellContent(data, col.isHoliday); return formatCellContent(data, col.isHoliday);
}); });
@@ -222,125 +223,207 @@ export function JobReportDayDialog({ isOpen, onClose, initialMonth }: JobReportD
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in" onClick={onClose}>
<div className="bg-gradient-to-br from-gray-900 to-gray-800 rounded-2xl shadow-2xl w-full max-w-7xl max-h-[90vh] flex flex-col"> <div
className="dialog-container rounded-3xl w-full max-w-7xl max-h-[95vh] overflow-hidden flex flex-col transition-all duration-300 animate-scale-in"
onClick={(e) => e.stopPropagation()}
>
{/* 헤더 */} {/* 헤더 */}
<div className="flex items-center justify-between p-6 border-b border-white/10"> <div className="dialog-header px-8 py-6 flex items-center justify-between">
<div className="flex items-center gap-4"> <div className="flex items-center gap-6">
<h2 className="text-2xl font-bold text-white"> </h2> <div className="flex items-center gap-4">
<div className="flex items-center gap-2"> <div className="p-2.5 bg-primary-500/20 rounded-xl">
<Clock className="w-6 h-6 text-primary-400" />
</div>
<div>
<h2 className="dialog-title text-xl"> </h2>
<p className="text-xs text-white/40 uppercase tracking-widest font-medium mt-0.5">Daily Working Hours Summary</p>
</div>
</div>
{/* 월 선택 UI */}
<div className="flex items-center gap-1 bg-white/5 p-1 rounded-xl border border-white/10">
<button <button
onClick={() => changeMonth(-1)} onClick={() => changeMonth(-1)}
className="p-2 hover:bg-white/10 rounded-lg transition-colors" className="p-2 hover:bg-white/10 rounded-lg text-white/50 hover:text-white transition-all active:scale-95"
title="이전 달"
> >
<ChevronLeft className="w-5 h-5 text-white" /> <ChevronLeft className="w-5 h-5" />
</button> </button>
<span className="text-lg font-medium text-white min-w-[100px] text-center"> <div className="px-4 flex items-center gap-2">
{currentMonth} <Calendar className="w-4 h-4 text-primary-400" />
</span> <span className="text-sm font-bold text-white font-mono min-w-[80px] text-center italic tracking-wider">
{currentMonth}
</span>
</div>
<button <button
onClick={() => changeMonth(1)} onClick={() => changeMonth(1)}
className="p-2 hover:bg-white/10 rounded-lg transition-colors" className="p-2 hover:bg-white/10 rounded-lg text-white/50 hover:text-white transition-all active:scale-95"
title="다음 달"
> >
<ChevronRight className="w-5 h-5 text-white" /> <ChevronRight className="w-5 h-5" />
</button> </button>
</div> </div>
</div> </div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-3">
<button <button
onClick={exportToExcel} onClick={exportToExcel}
className="px-4 py-2 bg-green-500 hover:bg-green-600 text-white rounded-lg transition-colors flex items-center gap-2" className="px-5 py-2.5 bg-green-500 hover:bg-green-600 text-white border border-green-500/20 rounded-xl transition-all font-bold flex items-center gap-2 shadow-lg shadow-green-500/20 active:scale-95"
> >
<Download className="w-4 h-4" /> <Download className="w-4 h-4" />
<span>CSV </span>
</button> </button>
<button <button
onClick={onClose} onClick={onClose}
className="p-2 hover:bg-white/10 rounded-lg transition-colors" className="p-2.5 text-white/30 hover:text-white hover:bg-white/10 rounded-xl transition-all"
> >
<X className="w-6 h-6 text-white" /> <X className="w-6 h-6" />
</button> </button>
</div> </div>
</div> </div>
{/* 테이블 */} {/* 테이블 컨텐츠 */}
<div className="flex-1 overflow-auto p-6"> <div className="flex-1 overflow-hidden flex flex-col p-6 bg-white/[0.02]">
{loading ? ( <div className="flex-1 glass-effect rounded-2xl border border-white/10 flex flex-col overflow-hidden shadow-2xl">
<div className="flex items-center justify-center h-full"> {loading ? (
<div className="text-white/50"> ...</div> <div className="flex-1 flex flex-col items-center justify-center">
</div> <RefreshCw className="w-12 h-12 text-primary-500/30 animate-spin mb-4" />
) : ( <p className="text-white/40 font-medium"> ...</p>
<table className="w-full border-collapse"> </div>
<thead className="sticky top-0 bg-gray-800 z-10"> ) : (
<tr> <div className="flex-1 overflow-auto custom-scrollbar">
<th className="px-3 py-2 text-left text-xs font-medium text-white/70 uppercase border border-white/10 bg-gray-800"> <table className="w-full border-separate border-spacing-0">
<thead className="sticky top-0 z-20">
</th> <tr>
{dayColumns.map(col => ( <th className="px-6 py-4 text-left text-xs font-bold text-white/70 uppercase tracking-widest bg-gray-900 border-b border-r border-white/10 sticky left-0 z-30">
<th
key={col.day} </th>
className={`px-2 py-2 text-center text-xs font-medium uppercase border border-white/10 ${col.isHoliday ? 'bg-green-900/30 text-green-400' : {dayColumns.map(col => (
col.dayOfWeek === '일' ? 'bg-red-900/30 text-red-400' : <th
col.dayOfWeek === '토' ? 'bg-blue-900/30 text-blue-400' : key={col.day}
'bg-gray-800 text-white/70' className={clsx(
}`} "px-2 py-3 text-center text-[10px] font-black uppercase tracking-tighter border-b border-r border-white/5 bg-gray-900/95 backdrop-blur-sm min-w-[50px]",
title={col.holidayMemo} col.isHoliday ? 'text-danger-400 bg-danger-500/10' :
> col.dayOfWeek === '일' ? 'text-danger-400' :
{col.day}<br />({col.dayOfWeek}) col.dayOfWeek === '토' ? 'text-primary-400' :
</th> 'text-white/40'
))} )}
<th className="px-3 py-2 text-center text-xs font-medium text-white/70 uppercase border border-white/10 bg-gray-800"> title={col.holidayMemo}
>
</th> <div className="flex flex-col items-center">
</tr> <span className="text-sm font-mono leading-none">{col.day}</span>
</thead> <span className="mt-1 opacity-60 leading-none">{col.dayOfWeek}</span>
<tbody> </div>
{userRows.length === 0 ? ( </th>
<tr> ))}
<td colSpan={dayColumns.length + 2} className="px-4 py-8 text-center text-white/50"> <th className="px-4 py-4 text-center text-xs font-bold text-primary-400 uppercase tracking-widest bg-gray-900 border-b border-white/10 sticky right-0 z-30 shadow-[-4px_0_12px_rgba(0,0,0,0.5)]">
. (Hr + OT)
</td> </th>
</tr>
) : (
userRows.map(row => (
<tr key={row.uid} className="hover:bg-white/5 transition-colors">
<td className="px-3 py-2 text-sm text-white border border-white/10 whitespace-nowrap">
{row.uname}
</td>
{dayColumns.map(col => {
const data = row.dailyData.get(col.day);
return (
<td
key={col.day}
className={`px-2 py-2 text-center text-sm border border-white/10 ${getCellStyle(data, col.isHoliday)}`}
>
{formatCellContent(data, col.isHoliday)}
</td>
);
})}
<td className="px-3 py-2 text-center text-sm text-white border border-white/10 font-medium whitespace-nowrap">
{row.totalHrs.toFixed(1)}+{row.totalOt.toFixed(1)}(*{row.totalHolidayOt.toFixed(1)})
</td>
</tr> </tr>
)) </thead>
)} <tbody className="divide-y divide-white/[0.03]">
</tbody> {userRows.length === 0 ? (
</table> <tr>
)} <td colSpan={dayColumns.length + 2} className="px-4 py-32 text-center">
<Users className="w-20 h-20 text-white/5 mx-auto mb-4" />
<p className="text-white/20 font-medium"> .</p>
</td>
</tr>
) : (
userRows.map((row: UserRow) => (
<tr key={row.uid} className="hover:bg-white/[0.03] transition-colors group">
<td className="px-6 py-2.5 text-sm font-bold text-white bg-gray-900/50 border-r border-white/10 sticky left-0 z-10 backdrop-blur-md group-hover:bg-primary-500/10 transition-colors">
{row.uname}
</td>
{dayColumns.map((col: DayColumn) => {
const data = row.dailyData.get(col.day);
return (
<td
key={col.day}
className={clsx(
"px-2 py-2 text-center text-xs border-r border-white/[0.02] last:border-r-0",
getCellStyle(data, col.isHoliday)
)}
>
<span className="font-mono font-medium tracking-tighter italic opacity-90">
{formatCellContent(data, col.isHoliday)}
</span>
</td>
);
})}
<td className="px-4 py-2.5 text-center bg-gray-900/50 border-l border-white/10 sticky right-0 z-10 backdrop-blur-md shadow-[-4px_0_12px_rgba(0,0,0,0.5)] group-hover:bg-primary-500/10 transition-colors">
<div className="flex items-center justify-center gap-1.5 font-mono text-xs font-bold">
<span className="text-white tracking-widest">{row.totalHrs.toFixed(1)}</span>
<span className="text-white/20 italic">+</span>
<span className="text-primary-400 tracking-widest">{row.totalOt.toFixed(1)}</span>
{row.totalHolidayOt > 0 && (
<span className="text-warning-400 ml-1 text-[10px] bg-warning-500/10 px-1 rounded">
*{row.totalHolidayOt.toFixed(1)}
</span>
)}
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
</div>
</div> </div>
{/* 범례 */} {/* 하단 범례 (Legend) */}
<div className="px-6 py-4 border-t border-white/10 bg-gray-800/50"> <div className="dialog-footer px-8 py-4 flex items-center gap-6 overflow-x-auto custom-scrollbar no-scrollbar">
<div className="flex flex-wrap gap-4 text-xs text-white/70"> <span className="text-[10px] font-black text-white/30 uppercase tracking-[0.2em] shrink-0 border-r border-white/10 pr-6 mr-2">Legend</span>
<div><span className="text-gray-400">--</span> : </div> <div className="flex items-center gap-6 text-[11px] font-bold whitespace-nowrap">
<div><span className="text-red-500"></span> : </div> <div className="flex items-center gap-2">
<div><span className="text-green-500">*8+2</span> : </div> <span className="w-5 h-5 rounded-md bg-white/5 flex items-center justify-center text-gray-500 font-mono italic">--</span>
<div><span className="text-blue-500">9+0</span> : 8 </div> <span className="text-white/40"> </span>
<div><span className="text-red-500">7+0</span> : 8 </div> </div>
<div><span className="text-purple-500">8+2</span> : 8+OT</div> <div className="flex items-center gap-2">
<span className="px-2 py-0.5 rounded-md bg-danger-500/20 text-danger-400 font-bold"></span>
<span className="text-white/40">/</span>
</div>
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 rounded-md bg-success-500/20 text-success-400 font-mono font-bold">*8+4</span>
<span className="text-white/40"> </span>
</div>
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 rounded-md bg-primary-500/20 text-primary-400 font-mono font-bold underline">10+0</span>
<span className="text-white/40"> </span>
</div>
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 rounded-md bg-danger-500/10 text-danger-500 font-mono font-bold">6+0</span>
<span className="text-white/40"> </span>
</div>
<div className="flex items-center gap-2">
<span className="px-2 py-0.5 rounded-md bg-warning-500/20 text-warning-400 font-mono font-bold italic">8+2</span>
<span className="text-white/40">OT </span>
</div>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
); );
} }
const RefreshCw = ({ className }: { className?: string }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
width="24" height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
className={className}
>
<path d="M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8" />
<path d="M21 3v5h-5" />
<path d="M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16" />
<path d="M3 21v-5h5" />
</svg>
);

View File

@@ -87,11 +87,11 @@ export function JobTypeSelectModal({
// 검색 필터 적용 // 검색 필터 적용
const filteredTypes = searchKey const filteredTypes = searchKey
? jobTypes.filter( ? jobTypes.filter(
(item) => (item) =>
item.type?.toLowerCase().includes(searchKey.toLowerCase()) || item.type?.toLowerCase().includes(searchKey.toLowerCase()) ||
item.jobgrp?.toLowerCase().includes(searchKey.toLowerCase()) || item.jobgrp?.toLowerCase().includes(searchKey.toLowerCase()) ||
item.process?.toLowerCase().includes(searchKey.toLowerCase()) item.process?.toLowerCase().includes(searchKey.toLowerCase())
) )
: jobTypes; : jobTypes;
// 그룹핑 // 그룹핑
@@ -200,12 +200,12 @@ export function JobTypeSelectModal({
> >
<div className="flex items-center justify-center min-h-screen p-4"> <div className="flex items-center justify-center min-h-screen p-4">
<div <div
className="glass-effect rounded-2xl w-full max-w-2xl animate-slide-up max-h-[85vh] flex flex-col" className="dialog-container rounded-2xl w-full max-w-2xl animate-slide-up max-h-[85vh] flex flex-col transition-all duration-300"
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
> >
{/* 헤더 */} {/* 헤더 */}
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between"> <div className="dialog-header px-6 py-4 flex items-center justify-between">
<h2 className="text-xl font-semibold text-white"> </h2> <h2 className="dialog-title"> </h2>
<button <button
onClick={onClose} onClick={onClose}
className="text-white/70 hover:text-white transition-colors" className="text-white/70 hover:text-white transition-colors"
@@ -288,11 +288,10 @@ export function JobTypeSelectModal({
return ( return (
<button <button
key={typePath} key={typePath}
className={`w-full px-8 py-1.5 text-left transition-colors ${ className={`w-full px-8 py-1.5 text-left transition-colors ${isSelected
isSelected
? 'bg-primary-500/40 text-primary-200' ? 'bg-primary-500/40 text-primary-200'
: 'text-white/70 hover:bg-white/10 hover:text-white' : 'text-white/70 hover:bg-white/10 hover:text-white'
}`} }`}
onClick={() => setSelectedPath(typePath)} onClick={() => setSelectedPath(typePath)}
onDoubleClick={() => typeNode.item && handleDoubleClick(typeNode.item)} onDoubleClick={() => typeNode.item && handleDoubleClick(typeNode.item)}
> >
@@ -335,7 +334,7 @@ export function JobTypeSelectModal({
)} )}
{/* 푸터 */} {/* 푸터 */}
<div className="px-6 py-4 border-t border-white/10 flex justify-end space-x-3"> <div className="dialog-footer px-6 py-4 flex justify-end space-x-3">
<button <button
onClick={onClose} onClick={onClose}
className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-colors" className="bg-white/20 hover:bg-white/30 text-white px-4 py-2 rounded-lg transition-colors"

View File

@@ -137,7 +137,7 @@ export function JobreportEditModal({
const lastReport = await comms.getLastJobReportByProject(formData.pidx, formData.projectName); const lastReport = await comms.getLastJobReportByProject(formData.pidx, formData.projectName);
if (lastReport.Success && lastReport.Data) { if (lastReport.Success && lastReport.Data) {
const updatedFormData = { ...formData }; const updatedFormData = { ...formData };
if (lastReport.Data.requestpart) { if (lastReport.Data.requestpart) {
updatedFormData.requestpart = lastReport.Data.requestpart; updatedFormData.requestpart = lastReport.Data.requestpart;
} }
@@ -261,14 +261,13 @@ export function JobreportEditModal({
> >
<div className="flex items-center justify-center min-h-screen p-4"> <div className="flex items-center justify-center min-h-screen p-4">
<div <div
className="glass-effect rounded-2xl w-full max-w-3xl animate-slide-up max-h-[90vh] overflow-y-auto" className="dialog-container rounded-2xl w-full max-w-3xl animate-slide-up max-h-[90vh] overflow-y-auto transition-all duration-300"
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
> >
{/* 헤더 */} {/* 헤더 */}
<div className={`px-6 py-4 border-b border-white/10 flex items-center justify-between sticky top-0 backdrop-blur z-10 ${ <div className={`dialog-header px-6 py-4 flex items-center justify-between sticky top-0 z-10 ${editingItem ? '' : ''
editingItem ? 'bg-slate-800/95' : 'bg-primary-600/30' }`}>
}`}> <h2 className="dialog-title flex items-center">
<h2 className="text-xl font-semibold text-white flex items-center">
<FileText className="w-5 h-5 mr-2" /> <FileText className="w-5 h-5 mr-2" />
{editingItem ? '업무일지 수정' : '업무일지 등록'} {editingItem ? '업무일지 수정' : '업무일지 등록'}
</h2> </h2>
@@ -517,7 +516,7 @@ export function JobreportEditModal({
</div> </div>
{/* 푸터 */} {/* 푸터 */}
<div className="px-6 py-4 border-t border-white/10 flex justify-between sticky bottom-0 bg-slate-800/95 backdrop-blur"> <div className="dialog-footer px-6 py-4 flex justify-between sticky bottom-0 backdrop-blur">
{/* 좌측: 삭제 버튼 (편집 모드일 때만) */} {/* 좌측: 삭제 버튼 (편집 모드일 때만) */}
<div> <div>
{editingItem && ( {editingItem && (

View File

@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react';
import { X, RefreshCw } from 'lucide-react'; import { X, RefreshCw } from 'lucide-react';
import { comms } from '@/communication'; import { comms } from '@/communication';
import { JobReportTypeItem } from '@/types'; import { JobReportTypeItem } from '@/types';
import { DevelopmentNotice } from '@/components/DevelopmentNotice';
interface JobreportTypeModalProps { interface JobreportTypeModalProps {
isOpen: boolean; isOpen: boolean;
@@ -51,10 +52,10 @@ export function JobreportTypeModal({
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-fade-in"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm animate-fade-in">
<div className="bg-gray-900 border border-white/10 rounded-2xl shadow-2xl w-full max-w-2xl overflow-hidden"> <div className="dialog-container rounded-2xl w-full max-w-2xl overflow-hidden transition-all duration-300">
{/* 헤더 */} {/* 헤더 */}
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between bg-white/5"> <div className="dialog-header px-6 py-4 flex items-center justify-between">
<h3 className="text-lg font-semibold text-white"> </h3> <h3 className="dialog-title"> </h3>
<button <button
onClick={onClose} onClick={onClose}
className="text-white/50 hover:text-white transition-colors" className="text-white/50 hover:text-white transition-colors"
@@ -65,6 +66,7 @@ export function JobreportTypeModal({
{/* 컨텐츠 */} {/* 컨텐츠 */}
<div className="p-6"> <div className="p-6">
<DevelopmentNotice />
<div className="mb-4 text-white/70 text-sm"> <div className="mb-4 text-white/70 text-sm">
: {startDate} ~ {endDate} : {startDate} ~ {endDate}
</div> </div>
@@ -126,7 +128,7 @@ export function JobreportTypeModal({
</div> </div>
{/* 푸터 */} {/* 푸터 */}
<div className="px-6 py-4 border-t border-white/10 flex justify-end bg-white/5"> <div className="dialog-footer px-6 py-4 flex justify-end">
<button <button
onClick={onClose} onClick={onClose}
className="px-4 py-2 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors" className="px-4 py-2 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors"

View File

@@ -97,14 +97,14 @@ export function ProjectSearchDialog({
onMouseDown={onClose} onMouseDown={onClose}
> >
<div <div
className="glass-effect rounded-xl w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col" className="dialog-container rounded-xl w-full max-w-2xl max-h-[80vh] overflow-hidden flex flex-col transition-all duration-300"
onMouseDown={(e) => e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()}
> >
{/* 헤더 */} {/* 헤더 */}
<div className="p-4 border-b border-white/10 flex items-center justify-between shrink-0"> <div className="dialog-header p-4 flex items-center justify-between shrink-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Folder className="w-5 h-5 text-primary-400" /> <Folder className="w-5 h-5 text-primary-400" />
<h2 className="text-lg font-semibold text-white">/ </h2> <h2 className="dialog-title">/ </h2>
</div> </div>
<button <button
onClick={onClose} onClick={onClose}
@@ -151,11 +151,10 @@ export function ProjectSearchDialog({
onSelect({ idx: project.idx, name: project.name }); onSelect({ idx: project.idx, name: project.name });
onClose(); onClose();
}} }}
className={`w-full text-left px-4 py-3 rounded-lg transition-colors flex items-center gap-3 ${ className={`w-full text-left px-4 py-3 rounded-lg transition-colors flex items-center gap-3 ${selectedProject?.idx === project.idx && selectedProject?.name === project.name
selectedProject?.idx === project.idx && selectedProject?.name === project.name
? 'bg-primary-500/30 border border-primary-400/50' ? 'bg-primary-500/30 border border-primary-400/50'
: 'bg-white/5 hover:bg-white/10 border border-transparent' : 'bg-white/5 hover:bg-white/10 border border-transparent'
}`} }`}
> >
{/* 아이콘 */} {/* 아이콘 */}
<div className={`shrink-0 ${project.source === 'project' ? 'text-blue-400' : 'text-gray-400'}`}> <div className={`shrink-0 ${project.source === 'project' ? 'text-blue-400' : 'text-gray-400'}`}>
@@ -174,11 +173,10 @@ export function ProjectSearchDialog({
)} )}
<span className="font-medium text-white truncate">{project.name}</span> <span className="font-medium text-white truncate">{project.name}</span>
{project.status && ( {project.status && (
<span className={`text-xs px-1.5 py-0.5 rounded ${ <span className={`text-xs px-1.5 py-0.5 rounded ${project.status === '진행' ? 'bg-green-500/20 text-green-400' :
project.status === '진행' ? 'bg-green-500/20 text-green-400' : project.status === '준비' ? 'bg-yellow-500/20 text-yellow-400' :
project.status === '준비' ? 'bg-yellow-500/20 text-yellow-400' : 'bg-gray-500/20 text-gray-400'
'bg-gray-500/20 text-gray-400' }`}>
}`}>
{project.status} {project.status}
</span> </span>
)} )}
@@ -209,7 +207,7 @@ export function ProjectSearchDialog({
</div> </div>
{/* 푸터 */} {/* 푸터 */}
<div className="p-4 border-t border-white/10 flex items-center justify-between shrink-0"> <div className="dialog-footer p-4 flex items-center justify-between shrink-0">
<span className="text-sm text-white/50"> <span className="text-sm text-white/50">
{projects.length} {projects.length}
{selectedProject && ` | 선택: ${selectedProject.name}`} {selectedProject && ` | 선택: ${selectedProject.name}`}

View File

@@ -116,10 +116,10 @@ export function KuntaeEditModal({ isOpen, onClose, onSave, initialData, mode }:
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in">
<div className="bg-[#1e1e2e] rounded-2xl shadow-2xl w-full max-w-lg border border-white/10 overflow-hidden"> <div className="dialog-container rounded-2xl w-full max-w-lg overflow-hidden transition-all duration-300">
{/* 헤더 */} {/* 헤더 */}
<div className="px-6 py-4 border-b border-white/10 flex justify-between items-center bg-white/5"> <div className="dialog-header px-6 py-4 flex justify-between items-center">
<h2 className="text-xl font-bold text-white flex items-center"> <h2 className="dialog-title flex items-center">
<Calendar className="w-5 h-5 mr-2 text-primary-400" /> <Calendar className="w-5 h-5 mr-2 text-primary-400" />
{title} {title}
</h2> </h2>
@@ -142,7 +142,7 @@ export function KuntaeEditModal({ isOpen, onClose, onSave, initialData, mode }:
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400" className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
> >
{CATE_OPTIONS.map(opt => ( {CATE_OPTIONS.map(opt => (
<option key={opt} value={opt} className="bg-[#1e1e2e]">{opt}</option> <option key={opt} value={opt} className="bg-bg-paper">{opt}</option>
))} ))}
</select> </select>
</div> </div>
@@ -262,7 +262,7 @@ export function KuntaeEditModal({ isOpen, onClose, onSave, initialData, mode }:
</div> </div>
{/* 버튼 */} {/* 버튼 */}
<div className="flex justify-end space-x-3 pt-4 border-t border-white/10"> <div className="dialog-footer flex justify-end space-x-3 pt-4">
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}

View File

@@ -188,10 +188,10 @@ export function KuntaeErrorCheckDialog({ isOpen, onClose }: KuntaeErrorCheckDial
return ( return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[10000]"> <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[10000]">
<div className="glass-effect-solid rounded-xl w-[900px] max-h-[90vh] flex flex-col"> <div className="dialog-container rounded-xl w-[900px] max-h-[90vh] flex flex-col transition-all duration-300">
{/* 헤더 */} {/* 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10"> <div className="dialog-header flex items-center justify-between px-6 py-4">
<h2 className="text-xl font-bold text-white flex items-center gap-2"> <h2 className="dialog-title flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-warning-400" /> <AlertTriangle className="w-5 h-5 text-warning-400" />
</h2> </h2>
@@ -355,9 +355,8 @@ export function KuntaeErrorCheckDialog({ isOpen, onClose }: KuntaeErrorCheckDial
{ngList.map((item) => ( {ngList.map((item) => (
<tr <tr
key={item.Date} key={item.Date}
className={`hover:bg-white/5 cursor-pointer ${ className={`hover:bg-white/5 cursor-pointer ${item.IsMagam ? 'text-blue-400' : 'text-danger-400'
item.IsMagam ? 'text-blue-400' : 'text-danger-400' }`}
}`}
onClick={() => toggleError(item.Date, item.IsMagam)} onClick={() => toggleError(item.Date, item.IsMagam)}
> >
<td className="px-2 py-2 text-center border-r border-white/10"> <td className="px-2 py-2 text-center border-r border-white/10">
@@ -396,7 +395,7 @@ export function KuntaeErrorCheckDialog({ isOpen, onClose }: KuntaeErrorCheckDial
</div> </div>
{/* 하단 버튼 */} {/* 하단 버튼 */}
<div className="px-6 py-4 border-t border-white/10"> <div className="dialog-footer px-6 py-4">
<button <button
onClick={handleFix} onClick={handleFix}
disabled={isChecking || isFixing || selectedErrors.size === 0} disabled={isChecking || isFixing || selectedErrors.size === 0}

View File

@@ -10,7 +10,7 @@ interface LayoutProps {
export function Layout({ isConnected, user }: LayoutProps) { export function Layout({ isConnected, user }: LayoutProps) {
return ( return (
<div className="min-h-screen bg-gradient-to-br from-blue-900 via-purple-900 to-indigo-900"> <div className="min-h-screen gradient-bg">
<div className="flex flex-col h-screen overflow-hidden"> <div className="flex flex-col h-screen overflow-hidden">
{/* Top Navigation Header */} {/* Top Navigation Header */}
<Header isConnected={isConnected} /> <Header isConnected={isConnected} />

View File

@@ -0,0 +1,21 @@
import { useState } from 'react';
import { Settings } from 'lucide-react';
import { SettingsDialog } from '../settings/SettingsDialog';
export function SettingsButton() {
const [isOpen, setIsOpen] = useState(false);
return (
<>
<button
onClick={() => setIsOpen(true)}
className="flex items-center justify-center p-2 rounded-lg text-white/70 hover:text-white hover:bg-white/10 transition-colors"
title="설정"
>
<Settings className="w-4 h-4" />
</button>
<SettingsDialog isOpen={isOpen} onClose={() => setIsOpen(false)} />
</>
);
}

View File

@@ -1,6 +1,7 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Clock, Wifi, WifiOff } from 'lucide-react'; import { Clock, Wifi, WifiOff } from 'lucide-react';
import { UserInfoButton } from './UserInfoButton'; import { UserInfoButton } from './UserInfoButton';
import { SettingsButton } from './SettingsButton';
import { comms } from '@/communication'; import { comms } from '@/communication';
interface StatusBarProps { interface StatusBarProps {
@@ -39,6 +40,8 @@ export function StatusBar({ userName, userDept, isConnected }: StatusBarProps) {
<footer className="glass-effect px-4 py-2 flex items-center justify-between text-sm"> <footer className="glass-effect px-4 py-2 flex items-center justify-between text-sm">
{/* Left: User Info */} {/* Left: User Info */}
<div className="flex items-center space-x-2"> <div className="flex items-center space-x-2">
{/* Settings Button */}
<SettingsButton />
<UserInfoButton userName={userName} userDept={userDept} /> <UserInfoButton userName={userName} userDept={userDept} />
</div> </div>

View File

@@ -1,7 +1,9 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import { User, LogOut, X } from 'lucide-react'; import { User, LogOut, X, UserCog, Key } from 'lucide-react';
import { comms } from '@/communication'; import { comms } from '@/communication';
import { UserInfoDialog } from '@/components/user/UserInfoDialog';
import { PasswordDialog } from '@/components/user/PasswordDialog';
interface UserInfoButtonProps { interface UserInfoButtonProps {
userName?: string; userName?: string;
@@ -10,8 +12,21 @@ interface UserInfoButtonProps {
export function UserInfoButton({ userName, userDept }: UserInfoButtonProps) { export function UserInfoButton({ userName, userDept }: UserInfoButtonProps) {
const [showLogoutDialog, setShowLogoutDialog] = useState(false); const [showLogoutDialog, setShowLogoutDialog] = useState(false);
const [showUserInfoDialog, setShowUserInfoDialog] = useState(false);
const [showPasswordDialog, setShowPasswordDialog] = useState(false);
const [processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
// ESC 키로 다이얼로그 닫기
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && showLogoutDialog) {
setShowLogoutDialog(false);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [showLogoutDialog]);
const handleLogout = async () => { const handleLogout = async () => {
setProcessing(true); setProcessing(true);
try { try {
@@ -31,6 +46,30 @@ export function UserInfoButton({ userName, userDept }: UserInfoButtonProps) {
} }
}; };
const handleUserInfoClick = () => {
setShowLogoutDialog(false);
setShowUserInfoDialog(true);
};
const handlePasswordClick = () => {
setShowLogoutDialog(false);
setShowPasswordDialog(true);
};
const handlePasswordChange = async (oldPw: string, newPw: string) => {
try {
const result = await comms.changePassword(oldPw, newPw);
if (result.Success) {
alert('비밀번호가 변경되었습니다.');
} else {
alert(result.Message || '비밀번호 변경에 실패했습니다.');
}
} catch (error) {
console.error('비밀번호 변경 오류:', error);
alert('비밀번호 변경 중 오류가 발생했습니다.');
}
};
if (!userName) return null; if (!userName) return null;
return ( return (
@@ -58,8 +97,8 @@ export function UserInfoButton({ userName, userDept }: UserInfoButtonProps) {
{/* 헤더 */} {/* 헤더 */}
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between"> <div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
<h2 className="text-lg font-semibold text-white flex items-center"> <h2 className="text-lg font-semibold text-white flex items-center">
<LogOut className="w-5 h-5 mr-2" /> <User className="w-5 h-5 mr-2" />
</h2> </h2>
<button <button
onClick={() => setShowLogoutDialog(false)} onClick={() => setShowLogoutDialog(false)}
@@ -79,16 +118,30 @@ export function UserInfoButton({ userName, userDept }: UserInfoButtonProps) {
{userDept && <p className="text-white/50 text-sm">{userDept}</p>} {userDept && <p className="text-white/50 text-sm">{userDept}</p>}
</div> </div>
<p className="text-white/70 text-center text-sm"> <p className="text-white/70 text-center text-sm">
? .
</p> </p>
</div> </div>
{/* 푸터 */} {/* 푸터 */}
<div className="px-6 py-4 border-t border-white/10 flex justify-center"> <div className="px-6 py-4 border-t border-white/10 flex justify-center gap-2">
<button
onClick={handleUserInfoClick}
className="bg-transparent border border-white/30 hover:bg-white/10 text-white px-3 py-2 rounded-lg transition-colors flex items-center text-sm"
>
<UserCog className="w-4 h-4 mr-2" />
</button>
<button
onClick={handlePasswordClick}
className="bg-transparent border border-white/30 hover:bg-white/10 text-white px-3 py-2 rounded-lg transition-colors flex items-center text-sm"
>
<Key className="w-4 h-4 mr-2" />
</button>
<button <button
onClick={handleLogout} onClick={handleLogout}
disabled={processing} disabled={processing}
className="bg-danger-500 hover:bg-danger-600 text-white px-6 py-2 rounded-lg transition-colors flex items-center disabled:opacity-50" className="bg-danger-500 hover:bg-danger-600 text-white px-3 py-2 rounded-lg transition-colors flex items-center disabled:opacity-50 text-sm"
> >
{processing ? ( {processing ? (
<span className="animate-spin mr-2"></span> <span className="animate-spin mr-2"></span>
@@ -102,6 +155,19 @@ export function UserInfoButton({ userName, userDept }: UserInfoButtonProps) {
</div>, </div>,
document.body document.body
)} )}
{/* 사용자 정보 수정 다이얼로그 */}
<UserInfoDialog
isOpen={showUserInfoDialog}
onClose={() => setShowUserInfoDialog(false)}
/>
{/* 비밀번호 변경 다이얼로그 */}
<PasswordDialog
isOpen={showPasswordDialog}
onClose={() => setShowPasswordDialog(false)}
onConfirm={handlePasswordChange}
/>
</> </>
); );
} }

View File

@@ -1,4 +1,5 @@
import { X, Save, Trash2 } from 'lucide-react'; import { X, Save, Trash2, ShieldCheck, Info, Package, Truck, User, Calendar, FileText, CheckCircle, XCircle } from 'lucide-react';
import { clsx } from 'clsx';
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import type { LicenseItem } from '@/types'; import type { LicenseItem } from '@/types';
@@ -86,65 +87,88 @@ export function LicenseEditDialog({ item, isOpen, onClose, onSave, onDelete }: L
if (!isOpen) return null; if (!isOpen) return null;
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50" onClick={onClose}> <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm animate-fade-in" onClick={onClose}>
<div className="glass-effect rounded-lg w-full max-w-3xl max-h-[90vh] overflow-y-auto m-4" onClick={(e) => e.stopPropagation()}> <div className="dialog-container rounded-3xl w-full max-w-3xl max-h-[90vh] overflow-hidden flex flex-col transition-all duration-300 animate-scale-in" onClick={(e) => e.stopPropagation()}>
{/* Header */} {/* Header */}
<div className="flex items-center justify-between p-4 border-b border-white/10"> <div className="dialog-header px-8 py-6 flex items-center justify-between">
<h2 className="text-xl font-semibold text-white"> <div className="flex items-center gap-4">
{formData.idx ? '라이선스 수정' : '라이선스 추가'} <div className="p-2.5 bg-primary-500/20 rounded-xl">
</h2> <ShieldCheck className="w-6 h-6 text-primary-400" />
</div>
<div>
<h2 className="dialog-title">
{formData.idx ? '라이선스 수정' : '라이선스 추가'}
</h2>
<p className="text-xs text-white/40 uppercase tracking-widest font-medium mt-0.5">Edit License Details</p>
</div>
</div>
<button <button
onClick={onClose} onClick={onClose}
className="text-white/70 hover:text-white transition-colors" className="p-2 text-white/30 hover:text-white hover:bg-white/10 rounded-xl transition-all"
> >
<X className="w-6 h-6" /> <X className="w-6 h-6" />
</button> </button>
</div> </div>
{/* Body */} {/* Body */}
<div className="p-6 space-y-6"> <div className="flex-1 overflow-y-auto custom-scrollbar p-8 space-y-8">
{/* 기본 정보 */} {/* 기본 정보 */}
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-sm font-semibold text-white/90 flex items-center space-x-2 border-b border-white/10 pb-2"> <h3 className="text-sm font-bold text-primary-400 flex items-center gap-2 uppercase tracking-widest py-1 border-b border-white/5 mb-4">
<span> </span> <Info className="w-4 h-4" />
</h3> </h3>
<div className="grid grid-cols-12 gap-4"> <div className="grid grid-cols-12 gap-5">
<div className="col-span-1 flex items-center"> <div className="col-span-12 md:col-span-2">
<label className="flex items-center space-x-2 cursor-pointer"> <label className="block text-xs font-bold text-white/30 uppercase tracking-widest mb-2 px-1"></label>
<button
type="button"
onClick={() => setFormData({ ...formData, expire: !formData.expire })}
className={clsx(
"w-full px-4 py-2.5 rounded-xl border transition-all flex items-center justify-center gap-2 font-bold text-sm",
formData.expire
? "bg-danger-500/20 border-danger-500/30 text-danger-400"
: "bg-success-500/20 border-success-500/30 text-success-400"
)}
>
{formData.expire ? (
<><XCircle className="w-4 h-4" /> </>
) : (
<><CheckCircle className="w-4 h-4" /> </>
)}
</button>
</div>
<div className="col-span-12 md:col-span-5">
<label className="block text-xs font-bold text-white/30 uppercase tracking-widest mb-2 px-1"> *</label>
<div className="relative group">
<Package className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-white/20 group-focus-within:text-primary-400 transition-colors" />
<input <input
type="checkbox" type="text"
checked={formData.expire || false} value={formData.name || ''}
onChange={(e) => setFormData({ ...formData, expire: e.target.checked })} onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-4 h-4" className="w-full pl-10 pr-4 py-2.5 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all font-medium"
placeholder="제품명을 입력하세요"
/> />
<span className="text-sm text-white/70"></span> </div>
</label>
</div> </div>
<div className="col-span-5"> <div className="col-span-12 md:col-span-2">
<label className="block text-sm text-white/70 mb-1"> *</label> <label className="block text-xs font-bold text-white/30 uppercase tracking-widest mb-2 px-1"></label>
<input
type="text"
value={formData.name || ''}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500"
/>
</div>
<div className="col-span-3">
<label className="block text-sm text-white/70 mb-1"></label>
<input <input
type="text" type="text"
value={formData.version || ''} value={formData.version || ''}
onChange={(e) => setFormData({ ...formData, version: e.target.value })} onChange={(e) => setFormData({ ...formData, version: e.target.value })}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500" className="w-full px-4 py-2.5 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all font-medium"
placeholder="v1.0"
/> />
</div> </div>
<div className="col-span-3"> <div className="col-span-12 md:col-span-3">
<label className="block text-sm text-white/70 mb-1"></label> <label className="block text-xs font-bold text-white/30 uppercase tracking-widest mb-2 px-1"></label>
<input <input
type="text" type="text"
value={formData.meterialNo || ''} value={formData.meterialNo || ''}
onChange={(e) => setFormData({ ...formData, meterialNo: e.target.value })} onChange={(e) => setFormData({ ...formData, meterialNo: e.target.value })}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500" className="w-full px-4 py-2.5 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all font-medium font-mono"
placeholder="M-0000"
/> />
</div> </div>
</div> </div>
@@ -152,26 +176,27 @@ export function LicenseEditDialog({ item, isOpen, onClose, onSave, onDelete }: L
{/* 공급 정보 */} {/* 공급 정보 */}
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-sm font-semibold text-white/90 flex items-center space-x-2 border-b border-white/10 pb-2"> <h3 className="text-sm font-bold text-primary-400 flex items-center gap-2 uppercase tracking-widest py-1 border-b border-white/5 mb-4">
<span> </span> <Truck className="w-4 h-4" />
</h3> </h3>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-5">
<div> <div>
<label className="block text-sm text-white/70 mb-1"></label> <label className="block text-xs font-bold text-white/30 uppercase tracking-widest mb-2 px-1"></label>
<input <input
type="text" type="text"
value={formData.supply || ''} value={formData.supply || ''}
onChange={(e) => setFormData({ ...formData, supply: e.target.value })} onChange={(e) => setFormData({ ...formData, supply: e.target.value })}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500" className="w-full px-4 py-2.5 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all font-medium"
/> />
</div> </div>
<div> <div>
<label className="block text-sm text-white/70 mb-1"></label> <label className="block text-xs font-bold text-white/30 uppercase tracking-widest mb-2 px-1"></label>
<input <input
type="text" type="text"
value={formData.manu || ''} value={formData.manu || ''}
onChange={(e) => setFormData({ ...formData, manu: e.target.value })} onChange={(e) => setFormData({ ...formData, manu: e.target.value })}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500" className="w-full px-4 py-2.5 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all font-medium"
/> />
</div> </div>
</div> </div>
@@ -179,35 +204,36 @@ export function LicenseEditDialog({ item, isOpen, onClose, onSave, onDelete }: L
{/* 사용 정보 */} {/* 사용 정보 */}
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-sm font-semibold text-white/90 flex items-center space-x-2 border-b border-white/10 pb-2"> <h3 className="text-sm font-bold text-primary-400 flex items-center gap-2 uppercase tracking-widest py-1 border-b border-white/5 mb-4">
<span> </span> <User className="w-4 h-4" />
</h3> </h3>
<div className="grid grid-cols-12 gap-4"> <div className="grid grid-cols-12 gap-5">
<div className="col-span-2"> <div className="col-span-12 md:col-span-2">
<label className="block text-sm text-white/70 mb-1"></label> <label className="block text-xs font-bold text-white/30 uppercase tracking-widest mb-2 px-1"></label>
<input <input
type="number" type="number"
value={formData.qty || 1} value={formData.qty || 1}
onChange={(e) => setFormData({ ...formData, qty: parseInt(e.target.value) || 1 })} onChange={(e) => setFormData({ ...formData, qty: parseInt(e.target.value) || 1 })}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500" className="w-full px-4 py-2.5 bg-white/5 border border-white/10 rounded-xl text-white focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all font-bold text-center"
/> />
</div> </div>
<div className="col-span-4"> <div className="col-span-12 md:col-span-4">
<label className="block text-sm text-white/70 mb-1"></label> <label className="block text-xs font-bold text-white/30 uppercase tracking-widest mb-2 px-1"></label>
<input <input
type="text" type="text"
value={formData.uids || ''} value={formData.uids || ''}
onChange={(e) => setFormData({ ...formData, uids: e.target.value })} onChange={(e) => setFormData({ ...formData, uids: e.target.value })}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500" className="w-full px-4 py-2.5 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all font-medium"
/> />
</div> </div>
<div className="col-span-6"> <div className="col-span-12 md:col-span-6">
<label className="block text-sm text-white/70 mb-1">S/N</label> <label className="block text-xs font-bold text-white/30 uppercase tracking-widest mb-2 px-1">S/N</label>
<input <input
type="text" type="text"
value={formData.serialNo || ''} value={formData.serialNo || ''}
onChange={(e) => setFormData({ ...formData, serialNo: e.target.value })} onChange={(e) => setFormData({ ...formData, serialNo: e.target.value })}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500" className="w-full px-4 py-2.5 bg-white/5 border border-white/10 rounded-xl text-white placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all font-medium font-mono"
/> />
</div> </div>
</div> </div>
@@ -215,74 +241,82 @@ export function LicenseEditDialog({ item, isOpen, onClose, onSave, onDelete }: L
{/* 기간 정보 */} {/* 기간 정보 */}
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-sm font-semibold text-white/90 flex items-center space-x-2 border-b border-white/10 pb-2"> <h3 className="text-sm font-bold text-primary-400 flex items-center gap-2 uppercase tracking-widest py-1 border-b border-white/5 mb-4">
<span> </span> <Calendar className="w-4 h-4" />
</h3> </h3>
<div className="grid grid-cols-2 gap-4"> <div className="grid grid-cols-2 gap-5">
<div> <div>
<label className="block text-sm text-white/70 mb-1"></label> <label className="block text-xs font-bold text-white/30 uppercase tracking-widest mb-2 px-1"></label>
<input <div className="relative group">
type="date" <Calendar className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-white/20 group-focus-within:text-primary-400 transition-colors pointer-events-none" />
value={formData.sdate || ''} <input
onChange={(e) => setFormData({ ...formData, sdate: e.target.value })} type="date"
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500" value={formData.sdate || ''}
/> onChange={(e) => setFormData({ ...formData, sdate: e.target.value })}
className="w-full pl-10 pr-4 py-2.5 bg-white/5 border border-white/10 rounded-xl text-white focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all font-medium custom-calendar-icon"
/>
</div>
</div> </div>
<div> <div>
<label className="block text-sm text-white/70 mb-1"></label> <label className="block text-xs font-bold text-white/30 uppercase tracking-widest mb-2 px-1"></label>
<input <div className="relative group">
type="date" <Calendar className="absolute left-3.5 top-1/2 -translate-y-1/2 w-4 h-4 text-white/20 group-focus-within:text-primary-400 transition-colors pointer-events-none" />
value={formData.edate || ''} <input
onChange={(e) => setFormData({ ...formData, edate: e.target.value })} type="date"
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500" value={formData.edate || ''}
/> onChange={(e) => setFormData({ ...formData, edate: e.target.value })}
className="w-full pl-10 pr-4 py-2.5 bg-white/5 border border-white/10 rounded-xl text-white focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all font-medium custom-calendar-icon"
/>
</div>
</div> </div>
</div> </div>
</div> </div>
{/* 비고 */} {/* 비고 */}
<div className="space-y-4"> <div className="space-y-4">
<h3 className="text-sm font-semibold text-white/90 flex items-center space-x-2 border-b border-white/10 pb-2"> <h3 className="text-sm font-bold text-primary-400 flex items-center gap-2 uppercase tracking-widest py-1 border-b border-white/5 mb-4">
<span></span> <FileText className="w-4 h-4" />
</h3> </h3>
<textarea <textarea
value={formData.remark || ''} value={formData.remark || ''}
onChange={(e) => setFormData({ ...formData, remark: e.target.value })} onChange={(e) => setFormData({ ...formData, remark: e.target.value })}
rows={3} rows={4}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded text-white focus:outline-none focus:border-blue-500 resize-none" className="w-full px-4 py-3 bg-white/5 border border-white/10 rounded-2xl text-white placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all font-medium resize-none"
placeholder="추가 메모를 입력하세요..." placeholder="추가 메모를 입력하세요..."
/> />
</div> </div>
</div> </div>
{/* Footer */} {/* Footer */}
<div className="flex items-center justify-between p-4 border-t border-white/10"> <div className="dialog-footer px-8 py-6 flex items-center justify-between">
<div> <div>
{formData.idx && onDelete && ( {formData.idx && onDelete && (
<button <button
onClick={handleDelete} onClick={handleDelete}
disabled={saving} disabled={saving}
className="flex items-center space-x-2 px-4 py-2 bg-red-500 hover:bg-red-600 disabled:bg-gray-600 text-white rounded-lg transition-colors" className="flex items-center gap-2 px-5 py-2.5 bg-danger-500/10 hover:bg-danger-500/20 text-danger-400 border border-danger-500/20 rounded-xl transition-all font-bold group active:scale-95"
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4 group-hover:shake" />
<span></span> <span></span>
</button> </button>
)} )}
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center gap-3">
<button <button
onClick={onClose} onClick={onClose}
disabled={saving} disabled={saving}
className="px-4 py-2 bg-gray-600 hover:bg-gray-700 disabled:bg-gray-800 text-white rounded-lg transition-colors" className="px-6 py-2.5 bg-white/5 hover:bg-white/10 text-white/70 hover:text-white border border-white/10 rounded-xl transition-all font-bold active:scale-95"
> >
</button> </button>
<button <button
onClick={handleSave} onClick={handleSave}
disabled={saving} disabled={saving}
className="flex items-center space-x-2 px-4 py-2 bg-blue-500 hover:bg-blue-600 disabled:bg-gray-600 text-white rounded-lg transition-colors" className="flex items-center gap-2 px-8 py-2.5 bg-primary-500 hover:bg-primary-600 text-white border border-primary-500/20 rounded-xl transition-all font-bold shadow-lg shadow-primary-500/20 active:scale-95 disabled:opacity-50"
> >
<Save className="w-4 h-4" /> <Save className={clsx("w-4 h-4", saving && "animate-spin")} />
<span>{saving ? '저장 중...' : '저장'}</span> <span>{saving ? '저장 중...' : '저장'}</span>
</button> </button>
</div> </div>

View File

@@ -4,12 +4,12 @@ import {
FolderOpen, FolderOpen,
Download, Download,
Search, Search,
X,
ChevronLeft,
ChevronRight,
CheckCircle, CheckCircle,
XCircle, XCircle,
ShieldCheck,
RefreshCw,
} from 'lucide-react'; } from 'lucide-react';
import { clsx } from 'clsx';
import { comms } from '@/communication'; import { comms } from '@/communication';
import { LicenseEditDialog } from './LicenseEditDialog'; import { LicenseEditDialog } from './LicenseEditDialog';
import type { LicenseItem } from '@/types'; import type { LicenseItem } from '@/types';
@@ -229,152 +229,203 @@ export function LicenseList() {
}; };
return ( return (
<div className="p-6 space-y-4"> <div className="space-y-6 animate-fade-in pb-4">
{/* Header */} {/* 라이선스 리스트 카드 (메모장 디자인 통일) */}
<div className="flex items-center justify-between"> <div className="glass-effect rounded-3xl overflow-hidden shadow-2xl border border-white/10">
<h1 className="text-2xl font-bold text-white"> </h1> <div className="px-6 py-4 border-b border-white/10 flex flex-col md:flex-row items-center justify-between gap-4">
<div className="flex items-center space-x-2"> <div className="flex items-center gap-3">
<button <div className="p-2 bg-primary-500/20 rounded-lg">
onClick={handleAdd} <ShieldCheck className="w-5 h-5 text-primary-400" />
disabled={loading} </div>
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" <h3 className="text-lg font-bold text-white tracking-tight"> </h3>
> </div>
<Plus className="w-4 h-4" />
<span></span>
</button>
<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"
>
<Download className="w-4 h-4" />
<span>CSV</span>
</button>
</div>
</div>
{/* Search */} <div className="flex items-center gap-3">
<div className="flex items-center space-x-2"> {/* 검색창 */}
<div className="flex-1 relative"> <div className="relative group w-48 md:w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/50" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-white/40 group-focus-within:text-primary-400 transition-colors" />
<input <input
type="text" type="text"
value={searchText} value={searchText}
onChange={(e) => setSearchText(e.target.value)} onChange={(e) => setSearchText(e.target.value)}
placeholder="검색 (제품명, 버전, 공급업체, 제조사, S/N, 자재번호, 비고)" placeholder="검색..."
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" 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 && ( {searchText && (
<button
onClick={() => setSearchText('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-white/20 hover:text-white transition-colors"
>
<XCircle className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* 개수 */}
<div className="flex items-center gap-2 bg-white/5 px-3 py-1.5 rounded-xl border border-white/10 h-[38px]">
<span className="text-primary-400 font-bold text-sm">{filteredList.length}</span>
<span className="text-white/40 text-[10px] uppercase"></span>
</div>
{/* 새로고침 */}
<button <button
onClick={() => setSearchText('')} onClick={loadData}
className="absolute right-3 top-1/2 -translate-y-1/2 text-white/50 hover:text-white" disabled={loading}
className="p-2 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl text-white/70 hover:text-white transition-all disabled:opacity-50"
title="새로고침"
> >
<X className="w-4 h-4" /> <RefreshCw className={clsx("w-4 h-4", loading && "animate-spin")} />
</button> </button>
{/* CSV */}
<button
onClick={handleExportCSV}
disabled={loading}
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" />
</button>
{/* 추가 버튼 */}
<button
onClick={handleAdd}
className="p-2 bg-success-500 hover:bg-success-600 border border-white/20 rounded-xl text-white transition-all shadow-lg shadow-success-500/20 active:scale-95"
title="라이선스 추가"
>
<Plus className="w-4 h-4" />
</button>
</div>
</div>
{/* 컬럼 헤더 (메모장 디자인 통일) */}
<div className="bg-white/10 px-6 py-3 border-b border-white/5 flex items-center gap-4 text-list-header text-white/opacity-header-muted font-list-header uppercase">
<div className="w-8 text-center uppercase"></div>
<div className="flex-1 uppercase"> / </div>
<div className="flex items-center gap-6 shrink-0">
<div className="flex items-center gap-4">
<div className="w-32 text-left uppercase"></div>
<div className="w-16 text-center uppercase"></div>
<div className="w-32 text-left uppercase"></div>
<div className="w-48 text-left uppercase"> </div>
</div>
<div className="w-8"></div> {/* 액션/폴더 버튼 공간 */}
</div>
</div>
<div className="divide-y divide-white/5 max-h-[calc(100vh-280px)] overflow-y-auto custom-scrollbar">
{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">
<ShieldCheck className="w-16 h-16 mx-auto text-white/10 mb-4" />
<p className="text-white/30 text-base"> </p>
</div>
) : (
paginatedList.map((item) => (
<div
key={item.idx}
className={clsx(
"px-6 py-2.5 hover:bg-white/[0.03] transition-all cursor-pointer group relative",
item.expire && "bg-danger-500/[0.02]"
)}
onClick={() => handleRowClick(item)}
>
<div className="flex items-center gap-4">
{/* 상태 아이콘 */}
<div className={clsx(
"w-8 h-8 rounded-lg flex items-center justify-center shrink-0 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 className="flex-1 min-w-0">
<div className="flex flex-col">
<h4 className="text-[var(--text-primary)] font-medium group-hover:text-primary-300 transition-colors truncate text-sm">
{item.name}
</h4>
<span className="text-[10px] text-white/30 truncate uppercase tracking-tighter">
{item.manu || 'Maker'}
</span>
</div>
</div>
{/* 메타데이터 그룹 */}
<div className="flex items-center gap-6 shrink-0">
<div className="flex items-center gap-4">
<span className="text-white/60 text-sm w-32 truncate text-left">
{item.version || '-'}
</span>
<span className="text-white/50 text-sm w-16 text-center font-mono">
{item.qty || 0}
</span>
<span className="text-white/60 text-sm w-32 truncate text-left">
{item.uids || '-'}
</span>
<span className="text-white/30 text-xs w-48 text-left font-mono truncate">
{item.serialNo || '-'}
</span>
</div>
{/* 폴더 버튼 */}
<div className="w-8 flex justify-end">
<button
onClick={(e) => handleOpenFolder(item, e)}
className="p-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-warning-400 transition-all border border-white/10 opacity-0 group-hover:opacity-100"
title="폴더 열기"
>
<FolderOpen className="w-3.5 h-3.5" />
</button>
</div>
</div>
</div>
</div>
))
)} )}
</div> </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 && ( {totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-white/10"> <div className="px-6 py-4 border-t border-white/10 flex items-center justify-between bg-white/[0.02]">
<div className="text-sm text-white/70"> <div className="text-white/40 text-xs font-medium">
{filteredList.length} {(currentPage - 1) * pageSize + 1}~ <span className="text-white">{filteredList.length}</span>
{Math.min(currentPage * pageSize, filteredList.length)} <span className="text-white ml-2">{(currentPage - 1) * pageSize + 1}-{Math.min(currentPage * pageSize, filteredList.length)}</span>
</div> </div>
<div className="flex items-center space-x-2"> <div className="flex items-center gap-2">
<button <button
onClick={goToPreviousPage} onClick={goToPreviousPage}
disabled={currentPage === 1} disabled={currentPage === 1}
className="p-2 text-white/70 hover:text-white disabled:text-white/30 disabled:cursor-not-allowed" className="w-8 h-8 flex items-center justify-center rounded-lg bg-white/5 text-white/70 hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed transition-all border border-white/10 text-xs"
> >
<ChevronLeft className="w-5 h-5" /> «
</button> </button>
<span className="text-sm text-white"> <div className="flex items-center bg-white/5 px-3 h-8 rounded-lg border border-white/10 text-xs font-bold">
{currentPage} / {totalPages} <span className="text-primary-400">{currentPage}</span>
</span> <span className="text-white/30 mx-1.5">/</span>
<span className="text-white/70">{totalPages}</span>
</div>
<button <button
onClick={goToNextPage} onClick={goToNextPage}
disabled={currentPage === totalPages} disabled={currentPage === totalPages}
className="p-2 text-white/70 hover:text-white disabled:text-white/30 disabled:cursor-not-allowed" className="w-8 h-8 flex items-center justify-center rounded-lg bg-white/5 text-white/70 hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed transition-all border border-white/10 text-xs"
> >
<ChevronRight className="w-5 h-5" /> »
</button> </button>
</div> </div>
</div> </div>
)} )}
</div> </div>
{/* Edit Dialog */} {/* 편집 다이얼로그 */}
<LicenseEditDialog <LicenseEditDialog
item={selectedItem} item={selectedItem}
isOpen={isDialogOpen} isOpen={isDialogOpen}

View File

@@ -132,12 +132,12 @@ export function MailTestDialog({ isOpen, onClose }: MailTestDialogProps) {
return ( return (
<div className="fixed inset-0 z-[10000] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"> <div className="fixed inset-0 z-[10000] flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="relative w-full max-w-3xl glass-effect-solid rounded-2xl shadow-2xl overflow-hidden"> <div className="dialog-container relative w-full max-w-3xl rounded-2xl overflow-hidden transition-all duration-300">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10"> <div className="dialog-header flex items-center justify-between px-6 py-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Mail className="w-5 h-5 text-primary-400" /> <Mail className="w-5 h-5 text-primary-400" />
<h2 className="text-lg font-semibold text-white"> </h2> <h2 className="dialog-title"> </h2>
</div> </div>
<button <button
onClick={onClose} onClick={onClose}
@@ -238,7 +238,7 @@ export function MailTestDialog({ isOpen, onClose }: MailTestDialogProps) {
</div> </div>
{/* Footer */} {/* Footer */}
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-white/10 bg-black/20"> <div className="dialog-footer flex items-center justify-end gap-2 px-6 py-4">
<button <button
onClick={onClose} onClick={onClose}
disabled={processing} disabled={processing}

View File

@@ -26,7 +26,6 @@ export function NoteEditModal({
processing, processing,
onClose, onClose,
onSave, onSave,
initialEditMode = false,
}: NoteEditModalProps) { }: NoteEditModalProps) {
const [pdate, setPdate] = useState(''); const [pdate, setPdate] = useState('');
const [title, setTitle] = useState(''); const [title, setTitle] = useState('');
@@ -34,7 +33,6 @@ export function NoteEditModal({
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [share, setShare] = useState(false); const [share, setShare] = useState(false);
const [guid, setGuid] = useState(''); const [guid, setGuid] = useState('');
const [isEditMode, setIsEditMode] = useState(false);
// 현재 로그인 사용자 정보 로드 // 현재 로그인 사용자 정보 로드
const [currentUserId, setCurrentUserId] = useState(''); const [currentUserId, setCurrentUserId] = useState('');
@@ -66,19 +64,17 @@ export function NoteEditModal({
setDescription(editingItem.description || ''); setDescription(editingItem.description || '');
setShare(editingItem.share || false); setShare(editingItem.share || false);
setGuid(editingItem.guid || ''); setGuid(editingItem.guid || '');
setIsEditMode(initialEditMode);
} else { } else {
// 신규 메모 - 편집 모드로 시작 // 신규 메모
setPdate(new Date().toISOString().split('T')[0]); setPdate(new Date().toISOString().split('T')[0]);
setTitle(''); setTitle('');
setUid(currentUserId); setUid(currentUserId);
setDescription(''); setDescription('');
setShare(false); setShare(false);
setGuid(''); setGuid('');
setIsEditMode(true);
} }
} }
}, [isOpen, editingItem, currentUserId, initialEditMode]); }, [isOpen, editingItem, currentUserId]);
const handleSubmit = (e: React.FormEvent) => { const handleSubmit = (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -94,15 +90,15 @@ export function NoteEditModal({
return createPortal( return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-gray-900 rounded-2xl shadow-2xl w-full max-w-5xl max-h-[90vh] overflow-hidden border border-white/10"> <div className="dialog-container rounded-2xl w-full max-w-5xl max-h-[90vh] overflow-hidden transition-all duration-300">
{/* 헤더 */} {/* 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10"> <div className="dialog-header flex items-center justify-between px-6 py-4">
<h2 className="text-xl font-bold text-white"> <h2 className="dialog-title">
{!editingItem ? '새 메모 작성' : '메모 편집'} {!editingItem ? '새 메모 작성' : '메모 편집'}
</h2> </h2>
<button <button
onClick={onClose} onClick={onClose}
className="text-white/50 hover:text-white transition-colors" className="text-text-secondary hover:text-text-primary transition-colors"
> >
<X className="w-6 h-6" /> <X className="w-6 h-6" />
</button> </button>
@@ -117,47 +113,37 @@ export function NoteEditModal({
<div> <div>
<label className="flex items-center text-sm font-medium text-white/70 mb-2"> <label className="flex items-center text-sm font-medium text-white/70 mb-2">
<Calendar className="w-4 h-4 mr-2" /> <Calendar className="w-4 h-4 mr-2" />
{isEditMode && '*'}
</label> </label>
{isEditMode ? ( <input
<input type="date"
type="date" value={pdate}
value={pdate} onChange={(e) => setPdate(e.target.value)}
onChange={(e) => setPdate(e.target.value)} required
required disabled={!canEdit}
disabled={!canEdit} className="w-full h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:opacity-50 disabled:cursor-not-allowed"
className="w-full h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:opacity-50 disabled:cursor-not-allowed" />
/>
) : (
<div className="text-white text-sm">{pdate}</div>
)}
</div> </div>
{/* 작성자 */} {/* 작성자 */}
<div> <div>
<label className="flex items-center text-sm font-medium text-white/70 mb-2"> <label className="flex items-center text-sm font-medium text-white/70 mb-2">
<User className="w-4 h-4 mr-2" /> <User className="w-4 h-4 mr-2" />
{isEditMode && '*'}
</label> </label>
{isEditMode ? ( <input
<> type="text"
<input value={uid}
type="text" onChange={(e) => setUid(e.target.value)}
value={uid} required
onChange={(e) => setUid(e.target.value)} disabled={!canEdit || !canChangeUser}
required className="w-full h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={!canEdit || !canChangeUser} placeholder="사용자 ID"
className="w-full h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:opacity-50 disabled:cursor-not-allowed" />
placeholder="사용자 ID" {!canChangeUser && (
/> <p className="text-xs text-white/50 mt-1">
{!canChangeUser && (
<p className="text-xs text-white/50 mt-1"> </p>
</p>
)}
</>
) : (
<div className="text-white text-sm">{uid}</div>
)} )}
</div> </div>
@@ -166,33 +152,27 @@ export function NoteEditModal({
<label className="text-sm font-medium text-white/70 mb-2 block"> <label className="text-sm font-medium text-white/70 mb-2 block">
</label> </label>
{isEditMode ? ( <div className="flex items-center gap-2">
<> <input
<div className="flex items-center gap-2"> type="checkbox"
<input id="note-share"
type="checkbox" checked={share}
id="note-share" onChange={(e) => setShare(e.target.checked)}
checked={share} disabled={!canEdit || !canChangeShare}
onChange={(e) => setShare(e.target.checked)} className="w-4 h-4 bg-white/20 border border-white/30 rounded focus:ring-2 focus:ring-primary-400 disabled:opacity-50 disabled:cursor-not-allowed"
disabled={!canEdit || !canChangeShare} />
className="w-4 h-4 bg-white/20 border border-white/30 rounded focus:ring-2 focus:ring-primary-400 disabled:opacity-50 disabled:cursor-not-allowed" <label htmlFor="note-share" className="text-sm text-white/70 cursor-pointer">
/>
<label htmlFor="note-share" className="text-sm text-white/70 cursor-pointer"> </label>
</div>
</label> {!canChangeShare && (
</div> <p className="text-xs text-white/50 mt-1">
{!canChangeShare && (
<p className="text-xs text-white/50 mt-1"> </p>
</p>
)}
</>
) : (
<div className="text-white text-sm">{share ? '공유됨' : '비공유'}</div>
)} )}
</div> </div>
{!canEdit && isEditMode && ( {!canEdit && (
<div className="bg-red-500/20 border border-red-500/50 rounded-lg p-3 text-red-300 text-xs"> <div className="bg-red-500/20 border border-red-500/50 rounded-lg p-3 text-red-300 text-xs">
. .
</div> </div>
@@ -204,21 +184,17 @@ export function NoteEditModal({
{/* 제목 */} {/* 제목 */}
<div> <div>
<label className="text-sm font-medium text-white/70 mb-2 block"> <label className="text-sm font-medium text-white/70 mb-2 block">
{isEditMode && '*'}
</label> </label>
{isEditMode ? ( <input
<input type="text"
type="text" value={title}
value={title} onChange={(e) => setTitle(e.target.value)}
onChange={(e) => setTitle(e.target.value)} required
required disabled={!canEdit}
disabled={!canEdit} className="w-full h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:opacity-50 disabled:cursor-not-allowed"
className="w-full h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:opacity-50 disabled:cursor-not-allowed" placeholder="메모 제목을 입력하세요"
placeholder="메모 제목을 입력하세요" />
/>
) : (
<div className="text-white text-lg font-semibold">{title}</div>
)}
</div> </div>
{/* 내용 */} {/* 내용 */}
@@ -226,27 +202,21 @@ export function NoteEditModal({
<label className="text-sm font-medium text-white/70 mb-2 block"> <label className="text-sm font-medium text-white/70 mb-2 block">
</label> </label>
{isEditMode ? ( <textarea
<textarea value={description}
value={description} onChange={(e) => setDescription(e.target.value)}
onChange={(e) => setDescription(e.target.value)} disabled={!canEdit}
disabled={!canEdit} rows={18}
rows={18} className="w-full bg-white/20 border border-white/30 rounded-lg p-3 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:opacity-50 disabled:cursor-not-allowed resize-none"
className="w-full bg-white/20 border border-white/30 rounded-lg p-3 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:opacity-50 disabled:cursor-not-allowed resize-none" placeholder="메모 내용을 입력하세요"
placeholder="메모 내용을 입력하세요" />
/>
) : (
<div className="bg-white/10 border border-white/20 rounded-lg p-4 text-white whitespace-pre-wrap min-h-[400px]">
{description || '내용이 없습니다.'}
</div>
)}
</div> </div>
</div> </div>
</div> </div>
</form> </form>
{/* 하단 버튼 */} {/* 하단 버튼 */}
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-white/10 bg-white/5"> <div className="dialog-footer flex items-center justify-end gap-3 px-6 py-4">
<button <button
type="button" type="button"
onClick={onClose} onClick={onClose}
@@ -255,7 +225,7 @@ export function NoteEditModal({
> >
</button> </button>
{isEditMode && canEdit && ( {canEdit && (
<button <button
type="submit" type="submit"
onClick={handleSubmit} onClick={handleSubmit}

View File

@@ -21,13 +21,13 @@ export function NoteViewModal({ isOpen, note, onClose, onEdit, onDelete }: NoteV
return createPortal( return createPortal(
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm">
<div className="bg-gray-900 rounded-2xl shadow-2xl w-full max-w-2xl max-h-[80vh] overflow-hidden border border-white/10"> <div className="dialog-container rounded-2xl w-full max-w-2xl max-h-[80vh] overflow-hidden transition-all duration-300">
{/* 헤더 */} {/* 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10"> <div className="dialog-header flex items-center justify-between px-6 py-4">
<h2 className="text-xl font-bold text-white">{note.title || '제목 없음'}</h2> <h2 className="dialog-title">{note.title || '제목 없음'}</h2>
<button <button
onClick={onClose} onClick={onClose}
className="text-white/50 hover:text-white transition-colors" className="text-text-secondary hover:text-text-primary transition-colors"
> >
<X className="w-6 h-6" /> <X className="w-6 h-6" />
</button> </button>
@@ -41,7 +41,7 @@ export function NoteViewModal({ isOpen, note, onClose, onEdit, onDelete }: NoteV
</div> </div>
{/* 하단 버튼 */} {/* 하단 버튼 */}
<div className="flex items-center justify-between px-6 py-4 border-t border-white/10 bg-white/5"> <div className="dialog-footer flex items-center justify-between px-6 py-4">
<button <button
onClick={handleDelete} onClick={handleDelete}
className="px-4 py-2 rounded-lg bg-danger-500 hover:bg-danger-600 text-white transition-colors flex items-center gap-2" className="px-4 py-2 rounded-lg bg-danger-500 hover:bg-danger-600 text-white transition-colors flex items-center gap-2"

View File

@@ -157,11 +157,11 @@ export function PartListDialog({ projectIdx, projectName, onClose }: PartListDia
return ( return (
<div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4"> <div className="fixed inset-0 bg-black/70 flex items-center justify-center z-50 p-4">
<div className="bg-slate-800/95 backdrop-blur rounded-lg w-full max-w-7xl max-h-[90vh] flex flex-col shadow-2xl border border-white/10"> <div className="dialog-container rounded-lg w-full max-w-7xl max-h-[90vh] flex flex-col overflow-hidden transition-all duration-300">
{/* 헤더 */} {/* 헤더 */}
<div className="flex items-center justify-between p-4 border-b border-white/10 bg-primary-600/30 sticky top-0 z-10"> <div className="dialog-header flex items-center justify-between p-4 sticky top-0 z-10">
<div> <div>
<h2 className="text-lg font-bold text-white"></h2> <h2 className="dialog-title"></h2>
<p className="text-sm text-white/60">{projectName}</p> <p className="text-sm text-white/60">{projectName}</p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -225,9 +225,8 @@ export function PartListDialog({ projectIdx, projectName, onClose }: PartListDia
return ( return (
<tr <tr
key={part.idx} key={part.idx}
className={`border-b border-white/5 hover:bg-white/5 transition-colors ${ className={`border-b border-white/5 hover:bg-white/5 transition-colors ${isEditing ? 'bg-primary-500/10' : ''
isEditing ? 'bg-primary-500/10' : '' }`}
}`}
> >
<td className="px-2 py-2"> <td className="px-2 py-2">
{isEditing ? ( {isEditing ? (
@@ -500,7 +499,7 @@ export function PartListDialog({ projectIdx, projectName, onClose }: PartListDia
{/* 합계 */} {/* 합계 */}
{parts.length > 0 && ( {parts.length > 0 && (
<div className="p-4 border-t border-white/10 bg-slate-900/50"> <div className="dialog-footer p-4">
<div className="flex justify-end gap-4 text-sm"> <div className="flex justify-end gap-4 text-sm">
<span className="text-white/70"> <span className="text-white/70">
<span className="text-white font-medium">{parts.length}</span> <span className="text-white font-medium">{parts.length}</span>

View File

@@ -108,9 +108,9 @@ export function ProjectDetailDialog({ project, onClose }: ProjectDetailDialogPro
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-gray-900/95 border border-white/10 rounded-2xl shadow-2xl w-[1200px] h-[90vh] flex flex-col"> <div className="dialog-container rounded-2xl w-[1200px] h-[90vh] flex flex-col overflow-hidden transition-all duration-300">
{/* 헤더 */} {/* 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 shrink-0"> <div className="dialog-header flex items-center justify-between px-6 py-4 shrink-0">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">
<select <select
value={formData.status} value={formData.status}
@@ -473,7 +473,7 @@ export function ProjectDetailDialog({ project, onClose }: ProjectDetailDialogPro
</div> </div>
{/* 푸터 */} {/* 푸터 */}
<div className="flex items-center justify-between px-6 py-3 border-t border-white/10 shrink-0 bg-white/5"> <div className="dialog-footer flex items-center justify-between px-6 py-3 shrink-0">
<span className="text-sm text-white/50">PNO: {project.pno || '-'} | Jasmin: {project.jasmin || '-'} | : {formData.progress}%</span> <span className="text-sm text-white/50">PNO: {project.pno || '-'} | Jasmin: {project.jasmin || '-'} | : {formData.progress}%</span>
<button <button
onClick={onClose} onClick={onClose}

View File

@@ -0,0 +1,290 @@
import { useState, useEffect } from 'react';
import { createPortal } from 'react-dom';
import { X, Settings as SettingsIcon, Monitor, Palette, Save } from 'lucide-react';
import { useTheme } from '@/context/ThemeContext';
import { comms } from '@/communication';
import { SettingsModel } from '@/types';
interface SettingsDialogProps {
isOpen: boolean;
onClose: () => void;
}
export function SettingsDialog({ isOpen, onClose }: SettingsDialogProps) {
const { theme, setTheme } = useTheme();
const [activeTab, setActiveTab] = useState<'general' | 'theme'>('theme');
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [settings, setSettings] = useState<SettingsModel | null>(null);
// 설정 로드
useEffect(() => {
if (isOpen) {
loadSettings();
}
}, [isOpen]);
const loadSettings = async () => {
setLoading(true);
console.log('Settings Load Start: comms.getSettings() call');
try {
const response = await comms.getSettings();
console.log('Settings response:', response);
// 백엔드에서 객체를 직접 반환함 (ApiResponse 래퍼 없음)
// Settings response: {Xml: {...}, Theme: "...", ...}
if (response) {
// @ts-ignore
const settingsData = response as SettingsModel;
setSettings(settingsData);
// DB에 저장된 테마가 있다면 현재 테마와 동기화
if (settingsData.Theme && settingsData.Theme !== theme) {
// DB 테마가 현재 로컬 테마와 다르다면?
// 일단 UI 상의 선택 상태는 DB 값으로 설정.
}
}
} catch (error) {
console.error('설정 로드 실패:', error);
} finally {
setLoading(false);
}
};
const handleSave = async () => {
if (!settings) return;
setSaving(true);
try {
// 1. 백엔드 저장
const response = await comms.saveSettings(settings);
if (response.Success) {
// 2. 테마 변경 적용 (Context 업데이트)
if (settings.Theme && settings.Theme !== theme) {
setTheme(settings.Theme as any);
}
alert('설정이 저장되었습니다.');
onClose();
} else {
alert('저장 실패: ' + (response.Message || '알 수 없는 오류'));
}
} catch (error) {
console.error('설정 저장 실패:', error);
alert('저장 중 오류가 발생했습니다.');
} finally {
setSaving(false);
}
};
const handleThemeSelect = (selectedTheme: string) => {
if (settings) {
setSettings({ ...settings, Theme: selectedTheme });
}
};
const handleCheckboxChange = (field: keyof SettingsModel, checked: boolean) => {
if (settings) {
setSettings({ ...settings, [field]: checked });
}
};
if (!isOpen) return null;
return createPortal(
<div className="fixed inset-0 z-[1000] flex items-center justify-center bg-black/50 backdrop-blur-sm">
<div className="dialog-container rounded-2xl w-full max-w-2xl animate-slide-up mx-4 overflow-hidden flex flex-col max-h-[80vh] transition-all duration-300">
{/* Header */}
<div className="dialog-header px-6 py-4 flex items-center justify-between">
<h2 className="dialog-title 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="dialog-footer px-6 py-4 flex justify-end space-x-3">
<button
onClick={onClose}
className="px-4 py-2 rounded-lg text-white/70 hover:text-white hover:bg-white/5 transition-colors"
>
</button>
<button
onClick={handleSave}
disabled={saving || loading}
className="px-6 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white shadow-lg shadow-primary-500/30 transition-all flex items-center disabled:opacity-50 disabled:cursor-not-allowed"
>
{saving ? (
<span className="animate-spin mr-2"></span>
) : (
<Save className="w-4 h-4 mr-2" />
)}
</button>
</div>
</div>
</div>,
document.body
);
}

View File

@@ -0,0 +1,65 @@
import { SettingsModel } from '@/types';
interface SettingsGeneralProps {
settings: SettingsModel;
onChange: (field: keyof SettingsModel, checked: boolean) => void;
}
export function SettingsGeneral({ settings, onChange }: SettingsGeneralProps) {
return (
<div className="space-y-6">
<div className="bg-white/5 p-4 rounded-lg overflow-auto max-h-96">
<h3 className="text-white/90 font-medium mb-2"> (Debug)</h3>
<pre className="text-xs text-white/70 whitespace-pre-wrap font-mono">
{JSON.stringify(settings, null, 2)}
</pre>
</div>
<div className="space-y-4">
<h3 className="text-lg font-semibold text-white/90 mb-4"> </h3>
<label className="flex items-center space-x-3 cursor-pointer group">
<div className="relative">
<input
type="checkbox"
className="sr-only peer"
checked={settings.FullScreen}
onChange={(e) => onChange('FullScreen', e.target.checked)}
/>
<div className="w-10 h-6 bg-white/20 rounded-full peer peer-checked:bg-primary-500 peer-focus:ring-2 peer-focus:ring-primary-500/50 transition-all"></div>
<div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full transition-all peer-checked:translate-x-4"></div>
</div>
<span className="text-white/80 group-hover:text-white transition-colors"> </span>
</label>
<label className="flex items-center space-x-3 cursor-pointer group">
<div className="relative">
<input
type="checkbox"
className="sr-only peer"
checked={settings.DupWindow}
onChange={(e) => onChange('DupWindow', e.target.checked)}
/>
<div className="w-10 h-6 bg-white/20 rounded-full peer peer-checked:bg-primary-500 peer-focus:ring-2 peer-focus:ring-primary-500/50 transition-all"></div>
<div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full transition-all peer-checked:translate-x-4"></div>
</div>
<span className="text-white/80 group-hover:text-white transition-colors"> </span>
</label>
<label className="flex items-center space-x-3 cursor-pointer group">
<div className="relative">
<input
type="checkbox"
className="sr-only peer"
checked={settings.Disable8HourOver}
onChange={(e) => onChange('Disable8HourOver', e.target.checked)}
/>
<div className="w-10 h-6 bg-white/20 rounded-full peer peer-checked:bg-primary-500 peer-focus:ring-2 peer-focus:ring-primary-500/50 transition-all"></div>
<div className="absolute left-1 top-1 w-4 h-4 bg-white rounded-full transition-all peer-checked:translate-x-4"></div>
</div>
<span className="text-white/80 group-hover:text-white transition-colors">8 </span>
</label>
</div>
</div>
);
}

View File

@@ -0,0 +1,82 @@
import { SettingsModel } from '@/types';
import { Theme } from '@/context/ThemeContext';
interface SettingsThemeProps {
settings: SettingsModel;
currentTheme: Theme;
onSelect: (theme: string) => void;
}
export function SettingsTheme({ settings, currentTheme, onSelect }: SettingsThemeProps) {
return (
<div className="space-y-6">
<h3 className="text-lg font-semibold text-white/90"> </h3>
<div className="grid grid-cols-1 gap-4">
{/* Dark Theme */}
<button
onClick={() => onSelect('dark')}
className={`relative group p-4 rounded-xl border-2 transition-all duration-300 text-left ${settings.Theme === 'dark' || (!settings.Theme && currentTheme === 'dark')
? 'border-primary-500 bg-primary-500/10'
: 'border-white/10 hover:border-white/30 hover:bg-white/5'
}`}
>
<div className="flex items-center justify-between mb-2">
<span className="text-white font-medium">Dark ()</span>
{(settings.Theme === 'dark') && <div className="w-2 h-2 rounded-full bg-primary-500"></div>}
</div>
<div className="h-16 rounded-lg bg-[#1e1e2e] border border-white/10 flex overflow-hidden">
<div className="w-1/4 bg-[#181825] border-r border-white/5"></div>
<div className="flex-1 p-2">
<div className="h-2 w-2/3 bg-white/10 rounded mb-2"></div>
<div className="h-8 w-full bg-[#1e1e2e] border border-primary-500/30 rounded"></div>
</div>
</div>
</button>
{/* PSH_PINK Theme */}
<button
onClick={() => onSelect('PSH_PINK')}
className={`relative group p-4 rounded-xl border-2 transition-all duration-300 text-left ${settings.Theme === 'PSH_PINK'
? 'border-[#FF00FF] bg-[#FF00FF]/10'
: 'border-white/10 hover:border-white/30 hover:bg-white/5'
}`}
>
<div className="flex items-center justify-between mb-2">
<span className="text-white font-medium">PSH Pink</span>
{settings.Theme === 'PSH_PINK' && <div className="w-2 h-2 rounded-full bg-[#FF00FF]"></div>}
</div>
<div className="h-16 rounded-lg bg-[#2D0A1E] border border-[#FF66FF]/30 flex overflow-hidden">
<div className="w-1/4 bg-[#1A0512] border-r border-[#FF66FF]/20"></div>
<div className="flex-1 p-2">
<div className="h-2 w-2/3 bg-[#FF66FF]/20 rounded mb-2"></div>
<div className="h-8 w-full bg-[#3D0D28] border border-[#FF00FF] rounded relative overflow-hidden">
<div className="absolute inset-0 bg-gradient-to-r from-transparent via-[#FF00FF]/10 to-transparent"></div>
</div>
</div>
</div>
</button>
{/* JW_SKY Theme */}
<button
onClick={() => onSelect('JW_SKY')}
className={`relative group p-4 rounded-xl border-2 transition-all duration-300 text-left ${settings.Theme === 'JW_SKY'
? 'border-sky-400 bg-sky-400/10'
: 'border-white/10 hover:border-white/30 hover:bg-white/5'
}`}
>
<div className="flex items-center justify-between mb-2">
<span className="text-white font-medium">JW Sky</span>
{settings.Theme === 'JW_SKY' && <div className="w-2 h-2 rounded-full bg-sky-400"></div>}
</div>
<div className="h-16 rounded-lg bg-[#0F172A] border border-sky-400/30 flex overflow-hidden">
<div className="w-1/4 bg-[#020617] border-r border-sky-400/20"></div>
<div className="flex-1 p-2">
<div className="h-2 w-2/3 bg-sky-400/20 rounded mb-2"></div>
<div className="h-8 w-full bg-[#1E293B] border border-sky-400 rounded"></div>
</div>
</div>
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,190 @@
import { useState, useEffect } from 'react';
import { X, Key, Lock, ShieldCheck, CheckCircle } from 'lucide-react';
import { createPortal } from 'react-dom';
import { clsx } from 'clsx';
interface PasswordDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: (oldPassword: string, newPassword: string) => void;
}
export function PasswordDialog({ isOpen, onClose, onConfirm }: PasswordDialogProps) {
const [oldPassword, setOldPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
// ESC 키로 닫기
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
const handleSubmit = () => {
if (!oldPassword) {
setError('기존 비밀번호를 입력하세요.');
return;
}
if (!newPassword) {
setError('새 비밀번호를 입력하세요.');
return;
}
if (newPassword !== confirmPassword) {
setError('새 비밀번호가 일치하지 않습니다.');
return;
}
if (newPassword.length < 4) {
setError('비밀번호는 4자 이상이어야 합니다.');
return;
}
onConfirm(oldPassword, newPassword);
// 초기화는 부모에서 성공 시 처리하거나, 여기서 닫힐 때 처리
// 하지만 단순화를 위해 여기서 초기화
setTimeout(() => {
setOldPassword('');
setNewPassword('');
setConfirmPassword('');
setError('');
}, 300);
onClose();
};
if (!isOpen) return null;
return createPortal(
<div className="fixed inset-0 z-[10001] flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="dialog-container rounded-2xl w-full max-w-sm animate-slide-up overflow-hidden transition-all duration-300">
{/* Header */}
<div className="dialog-header relative px-6 py-6">
<div className="flex items-center justify-between relative z-10">
<h3 className="dialog-title gap-3">
<div className="p-2 bg-yellow-500/20 rounded-xl border border-yellow-500/30">
<Key className="w-5 h-5 text-yellow-500" />
</div>
</h3>
<button
onClick={onClose}
className="p-1 rounded-lg text-white/50 hover:text-white hover:bg-white/10 transition-colors"
>
<X className="w-5 h-5" />
</button>
</div>
{/* Decorative background glow */}
<div className="absolute top-0 right-0 w-32 h-32 bg-yellow-500/10 rounded-full blur-3xl -mr-16 -mt-16 pointer-events-none"></div>
</div>
{/* Content */}
<div className="p-6 space-y-5">
<div className="space-y-4">
<div className="group">
<label className="block text-xs uppercase tracking-wider text-white/50 mb-1.5 font-medium ml-1">
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-white/40 group-focus-within:text-yellow-500 transition-colors">
<Lock className="w-4 h-4" />
</div>
<input
type="password"
value={oldPassword}
onChange={(e) => {
setOldPassword(e.target.value);
if (error) setError('');
}}
className="w-full pl-10 pr-4 py-3 bg-black/20 border border-white/10 rounded-xl text-white placeholder-white/30 focus:outline-none focus:border-yellow-500/50 focus:bg-black/40 transition-all font-mono"
placeholder="현재 비밀번호 입력"
/>
</div>
</div>
<div className="group">
<label className="block text-xs uppercase tracking-wider text-white/50 mb-1.5 font-medium ml-1">
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-white/40 group-focus-within:text-blue-500 transition-colors">
<Key className="w-4 h-4" />
</div>
<input
type="password"
value={newPassword}
onChange={(e) => {
setNewPassword(e.target.value);
if (error) setError('');
}}
className="w-full pl-10 pr-4 py-3 bg-black/20 border border-white/10 rounded-xl text-white placeholder-white/30 focus:outline-none focus:border-blue-500/50 focus:bg-black/40 transition-all font-mono"
placeholder="새로운 비밀번호"
/>
</div>
</div>
<div className="group">
<label className="block text-xs uppercase tracking-wider text-white/50 mb-1.5 font-medium ml-1">
</label>
<div className="relative">
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none text-white/40 group-focus-within:text-green-500 transition-colors">
<ShieldCheck className="w-4 h-4" />
</div>
<input
type="password"
value={confirmPassword}
onChange={(e) => {
setConfirmPassword(e.target.value);
if (error) setError('');
}}
className={clsx(
"w-full pl-10 pr-4 py-3 bg-black/20 border rounded-xl text-white placeholder-white/30 focus:outline-none transition-all font-mono",
confirmPassword && newPassword === confirmPassword
? "border-green-500/50 focus:border-green-500/50 bg-green-500/5"
: "border-white/10 focus:border-green-500/50 focus:bg-black/40"
)}
placeholder="비밀번호 다시 입력"
/>
{confirmPassword && newPassword === confirmPassword && (
<div className="absolute inset-y-0 right-0 pr-3 flex items-center text-green-500 animate-in fade-in zoom-in duration-200">
<CheckCircle className="w-4 h-4" />
</div>
)}
</div>
</div>
</div>
{error && (
<div className="px-3 py-2 bg-red-500/10 border border-red-500/20 rounded-lg text-red-400 text-sm flex items-start gap-2 animate-in slide-in-from-top-1">
<span className="mt-0.5 block w-1.5 h-1.5 rounded-full bg-red-500 shrink-0"></span>
{error}
</div>
)}
</div>
{/* Footer */}
<div className="dialog-footer flex justify-end gap-3 px-6 py-4">
<button
onClick={onClose}
className="px-4 py-2.5 rounded-xl border border-white/10 hover:bg-white/5 text-white/70 hover:text-white transition-all text-sm font-medium"
>
</button>
<button
onClick={handleSubmit}
className="px-6 py-2.5 rounded-xl bg-gradient-to-r from-blue-600 to-indigo-600 hover:from-blue-500 hover:to-indigo-500 text-white shadow-lg shadow-blue-900/20 border border-blue-500/20 transition-all active:scale-[0.98] text-sm font-medium flex items-center gap-2"
>
<Key className="w-4 h-4" />
</button>
</div>
</div>
</div>,
document.body
);
}

View File

@@ -232,15 +232,15 @@ export function UserGroupDialog({ isOpen, onClose }: UserGroupDialogProps) {
return ( return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm p-4">
<div className="bg-gray-900/95 border border-white/10 rounded-2xl shadow-2xl w-[1000px] max-h-[85vh] flex flex-col"> <div className="dialog-container rounded-2xl w-[1000px] max-h-[85vh] flex flex-col overflow-hidden transition-all duration-300">
{/* 헤더 */} {/* 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 shrink-0"> <div className="dialog-header flex items-center justify-between px-6 py-4 shrink-0">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="p-2 bg-primary-500/20 rounded-lg"> <div className="p-2 bg-primary-500/20 rounded-lg">
<Users className="w-5 h-5 text-primary-400" /> <Users className="w-5 h-5 text-primary-400" />
</div> </div>
<div> <div>
<h2 className="text-lg font-semibold text-white"></h2> <h2 className="dialog-title"></h2>
<p className="text-white/50 text-sm">/ </p> <p className="text-white/50 text-sm">/ </p>
</div> </div>
</div> </div>
@@ -350,7 +350,7 @@ export function UserGroupDialog({ isOpen, onClose }: UserGroupDialogProps) {
</div> </div>
{/* 푸터 */} {/* 푸터 */}
<div className="flex items-center justify-between px-6 py-3 border-t border-white/10 shrink-0 bg-white/5"> <div className="dialog-footer flex items-center justify-between px-6 py-3 shrink-0">
<span className="text-sm text-white/50">{filteredItems.length} </span> <span className="text-sm text-white/50">{filteredItems.length} </span>
<button <button
onClick={onClose} onClick={onClose}
@@ -364,9 +364,9 @@ export function UserGroupDialog({ isOpen, onClose }: UserGroupDialogProps) {
{/* 그룹 편집 모달 */} {/* 그룹 편집 모달 */}
{showModal && ( {showModal && (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4"> <div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4">
<div className="bg-gray-800 rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col"> <div className="dialog-container rounded-2xl w-full max-w-2xl max-h-[90vh] overflow-hidden flex flex-col transition-all duration-300">
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10"> <div className="dialog-header flex items-center justify-between px-6 py-4">
<h2 className="text-xl font-bold text-white"> <h2 className="dialog-title">
{editingItem ? '그룹 수정' : '새 그룹'} {editingItem ? '그룹 수정' : '새 그룹'}
</h2> </h2>
<button <button
@@ -472,10 +472,10 @@ export function UserGroupDialog({ isOpen, onClose }: UserGroupDialogProps) {
{/* 권한 설정 모달 */} {/* 권한 설정 모달 */}
{showPermissionModal && editingItem && ( {showPermissionModal && editingItem && (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4"> <div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4">
<div className="bg-gray-800 rounded-2xl w-full max-w-md max-h-[90vh] overflow-hidden flex flex-col"> <div className="dialog-container rounded-2xl w-full max-w-md max-h-[90vh] overflow-hidden flex flex-col transition-all duration-300">
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10"> <div className="dialog-header flex items-center justify-between px-6 py-4">
<div> <div>
<h2 className="text-xl font-bold text-white"> </h2> <h2 className="dialog-title"> </h2>
<p className="text-white/60 text-sm">{editingItem.dept}</p> <p className="text-white/60 text-sm">{editingItem.dept}</p>
</div> </div>
<button <button

View File

@@ -1,8 +1,10 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { X, Save, Key, User, Mail, Building2, Briefcase, Calendar, FileText } from 'lucide-react'; import { createPortal } from 'react-dom';
import { X, Save, User, Mail, Briefcase, Calendar, FileText, Palette } from 'lucide-react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import { comms } from '@/communication'; import { comms } from '@/communication';
import { UserInfoDetail } from '@/types'; import { UserInfoDetail } from '@/types';
import { useTheme, Theme } from '@/context/ThemeContext';
interface UserInfoDialogProps { interface UserInfoDialogProps {
isOpen: boolean; isOpen: boolean;
@@ -11,120 +13,11 @@ interface UserInfoDialogProps {
onSave?: () => void; onSave?: () => void;
} }
interface PasswordDialogProps {
isOpen: boolean;
onClose: () => void;
onConfirm: (oldPassword: string, newPassword: string) => void;
}
function PasswordDialog({ isOpen, onClose, onConfirm }: PasswordDialogProps) {
const [oldPassword, setOldPassword] = useState('');
const [newPassword, setNewPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
const [error, setError] = useState('');
// ESC 키로 닫기
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen) {
onClose();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);
const handleSubmit = () => {
if (!newPassword) {
setError('새 비밀번호를 입력하세요.');
return;
}
if (newPassword !== confirmPassword) {
setError('새 비밀번호가 일치하지 않습니다.');
return;
}
onConfirm(oldPassword, newPassword);
setOldPassword('');
setNewPassword('');
setConfirmPassword('');
setError('');
onClose();
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[10001]">
<div className="glass-effect-solid rounded-xl p-6 w-full max-w-sm">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white flex items-center gap-2">
<Key className="w-5 h-5" />
</h3>
<button onClick={onClose} className="text-white/60 hover:text-white">
<X className="w-5 h-5" />
</button>
</div>
<div className="space-y-4">
<div>
<label className="block text-sm text-white/70 mb-1"> </label>
<input
type="password"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40"
placeholder="기존 비밀번호"
/>
</div>
<div>
<label className="block text-sm text-white/70 mb-1"> </label>
<input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40"
placeholder="새 비밀번호"
/>
</div>
<div>
<label className="block text-sm text-white/70 mb-1"> </label>
<input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40"
placeholder="새 비밀번호 확인"
/>
</div>
{error && <p className="text-red-400 text-sm">{error}</p>}
<div className="flex justify-end gap-2 mt-4">
<button
onClick={onClose}
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white/70 hover:text-white transition-colors"
>
</button>
<button
onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white transition-colors"
>
</button>
</div>
</div>
</div>
</div>
);
}
export function UserInfoDialog({ isOpen, onClose, userId, onSave }: UserInfoDialogProps) { export function UserInfoDialog({ isOpen, onClose, userId, onSave }: UserInfoDialogProps) {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [showPasswordDialog, setShowPasswordDialog] = useState(false);
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null); const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const { theme, setTheme } = useTheme();
const [formData, setFormData] = useState<UserInfoDetail>({ const [formData, setFormData] = useState<UserInfoDetail>({
Id: '', Id: '',
@@ -152,16 +45,16 @@ export function UserInfoDialog({ isOpen, onClose, userId, onSave }: UserInfoDial
} }
}, [isOpen, userId]); }, [isOpen, userId]);
// ESC 키로 닫기 (비밀번호 다이얼로그가 열려있지 않을 때만) // ESC 키로 닫기
useEffect(() => { useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => { const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape' && isOpen && !showPasswordDialog) { if (e.key === 'Escape' && isOpen) {
onClose(); onClose();
} }
}; };
window.addEventListener('keydown', handleKeyDown); window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose, showPasswordDialog]); }, [isOpen, onClose]);
const loadUserInfo = async () => { const loadUserInfo = async () => {
setLoading(true); setLoading(true);
@@ -204,268 +97,363 @@ export function UserInfoDialog({ isOpen, onClose, userId, onSave }: UserInfoDial
} }
}; };
const handleChangePassword = async (oldPassword: string, newPassword: string) => {
try {
const response = await comms.changePassword(oldPassword, newPassword);
if (response.Success) {
setMessage({ type: 'success', text: '비밀번호가 변경되었습니다.' });
} else {
setMessage({ type: 'error', text: response.Message || '비밀번호 변경에 실패했습니다.' });
}
} catch (error) {
setMessage({ type: 'error', text: '비밀번호 변경 중 오류가 발생했습니다.' });
}
};
const handleInputChange = (field: keyof UserInfoDetail, value: string | boolean) => { const handleInputChange = (field: keyof UserInfoDetail, value: string | boolean) => {
setFormData((prev) => ({ ...prev, [field]: value })); setFormData((prev) => ({ ...prev, [field]: value }));
}; };
const handleThemeChange = (newTheme: Theme) => {
setTheme(newTheme);
};
if (!isOpen) return null; if (!isOpen) return null;
return ( return createPortal(
<> <>
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-[10000]"> <div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-[10000] animate-fade-in">
<div className="glass-effect-solid rounded-xl w-full max-w-2xl max-h-[90vh] overflow-hidden"> <div className="dialog-container w-full max-w-4xl h-[85vh] flex flex-col shadow-2xl border border-white/10 bg-[#1a1b2e]/90 backdrop-blur-xl rounded-3xl overflow-hidden">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10"> <div className="dialog-header px-8 py-6 border-b border-white/10 bg-white/5 flex items-center justify-between shrink-0">
<h2 className="text-xl font-semibold text-white flex items-center gap-2"> <div className="flex items-center gap-4">
<User className="w-6 h-6" /> <div className="p-3 bg-primary-500/20 rounded-xl">
<User className="w-6 h-6 text-primary-400" />
</h2> </div>
<div>
<h2 className="dialog-title text-2xl"> </h2>
<p className="text-white/40 text-xs mt-1"> .</p>
</div>
</div>
<button <button
onClick={onClose} onClick={onClose}
className="text-white/60 hover:text-white transition-colors" className="p-2 hover:bg-white/10 rounded-full text-white/40 hover:text-white transition-all transform hover:rotate-90"
> >
<X className="w-6 h-6" /> <X className="w-6 h-6" />
</button> </button>
</div> </div>
{/* Content */} {/* Content */}
<div className="p-6 overflow-y-auto max-h-[calc(90vh-140px)] custom-scrollbar"> <div className="flex-1 overflow-hidden">
{loading ? ( {loading ? (
<div className="flex items-center justify-center py-12"> <div className="h-full flex flex-col items-center justify-center gap-4">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-white"></div> <div className="animate-spin rounded-full h-10 w-10 border-b-2 border-primary-400"></div>
<p className="text-white/40 text-sm"> ...</p>
</div> </div>
) : ( ) : (
<div className="space-y-6"> <div className="flex flex-col lg:flex-row h-full">
{/* 기본 정보 */} {/* Left Panel: Profile Info */}
<div className="grid grid-cols-2 gap-4"> <div className="flex-1 overflow-y-auto custom-scrollbar p-8 border-r border-white/10 bg-white/[0.02]">
<div> <div className="space-y-8">
<label className="block text-sm text-white/70 mb-1 flex items-center gap-1"> {/* Identity Section */}
<User className="w-4 h-4" /> <section className="space-y-4">
<div className="flex items-center gap-2 mb-2">
</label> <div className="w-1 h-4 bg-primary-500 rounded-full"></div>
<input <h3 className="text-sm font-bold text-white/80 uppercase tracking-wider"> </h3>
type="text" </div>
value={formData.Id}
disabled <div className="grid grid-cols-1 md:grid-cols-2 gap-5">
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white/50" <div className="space-y-1.5">
/> <label className="text-[10px] uppercase tracking-widest font-bold text-white/30 ml-1"></label>
</div> <div className="px-4 py-3.5 bg-white/5 border border-white/10 rounded-xl text-white/50 font-mono text-sm flex items-center gap-2">
<div> <span className="w-2 h-2 rounded-full bg-white/20"></span>
<label className="block text-sm text-white/70 mb-1"></label> {formData.Id}
<input </div>
type="text" </div>
value={formData.NameK} <div className="space-y-1.5">
onChange={(e) => handleInputChange('NameK', e.target.value)} <label className="text-[10px] uppercase tracking-widest font-bold text-white/30 ml-1"></label>
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40" <div className="relative group">
placeholder="이름" <Briefcase className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-white/20 group-focus-within:text-primary-400 transition-colors" />
/> <input
</div> type="text"
<div> value={formData.Grade}
<label className="block text-sm text-white/70 mb-1"></label> onChange={(e) => handleInputChange('Grade', e.target.value)}
<input className="w-full bg-white/5 border border-white/10 rounded-xl pl-11 pr-4 py-3.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-primary-500/50 focus:bg-white/10 transition-all font-medium"
type="text" placeholder="직책 입력"
value={formData.NameE} />
onChange={(e) => handleInputChange('NameE', e.target.value)} </div>
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40" </div>
placeholder="English Name" <div className="space-y-1.5">
/> <label className="text-[10px] uppercase tracking-widest font-bold text-white/30 ml-1"> ()</label>
</div> <input
<div> type="text"
<label className="block text-sm text-white/70 mb-1 flex items-center gap-1"> value={formData.NameK}
<Building2 className="w-4 h-4" /> onChange={(e) => handleInputChange('NameK', e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-primary-500/50 focus:bg-white/10 transition-all font-bold tracking-tight"
</label> placeholder="이름 입력"
<input />
type="text" </div>
value={formData.Dept} <div className="space-y-1.5">
onChange={(e) => handleInputChange('Dept', e.target.value)} <label className="text-[10px] uppercase tracking-widest font-bold text-white/30 ml-1"> ()</label>
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40" <input
placeholder="부서" type="text"
/> value={formData.NameE}
</div> onChange={(e) => handleInputChange('NameE', e.target.value)}
<div> className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-primary-500/50 focus:bg-white/10 transition-all font-medium"
<label className="block text-sm text-white/70 mb-1 flex items-center gap-1"> placeholder="English Name"
<Briefcase className="w-4 h-4" /> />
</div>
</label> </div>
<input </section>
type="text"
value={formData.Grade} {/* Contact Section */}
onChange={(e) => handleInputChange('Grade', e.target.value)} <section className="space-y-4">
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40" <div className="flex items-center gap-2 mb-2">
placeholder="직책" <div className="w-1 h-4 bg-secondary-500 rounded-full"></div>
/> <h3 className="text-sm font-bold text-white/80 uppercase tracking-wider"> </h3>
</div> </div>
<div>
<label className="block text-sm text-white/70 mb-1"></label> <div className="space-y-5">
<input <div className="space-y-1.5">
type="text" <label className="text-[10px] uppercase tracking-widest font-bold text-white/30 ml-1"></label>
value={formData.Process} <div className="relative group">
onChange={(e) => handleInputChange('Process', e.target.value)} <Mail className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-white/20 group-focus-within:text-secondary-400 transition-colors" />
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40" <input
placeholder="공정" type="email"
/> value={formData.Email}
onChange={(e) => handleInputChange('Email', e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-xl pl-11 pr-4 py-3.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-secondary-500/50 focus:bg-white/10 transition-all font-medium"
placeholder="email@company.com"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div className="space-y-1.5">
<label className="text-[10px] uppercase tracking-widest font-bold text-white/30 ml-1"> ()</label>
<input
type="text"
value={formData.Tel}
onChange={(e) => handleInputChange('Tel', e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-secondary-500/50 focus:bg-white/10 transition-all"
placeholder="내선번호"
/>
</div>
<div className="space-y-1.5">
<label className="text-[10px] uppercase tracking-widest font-bold text-white/30 ml-1"></label>
<input
type="text"
value={formData.Hp}
onChange={(e) => handleInputChange('Hp', e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-secondary-500/50 focus:bg-white/10 transition-all"
placeholder="010-0000-0000"
/>
</div>
</div>
</div>
</section>
{/* Employment Section */}
<section className="space-y-4">
<div className="flex items-center gap-2 mb-2">
<div className="w-1 h-4 bg-indigo-500 rounded-full"></div>
<h3 className="text-sm font-bold text-white/80 uppercase tracking-wider"> </h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<div className="space-y-1.5">
<label className="text-[10px] uppercase tracking-widest font-bold text-white/30 ml-1"></label>
<input
type="text"
value={formData.Dept}
onChange={(e) => handleInputChange('Dept', e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500/50 focus:bg-white/10 transition-all"
placeholder="소속 부서"
/>
</div>
<div className="space-y-1.5">
<label className="text-[10px] uppercase tracking-widest font-bold text-white/30 ml-1"></label>
<input
type="text"
value={formData.Process}
onChange={(e) => handleInputChange('Process', e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500/50 focus:bg-white/10 transition-all"
placeholder="담당 공정"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-5">
<div className="space-y-1.5">
<label className="text-[10px] uppercase tracking-widest font-bold text-white/30 ml-1"></label>
<div className="relative">
<Calendar className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-white/20 pointer-events-none" />
<input
type="date"
value={formData.DateIn}
onChange={(e) => handleInputChange('DateIn', e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-xl pl-11 pr-4 py-3.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500/50 focus:bg-white/10 transition-all [color-scheme:dark] font-mono"
/>
</div>
</div>
<div className="space-y-1.5">
<label className="text-[10px] uppercase tracking-widest font-bold text-white/30 ml-1"></label>
<div className="relative">
<Calendar className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-white/20 pointer-events-none" />
<input
type="date"
value={formData.DateO}
onChange={(e) => handleInputChange('DateO', e.target.value)}
className="w-full bg-white/5 border border-white/10 rounded-xl pl-11 pr-4 py-3.5 text-sm text-white focus:outline-none focus:ring-1 focus:ring-indigo-500/50 focus:bg-white/10 transition-all [color-scheme:dark] font-mono"
/>
</div>
</div>
</div>
<div className="pt-2 flex flex-wrap gap-4">
<label className={clsx(
"flex items-center gap-3 px-4 py-2.5 rounded-xl border transition-all cursor-pointer flex-1",
formData.UseJobReport ? "bg-primary-500/20 border-primary-500/30" : "bg-white/5 border-white/10 hover:bg-white/10"
)}>
<div className={clsx(
"w-5 h-5 rounded-md border flex items-center justify-center transition-colors",
formData.UseJobReport ? "bg-primary-500 border-primary-500 text-white" : "border-white/30 text-transparent"
)}>
<Briefcase className="w-3 h-3" />
</div>
<input
type="checkbox"
checked={formData.UseJobReport}
onChange={(e) => handleInputChange('UseJobReport', e.target.checked)}
className="hidden"
/>
<span className={clsx("text-xs font-bold", formData.UseJobReport ? "text-primary-300" : "text-white/50")}> </span>
</label>
<label className={clsx(
"flex items-center gap-3 px-4 py-2.5 rounded-xl border transition-all cursor-pointer flex-1",
formData.UseUserState ? "bg-primary-500/20 border-primary-500/30" : "bg-white/5 border-white/10 hover:bg-white/10"
)}>
<div className={clsx(
"w-5 h-5 rounded-md border flex items-center justify-center transition-colors",
formData.UseUserState ? "bg-primary-500 border-primary-500 text-white" : "border-white/30 text-transparent"
)}>
<User className="w-3 h-3" />
</div>
<input
type="checkbox"
checked={formData.UseUserState}
onChange={(e) => handleInputChange('UseUserState', e.target.checked)}
className="hidden"
/>
<span className={clsx("text-xs font-bold", formData.UseUserState ? "text-primary-300" : "text-white/50")}> </span>
</label>
<label className={clsx(
"flex items-center gap-3 px-4 py-2.5 rounded-xl border transition-all cursor-pointer flex-1",
formData.ExceptHoly ? "bg-primary-500/20 border-primary-500/30" : "bg-white/5 border-white/10 hover:bg-white/10"
)}>
<div className={clsx(
"w-5 h-5 rounded-md border flex items-center justify-center transition-colors",
formData.ExceptHoly ? "bg-primary-500 border-primary-500 text-white" : "border-white/30 text-transparent"
)}>
<Calendar className="w-3 h-3" />
</div>
<input
type="checkbox"
checked={formData.ExceptHoly}
onChange={(e) => handleInputChange('ExceptHoly', e.target.checked)}
className="hidden"
/>
<span className={clsx("text-xs font-bold", formData.ExceptHoly ? "text-primary-300" : "text-white/50")}> </span>
</label>
</div>
</section>
</div> </div>
</div> </div>
{/* 이메일 */} {/* Right Panel: Preferences & Memo */}
<div> <div className="w-full lg:w-96 flex flex-col h-full bg-[#131426]/50">
<label className="block text-sm text-white/70 mb-1 flex items-center gap-1"> <div className="p-6 md:p-8 space-y-8 flex-1 overflow-y-auto custom-scrollbar">
<Mail className="w-4 h-4" /> {/* Theme Section */}
<section className="space-y-4">
</label> <div className="flex items-center gap-2 mb-2">
<input <Palette className="w-4 h-4 text-purple-400" />
type="email" <h3 className="text-sm font-bold text-white/80 uppercase tracking-wider"> </h3>
value={formData.Email} </div>
onChange={(e) => handleInputChange('Email', e.target.value)}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40"
placeholder="email@example.com"
/>
</div>
{/* 입/퇴사 정보 */} <div className="grid grid-cols-1 gap-3">
<div className="grid grid-cols-2 gap-4"> {[
<div> { id: 'dark', name: 'Standard Dark', desc: '기본 어두운 테마', gradient: 'from-blue-600 to-indigo-900', border: 'border-blue-500' },
<label className="block text-sm text-white/70 mb-1 flex items-center gap-1"> { id: 'PSH_PINK', name: 'Vibrant Pink', desc: '발랄한 핑크 테마', gradient: 'from-pink-500 to-rose-500', border: 'border-pink-500' },
<Calendar className="w-4 h-4" /> { id: 'JW_SKY', name: 'Fresh Sky', desc: '시원한 하늘 테마', gradient: 'from-sky-400 to-blue-500', border: 'border-sky-500' },
].map((t) => (
</label> <button
<input key={t.id}
type="text" onClick={() => handleThemeChange(t.id as Theme)}
value={formData.DateIn} className={clsx(
onChange={(e) => handleInputChange('DateIn', e.target.value)} "group relative overflow-hidden rounded-xl border transition-all duration-300 text-left p-4",
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40" theme === t.id
placeholder="YYYY-MM-DD" ? `bg-white/5 ${t.border} ring-1 ring-white/20`
/> : "bg-white/5 border-white/10 hover:border-white/30 hover:bg-white/10"
</div> )}
<div> >
<label className="block text-sm text-white/70 mb-1 flex items-center gap-1"> <div className={clsx("absolute inset-0 opacity-10 bg-gradient-to-br transition-opacity", t.gradient, theme === t.id ? "opacity-20" : "group-hover:opacity-15")} />
<Calendar className="w-4 h-4" /> <div className="relative z-10 flex items-center justify-between">
<div>
</label> <h4 className={clsx("font-bold text-sm", theme === t.id ? "text-white" : "text-white/70 group-hover:text-white")}>{t.name}</h4>
<input <p className="text-xs text-white/40 mt-0.5">{t.desc}</p>
type="text" </div>
value={formData.DateO} <div className={clsx("w-8 h-8 rounded-full bg-gradient-to-br shadow-lg", t.gradient)} />
onChange={(e) => handleInputChange('DateO', e.target.value)} </div>
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40" </button>
placeholder="YYYY-MM-DD" ))}
/> </div>
</section>
{/* Memo Section */}
<section className="flex-1 flex flex-col min-h-[200px]">
<div className="flex items-center gap-2 mb-4">
<FileText className="w-4 h-4 text-white/40" />
<h3 className="text-sm font-bold text-white/80 uppercase tracking-wider"> & </h3>
</div>
<div className="flex-1 relative group">
<textarea
value={formData.Memo}
onChange={(e) => handleInputChange('Memo', e.target.value)}
className="w-full h-full bg-white/5 border border-white/10 rounded-xl p-4 text-sm text-white placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-primary-500/50 focus:bg-white/10 transition-all resize-none leading-relaxed"
placeholder="사용자에 대한 추가 정보를 입력하세요..."
/>
<div className="absolute bottom-4 right-4 text-[10px] text-white/20 font-mono">
MEMO AREA
</div>
</div>
</section>
</div> </div>
</div> </div>
{/* 비고 */}
<div>
<label className="block text-sm text-white/70 mb-1 flex items-center gap-1">
<FileText className="w-4 h-4" />
</label>
<textarea
value={formData.Memo}
onChange={(e) => handleInputChange('Memo', e.target.value)}
rows={3}
className="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40 resize-none"
placeholder="비고"
/>
</div>
{/* 옵션 체크박스 */}
<div className="flex flex-wrap gap-6">
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.UseJobReport}
onChange={(e) => handleInputChange('UseJobReport', e.target.checked)}
className="w-4 h-4 rounded border-white/20 bg-white/10 text-blue-600"
/>
<span className="text-white/70"> </span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.UseUserState}
onChange={(e) => handleInputChange('UseUserState', e.target.checked)}
className="w-4 h-4 rounded border-white/20 bg-white/10 text-blue-600"
/>
<span className="text-white/70"> </span>
</label>
<label className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={formData.ExceptHoly}
onChange={(e) => handleInputChange('ExceptHoly', e.target.checked)}
className="w-4 h-4 rounded border-white/20 bg-white/10 text-blue-600"
/>
<span className="text-white/70"> </span>
</label>
</div>
{/* 메시지 */}
{message && (
<div
className={clsx(
'px-4 py-2 rounded-lg text-sm',
message.type === 'success' ? 'bg-green-600/20 text-green-400' : 'bg-red-600/20 text-red-400'
)}
>
{message.text}
</div>
)}
</div> </div>
)} )}
</div> </div>
{/* Footer */} {/* Footer */}
<div className="flex items-center justify-between px-6 py-4 border-t border-white/10"> <div className="dialog-footer px-8 py-5 border-t border-white/10 bg-[#131426] shrink-0 flex items-center justify-between">
<button <div className="text-xs text-white/30 font-medium">
onClick={() => setShowPasswordDialog(true)} {message && (
className="flex items-center gap-2 px-4 py-2 bg-yellow-600/20 hover:bg-yellow-600/30 text-yellow-400 rounded-lg transition-colors" <span className={clsx(
> "px-3 py-1.5 rounded-lg inline-flex items-center gap-2",
<Key className="w-4 h-4" /> message.type === 'success' ? "bg-green-500/10 text-green-400" : "bg-red-500/10 text-red-400"
)}>
</button> <div className={clsx("w-1.5 h-1.5 rounded-full", message.type === 'success' ? "bg-green-500" : "bg-red-500")} />
<div className="flex gap-2"> {message.text}
</span>
)}
</div>
<div className="flex gap-3">
<button <button
onClick={onClose} onClick={onClose}
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white/70 hover:text-white transition-colors" className="px-6 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-white/70 hover:text-white text-sm font-bold transition-all active:scale-95"
> >
</button> </button>
<button <button
onClick={handleSave} onClick={handleSave}
disabled={saving} disabled={saving}
className={clsx( className="px-8 py-2.5 bg-primary-500 hover:bg-primary-600 border border-white/20 rounded-xl text-white text-sm font-bold transition-all shadow-lg shadow-primary-500/20 active:scale-95 flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
'flex items-center gap-2 px-4 py-2 rounded-lg transition-colors',
saving
? 'bg-blue-600/50 text-white/50 cursor-not-allowed'
: 'bg-blue-600 hover:bg-blue-700 text-white'
)}
> >
<Save className="w-4 h-4" /> {saving ? <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin" /> : <Save className="w-4 h-4" />}
{saving ? '저장 중...' : '저장'} {saving ? '저장 중...' : '저장 완료'}
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</>,
<PasswordDialog document.body
isOpen={showPasswordDialog}
onClose={() => setShowPasswordDialog(false)}
onConfirm={handleChangePassword}
/>
</>
); );
} }

View File

@@ -98,18 +98,18 @@ export function UserSearchDialog({
return ( return (
<div <div
className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" className="fixed inset-0 bg-black/60 flex items-center justify-center z-50 backdrop-blur-sm"
onClick={onClose} onClick={onClose}
> >
<div <div
className="glass-effect rounded-xl w-full max-w-lg max-h-[80vh] overflow-hidden flex flex-col" className="dialog-container rounded-xl w-full max-w-lg max-h-[80vh] overflow-hidden flex flex-col transition-all duration-300"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{/* 헤더 */} {/* 헤더 */}
<div className="p-4 border-b border-white/10 flex items-center justify-between shrink-0"> <div className="dialog-header p-4 flex items-center justify-between shrink-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Users className="w-5 h-5 text-primary-400" /> <Users className="w-5 h-5 text-primary-400" />
<h2 className="text-lg font-semibold text-white">{title}</h2> <h2 className="dialog-title">{title}</h2>
</div> </div>
<button <button
onClick={onClose} onClick={onClose}
@@ -156,11 +156,10 @@ export function UserSearchDialog({
onSelect(user); onSelect(user);
onClose(); onClose();
}} }}
className={`w-full text-left px-4 py-3 rounded-lg transition-colors flex items-center gap-3 ${ className={`w-full text-left px-4 py-3 rounded-lg transition-colors flex items-center gap-3 ${selectedUser?.id === user.id
selectedUser?.id === user.id
? 'bg-primary-500/30 border border-primary-400/50' ? 'bg-primary-500/30 border border-primary-400/50'
: 'bg-white/5 hover:bg-white/10 border border-transparent' : 'bg-white/5 hover:bg-white/10 border border-transparent'
}`} }`}
> >
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -186,7 +185,7 @@ export function UserSearchDialog({
</div> </div>
{/* 푸터 */} {/* 푸터 */}
<div className="p-4 border-t border-white/10 flex items-center justify-between shrink-0"> <div className="dialog-footer p-4 flex items-center justify-between shrink-0">
<span className="text-sm text-white/50"> <span className="text-sm text-white/50">
{filteredUsers.length} {selectedUser && `| 선택: ${selectedUser.id} (${selectedUser.name})`} {filteredUsers.length} {selectedUser && `| 선택: ${selectedUser.id} (${selectedUser.name})`}
</span> </span>

View File

@@ -0,0 +1,74 @@
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
import { comms } from '@/communication';
export type Theme = 'dark' | 'PSH_PINK' | 'JW_SKY';
interface ThemeContextType {
theme: Theme;
setTheme: (theme: Theme) => void;
}
const ThemeContext = createContext<ThemeContextType | undefined>(undefined);
const THEME_KEY = 'app-theme';
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setTheme] = useState<Theme>(() => {
const savedTheme = localStorage.getItem(THEME_KEY);
return (savedTheme as Theme) || 'dark';
});
// 백엔드 설정 로드
useEffect(() => {
const loadSettings = async () => {
try {
const response = await comms.getSettings();
if (response.Success && response.Data?.Theme) {
const savedTheme = response.Data.Theme as Theme;
if (['dark', 'PSH_PINK', 'JW_SKY'].includes(savedTheme)) {
setTheme(savedTheme);
}
}
} catch (error) {
console.error('Failed to load theme from settings:', error);
}
};
loadSettings();
}, []);
useEffect(() => {
const root = window.document.documentElement;
// 이전 테마 클래스 제거
root.classList.remove('dark', 'theme-pink', 'theme-sky');
// 새 테마 클래스 추가
switch (theme) {
case 'dark':
root.classList.add('dark');
break;
case 'PSH_PINK':
root.classList.add('theme-pink');
break;
case 'JW_SKY':
root.classList.add('theme-sky');
break;
}
localStorage.setItem(THEME_KEY, theme);
}, [theme]);
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (context === undefined) {
throw new Error('useTheme must be used within a ThemeProvider');
}
return context;
}

View File

@@ -2,21 +2,214 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
@layer base {
:root {
/* =========================================================================
1. Default Dark Theme (Purple/Blue base) - "기본 어두운 테마"
========================================================================= */
/* [Background Colors] - 메인 배경 및 컴포넌트 배경 */
--bg-main: #111827;
/* 전체 앱의 기본 배경색 (Deep Dark Blue) */
--bg-paper: #1f2937;
/* 카드, 모달 등 컨테이너의 배경색 */
/* [Background Gradients] - 배경에 깊이감을 더하는 그라디언트 색상 */
--bg-gradient-start: #1e3a8a;
/* 시작점: Deep Blue */
--bg-gradient-mid: #581c87;
/* 중간점: Purple (보라빛 포인트) */
--bg-gradient-end: #312e81;
/* 끝점: Indigo */
/* [Text Colors] - 텍스트 계층 구조 */
--text-primary: #f9fafb;
/* 주요 텍스트 (거의 흰색, 가독성 최우선) */
--text-secondary: #9ca3af;
/* 보조 텍스트 (연한 회색, 설명 문구 등) */
--text-muted: #6b7280;
/* 비활성 텍스트 (짙은 회색, 힌트 등) */
/* [Border Colors] - 테두리 및 구분선 */
--border-color: rgba(255, 255, 255, 0.1);
/* 유리 효과(Glass)에 사용되는 반투명 테두리 */
--border-base: #374151;
/* 일반적인 불투명 테두리 (Solid Border) */
/* [Primary Brand Colors] - 포인트 컬러 (RGB 값으로 정의하여 투명도 조절 용이) */
/* Tailwind에서 alpha 값을 조절하기 위해 R, G, B 숫자로 정의함 */
--color-primary: 59, 130, 246;
/* Main: Blue-500 */
--color-primary-light: 96, 165, 250;
/* Light: Blue-400 (Hover 시 등) */
--color-primary-dark: 37, 99, 235;
/* Dark: Blue-600 (Click 시 등) */
--color-accent: 139, 92, 246;
/* Accent: Violet-500 (강조용 보라색) */
/* [Glassmorphism Tokens] - 유리 효과 제어 변수 */
/* 테마별로 투명도와 색상을 다르게 적용하여 가독성을 확보함 */
--glass-bg: rgba(255, 255, 255, 0.25);
/* 유리 배경의 기본 투명도 */
--glass-border: rgba(255, 255, 255, 0.18);
/* 유리 테두리의 투명도 */
/* [Typography Tokens] *NEW* - 리스트 헤더 등 공통 타이포그래피 표준 */
/* 모든 리스트(업무일지, 월별근무표 등)의 헤더 스타일을 이곳에서 통합 관리합니다. */
--fs-list-header: 0.6875rem;
/* 11px: 목록 헤더 폰트 크기 (작고 깔끔하게) */
/* 11px: 목록 헤더 폰트 크기 (작고 깔끔하게) */
--fs-list-item: 0.8125rem;
/* 13px: 목록 본문 폰트 크기 */
--fw-list-header: 500;
/* 500(Medium): 목록 헤더 폰트 굵기 (너무 두껍지 않게) */
--header-muted-opacity: 0.5;
/* 0.5: 목록 헤더 텍스트 투명도 (본문보다 연하게 처리) */
/* [Dialog Tokens] *NEW* - 다이얼로그 전용 스타일 */
--dialog-bg: var(--glass-bg);
--dialog-border: var(--glass-border);
--dialog-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
--dialog-header-bg: transparent;
--dialog-header-border: var(--glass-border);
--dialog-footer-bg: transparent;
--dialog-footer-border: var(--glass-border);
--dialog-title-color: #f9fafb;
--dialog-title-size: 1.125rem;
}
.theme-pink {
/* "PSH_PINK" Theme - Magenta & Pink */
/* Background: Pinkish base */
--bg-main: #501025;
/* Deep pink/wine */
--bg-paper: #1f0510;
/* Very dark pink */
--bg-gradient-start: #be185d;
/* pink-700 */
--bg-gradient-mid: #9d174d;
/* pink-800 */
--bg-gradient-end: #831843;
/* pink-900 */
--text-primary: #fce7f3;
/* pink-100 */
--text-secondary: #fbcfe8;
/* pink-200 */
--text-muted: #f9a8d4;
/* pink-300 */
--border-color: rgba(255, 192, 203, 0.4);
/* Pink border */
--border-base: #9d174d;
/* pink-800 */
/* Primary: Magenta (#FF00FF -> 255, 0, 255) */
--color-primary: 255, 0, 255;
/* Magenta */
--color-primary-light: 255, 105, 180;
/* HotPink */
--color-primary-dark: 199, 21, 133;
/* MediumVioletRed */
/* Accent: Pink (#FFC0CB -> 255, 192, 203) */
--color-accent: 255, 192, 203;
/* Pink */
--glass-bg: rgba(255, 0, 255, 0.1);
--glass-border: rgba(255, 192, 203, 0.4);
/* Dialog Pink Overrides */
--dialog-bg: rgba(80, 16, 37, 0.9);
--dialog-border: rgba(236, 72, 153, 0.3);
--dialog-shadow: 0 25px 50px -12px rgba(131, 24, 67, 0.3);
--dialog-header-bg: linear-gradient(to right, rgba(236, 72, 153, 0.1), transparent);
--dialog-header-border: rgba(236, 72, 153, 0.2);
--dialog-footer-bg: rgba(236, 72, 153, 0.05);
--dialog-footer-border: rgba(236, 72, 153, 0.2);
--dialog-title-color: #fce7f3;
}
.theme-sky {
/* "JW_SKY" Theme - Sky Blue & White/Blue */
--bg-main: #0c4a6e;
/* sky-900 */
--bg-paper: #082f49;
/* sky-950 */
--bg-gradient-start: #38bdf8;
/* sky-400 */
--bg-gradient-mid: #0ea5e9;
/* sky-500 */
--bg-gradient-end: #0284c7;
/* sky-600 */
--text-primary: #f0f9ff;
/* sky-50 */
--text-secondary: #bae6fd;
/* sky-200 */
--text-muted: #7dd3fc;
/* sky-300 */
--border-color: rgba(186, 230, 253, 0.3);
/* sky-200 / 0.3 */
--border-base: #0369a1;
/* sky-700 */
--color-primary: 14, 165, 233;
/* sky-500 */
--color-primary-light: 56, 189, 248;
/* sky-400 */
--color-primary-dark: 2, 132, 199;
/* sky-600 */
--color-accent: 255, 255, 255;
/* White accent */
--glass-bg: rgba(255, 255, 255, 0.2);
--glass-border: rgba(255, 255, 255, 0.2);
/* Dialog Sky Overrides */
--dialog-bg: rgba(8, 47, 73, 0.9);
--dialog-border: rgba(14, 165, 233, 0.3);
--dialog-shadow: 0 25px 50px -12px rgba(12, 74, 110, 0.3);
--dialog-header-bg: linear-gradient(to right, rgba(14, 165, 233, 0.1), transparent);
--dialog-header-border: rgba(14, 165, 233, 0.2);
--dialog-footer-bg: rgba(14, 165, 233, 0.05);
--dialog-footer-border: rgba(14, 165, 233, 0.2);
--dialog-title-color: #f0f9ff;
}
body {
background-color: var(--bg-main);
color: var(--text-primary);
}
}
.glass-effect { .glass-effect {
background: rgba(255, 255, 255, 0.25); background: var(--glass-bg);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.18); border: 1px solid var(--glass-border);
} }
/* 드롭다운 메뉴용 불투명 배경 */ /* 드롭다운 메뉴용 불투명 배경 */
.glass-effect-solid { .glass-effect-solid {
background: rgba(30, 41, 59, 0.95); background: var(--bg-paper);
backdrop-filter: blur(10px); backdrop-filter: blur(10px);
border: 1px solid rgba(255, 255, 255, 0.2); border: 1px solid var(--border-color);
} }
.gradient-bg { .gradient-bg {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); background: linear-gradient(135deg, var(--bg-gradient-start) 0%, var(--bg-gradient-end) 100%);
} }
.card-hover { .card-hover {
@@ -49,14 +242,43 @@
/* 드롭다운 스타일 */ /* 드롭다운 스타일 */
select option { select option {
background-color: #1f2937; background-color: var(--bg-paper);
color: white; color: var(--text-primary);
} }
select:focus option:checked { select:focus option:checked {
background-color: #3b82f6; background-color: rgb(var(--color-primary));
} }
select option:hover { select option:hover {
background-color: #374151; background-color: var(--bg-gradient-mid);
} }
/* Dialog Utility Classes */
.dialog-container {
background: var(--dialog-bg);
border: 1px solid var(--dialog-border);
box-shadow: var(--dialog-shadow);
backdrop-filter: blur(10px);
}
.dialog-header {
background: var(--dialog-header-bg);
border-bottom: 1px solid var(--dialog-header-border);
}
.dialog-footer {
background: var(--dialog-footer-bg);
border-top: 1px solid var(--dialog-footer-border);
}
.dialog-title {
color: var(--dialog-title-color);
font-weight: 600;
/* semibold */
font-size: var(--dialog-title-size);
display: flex;
align-items: center;
gap: 0.5rem;
/* gap-2 */
}

View File

@@ -1,7 +1,19 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { FileText, Search, RefreshCw, Calendar, Edit3, User, Plus } from 'lucide-react'; import {
FileText,
Search,
RefreshCw,
Calendar,
Edit3,
User,
Plus,
MessageSquare,
ChevronRight,
X,
} from 'lucide-react';
import { comms } from '@/communication'; import { comms } from '@/communication';
import { BoardItem } from '@/types'; import { BoardItem } from '@/types';
import { clsx } from 'clsx';
interface BoardListProps { interface BoardListProps {
bidx: number; bidx: number;
@@ -11,9 +23,9 @@ interface BoardListProps {
categories?: { value: string; label: string; color: string }[]; categories?: { value: string; label: string; color: string }[];
} }
export function BoardList({ export function BoardList({
bidx, bidx,
title, title,
icon = <FileText className="w-5 h-5" />, icon = <FileText className="w-5 h-5" />,
defaultCategory = 'PATCH', defaultCategory = 'PATCH',
categories = [ categories = [
@@ -192,7 +204,7 @@ export function BoardList({
try { try {
const isNew = editFormData.idx === 0; const isNew = editFormData.idx === 0;
if (isNew) { if (isNew) {
// 신규 등록 // 신규 등록
const response = await comms.addBoard( const response = await comms.addBoard(
@@ -202,7 +214,7 @@ export function BoardList({
editFormData.title || '', editFormData.title || '',
editFormData.contents || '' editFormData.contents || ''
); );
if (response.Success) { if (response.Success) {
setShowEditModal(false); setShowEditModal(false);
setEditFormData(null); setEditFormData(null);
@@ -219,7 +231,7 @@ export function BoardList({
editFormData.title || '', editFormData.title || '',
editFormData.contents || '' editFormData.contents || ''
); );
if (response.Success) { if (response.Success) {
setShowEditModal(false); setShowEditModal(false);
setEditFormData(null); setEditFormData(null);
@@ -241,7 +253,7 @@ export function BoardList({
try { try {
const response = await comms.deleteBoard(editFormData.idx); const response = await comms.deleteBoard(editFormData.idx);
if (response.Success) { if (response.Success) {
setShowEditModal(false); setShowEditModal(false);
setEditFormData(null); setEditFormData(null);
@@ -284,7 +296,7 @@ export function BoardList({
const getCategoryColor = (cate: string) => { const getCategoryColor = (cate: string) => {
const category = categories.find(c => c.value.toUpperCase() === cate.toUpperCase()); const category = categories.find(c => c.value.toUpperCase() === cate.toUpperCase());
if (!category) return 'bg-gray-500/20 text-gray-400'; if (!category) return 'bg-gray-500/20 text-gray-400';
switch (category.color) { switch (category.color) {
case 'lime': return 'bg-lime-500/20 text-lime-400'; case 'lime': return 'bg-lime-500/20 text-lime-400';
case 'red': return 'bg-red-500/20 text-red-400'; case 'red': return 'bg-red-500/20 text-red-400';
@@ -296,133 +308,180 @@ export function BoardList({
return ( return (
<div className="space-y-6 animate-fade-in"> <div className="space-y-6 animate-fade-in">
{/* 검색 필터 */}
<div className="glass-effect rounded-2xl p-6">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 flex-1">
<label className="text-white/70 text-sm font-medium whitespace-nowrap"></label>
<input
type="text"
value={searchKey}
onChange={(e) => setSearchKey(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="제목, 내용, 작성자 등"
className="flex-1 h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400"
/>
</div>
<button
onClick={handleSearch}
disabled={loading}
className="h-10 bg-primary-500 hover:bg-primary-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center disabled:opacity-50"
>
{loading ? (
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
) : (
<Search className="w-4 h-4 mr-2" />
)}
</button>
<button
onClick={() => {
setEditFormData({
idx: 0,
bidx: bidx,
gcode: '',
header: '',
cate: defaultCategory,
title: '',
contents: '',
file: '',
guid: '',
url: '',
wuid: '',
wuid_name: '',
wdate: null,
project: '',
pidx: 0,
close: false,
remark: ''
});
setShowEditModal(true);
}}
disabled={!(userLevel >= 9 || userId === '395552')}
className="h-10 bg-green-500 hover:bg-green-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center disabled:opacity-30 disabled:cursor-not-allowed"
>
<Plus className="w-4 h-4 mr-2" />
</button>
</div>
</div>
{/* 게시판 목록 */} {/* 게시판 목록 */}
<div className="glass-effect rounded-2xl overflow-hidden"> <div className="glass-effect rounded-3xl overflow-hidden shadow-2xl border border-white/10">
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between"> <div className="px-6 py-4 border-b border-white/10 flex flex-col md:flex-row items-center justify-between gap-4">
<h3 className="text-lg font-semibold text-white flex items-center"> <div className="flex items-center gap-3">
{icon} <div className="p-2 bg-primary-500/20 rounded-lg">
<span className="ml-2">{title}</span> {icon}
</h3> </div>
<span className="text-white/60 text-sm">{boardList.length}</span> <h3 className="text-lg font-bold text-white tracking-tight">{title}</h3>
</div>
<div className="flex items-center gap-3">
{/* 검색창 */}
<div className="relative group w-48 md:w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-white/40 group-focus-within:text-primary-400 transition-colors" />
<input
type="text"
value={searchKey}
onChange={(e) => setSearchKey(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="검색..."
className="w-full bg-white/5 border border-white/10 rounded-xl pl-9 pr-8 py-1.5 text-xs text-white placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all backdrop-blur-sm"
/>
{searchKey && (
<button
onClick={() => {
setSearchKey('');
loadData();
}}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-white/20 hover:text-white transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* 개수 */}
<div className="flex items-center gap-2 bg-white/5 px-3 py-1.5 rounded-xl border border-white/10 h-[38px]">
<span className="text-primary-400 font-bold text-sm">{boardList.length}</span>
<span className="text-white/40 text-[10px] uppercase"></span>
</div>
{/* 새로고침 */}
<button
onClick={loadData}
disabled={loading}
className="p-2 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl text-white/70 hover:text-white transition-all disabled:opacity-50"
title="새로고침"
>
<RefreshCw className={clsx("w-4 h-4", loading && "animate-spin")} />
</button>
{/* 추가 버튼 */}
<button
onClick={() => {
setEditFormData({
idx: 0,
bidx: bidx,
gcode: '',
header: '',
cate: defaultCategory,
title: '',
contents: '',
file: '',
guid: '',
url: '',
wuid: '',
wuid_name: '',
wdate: null,
project: '',
pidx: 0,
close: false,
remark: ''
});
setShowEditModal(true);
}}
disabled={!(userLevel >= 9 || userId === '395552')}
className="p-2 bg-success-500 hover:bg-success-600 border border-white/20 rounded-xl text-white transition-all shadow-lg shadow-success-500/20 active:scale-95 disabled:opacity-30 disabled:cursor-not-allowed"
title="추가"
>
<Plus className="w-4 h-4" />
</button>
</div>
</div> </div>
<div className="divide-y divide-white/10 max-h-[calc(100vh-300px)] overflow-y-auto"> <div className="divide-y divide-white/5 max-h-[calc(100vh-280px)] overflow-y-auto custom-scrollbar">
{loading ? ( {loading ? (
<div className="px-6 py-8 text-center"> <div className="px-6 py-12 text-center">
<div className="flex items-center justify-center"> <RefreshCw className="w-10 h-10 mx-auto mb-4 animate-spin text-primary-500/50" />
<RefreshCw className="w-5 h-5 mr-2 animate-spin text-white/50" /> <p className="text-white/50 font-medium text-sm"> ...</p>
<span className="text-white/50"> ...</span>
</div>
</div> </div>
) : boardList.length === 0 ? ( ) : boardList.length === 0 ? (
<div className="px-6 py-8 text-center"> <div className="px-6 py-20 text-center">
<FileText className="w-12 h-12 mx-auto mb-3 text-white/30" /> <div className="relative inline-block mb-4">
<p className="text-white/50"> .</p> <FileText className="w-16 h-16 mx-auto text-white/10" />
</div>
<p className="text-white/30 font-medium"> .</p>
</div> </div>
) : ( ) : (
boardList.map((item) => ( boardList.map((item) => (
<div <div
key={item.idx} key={item.idx}
className="px-6 py-3 hover:bg-white/5 transition-colors cursor-pointer" className="group px-6 py-4 hover:bg-white/[0.03] transition-all cursor-pointer relative"
onClick={() => handleRowClick(item)} onClick={() => handleRowClick(item)}
style={{ paddingLeft: `${24 + (item.depth || 0) * 24}px` }}
> >
<div className="flex items-center gap-3"> <div className="flex items-center gap-4">
<div className="flex items-center gap-2 flex-shrink-0"> {/* 카테고리/상태 */}
<div className="flex items-center gap-2 shrink-0 min-w-[120px]">
{item.depth && item.depth > 0 && ( {item.depth && item.depth > 0 && (
<span className="text-white/40 text-xs mr-1"></span> <div className="ml-2 mr-1">
<ChevronRight className="w-4 h-4 text-white/20" />
</div>
)} )}
{item.cate && ( {item.cate && (
<span className={`px-2 py-0.5 text-xs rounded whitespace-nowrap ${getCategoryColor(item.cate)}`}> <span className={clsx(
"px-2.5 py-1 text-xs font-bold rounded-md tracking-wider uppercase shadow-sm",
getCategoryColor(item.cate)
)}>
{item.cate} {item.cate}
</span> </span>
)} )}
{item.header && ( {item.header && (
<span className="px-2 py-0.5 bg-primary-500/20 text-primary-400 text-xs rounded whitespace-nowrap"> <span className="px-2.5 py-1 bg-primary-500/10 text-primary-400 text-xs font-bold rounded-md border border-primary-500/20 whitespace-nowrap">
{item.header} {item.header}
</span> </span>
)} )}
</div> </div>
<div className="flex items-center text-white/60 text-xs flex-shrink-0 mr-3">
<Calendar className="w-3 h-3 mr-1" /> {/* 제목 섹션 */}
{formatDate(item.wdate)} <div className="flex-1 min-w-0">
<div className="flex items-center gap-3">
<h4 className="text-sm font-bold text-white group-hover:text-primary-400 transition-colors truncate">
{item.title || '(댓글)'}
</h4>
<div className="flex items-center gap-2 shrink-0">
{isNew(item.wdate) && (
<span className="px-1.5 py-0.5 bg-danger-500 text-white text-[10px] font-bold rounded-sm animate-pulse">
NEW
</span>
)}
{(item.reply_count ?? 0) > 0 && (
<div className="flex items-center gap-1 px-2 py-0.5 bg-white/5 rounded-full border border-white/10 group-hover:border-primary-500/30 transition-colors">
<MessageSquare className="w-3 h-3 text-primary-400" />
<span className="text-xs font-bold text-primary-400">{item.reply_count}</span>
</div>
)}
</div>
</div>
</div> </div>
<h4 className="text-white font-medium flex-1 min-w-0 flex items-center gap-2">
<span className="truncate">{item.title || '(댓글)'}</span> {/* 정보 섹션 */}
{isNew(item.wdate) && ( <div className="flex items-center gap-8 shrink-0">
<span className="px-1.5 py-0.5 bg-yellow-500 text-white text-[10px] rounded font-bold animate-pulse flex-shrink-0"> <div className="flex items-center gap-6">
NEW <div className="w-24 flex items-center gap-2 justify-end">
</span> <div className="w-6 h-6 rounded-full bg-white/5 flex items-center justify-center border border-white/10">
)} <User className="w-3.5 h-3.5 text-white/40" />
{(item.reply_count ?? 0) > 0 && ( </div>
<span className="px-1.5 py-0.5 bg-blue-500/20 text-blue-400 text-[10px] rounded flex-shrink-0"> <span className="text-sm font-medium text-white/70 truncate max-w-[80px]">
💬 {item.reply_count} {item.wuid_name || item.wuid}
</span> </span>
)} </div>
</h4> <div className="w-28 text-right flex items-center gap-2 justify-end">
<div className="flex items-center text-white/60 text-xs flex-shrink-0"> <Calendar className="w-3.5 h-3.5 text-white/30" />
<User className="w-3 h-3 mr-1" /> <span className="text-sm text-white/50 font-mono tracking-tight">
{item.wuid_name || item.wuid} {formatDate(item.wdate)}
</span>
</div>
</div>
</div>
{/* 호버 시 나타나는 화살표 */}
<div className="absolute right-4 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-all transform translate-x-2 group-hover:translate-x-0 hidden md:block">
<ChevronRight className="w-5 h-5 text-primary-500/40" />
</div> </div>
</div> </div>
</div> </div>
@@ -433,87 +492,100 @@ export function BoardList({
{/* 상세 모달 */} {/* 상세 모달 */}
{showModal && selectedItem && ( {showModal && selectedItem && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md animate-fade-in">
<div className="bg-gray-900 rounded-2xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden border border-white/10"> <div className="bg-[#1a1b2e]/90 rounded-3xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden border border-white/10 flex flex-col backdrop-blur-xl">
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10"> {/* 헤더 */}
<div className="flex items-center gap-2"> <div className="flex items-center justify-between px-8 py-6 border-b border-white/10 bg-white/5">
{selectedItem.header && ( <div className="flex items-center gap-3">
<span className="px-2 py-1 bg-primary-500/20 text-primary-400 text-sm rounded"> <div className="flex items-center gap-2">
{selectedItem.header} {selectedItem.cate && (
</span> <span className={clsx(
)} "px-2.5 py-1 text-[10px] font-bold rounded-md tracking-wider uppercase",
{selectedItem.cate && ( getCategoryColor(selectedItem.cate)
<span className={`px-2 py-1 text-sm rounded ${getCategoryColor(selectedItem.cate)}`}> )}>
{selectedItem.cate} {selectedItem.cate}
</span> </span>
)} )}
<h2 className="text-xl font-bold text-white ml-2">{selectedItem.title}</h2> {selectedItem.header && (
<span className="px-2.5 py-1 bg-primary-500/10 text-primary-400 text-[10px] font-bold rounded-md border border-primary-500/20 whitespace-nowrap">
{selectedItem.header}
</span>
)}
</div>
<h2 className="text-xl font-bold text-white ml-2 tracking-tight">{selectedItem.title}</h2>
</div> </div>
<button <button
onClick={() => setShowModal(false)} onClick={() => setShowModal(false)}
className="text-white/50 hover:text-white transition-colors" className="p-2 hover:bg-white/10 rounded-full text-white/40 hover:text-white transition-all transform hover:rotate-90"
> >
<span className="text-2xl">×</span> <X className="w-6 h-6" />
</button> </button>
</div> </div>
<div className="px-6 py-4 border-b border-white/10 flex items-center gap-4 text-sm text-white/60"> {/* 정보바 */}
<div className="flex items-center"> <div className="px-8 py-3 border-b border-white/5 bg-white/[0.02] flex items-center gap-6 text-xs text-white/50 font-medium">
<User className="w-4 h-4 mr-1" /> <div className="flex items-center gap-2">
{selectedItem.wuid_name || selectedItem.wuid} <div className="w-5 h-5 rounded-full bg-white/5 flex items-center justify-center border border-white/10">
<User className="w-3 h-3 text-white/40" />
</div>
<span className="text-white/70">{selectedItem.wuid_name || selectedItem.wuid}</span>
</div> </div>
<div className="flex items-center"> <div className="flex items-center gap-2 border-l border-white/10 pl-6">
<Calendar className="w-4 h-4 mr-1" /> <Calendar className="w-3.5 h-3.5" />
{formatDate(selectedItem.wdate)} <span>{formatDate(selectedItem.wdate)}</span>
</div> </div>
</div> </div>
<div className="overflow-y-auto max-h-[calc(90vh-400px)] p-6"> {/* 본문 */}
<div className="overflow-y-auto flex-1 p-8 space-y-8 custom-scrollbar">
{selectedItem.contents && selectedItem.contents.trim() && ( {selectedItem.contents && selectedItem.contents.trim() && (
<div className="prose prose-invert max-w-none mb-8"> <div className="prose prose-invert max-w-none">
<div className="text-white whitespace-pre-wrap">{selectedItem.contents}</div> <div className="text-white/90 leading-relaxed whitespace-pre-wrap text-[15px] font-medium opacity-90">{selectedItem.contents}</div>
</div> </div>
)} )}
{/* 답글 목록 */} {/* 답글 목록 */}
{replyPosts.length > 0 && ( {replyPosts.length > 0 && (
<div className={selectedItem.contents && selectedItem.contents.trim() ? "border-t border-white/10 pt-6 mb-6" : "mb-6"}> <div className="space-y-4">
<h3 className="text-lg font-semibold text-white mb-4"> <div className="flex items-center gap-3 mb-2">
{replyPosts.length} <div className="w-1 h-5 bg-success-500 rounded-full shadow-[0_0_10px_rgba(34,197,94,0.5)]"></div>
</h3> <h3 className="text-sm font-bold text-white tracking-wide uppercase opacity-70">
<span className="text-success-400">{replyPosts.length}</span>
</h3>
</div>
<div className="space-y-3"> <div className="space-y-3">
{replyPosts.map((replyPost) => ( {replyPosts.map((replyPost) => (
<div <div
key={replyPost.idx} key={replyPost.idx}
className="bg-white/5 hover:bg-white/10 rounded-lg p-4 cursor-pointer transition-colors" className="group bg-white/5 hover:bg-white/10 border border-white/5 hover:border-white/10 rounded-2xl p-5 cursor-pointer transition-all active:scale-[0.98]"
onClick={() => handleRowClick(replyPost)} onClick={() => handleRowClick(replyPost)}
> >
<div className="flex items-center justify-between mb-2"> <div className="flex items-center justify-between mb-3">
<h4 className="text-white font-medium flex items-center gap-2"> <h4 className="text-sm font-bold text-white flex items-center gap-3">
{replyPost.depth && replyPost.depth > 0 && ( {replyPost.depth && replyPost.depth > 0 && (
<span className="text-white/40 text-sm"></span> <ChevronRight className="w-4 h-4 text-white/20" />
)} )}
<span>{replyPost.title}</span> <span className="group-hover:text-primary-400 transition-colors">{replyPost.title}</span>
{isNew(replyPost.wdate) && ( {isNew(replyPost.wdate) && (
<span className="px-1.5 py-0.5 bg-yellow-500 text-white text-[10px] rounded font-bold"> <span className="px-1.5 py-0.5 bg-danger-500 text-white text-[9px] font-bold rounded-sm">
NEW NEW
</span> </span>
)} )}
</h4> </h4>
<div className="flex items-center gap-3 text-xs text-white/60"> <div className="flex items-center gap-4 text-[10px] text-white/40 font-bold uppercase tracking-wider">
<div className="flex items-center"> <span className="flex items-center gap-1.5">
<User className="w-3 h-3 mr-1" /> <User className="w-3 h-3" />
{replyPost.wuid_name || replyPost.wuid} {replyPost.wuid_name || replyPost.wuid}
</div> </span>
<div className="flex items-center"> <span className="flex items-center gap-1.5">
<Calendar className="w-3 h-3 mr-1" /> <Calendar className="w-3 h-3" />
{formatDate(replyPost.wdate)} {formatDate(replyPost.wdate)}
</div> </span>
</div> </div>
</div> </div>
{replyPost.contents && ( {replyPost.contents && (
<div className="text-white/60 text-sm line-clamp-2"> <div className="text-white/50 text-xs line-clamp-2 leading-relaxed italic">
{replyPost.contents} {replyPost.contents}
</div> </div>
)} )}
@@ -523,39 +595,48 @@ export function BoardList({
</div> </div>
)} )}
{/* 댓글 목록 */} {/* 댓글 섹션 */}
<div className={(selectedItem.contents && selectedItem.contents.trim()) || replyPosts.length > 0 ? "border-t border-white/10 pt-6" : ""}> <div className="space-y-6">
<h3 className="text-lg font-semibold text-white mb-4"> <div className="flex items-center gap-3 mb-2">
{replies.length} <div className="w-1 h-5 bg-primary-500 rounded-full shadow-[0_0_10px_rgba(59,130,246,0.5)]"></div>
</h3> <h3 className="text-sm font-bold text-white tracking-wide uppercase opacity-70">
<span className="text-primary-400">{replies.length}</span>
<div className="space-y-4 mb-6"> </h3>
</div>
<div className="space-y-4">
{replies.map((reply) => ( {replies.map((reply) => (
<div key={reply.idx} className="bg-white/5 rounded-lg p-4"> <div key={reply.idx} className="bg-white/5 border border-white/5 rounded-2xl p-5 group hover:bg-white/[0.07] transition-all">
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center justify-between mb-3">
<User className="w-4 h-4 text-white/60" /> <div className="flex items-center gap-2">
<span className="text-sm text-white/80">{reply.wuid_name || reply.wuid}</span> <div className="w-7 h-7 rounded-full bg-primary-500/10 flex items-center justify-center border border-primary-500/20">
<span className="text-xs text-white/50">{formatDate(reply.wdate)}</span> <User className="w-3.5 h-3.5 text-primary-400" />
</div>
<div className="flex flex-col">
<span className="text-[11px] font-bold text-white/80">{reply.wuid_name || reply.wuid}</span>
<span className="text-[9px] text-white/30 font-bold tracking-wider">{formatDate(reply.wdate)}</span>
</div>
</div>
</div> </div>
<div className="text-white/70 text-sm whitespace-pre-wrap">{reply.contents}</div> <div className="text-white/70 text-[13px] leading-relaxed whitespace-pre-wrap pl-[36px]">{reply.contents}</div>
</div> </div>
))} ))}
</div> </div>
{/* 댓글 입력 */} {/* 댓글 입력 */}
<div className="bg-white/5 rounded-lg p-4"> <div className="bg-white/[0.03] border border-white/10 rounded-2xl p-5 focus-within:border-primary-500/50 transition-all">
<textarea <textarea
value={commentText} value={commentText}
onChange={(e) => setCommentText(e.target.value)} onChange={(e) => setCommentText(e.target.value)}
placeholder="댓글을 입력하세요..." placeholder="공유하고 싶은 의견을 입력하세요..."
rows={3} rows={3}
className="w-full bg-white/10 border border-white/30 rounded-lg px-3 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 resize-none" className="w-full bg-transparent border-none text-white text-sm placeholder-white/20 focus:ring-0 resize-none font-medium"
/> />
<div className="flex justify-end mt-2"> <div className="flex justify-end mt-3 border-t border-white/5 pt-3">
<button <button
onClick={handleAddComment} onClick={handleAddComment}
disabled={!commentText.trim()} disabled={!commentText.trim()}
className="px-4 py-2 bg-primary-500 hover:bg-primary-600 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg transition-colors" className="px-6 py-2 bg-primary-500 hover:bg-primary-600 disabled:opacity-30 disabled:cursor-not-allowed text-white text-xs font-bold rounded-xl transition-all shadow-lg shadow-primary-500/20 active:scale-95"
> >
</button> </button>
@@ -564,23 +645,25 @@ export function BoardList({
</div> </div>
</div> </div>
<div className="flex items-center justify-between px-6 py-4 border-t border-white/10 bg-white/5"> {/* 하단 버튼 바 */}
<div className="flex items-center gap-2"> <div className="flex items-center justify-between px-8 py-6 border-t border-white/10 bg-white/5">
<div className="flex items-center gap-3">
<button <button
onClick={() => setShowReplyModal(true)} onClick={() => setShowReplyModal(true)}
className="px-4 py-2 rounded-lg bg-green-500 hover:bg-green-600 text-white transition-colors" className="px-5 py-2.5 rounded-xl bg-success-500 hover:bg-success-600 text-white text-sm font-bold transition-all shadow-lg shadow-success-500/20 active:scale-95 flex items-center gap-2"
> >
<MessageSquare className="w-4 h-4" />
</button> </button>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-3">
{(userLevel >= 9 || userId === '395552') && ( {(userLevel >= 9 || userId === '395552') && (
<> <>
<button <button
onClick={handleEditClick} onClick={handleEditClick}
className="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white transition-colors flex items-center" className="px-5 py-2.5 rounded-xl bg-primary-500 hover:bg-primary-600 text-white text-sm font-bold transition-all shadow-lg shadow-primary-500/20 active:scale-95 flex items-center gap-2"
> >
<Edit3 className="w-4 h-4 mr-2" /> <Edit3 className="w-4 h-4" />
</button> </button>
<button <button
@@ -600,7 +683,7 @@ export function BoardList({
alert('삭제 중 오류가 발생했습니다.'); alert('삭제 중 오류가 발생했습니다.');
} }
}} }}
className="px-4 py-2 rounded-lg bg-red-500 hover:bg-red-600 text-white transition-colors" className="px-5 py-2.5 rounded-xl bg-danger-500/10 hover:bg-danger-500 text-danger-500 hover:text-white border border-danger-500/30 hover:border-danger-500 text-sm font-bold transition-all active:scale-95"
> >
</button> </button>
@@ -608,7 +691,7 @@ export function BoardList({
)} )}
<button <button
onClick={() => setShowModal(false)} onClick={() => setShowModal(false)}
className="px-4 py-2 rounded-lg bg-white/10 hover:bg-white/20 text-white transition-colors" className="px-5 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-white/70 hover:text-white text-sm font-bold transition-all active:scale-95"
> >
</button> </button>
@@ -620,85 +703,87 @@ export function BoardList({
{/* 편집 모달 */} {/* 편집 모달 */}
{showEditModal && editFormData && ( {showEditModal && editFormData && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md animate-fade-in">
<div className="bg-gray-900 rounded-2xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden border border-white/10"> <div className="bg-[#1a1b2e]/90 rounded-3xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden border border-white/10 flex flex-col backdrop-blur-xl">
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10"> <div className="flex items-center justify-between px-8 py-6 border-b border-white/10 bg-white/5">
<h2 className="text-xl font-bold text-white flex items-center"> <h2 className="text-xl font-bold text-white flex items-center tracking-tight">
<Edit3 className="w-5 h-5 mr-2" /> <div className="p-2 bg-primary-500/20 rounded-lg mr-3">
<Edit3 className="w-5 h-5 text-primary-400" />
</div>
{editFormData.idx === 0 ? `${title} 등록` : `${title} 편집`} {editFormData.idx === 0 ? `${title} 등록` : `${title} 편집`}
</h2> </h2>
<button <button
onClick={() => setShowEditModal(false)} onClick={() => setShowEditModal(false)}
className="text-white/50 hover:text-white transition-colors" className="p-2 hover:bg-white/10 rounded-full text-white/40 hover:text-white transition-all transform hover:rotate-90"
> >
<span className="text-2xl">×</span> <X className="w-6 h-6" />
</button> </button>
</div> </div>
<div className="overflow-y-auto max-h-[calc(90vh-180px)] p-6 space-y-4"> <div className="overflow-y-auto flex-1 p-8 space-y-6 custom-scrollbar">
<div className="flex items-center gap-3"> <div className="grid grid-cols-1 md:grid-cols-4 gap-6">
<div className="w-32"> <div className="md:col-span-1">
<label className="block text-white/70 text-xs font-medium mb-1"></label> <label className="block text-white/50 text-[11px] font-bold uppercase tracking-wider mb-2"></label>
<select <select
value={editFormData.cate || defaultCategory} value={editFormData.cate || defaultCategory}
onChange={(e) => setEditFormData({ ...editFormData, cate: e.target.value })} onChange={(e) => setEditFormData({ ...editFormData, cate: e.target.value })}
className="w-full h-9 bg-white/10 border border-white/30 rounded-lg px-2 text-white text-sm focus:outline-none focus:ring-2 focus:ring-primary-400" className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-white text-sm font-medium focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all appearance-none cursor-pointer"
> >
{categories.map((cat) => ( {categories.map((cat) => (
<option key={cat.value} value={cat.value} className="bg-gray-800"> <option key={cat.value} value={cat.value} className="bg-[#1a1b2e] text-white">
{cat.label} {cat.label}
</option> </option>
))} ))}
</select> </select>
</div> </div>
<div className="flex-1"> <div className="md:col-span-3">
<label className="block text-white/70 text-xs font-medium mb-1"></label> <label className="block text-white/50 text-[11px] font-bold uppercase tracking-wider mb-2"></label>
<input <input
type="text" type="text"
value={editFormData.title || ''} value={editFormData.title || ''}
onChange={(e) => setEditFormData({ ...editFormData, title: e.target.value })} onChange={(e) => setEditFormData({ ...editFormData, title: e.target.value })}
className="w-full h-9 bg-white/10 border border-white/30 rounded-lg px-3 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400" className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-white text-sm font-medium placeholder-white/10 focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all"
placeholder="제목" placeholder="내용을 요약할 제목을 입력하세요"
/> />
</div> </div>
</div> </div>
<div> <div>
<label className="block text-white/70 text-sm font-medium mb-2"></label> <label className="block text-white/50 text-[11px] font-bold uppercase tracking-wider mb-2"> </label>
<textarea <textarea
value={editFormData.contents || ''} value={editFormData.contents || ''}
onChange={(e) => setEditFormData({ ...editFormData, contents: e.target.value })} onChange={(e) => setEditFormData({ ...editFormData, contents: e.target.value })}
rows={15} rows={15}
className="w-full bg-white/10 border border-white/30 rounded-lg px-3 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 resize-none" className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-4 text-white text-sm font-medium placeholder-white/10 focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all resize-none leading-relaxed custom-scrollbar"
placeholder="내용을 입력하세요..." placeholder="패치 내역 또는 게시글의 상세 내용을 자유롭게 작성하세요..."
/> />
</div> </div>
</div> </div>
<div className="flex items-center justify-between px-6 py-4 border-t border-white/10 bg-white/5"> <div className="flex items-center justify-between px-8 py-6 border-t border-white/10 bg-white/5">
<div> <div>
{editFormData && editFormData.idx > 0 && ( {editFormData && editFormData.idx > 0 && (
<button <button
onClick={handleDelete} onClick={handleDelete}
className="px-4 py-2 rounded-lg bg-red-500 hover:bg-red-600 text-white transition-colors" className="px-6 py-2.5 rounded-xl bg-danger-500/10 hover:bg-danger-500 text-danger-500 hover:text-white border border-danger-500/30 hover:border-danger-500 text-sm font-bold transition-all active:scale-95"
> >
</button> </button>
)} )}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-3">
<button <button
onClick={() => setShowEditModal(false)} onClick={() => setShowEditModal(false)}
className="px-4 py-2 rounded-lg bg-white/10 hover:bg-white/20 text-white transition-colors" className="px-6 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-white/70 hover:text-white text-sm font-bold transition-all active:scale-95"
> >
</button> </button>
<button <button
onClick={handleEditSave} onClick={handleEditSave}
className="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white transition-colors" className="px-8 py-2.5 rounded-xl bg-primary-500 hover:bg-primary-600 text-white text-sm font-bold transition-all shadow-lg shadow-primary-500/20 active:scale-95"
> >
</button> </button>
</div> </div>
</div> </div>
@@ -708,60 +793,62 @@ export function BoardList({
{/* 답글 달기 모달 */} {/* 답글 달기 모달 */}
{showReplyModal && selectedItem && ( {showReplyModal && selectedItem && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md animate-fade-in">
<div className="bg-gray-900 rounded-2xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden border border-white/10"> <div className="bg-[#1a1b2e]/90 rounded-3xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden border border-white/10 flex flex-col backdrop-blur-xl">
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10"> <div className="flex items-center justify-between px-8 py-6 border-b border-white/10 bg-white/5">
<h2 className="text-xl font-bold text-white flex items-center"> <h2 className="text-xl font-bold text-white flex items-center tracking-tight">
<Edit3 className="w-5 h-5 mr-2" /> <div className="p-2 bg-success-500/20 rounded-lg mr-3">
<MessageSquare className="w-5 h-5 text-success-400" />
</div>
</h2> </h2>
<button <button
onClick={() => setShowReplyModal(false)} onClick={() => setShowReplyModal(false)}
className="text-white/50 hover:text-white transition-colors" className="p-2 hover:bg-white/10 rounded-full text-white/40 hover:text-white transition-all transform hover:rotate-90"
> >
<span className="text-2xl">×</span> <X className="w-6 h-6" />
</button> </button>
</div> </div>
<div className="overflow-y-auto max-h-[calc(90vh-180px)] p-6 space-y-4"> <div className="overflow-y-auto flex-1 p-8 space-y-6 custom-scrollbar">
<div className="bg-white/5 rounded-lg p-4 mb-4"> <div className="bg-white/5 border border-white/5 rounded-2xl p-6">
<div className="text-sm text-white/60 mb-2"></div> <div className="text-[10px] font-bold text-white/30 uppercase tracking-widest mb-1.5"> </div>
<div className="text-white font-medium">{selectedItem.title}</div> <div className="text-white font-semibold text-base">{selectedItem.title}</div>
</div> </div>
<div> <div>
<label className="block text-white/70 text-sm font-medium mb-2"> </label> <label className="block text-white/50 text-[11px] font-bold uppercase tracking-wider mb-2"> </label>
<input <input
type="text" type="text"
value={replyFormData.title} value={replyFormData.title}
onChange={(e) => setReplyFormData({ ...replyFormData, title: e.target.value })} onChange={(e) => setReplyFormData({ ...replyFormData, title: e.target.value })}
className="w-full h-10 bg-white/10 border border-white/30 rounded-lg px-3 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400" className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-2.5 text-white text-sm font-medium placeholder-white/10 focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all"
placeholder="답글 제목을 입력하세요" placeholder="답글 제목을 입력하세요"
/> />
</div> </div>
<div> <div>
<label className="block text-white/70 text-sm font-medium mb-2"> </label> <label className="block text-white/50 text-[11px] font-bold uppercase tracking-wider mb-2"> </label>
<textarea <textarea
value={replyFormData.contents} value={replyFormData.contents}
onChange={(e) => setReplyFormData({ ...replyFormData, contents: e.target.value })} onChange={(e) => setReplyFormData({ ...replyFormData, contents: e.target.value })}
rows={15} rows={12}
className="w-full bg-white/10 border border-white/30 rounded-lg px-3 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 resize-none" className="w-full bg-white/5 border border-white/10 rounded-2xl px-5 py-4 text-white text-sm font-medium placeholder-white/10 focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all resize-none leading-relaxed custom-scrollbar"
placeholder="답글 내용을 입력하세요..." placeholder="답글 내용을 상세히 작성하세요..."
/> />
</div> </div>
</div> </div>
<div className="flex items-center justify-end gap-2 px-6 py-4 border-t border-white/10 bg-white/5"> <div className="flex items-center justify-end gap-3 px-8 py-6 border-t border-white/10 bg-white/5">
<button <button
onClick={() => setShowReplyModal(false)} onClick={() => setShowReplyModal(false)}
className="px-4 py-2 rounded-lg bg-white/10 hover:bg-white/20 text-white transition-colors" className="px-6 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-white/70 hover:text-white text-sm font-bold transition-all active:scale-95"
> >
</button> </button>
<button <button
onClick={handleAddReply} onClick={handleAddReply}
className="px-4 py-2 rounded-lg bg-primary-500 hover:bg-primary-600 text-white transition-colors" className="px-8 py-2.5 rounded-xl bg-primary-500 hover:bg-primary-600 text-white text-sm font-bold transition-all shadow-lg shadow-primary-500/20 active:scale-95"
> >
</button> </button>

View File

@@ -1,13 +1,19 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Building, Search, RefreshCw } from 'lucide-react'; import { Building, Search, RefreshCw, Plus } from 'lucide-react';
import { comms } from '@/communication'; import { comms } from '@/communication';
import type { CustomItem } from '@/types'; import type { CustomItem } from '@/types';
import clsx from 'clsx';
import { CustomEditDialog } from '@/components/customs/CustomEditDialog';
export function Customs() { export function Customs() {
const [customsList, setCustomsList] = useState<CustomItem[]>([]); const [customsList, setCustomsList] = useState<CustomItem[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [searchKey, setSearchKey] = useState(''); const [searchKey, setSearchKey] = useState('');
// 추가/편집 다이얼로그 상태
const [isDialogOpen, setIsDialogOpen] = useState(false);
const [selectedItem, setSelectedItem] = useState<CustomItem | null>(null);
useEffect(() => { useEffect(() => {
loadData(); loadData();
}, []); }, []);
@@ -36,92 +42,171 @@ export function Customs() {
loadData(); loadData();
}; };
const handleEdit = (item: CustomItem) => {
setSelectedItem(item);
setIsDialogOpen(true);
};
const handleAdd = () => {
setSelectedItem(null);
setIsDialogOpen(true);
};
return ( return (
<div className="space-y-6 animate-fade-in"> <div className="space-y-6 animate-fade-in pb-4">
{/* 검색 필터 */} {/* 업체 정보 리스트 카드 (메모장 디자인 통일) */}
<div className="glass-effect rounded-2xl p-6"> <div className="glass-effect rounded-3xl overflow-hidden shadow-2xl border border-white/10">
<div className="flex items-center gap-4"> <div className="px-6 py-4 border-b border-white/10 flex flex-col md:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-2 flex-1"> <div className="flex items-center gap-3">
<label className="text-white/70 text-sm font-medium whitespace-nowrap"></label> <div className="p-2 bg-primary-500/20 rounded-lg">
<input <Building className="w-5 h-5 text-primary-400" />
type="text" </div>
value={searchKey} <div>
onChange={(e) => setSearchKey(e.target.value)} <h3 className="text-lg font-bold text-white tracking-tight"> </h3>
onKeyDown={(e) => e.key === 'Enter' && handleSearch()} </div>
placeholder="업체명, 대표자, 전화번호, 이메일, 담당자 등"
className="flex-1 h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400"
/>
</div> </div>
<button <div className="flex items-center gap-3">
onClick={handleSearch} {/* 검색창 */}
disabled={loading} <div className="relative group w-48 md:w-80">
className="h-10 bg-primary-500 hover:bg-primary-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center disabled:opacity-50" <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-white/40 group-focus-within:text-primary-400 transition-colors" />
> <input
{loading ? ( type="text"
<RefreshCw className="w-4 h-4 mr-2 animate-spin" /> value={searchKey}
) : ( onChange={(e) => setSearchKey(e.target.value)}
<Search className="w-4 h-4 mr-2" /> onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
)} placeholder="업체명, 대표자, 번호 검색..."
className="w-full bg-white/5 border border-white/10 rounded-xl pl-9 pr-8 py-1.5 text-xs text-white placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all backdrop-blur-sm"
</button> />
</div> {searchKey && (
</div> <button
onClick={() => { setSearchKey(''); loadData(); }}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-white/20 hover:text-white transition-colors"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* 업체 목록 */} {/* 개수 */}
<div className="glass-effect rounded-2xl overflow-hidden"> <div className="flex items-center gap-2 bg-white/5 px-3 py-1.5 rounded-xl border border-white/10 h-[40px]">
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between"> <span className="text-primary-400 font-bold text-sm">{customsList.length}</span>
<h3 className="text-lg font-semibold text-white flex items-center"> <span className="text-white/40 text-[10px] uppercase"></span>
<Building className="w-5 h-5 mr-2" /> </div>
</h3> {/* 새로고침 */}
<span className="text-white/60 text-sm">{customsList.length}</span> <button
onClick={loadData}
disabled={loading}
className="p-2 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl text-white/70 hover:text-white transition-all disabled:opacity-50 h-[40px] w-[40px] flex items-center justify-center"
title="새로고침"
>
<RefreshCw className={clsx("w-4 h-4", loading && "animate-spin")} />
</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 h-[40px] w-[40px] flex items-center justify-center group"
title="업체 추가"
>
<Plus className="w-4 h-4 group-hover:rotate-90 transition-transform" />
</button>
</div>
</div> </div>
<div className="divide-y divide-white/10 max-h-[calc(100vh-300px)] overflow-y-auto"> {/* 컬럼 헤더 (메모장 디자인 통일) */}
<div className="bg-white/10 px-6 py-3 border-b border-white/5 flex items-center gap-4 text-list-header text-white/opacity-header-muted font-list-header uppercase">
<div className="w-8"></div>
<div className="flex-1 uppercase"> / </div>
<div className="flex items-center gap-6 shrink-0 uppercase">
<div className="w-24"></div>
<div className="w-36"> / </div>
<div className="w-24"></div>
<div className="w-80 truncate"></div>
</div>
</div>
<div className="divide-y divide-white/5 max-h-[calc(100vh-280px)] overflow-y-auto custom-scrollbar">
{loading ? ( {loading ? (
<div className="px-6 py-8 text-center"> <div className="px-6 py-12 text-center">
<div className="flex items-center justify-center"> <RefreshCw className="w-10 h-10 mx-auto mb-4 animate-spin text-primary-500/50" />
<RefreshCw className="w-5 h-5 mr-2 animate-spin text-white/50" /> <p className="text-white/50 font-medium text-sm"> ...</p>
<span className="text-white/50"> ...</span>
</div>
</div> </div>
) : customsList.length === 0 ? ( ) : customsList.length === 0 ? (
<div className="px-6 py-8 text-center"> <div className="px-6 py-20 text-center">
<Building className="w-12 h-12 mx-auto mb-3 text-white/30" /> <Building className="w-16 h-16 mx-auto text-white/10 mb-4" />
<p className="text-white/50"> .</p> <p className="text-white/30 text-base font-bold"> </p>
<p className="text-white/40 text-sm mt-2"> API .</p> <p className="text-white/10 text-[10px] mt-2 uppercase tracking-[0.2em]">No matching company records</p>
</div> </div>
) : ( ) : (
customsList.map((item) => ( customsList.map((item) => (
<div <div
key={item.idx} key={item.idx}
className="px-6 py-4 hover:bg-white/5 transition-colors" onClick={() => handleEdit(item)}
className="px-6 py-3 hover:bg-white/[0.03] transition-all cursor-pointer group"
> >
<div className="flex items-center justify-between gap-4"> <div className="flex items-center gap-4">
{/* 구분 아이콘 */}
<div className="w-8 h-8 rounded-lg bg-white/5 flex items-center justify-center shrink-0 text-white/20 group-hover:text-primary-400 group-hover:bg-primary-500/10 transition-all">
<Building className="w-4 h-4" />
</div>
{/* 업체명 및 정보 */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<h4 className="text-white font-medium mb-1"> <div className="flex flex-col">
{item.name} <div className="flex items-center gap-2">
{item.name2 && <span className="text-white/60 text-sm ml-2">({item.name2})</span>} <h4 className="text-[var(--text-primary)] font-bold group-hover:text-primary-300 transition-colors truncate text-sm">
</h4> {item.name}
<div className="flex items-center gap-4 text-white/60 text-sm flex-wrap"> </h4>
{item.grp && <div>: {item.grp}</div>} {item.name2 && <span className="text-white/30 text-[10px]">({item.name2})</span>}
{item.owner && <div>: {item.owner}</div>} </div>
{item.uptae && <div>: {item.uptae}</div>} <span className="text-[10px] text-white/30 truncate uppercase tracking-tighter mt-0.5">
{item.address && <div>{item.address}</div>} {item.grp || '미분류'} | {item.uptae || '-'}
</span>
</div> </div>
</div> </div>
<div className="flex flex-col items-end gap-1 flex-shrink-0 text-white/60 text-sm">
{item.tel && <div>{item.tel}</div>} {/* 데이터 그룹 */}
{item.email && <div>{item.email}</div>} <div className="flex items-center gap-6 shrink-0">
{item.staff && <div>: {item.staff}</div>} <div className="w-24 text-sm text-white/70">
{item.owner || '-'}
</div>
<div className="w-36 flex flex-col">
<span className="text-sm text-white/80">{item.tel || '-'}</span>
<span className="text-[10px] text-white/20 truncate lowercase">{item.email || ''}</span>
</div>
<div className="w-24 text-sm text-white/60 font-medium">
{item.staff || '-'}
</div>
<div className="w-80 text-xs text-white/30 truncate font-light" title={item.address}>
{item.address || '-'}
</div>
</div> </div>
</div> </div>
</div> </div>
)) ))
)} )}
</div> </div>
{/* 하단 푸터 (메모장 기반) */}
<div className="px-6 py-2 flex items-center justify-between bg-white/[0.02] border-t border-white/5">
<div className="text-white/20 text-[9px] font-bold uppercase tracking-[0.2em] py-2">
Company Directory System <span className="text-white/5 mx-2">/</span>
Records <span className="text-primary-400/50 font-mono tracking-normal">{customsList.length}</span>
</div>
<div className="text-[9px] text-white/10 italic">
Reference sync: {new Date().toLocaleDateString()}
</div>
</div>
</div> </div>
<CustomEditDialog
isOpen={isDialogOpen}
onClose={() => setIsDialogOpen(false)}
onSaved={loadData}
item={selectedItem}
/>
</div> </div>
); );
} }

View File

@@ -187,7 +187,7 @@ export function Dashboard() {
// 최근 15일간 업무일지 미등록(8시간 미만) 확인 // 최근 15일간 업무일지 미등록(8시간 미만) 확인
if (jobreportHistoryResponse.Success && jobreportHistoryResponse.Data) { if (jobreportHistoryResponse.Success && jobreportHistoryResponse.Data) {
const dailyWork: { [key: string]: number } = {}; const dailyWork: { [key: string]: number } = {};
// 날짜별 시간 합계 계산 // 날짜별 시간 합계 계산
jobreportHistoryResponse.Data.forEach((item: JobReportItem) => { jobreportHistoryResponse.Data.forEach((item: JobReportItem) => {
if (item.pdate) { if (item.pdate) {
@@ -197,22 +197,22 @@ export function Dashboard() {
}); });
const insufficientDays: { date: string; hrs: number }[] = []; const insufficientDays: { date: string; hrs: number }[] = [];
// 어제부터 15일 전까지 확인 (오늘은 제외) // 어제부터 15일 전까지 확인 (오늘은 제외)
for (let i = 1; i <= 15; i++) { for (let i = 1; i <= 15; i++) {
const d = new Date(now); const d = new Date(now);
d.setDate(now.getDate() - i); d.setDate(now.getDate() - i);
const dStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; const dStr = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
// 주말(토:6, 일:0) 제외
if (d.getDay() === 0 || d.getDay() === 6) continue;
const hrs = dailyWork[dStr] || 0; // 주말(토:6, 일:0) 제외
if (hrs < 8) { if (d.getDay() === 0 || d.getDay() === 6) continue;
insufficientDays.push({ date: dStr, hrs });
} const hrs = dailyWork[dStr] || 0;
if (hrs < 8) {
insufficientDays.push({ date: dStr, hrs });
}
} }
setUnregisteredJobReportCount(insufficientDays.length); setUnregisteredJobReportCount(insufficientDays.length);
setUnregisteredJobReportDays(insufficientDays); setUnregisteredJobReportDays(insufficientDays);
} }
@@ -516,24 +516,24 @@ export function Dashboard() {
try { try {
const response = editingNote const response = editingNote
? await comms.editNote( ? await comms.editNote(
editingNote.idx, editingNote.idx,
formData.pdate, formData.pdate,
formData.title, formData.title,
formData.uid, formData.uid,
formData.description, formData.description,
'', '',
formData.share, formData.share,
formData.guid formData.guid
) )
: await comms.addNote( : await comms.addNote(
formData.pdate, formData.pdate,
formData.title, formData.title,
formData.uid, formData.uid,
formData.description, formData.description,
'', '',
formData.share, formData.share,
formData.guid formData.guid
); );
if (response.Success) { if (response.Success) {
setShowNoteEditModal(false); setShowNoteEditModal(false);
@@ -569,149 +569,149 @@ export function Dashboard() {
{/* 통계 카드 */} {/* 통계 카드 */}
<div className="grid grid-cols-1 md:grid-cols-5 gap-6"> <div className="grid grid-cols-1 md:grid-cols-5 gap-6">
<StatCard <StatCard
title="구매요청 (NR)" title="구매요청 (NR)"
value={purchaseNR} value={purchaseNR}
icon={<ShoppingCart className="w-6 h-6 text-primary-400" />} icon={<ShoppingCart className="w-6 h-6 text-primary-400" />}
color="text-primary-400" color="text-primary-400"
onClick={loadNRList} onClick={loadNRList}
/> />
<StatCard <StatCard
title="구매요청 (CR)" title="구매요청 (CR)"
value={purchaseCR} value={purchaseCR}
icon={<FileCheck className="w-6 h-6 text-success-400" />} icon={<FileCheck className="w-6 h-6 text-success-400" />}
color="text-success-400" color="text-success-400"
onClick={loadCRList} onClick={loadCRList}
/> />
<StatCard <StatCard
title="미완료 할일" title="미완료 할일"
value={todoCount} value={todoCount}
icon={<ClipboardList className="w-6 h-6 text-warning-400" />} icon={<ClipboardList className="w-6 h-6 text-warning-400" />}
color="text-warning-400" color="text-warning-400"
onClick={() => navigate('/todo')} onClick={() => navigate('/todo')}
/> />
<StatCard <StatCard
title="업무일지 미등록" title="업무일지 미등록"
value={`${unregisteredJobReportCount}`} value={`${unregisteredJobReportCount}`}
icon={<AlertTriangle className="w-6 h-6 text-danger-400" />} icon={<AlertTriangle className="w-6 h-6 text-danger-400" />}
color="text-danger-400" color="text-danger-400"
onClick={() => setShowUnregisteredModal(true)} onClick={() => setShowUnregisteredModal(true)}
/> />
<StatCard <StatCard
title="금일 업무일지" title="금일 업무일지"
value={`${todayWorkHrs}시간`} value={`${todayWorkHrs}시간`}
icon={<Clock className="w-6 h-6 text-cyan-400" />} icon={<Clock className="w-6 h-6 text-cyan-400" />}
color="text-cyan-400" color="text-cyan-400"
onClick={() => navigate('/jobreport')} onClick={() => navigate('/jobreport')}
/> />
</div> </div>
{/* 할일 목록 */} {/* 할일 목록 */}
<div className="glass-effect rounded-2xl overflow-hidden"> <div className="glass-effect rounded-2xl overflow-hidden">
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between"> <div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
<h3 className="text-lg font-semibold text-white flex items-center"> <h3 className="text-lg font-semibold text-white flex items-center">
<AlertTriangle className="w-5 h-5 mr-2 text-warning-400" /> <AlertTriangle className="w-5 h-5 mr-2 text-warning-400" />
</h3> </h3>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={handleTodoAdd} onClick={handleTodoAdd}
className="p-1.5 rounded-lg bg-primary-500/20 text-primary-400 hover:bg-primary-500/30 transition-colors" className="p-1.5 rounded-lg bg-primary-500/20 text-primary-400 hover:bg-primary-500/30 transition-colors"
title="할일 추가" title="할일 추가"
>
<Plus className="w-4 h-4" />
</button>
<button
onClick={() => navigate('/todo')}
className="p-1.5 rounded-lg bg-white/10 text-white/70 hover:bg-white/20 transition-colors"
title="전체보기"
>
<List className="w-4 h-4" />
</button>
</div>
</div>
<div className="divide-y divide-white/10">
{urgentTodos.length > 0 ? (
urgentTodos.map((todo) => (
<div
key={todo.idx}
className="px-6 py-4 hover:bg-white/5 transition-colors cursor-pointer"
onClick={() => handleTodoEdit(todo)}
> >
<div className="flex items-center justify-between"> <Plus className="w-4 h-4" />
<div className="flex items-center space-x-4 flex-1 min-w-0"> </button>
{todo.flag && ( <button
<Flag className="w-4 h-4 text-warning-400 flex-shrink-0" /> onClick={() => navigate('/todo')}
)} className="p-1.5 rounded-lg bg-white/10 text-white/70 hover:bg-white/20 transition-colors"
<div className="flex-1 min-w-0"> title="전체보기"
<p className="text-white font-medium"> >
{todo.request && ( <List className="w-4 h-4" />
<span className="text-xs text-primary-400 mr-2"> </button>
({todo.request}) </div>
</span> </div>
)}
{todo.title || '제목 없음'} <div className="divide-y divide-white/10">
</p> {urgentTodos.length > 0 ? (
<p className="text-white/60 text-sm line-clamp-1 mt-1"> urgentTodos.map((todo) => (
{todo.remark} <div
</p> key={todo.idx}
className="px-6 py-4 hover:bg-white/5 transition-colors cursor-pointer"
onClick={() => handleTodoEdit(todo)}
>
<div className="flex items-center justify-between">
<div className="flex items-center space-x-4 flex-1 min-w-0">
{todo.flag && (
<Flag className="w-4 h-4 text-warning-400 flex-shrink-0" />
)}
<div className="flex-1 min-w-0">
<p className="text-white font-medium">
{todo.request && (
<span className="text-xs text-primary-400 mr-2">
({todo.request})
</span>
)}
{todo.title || '제목 없음'}
</p>
<p className="text-white/60 text-sm line-clamp-1 mt-1">
{todo.remark}
</p>
</div>
</div>
<div className="flex items-center space-x-3 flex-shrink-0 ml-4">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getPriorityClass(todo.seqno)}`}>
{getPriorityText(todo.seqno)}
</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusClass(todo.status)}`}>
{getStatusText(todo.status)}
</span>
{todo.expire && (
<span className={`text-xs ${new Date(todo.expire) < new Date() ? 'text-danger-400' : 'text-white/60'}`}>
{new Date(todo.expire).toLocaleDateString('ko-KR')}
</span>
)}
</div> </div>
</div> </div>
<div className="flex items-center space-x-3 flex-shrink-0 ml-4">
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getPriorityClass(todo.seqno)}`}>
{getPriorityText(todo.seqno)}
</span>
<span className={`px-2 py-1 rounded-full text-xs font-medium ${getStatusClass(todo.status)}`}>
{getStatusText(todo.status)}
</span>
{todo.expire && (
<span className={`text-xs ${new Date(todo.expire) < new Date() ? 'text-danger-400' : 'text-white/60'}`}>
{new Date(todo.expire).toLocaleDateString('ko-KR')}
</span>
)}
</div>
</div> </div>
))
) : (
<div className="px-6 py-8 text-center text-white/50">
<CheckCircle className="w-12 h-12 mx-auto mb-3 text-success-400/50" />
<p> </p>
</div>
)}
</div>
{allUrgentTodos.length > 6 && (
<div className="px-6 py-3 border-t border-white/10 flex items-center justify-between">
<span className="text-xs text-white/50">
{(todoPage - 1) * 6 + 1}-{Math.min(todoPage * 6, allUrgentTodos.length)} / {allUrgentTodos.length}
</span>
<div className="flex gap-1">
<button
onClick={() => setTodoPage(p => Math.max(1, p - 1))}
disabled={todoPage === 1}
className="px-2 py-1 rounded bg-white/10 hover:bg-white/20 text-white/70 disabled:opacity-30 disabled:cursor-not-allowed transition-colors text-xs"
>
</button>
<button
onClick={() => setTodoPage(p => Math.min(Math.ceil(allUrgentTodos.length / 6), p + 1))}
disabled={todoPage >= Math.ceil(allUrgentTodos.length / 6)}
className="px-2 py-1 rounded bg-white/10 hover:bg-white/20 text-white/70 disabled:opacity-30 disabled:cursor-not-allowed transition-colors text-xs"
>
</button>
</div> </div>
))
) : (
<div className="px-6 py-8 text-center text-white/50">
<CheckCircle className="w-12 h-12 mx-auto mb-3 text-success-400/50" />
<p> </p>
</div> </div>
)} )}
</div> </div>
{allUrgentTodos.length > 6 && (
<div className="px-6 py-3 border-t border-white/10 flex items-center justify-between">
<span className="text-xs text-white/50">
{(todoPage - 1) * 6 + 1}-{Math.min(todoPage * 6, allUrgentTodos.length)} / {allUrgentTodos.length}
</span>
<div className="flex gap-1">
<button
onClick={() => setTodoPage(p => Math.max(1, p - 1))}
disabled={todoPage === 1}
className="px-2 py-1 rounded bg-white/10 hover:bg-white/20 text-white/70 disabled:opacity-30 disabled:cursor-not-allowed transition-colors text-xs"
>
</button>
<button
onClick={() => setTodoPage(p => Math.min(Math.ceil(allUrgentTodos.length / 6), p + 1))}
disabled={todoPage >= Math.ceil(allUrgentTodos.length / 6)}
className="px-2 py-1 rounded bg-white/10 hover:bg-white/20 text-white/70 disabled:opacity-30 disabled:cursor-not-allowed transition-colors text-xs"
>
</button>
</div>
</div>
)}
</div>
{/* 업무일지 미등록 상세 모달 */} {/* 업무일지 미등록 상세 모달 */}
{showUnregisteredModal && ( {showUnregisteredModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-slate-900 border border-white/10 rounded-2xl w-full max-w-md shadow-2xl overflow-hidden animate-scale-in"> <div className="dialog-container border border-white/10 rounded-2xl w-full max-w-md overflow-hidden animate-scale-in transition-all duration-300">
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 bg-white/5"> <div className="dialog-header flex items-center justify-between px-6 py-4 border-b border-white/10 bg-white/5">
<h3 className="text-lg font-semibold text-white flex items-center gap-2"> <h3 className="dialog-title flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-danger-400" /> <AlertTriangle className="w-5 h-5 text-danger-400" />
</h3> </h3>
@@ -722,12 +722,12 @@ export function Dashboard() {
<X className="w-5 h-5" /> <X className="w-5 h-5" />
</button> </button>
</div> </div>
<div className="p-6 max-h-[60vh] overflow-y-auto"> <div className="p-6 max-h-[60vh] overflow-y-auto">
<p className="text-white/70 text-sm mb-4"> <p className="text-white/70 text-sm mb-4">
15( ) 8 . 15( ) 8 .
</p> </p>
{unregisteredJobReportDays.length === 0 ? ( {unregisteredJobReportDays.length === 0 ? (
<div className="text-center py-8 text-white/50"> <div className="text-center py-8 text-white/50">
. .
@@ -753,12 +753,12 @@ export function Dashboard() {
</div> </div>
)} )}
</div> </div>
<div className="px-6 py-4 border-t border-white/10 bg-white/5 flex justify-end"> <div className="dialog-footer px-6 py-4 border-t border-white/10 bg-white/5 flex justify-end">
<button <button
onClick={() => { onClick={() => {
setShowUnregisteredModal(false); setShowUnregisteredModal(false);
navigate('/jobreport'); navigate('/jobreport');
}} }}
className="px-4 py-2 bg-primary-500 hover:bg-primary-600 text-white rounded-lg transition-colors text-sm font-medium" className="px-4 py-2 bg-primary-500 hover:bg-primary-600 text-white rounded-lg transition-colors text-sm font-medium"
> >
@@ -897,11 +897,10 @@ export function Dashboard() {
key={option.value} key={option.value}
type="button" type="button"
onClick={() => setTodoFormData(prev => ({ ...prev, status: option.value as TodoStatus }))} onClick={() => setTodoFormData(prev => ({ ...prev, status: option.value as TodoStatus }))}
className={`px-3 py-1 rounded-lg text-xs font-medium border transition-all ${ className={`px-3 py-1 rounded-lg text-xs font-medium border transition-all ${todoFormData.status === option.value
todoFormData.status === option.value ? getStatusClass(option.value)
? getStatusClass(option.value) : 'bg-white/10 text-white/50 border-white/20 hover:bg-white/20'
: 'bg-white/10 text-white/50 border-white/20 hover:bg-white/20' }`}
}`}
> >
{option.label} {option.label}
</button> </button>
@@ -1058,11 +1057,10 @@ export function Dashboard() {
key={option.value} key={option.value}
type="button" type="button"
onClick={() => setTodoFormData(prev => ({ ...prev, status: option.value as TodoStatus }))} onClick={() => setTodoFormData(prev => ({ ...prev, status: option.value as TodoStatus }))}
className={`px-3 py-1 rounded-lg text-xs font-medium border transition-all ${ className={`px-3 py-1 rounded-lg text-xs font-medium border transition-all ${todoFormData.status === option.value
todoFormData.status === option.value ? getStatusClass(option.value)
? getStatusClass(option.value) : 'bg-white/10 text-white/50 border-white/20 hover:bg-white/20'
: 'bg-white/10 text-white/50 border-white/20 hover:bg-white/20' }`}
}`}
> >
{option.label} {option.label}
</button> </button>

View File

@@ -2,18 +2,17 @@ import { useState, useEffect, useCallback } from 'react';
import { import {
Calendar, Calendar,
Search, Search,
User, Filter,
XCircle,
RefreshCw, RefreshCw,
ChevronLeft,
ChevronRight,
Plus, Plus,
CheckCircle,
AlertCircle,
} from 'lucide-react'; } from 'lucide-react';
import { comms } from '../communication'; import { comms } from '../communication';
import { HolidayRequest, HolidayRequestSummary } from '../types'; import { HolidayRequest, HolidayRequestSummary, GroupUser } from '../types';
import { HolidayRequestDialog } from '../components/holiday/HolidayRequestDialog'; import { HolidayRequestDialog } from '../components/holiday/HolidayRequestDialog';
import { DevelopmentNotice } from '@/components/common/DevelopmentNotice'; import { DevelopmentNotice } from '@/components/DevelopmentNotice';
import { DateRangePicker } from '@/components/DateRangePicker';
import { UserSelector } from '@/components/UserSelector';
export default function HolidayRequestPage() { export default function HolidayRequestPage() {
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -22,7 +21,6 @@ export default function HolidayRequestPage() {
ApprovedDays: 0, ApprovedDays: 0,
ApprovedTimes: 0, ApprovedTimes: 0,
PendingDays: 0, PendingDays: 0,
PendingDays: 0,
PendingTimes: 0 PendingTimes: 0
}); });
const [balance, setBalance] = useState({ days: 0, times: 0 }); const [balance, setBalance] = useState({ days: 0, times: 0 });
@@ -30,13 +28,14 @@ export default function HolidayRequestPage() {
// 필터 상태 // 필터 상태
const [startDate, setStartDate] = useState(''); const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState(''); const [endDate, setEndDate] = useState('');
const [filterText, setFilterText] = useState('');
const [selectedUserId, setSelectedUserId] = useState('%'); const [selectedUserId, setSelectedUserId] = useState('%');
const [userLevel, setUserLevel] = useState(0); const [userLevel, setUserLevel] = useState(0);
const [currentUserId, setCurrentUserId] = useState(''); const [currentUserId, setCurrentUserId] = useState('');
const [currentUserName, setCurrentUserName] = useState(''); const [currentUserName, setCurrentUserName] = useState('');
// 사용자 목록 // 사용자 목록
const [users, setUsers] = useState<Array<{ id: string, name: string }>>([]); const [users, setUsers] = useState<GroupUser[]>([]);
// Dialog State // Dialog State
const [isDialogOpen, setIsDialogOpen] = useState(false); const [isDialogOpen, setIsDialogOpen] = useState(false);
@@ -80,30 +79,34 @@ export default function HolidayRequestPage() {
const loginStatus = await comms.checkLoginStatus(); const loginStatus = await comms.checkLoginStatus();
if (loginStatus.Success && loginStatus.IsLoggedIn && loginStatus.User) { if (loginStatus.Success && loginStatus.IsLoggedIn && loginStatus.User) {
const user = loginStatus.User as { Level?: number; Id?: string; NameK?: string; Name?: string }; const user = loginStatus.User as { Level?: number; Id?: string; NameK?: string; Name?: string };
setCurrentUserId(user.Id || ''); const userId = user.Id || '';
setCurrentUserName(user.NameK || user.Name || ''); const level = user.Level || 0;
setUserLevel(user.Level || 0);
// 사용자 목록 로드 setCurrentUserId(userId);
loadUsers(user.Level || 0); setCurrentUserName(user.NameK || user.Name || '');
setUserLevel(level);
// 기본 선택: 본인
setSelectedUserId(userId);
// 레벨 5 이상인 경우 사용자 목록 로드 (전체 조회 가능)
if (level >= 5) {
loadUsers();
} else {
// 권한이 없으면 본인만 선택 가능 (UI 콤보박스 비활성화)
setUsers([{ id: userId, name: user.NameK || user.Name || userId } as GroupUser]);
}
} }
} catch (error) { } catch (error) {
console.error('Failed to load user info:', error); console.error('Failed to load user info:', error);
} }
}; };
const loadUsers = async (level: number) => { const loadUsers = async () => {
try { try {
// 레벨 5 이상만 사용자 목록 조회 가능 const userList = await comms.getUserList('');
if (level >= 5) { if (userList && userList.length > 0) {
const userList = await comms.getUserList(''); setUsers(userList);
if (userList && userList.length > 0) {
const mappedUsers = userList.map((u: any) => ({
id: u.id || u.Id,
name: u.name || u.NameK || u.id
}));
setUsers([{ id: '%', name: '전체' }, ...mappedUsers]);
}
} }
} catch (error) { } catch (error) {
console.error('Failed to load users:', error); console.error('Failed to load users:', error);
@@ -125,6 +128,7 @@ export default function HolidayRequestPage() {
if (data.Summary) { if (data.Summary) {
setSummary(data.Summary); setSummary(data.Summary);
} }
console.log(summary, balance); // dummy use to avoid lint error while keeping states for future
} }
@@ -154,19 +158,7 @@ export default function HolidayRequestPage() {
} }
}, [startDate, endDate, selectedUserId, userLevel, currentUserId]); }, [startDate, endDate, selectedUserId, userLevel, currentUserId]);
// 월 이동
const moveMonth = (offset: number) => {
const current = new Date(startDate);
current.setMonth(current.getMonth() + offset);
const year = current.getFullYear();
const month = current.getMonth();
const newStart = new Date(year, month, 1);
const newEnd = new Date(year, month + 1, 0);
setStartDate(formatDate(newStart));
setEndDate(formatDate(newEnd));
};
const getCategoryName = (cate: string) => { const getCategoryName = (cate: string) => {
const categories: { [key: string]: string } = { const categories: { [key: string]: string } = {
@@ -185,20 +177,22 @@ export default function HolidayRequestPage() {
}; };
// 카운팅: 미래 휴가 (예정일 + 건수) // 카운팅: 미래 휴가 (예정일 + 건수)
const scheduledStats = requests.reduce((acc, req) => { /*
if (!req.sdate) return acc; const scheduledStats = requests.reduce((acc, req) => {
const today = new Date(); if (!req.sdate) return acc;
today.setHours(0, 0, 0, 0); const today = new Date();
const startDate = new Date(req.sdate); today.setHours(0, 0, 0, 0);
// 오늘 이후에 시작하는 휴가 const startDate = new Date(req.sdate);
if (startDate > today) { // 오늘 이후에 시작하는 휴가
return { if (startDate > today) {
count: acc.count + 1, return {
days: acc.days + (req.HolyDays || 0) count: acc.count + 1,
}; days: acc.days + (req.HolyDays || 0)
} };
return acc; }
}, { count: 0, days: 0 }); return acc;
}, { count: 0, days: 0 });
*/
const isFuture = (dateStr?: string) => { const isFuture = (dateStr?: string) => {
if (!dateStr) return false; if (!dateStr) return false;
@@ -208,244 +202,241 @@ export default function HolidayRequestPage() {
return startDate > today; return startDate > today;
}; };
const filteredRequests = requests.filter(req => {
if (!filterText) return true;
const searchLower = filterText.toLowerCase();
return (
(req.name || '').toLowerCase().includes(searchLower) ||
(req.uid || '').toLowerCase().includes(searchLower) ||
(req.HolyReason || '').toLowerCase().includes(searchLower) ||
(req.HolyLocation || '').toLowerCase().includes(searchLower) ||
getCategoryName(req.cate).toLowerCase().includes(searchLower)
);
});
return ( return (
<div className="space-y-6 animate-fade-in"> <div className="space-y-6 animate-fade-in">
<div className="max-w-[1920px] mx-auto space-y-6"> <div className="max-w-[1920px] mx-auto space-y-6">
<DevelopmentNotice /> <DevelopmentNotice />
{/* 상단 컨트롤 바 */} {/* 상단 컨트롤 바 */}
<div className="glass-effect rounded-2xl p-6"> <div className="glass-effect rounded-2xl p-6 relative z-20">
<div className="flex flex-col space-y-4"> <div className="flex flex-col space-y-4">
<div className="flex flex-col md:flex-row gap-4 items-end md:items-center justify-between"> <div className="flex flex-col md:flex-row gap-4 items-end md:items-center justify-between">
{/* Date Move & Pick */} {/* Date Move & Pick */}
<div className="flex items-center gap-2 w-full md:w-auto"> <div className="flex items-center gap-4 w-full md:w-auto flex-wrap">
<button <DateRangePicker
onClick={() => moveMonth(-1)} startDate={startDate}
className="p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors" endDate={endDate}
title="이전 달" onChange={(start, end) => {
> setStartDate(start);
<ChevronLeft className="w-5 h-5" /> setEndDate(end);
</button> }}
align="vertical"
/>
<div className="grid grid-cols-2 gap-2"> <div className="w-full md:w-64">
<input <UserSelector
type="date" users={users}
value={startDate} selectedIds={selectedUserId === '%' ? [] : [selectedUserId]}
onChange={(e) => setStartDate(e.target.value)} onChange={(ids) => setSelectedUserId(ids[0] || '%')}
className="bg-white/20 border border-white/30 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 text-sm" placeholder="신청자 선택"
/> className="w-full"
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="bg-white/20 border border-white/30 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 text-sm"
/> />
</div> </div>
<button <div className="h-10 flex items-end">
onClick={() => moveMonth(1)} <button
className="p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors" onClick={loadData}
title="다음 달" disabled={loading}
> className="flex-1 md:flex-none bg-white/10 hover:bg-white/20 text-white px-4 py-2 rounded-lg transition-colors flex items-center justify-center disabled:opacity-50 h-[60px]"
<ChevronRight className="w-5 h-5" /> >
</button> {loading ? <RefreshCw className="w-4 h-4 mr-2 animate-spin" /> : <Search className="w-4 h-4 mr-2" />}
</button>
</div>
</div> </div>
{/* User Select (Manager) */}
{userLevel >= 5 && (
<div className="w-full md:w-64">
<div className="relative">
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-white/50" />
<select
value={selectedUserId}
onChange={(e) => setSelectedUserId(e.target.value)}
className="w-full bg-white/20 border border-white/30 rounded-lg pl-10 pr-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 appearance-none"
>
{users.map(user => (
<option key={user.id} value={user.id} className="bg-[#1e1e2e]">{user.name}</option>
))}
</select>
</div>
</div>
)}
{/* Buttons */}
<div className="flex gap-2 w-full md:w-auto"> <div className="flex gap-2 w-full md:w-auto">
{/* Refresh / Search */}
<button
onClick={loadData}
disabled={loading}
className="flex-1 md:flex-none bg-white/10 hover:bg-white/20 text-white px-4 py-2 rounded-lg transition-colors flex items-center justify-center disabled:opacity-50"
>
{loading ? <RefreshCw className="w-4 h-4 mr-2 animate-spin" /> : <Search className="w-4 h-4 mr-2" />}
</button>
{/* Add Request */} {/* Add Request */}
<button <button
onClick={() => { onClick={() => {
setSelectedRequest(null); setSelectedRequest(null);
setIsDialogOpen(true); setIsDialogOpen(true);
}} }}
className="flex-1 md:flex-none bg-primary-500 hover:bg-primary-600 text-white px-4 py-2 rounded-lg transition-colors flex items-center justify-center" className="flex-1 md:flex-none bg-primary-500 hover:bg-primary-600 text-white px-4 py-2 rounded-lg transition-colors flex items-center justify-center font-bold"
> >
<Plus className="w-4 h-4 mr-2" /> <Plus className="w-5 h-5 mr-1" />
</button> </button>
</div> </div>
</div> </div>
</div> </div>
</div>
</div>
<div className="border-t border-white/10 my-4"></div> {/* List Table */}
<div className="glass-effect rounded-2xl overflow-hidden shadow-2xl transition-all duration-300">
<div className="px-6 py-4 flex flex-col md:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary-500/20 rounded-lg">
<Calendar className="w-5 h-5 text-primary-400" />
</div>
<h3 className="text-lg font-bold text-[var(--text-primary)] tracking-tight">
</h3>
</div>
{/* Summary Stats Cards (Merged) */} <div className="flex items-center gap-4 w-full md:w-auto">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> {/* 검색 필터 */}
<div className="relative flex-1 md:w-64 group">
<StatCard <Filter className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-[var(--text-secondary)] group-focus-within:text-primary-400 transition-colors" />
title="휴가 예정" <input
value={`${scheduledStats.count}건 (${scheduledStats.days}일)`} type="text"
icon={<Calendar className="w-6 h-6 text-blue-400" />} value={filterText}
color={scheduledStats.count > 0 ? "text-blue-400" : "text-white/50"} onChange={(e) => setFilterText(e.target.value)}
/> placeholder="검색..."
<StatCard className="w-full bg-[var(--bg-paper)] border border-[var(--border-color)] rounded-xl pl-10 pr-10 py-2 text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary-500/50 transition-all text-sm placeholder-[var(--text-muted)]"
title="휴가 잔량 (일)" />
value={`${balance.days.toFixed(1)}`} {filterText && (
icon={<RefreshCw className="w-6 h-6 text-green-400" />} <button
color="text-green-400" onClick={() => setFilterText('')}
/> className="absolute right-3 top-1/2 transform -translate-y-1/2 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
<StatCard >
title="휴가 잔량 (시간)" <XCircle className="w-4 h-4" />
value={`${balance.times.toFixed(1)}시간`} </button>
icon={<RefreshCw className="w-6 h-6 text-purple-400" />} )}
color="text-purple-400" </div>
/> <div className="flex items-center gap-1 bg-[var(--bg-paper)] px-3 py-1.5 rounded-lg border border-[var(--border-color)]">
<span className="text-[var(--text-primary)] font-bold text-sm">{filteredRequests.length}</span>
<span className="text-[var(--text-secondary)] text-xs"></span>
{requests.length !== filteredRequests.length && <span className="text-[var(--text-muted)] text-xs ml-1">( {requests.length})</span>}
</div>
</div> </div>
</div> </div>
{/* List Table */} <div className="overflow-x-auto">
<div className="glass-effect rounded-2xl overflow-hidden"> <table className="w-full">
<div className="px-6 py-4 border-b border-white/10 flex justify-between items-center"> <thead className="bg-[var(--bg-paper)]/50">
<h3 className="text-lg font-semibold text-white"> </h3> <tr>
<span className="text-white/50 text-sm"> <th className="px-4 py-3 text-left text-xs font-medium text-[var(--text-secondary)] uppercase w-[50px]">No</th>
{requests.length} {/* <th className="px-4 py-3 text-left text-xs font-medium text-[var(--text-secondary)] uppercase">부서</th> <- Removed */}
</span> <th className="px-4 py-3 text-left text-xs font-medium text-[var(--text-secondary)] uppercase w-[100px]"></th>
</div> <th className="px-4 py-3 text-left text-xs font-medium text-[var(--text-secondary)] uppercase w-[80px]"></th>
<th className="px-4 py-3 text-center text-xs font-medium text-[var(--text-secondary)] uppercase w-[80px]"></th>
<div className="overflow-x-auto"> <th className="px-4 py-3 text-left text-xs font-medium text-[var(--text-secondary)] uppercase w-[140px]"></th>
<table className="w-full"> <th className="px-4 py-3 text-left text-xs font-medium text-[var(--text-secondary)] uppercase w-[140px]"></th>
<thead className="bg-white/10"> <th className="px-4 py-3 text-center text-xs font-medium text-[var(--text-secondary)] uppercase w-[60px]"></th>
<th className="px-4 py-3 text-center text-xs font-medium text-[var(--text-secondary)] uppercase w-[60px]"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--text-secondary)] uppercase w-[100px]"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-[var(--text-secondary)] uppercase"></th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--border-color)]">
{loading ? (
<tr> <tr>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase w-[50px]">No</th> <td colSpan={10} className="px-4 py-8 text-center bg-transparent">
{/* <th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">부서</th> <- Removed */} <div className="flex items-center justify-center">
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase w-[100px]"></th> <RefreshCw className="w-5 h-5 mr-2 animate-spin text-[var(--text-secondary)]" />
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase w-[80px]"></th> <span className="text-[var(--text-secondary)]"> ...</span>
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-[80px]"></th> </div>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase w-[140px]"></th> </td>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase w-[140px]"></th>
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-[60px]"></th>
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-[60px]"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase w-[100px]"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
</tr> </tr>
</thead> ) : filteredRequests.length === 0 ? (
<tbody className="divide-y divide-white/10"> <tr>
{loading ? ( <td colSpan={10} className="px-4 py-8 text-center text-[var(--text-secondary)] bg-transparent">
<tr> {filterText ? '검색 결과가 없습니다.' : '조회된 데이터가 없습니다.'}
<td colSpan={10} className="px-4 py-8 text-center bg-transparent"> </td>
<div className="flex items-center justify-center"> </tr>
<RefreshCw className="w-5 h-5 mr-2 animate-spin text-white/50" /> ) : (
<span className="text-white/50"> ...</span> filteredRequests.map((req, index) => {
</div> // 미래 휴가 배경색 처리
</td> const isFutureRequest = isFuture(req.sdate);
</tr>
) : requests.length === 0 ? (
<tr>
<td colSpan={9} className="px-4 py-8 text-center text-white/50 bg-transparent">
.
</td>
</tr>
) : (
requests.map((req, index) => {
// 미래 휴가 배경색 처리
const isFutureRequest = isFuture(req.sdate);
const rowClass = isFutureRequest
? "bg-blue-500/10 hover:bg-blue-500/20 transition-colors cursor-pointer"
: "hover:bg-white/5 transition-colors cursor-pointer";
return ( return (
<tr <tr
key={req.idx} key={req.idx}
className={rowClass} onClick={() => {
onClick={() => { // 승인되지 않았거나(1이 아님), 본인이거나, 관리자(Lev >=5)인 경우만 상세 보기 가능
if (req.conf !== 1 || req.uid === currentUserId || userLevel >= 5) {
setSelectedRequest(req); setSelectedRequest(req);
setIsDialogOpen(true); setIsDialogOpen(true);
}} }
> }}
<td className="px-4 py-3 text-white/50 text-sm">{index + 1}</td> className={`
{/* <td className="px-4 py-3 text-white/70 text-sm">{req.dept || '-'}</td> <- Removed */} ${index % 2 === 0 ? 'bg-[var(--bg-paper)]/30' : ''}
<td className="px-4 py-3 text-white text-sm font-medium">{req.name || '-'}</td> hover:bg-[var(--bg-paper)]/50 transition-colors cursor-pointer
<td className="px-4 py-3 text-sm"> ${(req.conf === 1 && req.uid !== currentUserId && userLevel < 5) ? 'opacity-50 grayscale cursor-not-allowed' : ''}
<span className={`px-2 py-1 rounded text-xs ${req.cate === '1' ? 'bg-primary-500/20 text-primary-300' : // 연차 `}
req.cate === '2' ? 'bg-blue-500/20 text-blue-300' : // 반차 >
req.cate === '5' ? 'bg-yellow-500/20 text-yellow-300' : // 외출 <td className="px-4 py-3 text-[var(--text-secondary)] text-sm">{index + 1}</td>
'bg-white/10 text-white/70' <td className="px-4 py-3 text-[var(--text-primary)] text-sm font-medium">{req.name || '-'}</td>
}`}> <td className="px-4 py-3 text-sm">
{getCategoryName(req.cate)} <span className={`px-2 py-1 rounded text-xs ${req.cate === '1' ? 'bg-primary-500/20 text-primary-300' : // 연차
</span> req.cate === '2' ? 'bg-blue-500/20 text-blue-300' : // 반차
</td> req.cate === '5' ? 'bg-green-500/20 text-green-300' : // 외출 (Green으로 변경 요청 있었음)
<td className="px-4 py-3 text-center"> 'bg-[var(--bg-paper)] text-[var(--text-secondary)]'
<span className={`px-2 py-1 rounded text-xs font-semibold ${req.conf === 1 }`}>
? 'bg-success-500/20 text-success-300' {getCategoryName(req.cate)}
: 'bg-danger-500/20 text-danger-300' </span>
}`}> </td>
{getConfirmStatusText(req.conf)} <td className="px-4 py-3 text-center">
</span> <span className={`px-2 py-1 rounded text-xs font-semibold ${req.conf === 1
</td> ? 'bg-success-500/20 text-success-300'
<td className="px-4 py-3 text-white text-sm"> : 'bg-danger-500/20 text-danger-300'
{formatDateShort(req.sdate)} }`}>
{isFutureRequest && <span className="ml-2 text-[10px] bg-blue-500 text-white px-1.5 py-0.5 rounded-full"></span>} {getConfirmStatusText(req.conf)}
</td> </span>
<td className="px-4 py-3 text-white text-sm">{formatDateShort(req.edate)}</td> </td>
<td className="px-4 py-3 text-center text-white text-sm">{req.HolyDays || 0}</td> <td className="px-4 py-3 text-[var(--text-primary)] text-sm">
<td className="px-4 py-3 text-center text-white text-sm">{req.HolyTimes || 0}</td> {formatDateShort(req.sdate)}
<td className="px-4 py-3 text-white text-sm max-w-[150px] truncate">{req.HolyLocation || '-'}</td> {isFutureRequest && <span className="ml-2 text-[10px] bg-blue-500 text-white px-1.5 py-0.5 rounded-full"></span>}
<td className="px-4 py-3 text-white/70 text-sm max-w-xs truncate" title={req.HolyReason || ''}> </td>
{req.HolyReason || '-'} <td className="px-4 py-3 text-[var(--text-primary)] text-sm">{formatDateShort(req.edate)}</td>
</td> <td className="px-4 py-3 text-center text-[var(--text-primary)] text-sm">{req.HolyDays || 0}</td>
</tr> <td className="px-4 py-3 text-center text-[var(--text-primary)] text-sm">{req.HolyTimes || 0}</td>
); <td className="px-4 py-3 text-[var(--text-primary)] text-sm max-w-[150px] truncate">{req.HolyLocation || '-'}</td>
}) <td className="px-4 py-3 text-[var(--text-secondary)] text-sm max-w-xs truncate" title={req.HolyReason || ''}>
)} {req.HolyReason || '-'}
</tbody> </td>
</table> </tr>
</div> );
})
)}
</tbody>
</table>
</div> </div>
</div>
{/* 다이얼로그 */} {/* 다이얼로그 */}
<HolidayRequestDialog <HolidayRequestDialog
isOpen={isDialogOpen} isOpen={isDialogOpen}
onClose={() => setIsDialogOpen(false)} onClose={() => setIsDialogOpen(false)}
onSave={() => { onSave={() => {
setIsDialogOpen(false); setIsDialogOpen(false);
loadData(); loadData();
}} }}
initialData={selectedRequest} initialData={selectedRequest}
currentUserName={currentUserName} currentUserName={currentUserName}
currentUserId={currentUserId} currentUserId={currentUserId}
userLevel={userLevel} userLevel={userLevel}
/> />
</div > </div > </div>
); );
} }
// 통계 카드 컴포넌트 (Local) // 통계 카드 컴포넌트 (Local)
/*
interface StatCardProps { interface StatCardProps {
title: string; title: string;
value: string | number; value: string | number;
icon: React.ReactNode; icon: React.ReactNode;
color: string; color: string;
} }
*/
/*
function StatCard({ title, value, icon, color }: StatCardProps) { function StatCard({ title, value, icon, color }: StatCardProps) {
return ( return (
<div className="glass-effect rounded-xl p-4 card-hover"> <div className="glass-effect rounded-xl p-4 card-hover">
@@ -461,3 +452,4 @@ function StatCard({ title, value, icon, color }: StatCardProps) {
</div> </div>
); );
} }
*/

View File

@@ -6,9 +6,8 @@ import { ItemInfo, ItemDetail, SupplierStaff, PurchaseHistoryItem } from '@/type
import { ItemEditDialog } from '@/components/items'; import { ItemEditDialog } from '@/components/items';
export function ItemsPage() { export function ItemsPage() {
const [categories, setCategories] = useState<string[]>([]);
const [items, setItems] = useState<ItemInfo[]>([]); const [items, setItems] = useState<ItemInfo[]>([]);
const [selectedCategory, setSelectedCategory] = useState<string>('all'); const [selectedCategory] = useState<string>('all');
const [searchKey, setSearchKey] = useState(''); const [searchKey, setSearchKey] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [filter, setFilter] = useState(''); const [filter, setFilter] = useState('');
@@ -24,20 +23,9 @@ export function ItemsPage() {
const [detailLoading, setDetailLoading] = useState(false); const [detailLoading, setDetailLoading] = useState(false);
useEffect(() => { useEffect(() => {
loadCategories(); // No categories to load
}, []); }, []);
const loadCategories = async () => {
try {
const result = await comms.getItemCategories();
if (result.Success && result.Data) {
setCategories(result.Data);
}
} catch (error) {
console.error('카테고리 로드 실패:', error);
}
};
const loadItems = async () => { const loadItems = async () => {
if (!searchKey.trim()) { if (!searchKey.trim()) {
alert('검색어를 입력하세요'); alert('검색어를 입력하세요');
@@ -180,258 +168,287 @@ export function ItemsPage() {
); );
return ( return (
<div className="h-full flex flex-col"> <div className="h-full flex flex-col space-y-4 animate-fade-in pb-4">
{/* 헤더 */} {/* 메인 컨테이너 */}
<div className="glass-effect rounded-xl p-4 mb-4">
<div className="flex items-center gap-4 flex-wrap">
<select
value={selectedCategory}
onChange={(e) => setSelectedCategory(e.target.value)}
className="px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white min-w-[150px]"
>
<option value="all" className="bg-slate-800">-- --</option>
{categories.map((c) => (
<option key={c} value={c} className="bg-slate-800">{c}</option>
))}
</select>
<div className="flex-1 flex items-center gap-2">
<div className="relative flex-1 max-w-xs">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/40" />
<input
type="text"
value={searchKey}
onChange={(e) => setSearchKey(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && loadItems()}
placeholder="품목명/SID/모델 검색..."
className="w-full pl-9 pr-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40"
/>
</div>
<button
onClick={loadItems}
className="flex items-center gap-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white transition-colors"
>
<Search className="w-4 h-4" />
</button>
</div>
<div className="flex items-center gap-2">
<input
type="text"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="필터..."
className="px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40 w-32"
/>
<button
onClick={handleAddNew}
className="flex items-center gap-1 px-3 py-2 bg-green-600 hover:bg-green-700 rounded-lg text-white transition-colors"
>
<Plus className="w-4 h-4" />
</button>
</div>
</div>
</div>
{/* 메인 컨텐츠: 목록 + 상세 패널 */}
<div className="flex-1 flex gap-4 min-h-0"> <div className="flex-1 flex gap-4 min-h-0">
{/* 품목 목록 (좌측) */} {/* 통합 리스트 카드 (좌측) */}
<div className="flex-1 glass-effect rounded-xl overflow-hidden flex flex-col"> <div className="flex-1 glass-effect rounded-3xl overflow-hidden shadow-2xl border border-white/10 flex flex-col">
<div className="p-4 border-b border-white/10 flex items-center gap-2"> {/* 헤더 영역 */}
<Package className="w-5 h-5 text-white/70" /> <div className="px-6 py-4 border-b border-white/10 flex flex-col md:flex-row items-center justify-between gap-4 bg-white/[0.02] shrink-0">
<h2 className="text-lg font-semibold text-white"> </h2> <div className="flex items-center gap-3">
<span className="text-sm text-white/50">({filteredItems.length})</span> <div className="p-2 bg-primary-500/20 rounded-lg">
<Package className="w-5 h-5 text-primary-400" />
</div>
<div className="flex flex-col">
<h3 className="text-lg font-bold text-white tracking-tight"> </h3>
</div>
<div className="flex items-center gap-2 px-3 py-1 bg-white/5 rounded-full border border-white/5 ml-2">
<span className="text-white/40 text-xs font-medium uppercase">Total</span>
<span className="text-primary-400 text-sm font-bold">{items.length}</span>
</div>
</div>
<div className="flex items-center gap-3 w-full md:w-auto">
<div className="relative group">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/40 group-focus-within:text-primary-400 transition-colors" />
<input
type="text"
value={searchKey}
onChange={(e) => setSearchKey(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && loadItems()}
placeholder="품목명, SID, 모델..."
className="w-64 bg-white/5 border border-white/10 rounded-xl pl-9 pr-4 py-2 text-sm text-white placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all"
/>
</div>
<div className="relative group">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-white/40" />
<input
type="text"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="결과 내 필터"
className="w-40 bg-white/5 border border-white/10 rounded-xl pl-8 pr-3 py-2 text-xs text-white placeholder-white/20 focus:outline-none focus:border-primary-500/30 transition-all"
/>
</div>
<button
onClick={loadItems}
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="검색 실행"
>
<Search className="w-4 h-4" />
</button>
<button
onClick={handleAddNew}
className="p-2 bg-success-500 hover:bg-success-600 border border-white/20 rounded-xl text-white transition-all shadow-lg shadow-success-500/20 active:scale-95 ml-2"
title="신규 품목 추가"
>
<Plus className="w-4 h-4" />
</button>
</div>
</div> </div>
<div className="flex-1 overflow-auto">
{/* 메인 리스트 헤더 (Note style) */}
<div className="bg-white/5 px-6 py-3 border-b border-white/5 flex items-center text-list-header font-list-header text-white/opacity-header-muted uppercase shrink-0">
<div className="w-28 px-4">SID</div>
<div className="w-24 px-4 text-center"></div>
<div className="flex-1 px-4"></div>
<div className="flex-1 px-4"></div>
<div className="w-24 px-4 text-right"></div>
<div className="w-32 px-4"></div>
</div>
{/* 리스트 본문 */}
<div className="flex-1 overflow-y-auto custom-scrollbar divide-y divide-white/5">
{loading ? ( {loading ? (
<div className="flex items-center justify-center h-32"> <div className="flex items-center justify-center h-full">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-white"></div> <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary-500"></div>
</div>
) : filteredItems.length === 0 ? (
<div className="flex flex-col items-center justify-center h-full text-white/40">
<Package className="w-12 h-12 mb-4 opacity-20" />
<p>{items.length === 0 ? '검색어를 입력하세요.' : '검색 결과가 없습니다.'}</p>
</div> </div>
) : ( ) : (
<table className="w-full text-sm"> filteredItems.map((item) => (
<thead className="bg-white/5 sticky top-0"> <div
<tr> key={item.idx || 'new'}
<th className="px-3 py-2 text-left font-medium text-white/70 w-28">SID</th> onClick={() => handleRowClick(item)}
<th className="px-3 py-2 text-left font-medium text-white/70 w-20"></th> className={clsx(
<th className="px-3 py-2 text-left font-medium text-white/70"></th> 'flex items-center px-6 py-3 hover:bg-white/[0.02] transition-colors cursor-pointer group text-[length:var(--fs-list-item)]',
<th className="px-3 py-2 text-left font-medium text-white/70"></th> item.disable && 'opacity-50 grayscale',
<th className="px-3 py-2 text-right font-medium text-white/70 w-24"></th> selectedItemDetail?.idx === item.idx && 'bg-primary-500/10'
<th className="px-3 py-2 text-left font-medium text-white/70 w-24"></th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{filteredItems.map((item) => (
<tr
key={item.idx || 'new'}
onClick={() => handleRowClick(item)}
onDoubleClick={() => handleRowDoubleClick(item)}
className={clsx(
'hover:bg-white/10 transition-colors cursor-pointer',
item.disable && 'opacity-50',
selectedItemDetail?.idx === item.idx && 'bg-blue-600/30'
)}
>
<td className="px-3 py-2 text-white font-mono">{item.sid}</td>
<td className="px-3 py-2 text-white/70">{item.cate}</td>
<td className="px-3 py-2 text-white">{item.name}</td>
<td className="px-3 py-2 text-white/70">{item.model}</td>
<td className="px-3 py-2 text-white text-right">{(item.price ?? 0).toLocaleString()}</td>
<td className="px-3 py-2 text-white/70">{item.supply}</td>
</tr>
))}
{filteredItems.length === 0 && (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-white/50">
{items.length === 0 ? '검색어를 입력하고 검색 버튼을 클릭하세요.' : '검색 결과가 없습니다.'}
</td>
</tr>
)} )}
</tbody> >
</table> <div
onClick={(e) => {
e.stopPropagation(); // 부모 Row의 클릭 이벤트(상세보기) 방지하고
handleRowDoubleClick(item); // 편집 다이얼로그 오픈 (함수명은 DoubleClick이지만 동작은 클릭으로 변경)
}}
className="w-28 px-4 font-mono text-[yellow] group-hover:text-[#ff7f66] transition-colors underline decoration-primary-500/50 hover:decoration-primary-500 cursor-pointer text-[15px]"
>
{item.sid}
</div>
<div className="w-24 px-4 text-center">
<span className="bg-white/10 px-2 py-0.5 rounded text-xs text-white/70">
{item.cate}
</span>
</div>
<div className="flex-1 px-4 text-white font-medium group-hover:text-white transition-colors">
{item.name}
</div>
<div className="flex-1 px-4 text-white/60">
{item.model}
</div>
<div className="w-24 px-4 text-right text-white/90 font-mono">
{(item.price ?? 0).toLocaleString()}
</div>
<div className="w-32 px-4 text-white/60 truncate">
{item.supply}
</div>
</div>
))
)} )}
</div> </div>
</div> </div>
{/* 상세 패널 (우측) */} {/* 상세 패널 (우측) - Glass Effect & Typography Update */}
<div className="w-80 flex flex-col gap-4"> <div className="w-80 flex flex-col gap-4">
{/* 이미지 */} {/* 이미지 */}
<div className="glass-effect rounded-xl p-3"> <div className="glass-effect rounded-3xl p-4 border border-white/10">
<div className="flex items-center gap-2 mb-2 border-b border-white/10 pb-2"> <div className="flex items-center gap-2 mb-3 border-b border-white/10 pb-2">
<Image className="w-4 h-4 text-white/70" /> <Image className="w-4 h-4 text-primary-400" />
<h3 className="text-sm font-medium text-white"> </h3> <h3 className="text-sm font-bold text-white tracking-tight"> </h3>
</div> </div>
<div className="aspect-[4/3] bg-white/5 rounded-lg flex items-center justify-center overflow-hidden"> <div className="aspect-[4/3] bg-black/20 rounded-xl flex items-center justify-center overflow-hidden border border-white/5 group relative">
{detailLoading ? ( {detailLoading ? (
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-white/50"></div> <div className="animate-spin rounded-full h-6 w-6 border-b-2 border-white/30"></div>
) : itemImage ? ( ) : itemImage ? (
<img <>
src={`data:image/jpeg;base64,${itemImage}`} <img
alt="품목 이미지" src={`data:image/jpeg;base64,${itemImage}`}
className="max-w-full max-h-full object-contain" alt="품목 이미지"
/> className="max-w-full max-h-full object-contain transition-transform duration-300 group-hover:scale-105"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/20 transition-colors pointer-events-none" />
</>
) : ( ) : (
<span className="text-white/30 text-sm"> </span> <div className="flex flex-col items-center gap-2 text-white/20">
<Image className="w-8 h-8 opacity-50" />
<span className="text-xs"> </span>
</div>
)} )}
</div> </div>
</div> </div>
{/* 공급처 담당자 */} {/* 공급처 담당자 */}
<div className="glass-effect rounded-xl p-3"> <div className="glass-effect rounded-3xl p-4 border border-white/10">
<div className="flex items-center gap-2 mb-2 border-b border-white/10 pb-2"> <div className="flex items-center gap-2 mb-3 border-b border-white/10 pb-2">
<Users className="w-4 h-4 text-white/70" /> <Users className="w-4 h-4 text-accent-400" />
<h3 className="text-sm font-medium text-white"> <h3 className="text-sm font-bold text-white tracking-tight">
{selectedItemDetail?.supply ? `[${selectedItemDetail.supply}] 담당자` : '공급처 담당자'} {selectedItemDetail?.supply ? `[${selectedItemDetail.supply}] 담당자` : '공급처 담당자'}
</h3> </h3>
</div> </div>
<div className="max-h-32 overflow-auto"> <div className="max-h-32 overflow-y-auto custom-scrollbar">
{detailLoading ? ( {detailLoading ? (
<div className="text-center py-2"> <div className="text-center py-4">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white/50 mx-auto"></div> <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white/30 mx-auto"></div>
</div> </div>
) : supplierStaff.length > 0 ? ( ) : supplierStaff.length > 0 ? (
<table className="w-full text-xs"> <table className="w-full text-xs">
<thead className="bg-white/5"> <thead className="bg-white/5 sticky top-0 backdrop-blur-sm">
<tr> <tr>
<th className="px-2 py-1 text-left text-white/60"></th> <th className="px-2 py-1.5 text-left text-list-header font-list-header text-white/opacity-header-muted uppercase"></th>
<th className="px-2 py-1 text-left text-white/60"></th> <th className="px-2 py-1.5 text-left text-list-header font-list-header text-white/opacity-header-muted uppercase"></th>
<th className="px-2 py-1 text-left text-white/60"></th> <th className="px-2 py-1.5 text-left text-list-header font-list-header text-white/opacity-header-muted uppercase"></th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-white/5"> <tbody className="divide-y divide-white/5">
{supplierStaff.map((staff) => ( {supplierStaff.map((staff) => (
<tr key={staff.idx}> <tr key={staff.idx} className="hover:bg-white/5 transition-colors">
<td className="px-2 py-1 text-white">{staff.name}</td> <td className="px-2 py-1.5 text-white/90">{staff.name}</td>
<td className="px-2 py-1 text-white/70">{staff.tel}</td> <td className="px-2 py-1.5 text-white/70">{staff.tel}</td>
<td className="px-2 py-1 text-white/70 truncate max-w-[100px]" title={staff.email}>{staff.email}</td> <td className="px-2 py-1.5 text-white/70 truncate max-w-[100px]" title={staff.email}>{staff.email}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
) : ( ) : (
<div className="text-center text-white/30 text-xs py-2"> </div> <div className="text-center text-white/30 text-xs py-4 flex flex-col items-center gap-1">
<Users className="w-6 h-6 opacity-20" />
<span> </span>
</div>
)} )}
</div> </div>
</div> </div>
{/* 최근 입고내역 */} {/* 최근 입고내역 */}
<div className="glass-effect rounded-xl p-3 flex-1 min-h-0 flex flex-col"> <div className="glass-effect rounded-3xl p-4 border border-white/10 flex-1 min-h-0 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 mb-2 border-b border-white/10 pb-2"> <div className="flex items-center gap-2 mb-3 border-b border-white/10 pb-2 shrink-0">
<TrendingDown className="w-4 h-4 text-green-400" /> <TrendingDown className="w-4 h-4 text-success-400" />
<h3 className="text-sm font-medium text-white"> </h3> <h3 className="text-sm font-bold text-white tracking-tight"> </h3>
</div> </div>
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-y-auto custom-scrollbar">
{detailLoading ? ( {detailLoading ? (
<div className="text-center py-2"> <div className="text-center py-4">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white/50 mx-auto"></div> <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white/30 mx-auto"></div>
</div> </div>
) : incomingHistory.length > 0 ? ( ) : incomingHistory.length > 0 ? (
<table className="w-full text-xs"> <table className="w-full text-xs">
<thead className="bg-white/5 sticky top-0"> <thead className="bg-white/5 sticky top-0 backdrop-blur-sm z-10">
<tr> <tr>
<th className="px-1 py-1 text-left text-white/60"></th> <th className="px-2 py-1.5 text-left text-list-header font-list-header text-white/opacity-header-muted uppercase"></th>
<th className="px-1 py-1 text-left text-white/60"></th> <th className="px-2 py-1.5 text-left text-list-header font-list-header text-white/opacity-header-muted uppercase"></th>
<th className="px-1 py-1 text-right text-white/60"></th> <th className="px-2 py-1.5 text-right text-list-header font-list-header text-white/opacity-header-muted uppercase"></th>
<th className="px-1 py-1 text-right text-white/60"></th> <th className="px-2 py-1.5 text-left text-list-header font-list-header text-white/opacity-header-muted uppercase pl-4"></th>
<th className="px-1 py-1 text-left text-white/60"></th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-white/5"> <tbody className="divide-y divide-white/5">
{incomingHistory.map((h) => ( {incomingHistory.map((h) => (
<tr key={h.idx}> <tr key={h.idx} className="hover:bg-white/5 transition-colors">
<td className="px-1 py-1 text-white/80 whitespace-nowrap">{h.date}</td> <td className="px-2 py-1.5 text-white/80 whitespace-nowrap font-mono text-[11px]">{h.date}</td>
<td className="px-1 py-1 text-white/70 truncate max-w-[50px]" title={h.request}>{h.request}</td> <td className="px-2 py-1.5 text-white/70 truncate max-w-[50px]" title={h.request}>{h.request}</td>
<td className="px-1 py-1 text-white text-right">{h.qty.toLocaleString()}</td> <td className="px-2 py-1.5 text-white text-right font-mono">{h.qty.toLocaleString()}</td>
<td className="px-1 py-1 text-white text-right">{h.price.toLocaleString()}</td> <td className="px-2 py-1.5 text-white/70 pl-4">{h.state}</td>
<td className="px-1 py-1 text-white/70">{h.state}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
) : ( ) : (
<div className="text-center text-white/30 text-xs py-2"> </div> <div className="text-center text-white/30 text-xs py-4 flex flex-col items-center gap-1">
<TrendingDown className="w-6 h-6 opacity-20" />
<span> </span>
</div>
)} )}
</div> </div>
</div> </div>
{/* 발주내역 */} {/* 발주내역 */}
<div className="glass-effect rounded-xl p-3 flex-1 min-h-0 flex flex-col"> <div className="glass-effect rounded-3xl p-4 border border-white/10 flex-1 min-h-0 flex flex-col overflow-hidden">
<div className="flex items-center gap-2 mb-2 border-b border-white/10 pb-2"> <div className="flex items-center gap-2 mb-3 border-b border-white/10 pb-2 shrink-0">
<ShoppingCart className="w-4 h-4 text-blue-400" /> <ShoppingCart className="w-4 h-4 text-blue-400" />
<h3 className="text-sm font-medium text-white"></h3> <h3 className="text-sm font-bold text-white tracking-tight"></h3>
</div> </div>
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-y-auto custom-scrollbar">
{detailLoading ? ( {detailLoading ? (
<div className="text-center py-2"> <div className="text-center py-4">
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white/50 mx-auto"></div> <div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white/30 mx-auto"></div>
</div> </div>
) : orderHistory.length > 0 ? ( ) : orderHistory.length > 0 ? (
<table className="w-full text-xs"> <table className="w-full text-xs">
<thead className="bg-white/5 sticky top-0"> <thead className="bg-white/5 sticky top-0 backdrop-blur-sm z-10">
<tr> <tr>
<th className="px-1 py-1 text-left text-white/60"></th> <th className="px-2 py-1.5 text-left text-list-header font-list-header text-white/opacity-header-muted uppercase"></th>
<th className="px-1 py-1 text-left text-white/60"></th> <th className="px-2 py-1.5 text-left text-list-header font-list-header text-white/opacity-header-muted uppercase"></th>
<th className="px-1 py-1 text-right text-white/60"></th> <th className="px-2 py-1.5 text-right text-list-header font-list-header text-white/opacity-header-muted uppercase"></th>
<th className="px-1 py-1 text-right text-white/60"></th> <th className="px-2 py-1.5 text-left text-list-header font-list-header text-white/opacity-header-muted uppercase pl-4"></th>
<th className="px-1 py-1 text-left text-white/60"></th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-white/5"> <tbody className="divide-y divide-white/5">
{orderHistory.map((h) => ( {orderHistory.map((h) => (
<tr key={h.idx}> <tr key={h.idx} className="hover:bg-white/5 transition-colors">
<td className="px-1 py-1 text-white/80 whitespace-nowrap">{h.date}</td> <td className="px-2 py-1.5 text-white/80 whitespace-nowrap font-mono text-[11px]">{h.date}</td>
<td className="px-1 py-1 text-white/70 truncate max-w-[50px]" title={h.request}>{h.request}</td> <td className="px-2 py-1.5 text-white/70 truncate max-w-[50px]" title={h.request}>{h.request}</td>
<td className="px-1 py-1 text-white text-right">{h.qty.toLocaleString()}</td> <td className="px-2 py-1.5 text-white text-right font-mono">{h.qty.toLocaleString()}</td>
<td className="px-1 py-1 text-white text-right">{h.price.toLocaleString()}</td> <td className="px-2 py-1.5 text-white/70 pl-4">{h.state}</td>
<td className="px-1 py-1 text-white/70">{h.state}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </table>
) : ( ) : (
<div className="text-center text-white/30 text-xs py-2"> </div> <div className="text-center text-white/30 text-xs py-4 flex flex-col items-center gap-1">
<ShoppingCart className="w-6 h-6 opacity-20" />
<span> </span>
</div>
)} )}
</div> </div>
</div> </div>

View File

@@ -9,16 +9,19 @@ import {
Calendar, Calendar,
AlertTriangle, AlertTriangle,
X, X,
XCircle,
} from 'lucide-react'; } from 'lucide-react';
import { comms } from '@/communication'; import { comms } from '@/communication';
import { JobReportItem, JobReportUser } from '@/types'; import { JobReportItem, GroupUser } from '@/types';
import { JobreportEditModal, JobreportFormData, initialFormData } from '@/components/jobreport/JobreportEditModal'; import { JobreportEditModal, JobreportFormData, initialFormData } from '@/components/jobreport/JobreportEditModal';
import { JobReportDayDialog } from '@/components/jobreport/JobReportDayDialog'; import { JobReportDayDialog } from '@/components/jobreport/JobReportDayDialog';
import { JobreportTypeModal } from '@/components/jobreport/JobreportTypeModal'; import { JobreportTypeModal } from '@/components/jobreport/JobreportTypeModal';
import { DateRangePicker } from '@/components/DateRangePicker';
import { UserSelector } from '@/components/UserSelector';
export function Jobreport() { export function Jobreport() {
const [jobreportList, setJobreportList] = useState<JobReportItem[]>([]); const [jobreportList, setJobreportList] = useState<JobReportItem[]>([]);
const [users, setUsers] = useState<JobReportUser[]>([]); const [users, setUsers] = useState<GroupUser[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
@@ -26,6 +29,7 @@ export function Jobreport() {
const [startDate, setStartDate] = useState(''); const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState(''); const [endDate, setEndDate] = useState('');
const [selectedUser, setSelectedUser] = useState(''); const [selectedUser, setSelectedUser] = useState('');
const [loginUserId, setLoginUserId] = useState('');
const [searchKey, setSearchKey] = useState(''); const [searchKey, setSearchKey] = useState('');
// 모달 상태 // 모달 상태
@@ -149,6 +153,7 @@ export function Jobreport() {
if (loginStatus.Success && loginStatus.IsLoggedIn && loginStatus.User) { if (loginStatus.Success && loginStatus.IsLoggedIn && loginStatus.User) {
userId = loginStatus.User.Id; userId = loginStatus.User.Id;
setSelectedUser(userId); setSelectedUser(userId);
setLoginUserId(userId);
} }
} catch (error) { } catch (error) {
console.error('로그인 정보 로드 오류:', error); console.error('로그인 정보 로드 오류:', error);
@@ -191,8 +196,30 @@ export function Jobreport() {
// 사용자 목록 로드 // 사용자 목록 로드
const loadUsers = async () => { const loadUsers = async () => {
try { try {
const result = await comms.getJobReportUsers(); const result = await comms.getUserList('');
setUsers(result || []); if (result) {
const today = new Date().toISOString().split('T')[0];
const includeResigned = true; // 퇴사자 포함 여부 (조회용이므로 포함)
const minLevel = 1; // 최소 레벨
const filtered = result.filter(u => {
// 1. 레벨 체크
if ((u.level || 0) < minLevel) return false;
// 2. 업무일지 사용여부 체크 (목록에 표시하되 토마토색으로 구분하기 위해 필터 해제)
if (!u.useJobReport) return false;
// 3. 퇴사자 체크
if (!includeResigned && u.outdate && u.outdate < today) {
return false;
}
return true;
});
setUsers(filtered);
} else {
setUsers([]);
}
} catch (error) { } catch (error) {
console.error('사용자 목록 로드 오류:', error); console.error('사용자 목록 로드 오류:', error);
} }
@@ -238,6 +265,12 @@ export function Jobreport() {
// 새 업무일지 추가 모달 // 새 업무일지 추가 모달
const openAddModal = () => { const openAddModal = () => {
// 본인이 아닌 경우 추가 불가
if (selectedUser !== loginUserId) {
alert('다른 사용자의 업무일지는 등록할 수 없습니다.');
return;
}
setEditingItem(null); setEditingItem(null);
setFormData({ setFormData({
...initialFormData, ...initialFormData,
@@ -436,166 +469,78 @@ export function Jobreport() {
handleSearch(); handleSearch();
}; };
// 빠른 날짜 선택 함수들
const setToday = () => {
const today = new Date();
setStartDate(formatDateLocal(today));
};
const setThisMonth = () => {
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0);
setStartDate(formatDateLocal(startOfMonth));
setEndDate(formatDateLocal(endOfMonth));
};
const setYesterday = () => {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
setStartDate(formatDateLocal(yesterday));
};
const setLastMonth = () => {
const now = new Date();
const lastMonthStart = new Date(now.getFullYear(), now.getMonth() - 1, 1);
const lastMonthEnd = new Date(now.getFullYear(), now.getMonth(), 0);
setStartDate(formatDateLocal(lastMonthStart));
setEndDate(formatDateLocal(lastMonthEnd));
};
return ( return (
<div className="space-y-6 animate-fade-in"> <div className="space-y-6 animate-fade-in">
{/* 검색 필터 */} {/* 검색 필터 */}
<div className="glass-effect rounded-2xl p-6"> <div className="glass-effect rounded-2xl p-6 relative z-20">
<div className="flex gap-6"> <div className="flex gap-6">
{/* 좌측: 필터 영역 */} {/* 좌측: 필터 영역 */}
<div className="flex-1"> <div className="flex-1">
<div className="flex items-start gap-3"> <div className="flex items-stretch gap-4">
{/* 빠른 날짜 선택 버튼 - 2x2 그리드 */} {/* 1. 기간 선택 (Vertical) */}
<div className="grid grid-cols-2 gap-2"> <div className="flex-shrink-0">
<button <DateRangePicker
onClick={setToday} startDate={startDate}
className="h-10 bg-white/10 hover:bg-white/20 text-white text-xs px-2 rounded-lg transition-colors whitespace-nowrap" endDate={endDate}
title="오늘 날짜로 설정" onChange={(s, e) => {
> setStartDate(s);
setEndDate(e);
</button> }}
<button align="vertical"
onClick={setYesterday} />
className="h-10 bg-white/10 hover:bg-white/20 text-white text-xs px-2 rounded-lg transition-colors whitespace-nowrap"
title="어제 날짜로 설정"
>
</button>
<button
onClick={setThisMonth}
className="h-10 bg-white/10 hover:bg-white/20 text-white text-xs px-2 rounded-lg transition-colors whitespace-nowrap"
title="이번 달 1일부터 말일까지"
>
</button>
<button
onClick={setLastMonth}
className="h-10 bg-white/10 hover:bg-white/20 text-white text-xs px-2 rounded-lg transition-colors whitespace-nowrap"
title="저번달 1일부터 말일까지"
>
</button>
</div> </div>
{/* 필터 입력 영역: 2행 2 */} {/* 2. 입력 필드 그룹 (담당자, 검색어) - 2 */}
<div className="grid grid-cols-2 gap-x-4 gap-y-3"> <div className="flex flex-col justify-between gap-2">
{/* 1행: 시작일, 담당자 */} {/* 담당자 */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-white/70 text-sm font-medium whitespace-nowrap w-12"></label> <UserSelector
<input users={users.map(u => ({
type="date" id: u.id,
value={startDate} name: u.name,
onChange={(e) => setStartDate(e.target.value)} process: u.processs,
className="w-36 h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white focus:outline-none focus:ring-2 focus:ring-primary-400" level: u.level,
/> useJobReport: u.useJobReport,
</div> outdate: u.outdate
<div className="flex items-center gap-2"> }))}
<label className="text-white/70 text-sm font-medium whitespace-nowrap w-12"></label> includeResigned={true}
<select onlyJobReportUsers={true}
value={selectedUser} selectedIds={selectedUser ? [selectedUser] : []}
onChange={(e) => setSelectedUser(e.target.value)} onChange={(ids) => setSelectedUser(ids[0] || '')}
className="w-44 h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white focus:outline-none focus:ring-2 focus:ring-primary-400" className="w-48"
> placeholder="전체"
<option value="" className="bg-gray-800"></option>
{users.map((user) => (
<option key={user.id} value={user.id} className="bg-gray-800">
{user.name}({user.id})
</option>
))}
</select>
</div>
{/* 2행: 종료일, 검색어 */}
<div className="flex items-center gap-2">
<label className="text-white/70 text-sm font-medium whitespace-nowrap w-12"></label>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-36 h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-white/70 text-sm font-medium whitespace-nowrap w-12"></label>
<input
type="text"
value={searchKey}
onChange={(e) => setSearchKey(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearchWithReset()}
placeholder="프로젝트, 내용 등"
className="w-44 h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400"
/> />
</div> </div>
</div> </div>
{/* 버튼 영역: 우측 수직 배치 */} {/* 3. 버튼 그룹 (높이 채우기) */}
<div className="flex flex-col gap-3"> <div className="flex gap-2">
<button <button
onClick={handleSearchWithReset} onClick={handleSearchWithReset}
disabled={loading} disabled={loading}
className="h-10 bg-primary-500 hover:bg-primary-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center disabled:opacity-50" className="h-full min-h-[5.5rem] bg-primary-500 hover:bg-primary-600 border border-white/20 text-white px-6 rounded-lg transition-colors flex flex-col items-center justify-center gap-1 disabled:opacity-50 whitespace-nowrap"
> >
{loading ? ( {loading ? (
<RefreshCw className="w-4 h-4 mr-2 animate-spin" /> <RefreshCw className="w-5 h-5 animate-spin" />
) : ( ) : (
<Search className="w-4 h-4 mr-2" /> <Search className="w-5 h-5" />
)} )}
<span className="text-sm"></span>
</button> </button>
<button <button
onClick={openAddModal} onClick={openAddModal}
className="h-10 bg-success-500 hover:bg-success-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center" className="h-full min-h-[5.5rem] bg-success-500 hover:bg-success-600 border border-white/20 text-white px-6 rounded-lg transition-colors flex flex-col items-center justify-center gap-1 whitespace-nowrap"
> >
<Plus className="w-4 h-4 mr-2" /> <Plus className="w-5 h-5" />
<span className="text-sm"></span>
</button> </button>
</div> </div>
</div> </div>
</div> </div>
{/* 중앙: 집계 메뉴 */}
<div className="flex-shrink-0 flex flex-col gap-3 justify-center">
<button
onClick={() => setShowDayReportModal(true)}
className="h-10 bg-indigo-500 hover:bg-indigo-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center whitespace-nowrap"
>
<Calendar className="w-4 h-4 mr-2" />
</button>
<button
onClick={() => setShowTypeReportModal(true)}
className="h-10 bg-purple-500 hover:bg-purple-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center whitespace-nowrap"
>
<FileText className="w-4 h-4 mr-2" />
</button>
</div>
{/* 미등록 업무일지 카드 */} {/* 미등록 업무일지 카드 */}
<div className="flex-shrink-0 w-40"> <div className="flex-shrink-0 w-40">
@@ -633,27 +578,57 @@ export function Jobreport() {
</div> </div>
{/* 데이터 테이블 */} {/* 데이터 테이블 */}
<div className="glass-effect rounded-2xl overflow-hidden"> <div className="glass-effect rounded-2xl overflow-hidden shadow-2xl transition-all duration-300">
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between"> <div className="px-6 py-4 flex flex-col md:flex-row items-center justify-between gap-4">
<h3 className="text-lg font-semibold text-white flex items-center"> <div className="flex items-center gap-3">
<FileText className="w-5 h-5 mr-2" /> <div className="p-2 bg-primary-500/20 rounded-lg">
<FileText className="w-5 h-5 text-primary-400" />
</h3> </div>
<span className="text-white/60 text-sm">{jobreportList.length}</span> <h3 className="text-lg font-bold text-[var(--text-primary)] tracking-tight">
</h3>
</div>
<div className="flex items-center gap-4 w-full md:w-auto">
{/* 검색 필터 */}
<div className="relative flex-1 md:w-80 group">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-[var(--text-secondary)] group-focus-within:text-primary-400 transition-colors" />
<input
type="text"
value={searchKey}
onChange={(e) => setSearchKey(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearchWithReset()}
placeholder="검색..."
className="w-full bg-[var(--bg-paper)] border border-[var(--border-color)] rounded-xl pl-10 pr-10 py-2 text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary-500/50 transition-all text-sm placeholder-[var(--text-muted)]"
/>
{searchKey && (
<button
onClick={() => setSearchKey('')}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
<XCircle className="w-4 h-4" />
</button>
)}
</div>
<div className="flex items-center gap-1 bg-[var(--bg-paper)] px-3 py-1.5 rounded-lg border border-[var(--border-color)]">
<span className="text-[var(--text-primary)] font-bold text-sm">{jobreportList.length}</span>
<span className="text-[var(--text-secondary)] text-xs"></span>
</div>
</div>
</div> </div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full"> <table className="w-full">
<thead className="bg-white/10"> <thead className="bg-white/10">
<tr> <tr>
<th className="px-2 py-3 text-center text-xs font-medium text-white/70 uppercase w-10"></th> <th className="px-2 py-3 text-center text-list-header font-list-header text-white/opacity-header-muted uppercase w-10"></th>
<th className="px-2 py-3 text-left text-xs font-medium text-white/70 uppercase w-24"></th> <th className="px-2 py-3 text-left text-list-header font-list-header text-white/opacity-header-muted uppercase w-24"></th>
<th className="px-2 py-3 text-left text-xs font-medium text-white/70 uppercase" style={{ width: '35%' }}></th> <th className="px-2 py-3 text-left text-list-header font-list-header text-white/opacity-header-muted uppercase" style={{ width: '35%' }}></th>
<th className="px-2 py-3 text-left text-xs font-medium text-white/70 uppercase"></th> <th className="px-2 py-3 text-left text-list-header font-list-header text-white/opacity-header-muted uppercase"></th>
<th className="px-2 py-3 text-left text-xs font-medium text-white/70 uppercase"></th> <th className="px-2 py-3 text-left text-list-header font-list-header text-white/opacity-header-muted uppercase"></th>
<th className="px-2 py-3 text-left text-xs font-medium text-white/70 uppercase"></th> <th className="px-2 py-3 text-left text-list-header font-list-header text-white/opacity-header-muted uppercase"></th>
{canViewOT && <th className="px-2 py-3 text-left text-xs font-medium text-white/70 uppercase">OT</th>} {canViewOT && <th className="px-2 py-3 text-left text-list-header font-list-header text-white/opacity-header-muted uppercase">OT</th>}
<th className="px-2 py-3 text-left text-xs font-medium text-white/70 uppercase"></th> <th className="px-2 py-3 text-left text-list-header font-list-header text-white/opacity-header-muted uppercase"></th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-white/10"> <tbody className="divide-y divide-white/10">
@@ -793,6 +768,28 @@ export function Jobreport() {
initialMonth={startDate.substring(0, 7)} initialMonth={startDate.substring(0, 7)}
/> />
{/* 리스트가 하단 패널에 가려지지 않도록 빈 공간 추가 */}
<div className="h-24"></div>
{/* 하단 고정 상태바 (출력물 메뉴) */}
<div className="fixed bottom-12 left-0 right-0 z-40 bg-black/30 backdrop-blur-xl border-t border-white/10 h-14 flex items-center justify-center gap-4 shadow-2xl animate-slide-up">
<button
onClick={() => setShowDayReportModal(true)}
className="h-9 bg-indigo-500 hover:bg-indigo-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center whitespace-nowrap text-sm shadow-lg border border-white/10"
>
<Calendar className="w-4 h-4 mr-2" />
</button>
<div className="w-px h-5 bg-white/20"></div>
<button
onClick={() => setShowTypeReportModal(true)}
className="h-9 bg-purple-500 hover:bg-purple-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center whitespace-nowrap text-sm shadow-lg border border-white/10"
>
<FileText className="w-4 h-4 mr-2" />
</button>
</div>
{/* 업무형태별 집계 모달 */} {/* 업무형태별 집계 모달 */}
<JobreportTypeModal <JobreportTypeModal
isOpen={showTypeReportModal} isOpen={showTypeReportModal}
@@ -805,9 +802,9 @@ export function Jobreport() {
{/* 업무일지 미등록 상세 모달 */} {/* 업무일지 미등록 상세 모달 */}
{showUnregisteredModal && ( {showUnregisteredModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4"> <div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 backdrop-blur-sm p-4">
<div className="bg-slate-900 border border-white/10 rounded-2xl w-full max-w-md shadow-2xl overflow-hidden animate-scale-in"> <div className="dialog-container border border-white/10 rounded-2xl w-full max-w-md overflow-hidden animate-scale-in transition-all duration-300">
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 bg-white/5"> <div className="dialog-header flex items-center justify-between px-6 py-4 border-b border-white/10 bg-white/5">
<h3 className="text-lg font-semibold text-white flex items-center gap-2"> <h3 className="dialog-title flex items-center gap-2">
<AlertTriangle className="w-5 h-5 text-danger-400" /> <AlertTriangle className="w-5 h-5 text-danger-400" />
</h3> </h3>
@@ -850,7 +847,7 @@ export function Jobreport() {
)} )}
</div> </div>
<div className="px-6 py-4 border-t border-white/10 bg-white/5 flex justify-end"> <div className="dialog-footer px-6 py-4 border-t border-white/10 bg-white/5 flex justify-end">
<button <button
onClick={() => setShowUnregisteredModal(false)} onClick={() => setShowUnregisteredModal(false)}
className="px-4 py-2 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors text-sm font-medium" className="px-4 py-2 bg-white/10 hover:bg-white/20 text-white rounded-lg transition-colors text-sm font-medium"

View File

@@ -10,21 +10,19 @@ import {
Plus, Plus,
Edit, Edit,
Copy, Copy,
User,
ChevronLeft,
ChevronRight,
Filter Filter
} from 'lucide-react'; } from 'lucide-react';
import { DateRangePicker } from '@/components/DateRangePicker';
import { UserSelector } from '@/components/UserSelector';
import { comms } from '@/communication'; import { comms } from '@/communication';
import { KuntaeModel, HolydayPermission, HolydayUser, HolydayBalance } from '@/types'; import { KuntaeModel, HolydayPermission, GroupUser, HolydayBalance } from '@/types';
import { KuntaeEditModal, KuntaeFormData } from '@/components/kuntae/KuntaeEditModal'; import { KuntaeEditModal, KuntaeFormData } from '@/components/kuntae/KuntaeEditModal';
import { DevelopmentNotice } from '@/components/common/DevelopmentNotice'; import { DevelopmentNotice } from '@/components/DevelopmentNotice';
export function Kuntae() { export function Kuntae() {
const [kuntaeList, setKuntaeList] = useState<KuntaeModel[]>([]); const [kuntaeList, setKuntaeList] = useState<KuntaeModel[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [_processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
// 검색 조건 // 검색 조건
const [startDate, setStartDate] = useState(''); const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState(''); const [endDate, setEndDate] = useState('');
@@ -33,7 +31,7 @@ export function Kuntae() {
// 권한 및 사용자 목록 // 권한 및 사용자 목록
const [permission, setPermission] = useState<HolydayPermission | null>(null); const [permission, setPermission] = useState<HolydayPermission | null>(null);
const [userList, setUserList] = useState<HolydayUser[]>([]); const [userList, setUserList] = useState<GroupUser[]>([]);
// 모달 상태 // 모달 상태
const [isModalOpen, setIsModalOpen] = useState(false); const [isModalOpen, setIsModalOpen] = useState(false);
@@ -56,13 +54,20 @@ export function Kuntae() {
setStartDate(sd); setStartDate(sd);
setEndDate(ed); setEndDate(ed);
console.log("init");
// 권한 조회 // 권한 조회
try { try {
const permResponse = await comms.getHolydayPermission(); const permResponse = await comms.getHolydayPermission();
if (permResponse.Success && permResponse.Data) { console.log('permResponse', permResponse);
if (permResponse.Success) {
// @ts-ignore - API 응답 타입 불일치 해결 // @ts-ignore - API 응답 타입 불일치 해결
setPermission(permResponse.Data); const permData = permResponse as any;
setPermission(permData);
// 기본값: 로그인된 사용자
if (permData.CurrentUserId) {
setSelectedUser(permData.CurrentUserId);
}
} }
} catch (error) { } catch (error) {
console.error('권한 조회 오류:', error); console.error('권한 조회 오류:', error);
@@ -71,30 +76,47 @@ export function Kuntae() {
init(); init();
}, []); }, []);
// 사용자 목록 로드 (기간 변경 시) // 사용자 목록 로드 (컴포넌트 마운트 시 한 번만 로드하면 됨, 또는 기간과 무관하게 전체 로드 후 필터)
// 여기서는 getUserList를 사용하므로 startDate/endDate 의존성 제거
useEffect(() => { useEffect(() => {
const loadUsers = async () => { const loadUsers = async () => {
if (!startDate || !endDate) return;
try { try {
const response = await comms.getHolydayUserList(startDate, endDate); const response = await comms.getUserList('');
// @ts-ignore - API 응답이 배열로 옴 if (response) {
if (Array.isArray(response)) { const today = new Date().toISOString().split('T')[0];
setUserList(response); const filtered = response.filter(u => {
} else if (response.Success && response.Data) { // 1. 레벨 체크 (1 이상)
// @ts-ignore if ((u.level || 0) < 1) return false;
setUserList(response.Data); // 2. 퇴사자 제외
if (u.outdate && u.outdate < today) return false;
return true;
});
setUserList(filtered);
} }
} catch (error) { } catch (error) {
console.error('사용자 목록 로드 오류:', error); console.error('사용자 목록 로드 오류:', error);
} }
}; };
loadUsers(); loadUsers();
}, [startDate, endDate]); }, []);
// 데이터 및 잔량 로드 // 데이터 및 잔량 로드
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
if (!startDate || !endDate) return; if (!startDate || !endDate) return;
// 권한 체크: 관리자(Level 5 이상)가 아닌 경우, 본인 외 조회 불가
// 초기 로드 시점 등 permission이 아직 없을 수 있으므로 체크
if (permission) {
const isManager = (permission.Level || 0) >= 5;
const currentUserId = permission.CurrentUserId;
if (!isManager && selectedUser !== currentUserId) {
alert('조회 권한이 없습니다. 본인 데이터로 조회합니다.');
setSelectedUser(currentUserId);
return; // setSelectedUser가 useEffect를 트리거하여 다시 loadData 실행됨
}
}
setLoading(true); setLoading(true);
try { try {
// 1. 목록 조회 // 1. 목록 조회
@@ -144,17 +166,7 @@ export function Kuntae() {
); );
}, [kuntaeList, filterText]); }, [kuntaeList, filterText]);
// 월 이동
const moveMonth = (offset: number) => {
const current = new Date(startDate);
current.setMonth(current.getMonth() + offset);
const startOfMonth = new Date(current.getFullYear(), current.getMonth(), 1);
const endOfMonth = new Date(current.getFullYear(), current.getMonth() + 1, 0);
setStartDate(startOfMonth.toISOString().split('T')[0]);
setEndDate(endOfMonth.toISOString().split('T')[0]);
};
// 삭제 // 삭제
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
@@ -239,63 +251,84 @@ export function Kuntae() {
<DevelopmentNotice /> <DevelopmentNotice />
{/* 상단 컨트롤 바 */} {/* 상단 컨트롤 바 */}
<div className="glass-effect rounded-2xl p-6"> <div className="glass-effect rounded-2xl p-6 relative z-20">
<div className="flex flex-col space-y-4"> <div className="flex flex-col space-y-4">
{/* 1행: 날짜, 사용자, 조회/등록 */} {/* 1행: 날짜, 사용자, 조회/등록 */}
<div className="flex flex-col md:flex-row gap-4 items-end md:items-center justify-between"> <div className="flex flex-col md:flex-row gap-4 items-end md:items-center justify-between">
{/* 날짜 선택 및 월 이동 */} {/* 날짜 선택 */}
<div className="flex items-center gap-2 w-full md:w-auto"> <div className="flex items-center gap-2 w-full md:w-auto">
<button <DateRangePicker
onClick={() => moveMonth(-1)} startDate={startDate}
className="p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors" endDate={endDate}
> onChange={(s, e) => {
<ChevronLeft className="w-5 h-5" /> setStartDate(s);
</button> setEndDate(e);
}}
<div className="grid grid-cols-2 gap-2"> align="vertical"
<input />
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="bg-white/20 border border-white/30 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 text-sm"
/>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="bg-white/20 border border-white/30 rounded-lg px-3 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 text-sm"
/>
</div>
<button
onClick={() => moveMonth(1)}
className="p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors"
>
<ChevronRight className="w-5 h-5" />
</button>
</div> </div>
{/* 사용자 선택 (관리자용) */} {/* 사용자 선택 */}
{permission?.CanManage && ( <div className="w-full md:w-64">
<div className="w-full md:w-64"> <UserSelector
<div className="relative"> users={userList.map(u => ({
<User className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-white/50" /> id: u.id,
<select name: u.name,
value={selectedUser} level: u.level,
onChange={(e) => setSelectedUser(e.target.value)} outdate: u.outdate
className="w-full bg-white/20 border border-white/30 rounded-lg pl-10 pr-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 appearance-none" }))}
> selectedIds={selectedUser ? [selectedUser] : []}
<option value="%" className="bg-[#1e1e2e]"> </option> onChange={(ids) => setSelectedUser(ids[0] || '')}
{userList.map(user => ( placeholder="사용자 선택"
<option key={user.uid} value={user.uid} className="bg-[#1e1e2e]"> className="w-full"
{user.UserName} ({user.uid}) />
</option> </div>
))}
</select> {/* 잔량 정보 (Compact) */}
</div> <div className="flex items-center gap-2 overflow-x-auto no-scrollbar hidden md:flex">
</div> {balances.length > 0 ? (
)} balances.map((bal, idx) => {
const remainDays = bal.TotalGenDays - bal.TotalUseDays;
const remainHours = bal.TotalGenHours - bal.TotalUseHours;
let icon = <Clock className="w-4 h-4" />;
let color = "text-white";
if (bal.cate === '연차') {
icon = <Calendar className="w-4 h-4 text-primary-400" />;
color = "text-primary-400";
} else if (bal.cate === '대체') {
icon = <RefreshCw className="w-4 h-4 text-success-400" />;
color = "text-success-400";
} else if (bal.cate === '휴가') {
icon = <CheckCircle className="w-4 h-4 text-warning-400" />;
color = "text-warning-400";
}
return (
<div key={idx} className="flex items-center gap-2 bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 whitespace-nowrap" title={`발생: ${bal.TotalGenDays} / 사용: ${bal.TotalUseDays}`}>
{icon}
<span className="text-sm text-white/70">{bal.cate}</span>
<span className={`text-sm font-bold ${color}`}>
{remainDays}
{remainHours > 0 && <span className="ml-1 text-xs opacity-70">({remainHours}h)</span>}
</span>
</div>
);
})
) : (
<>
<div className="flex items-center gap-2 bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 whitespace-nowrap">
<Calendar className="w-4 h-4 text-white/30" />
<span className="text-sm text-white/50"> -</span>
</div>
<div className="flex items-center gap-2 bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 whitespace-nowrap">
<RefreshCw className="w-4 h-4 text-white/30" />
<span className="text-sm text-white/50"> -</span>
</div>
</>
)}
</div>
{/* 조회 및 등록 버튼 */} {/* 조회 및 등록 버튼 */}
<div className="flex gap-2 w-full md:w-auto"> <div className="flex gap-2 w-full md:w-auto">
@@ -317,136 +350,107 @@ export function Kuntae() {
</div> </div>
</div> </div>
{/* 2행: 검색 필터 */}
<div className="flex items-center gap-2">
<div className="relative flex-1 max-w-md">
<Filter className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-white/50" /> </div>
</div>
{/* 데이터 테이블 */}
<div className="glass-effect rounded-2xl overflow-hidden shadow-2xl transition-all duration-300">
<div className="px-6 py-4 flex flex-col md:flex-row items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="p-2 bg-primary-500/20 rounded-lg">
<Clock className="w-5 h-5 text-primary-400" />
</div>
<h3 className="text-lg font-bold text-[var(--text-primary)] tracking-tight">
</h3>
</div>
<div className="flex items-center gap-4 w-full md:w-auto">
{/* 검색 필터 */}
<div className="relative flex-1 md:w-64 group">
<Filter className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-[var(--text-secondary)] group-focus-within:text-primary-400 transition-colors" />
<input <input
type="text" type="text"
value={filterText} value={filterText}
onChange={(e) => setFilterText(e.target.value)} onChange={(e) => setFilterText(e.target.value)}
placeholder="구분, 내용, 성명으로 검색..." placeholder="검색..."
className="w-full bg-white/10 border border-white/20 rounded-lg pl-10 pr-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 text-sm placeholder-white/30" className="w-full bg-[var(--bg-paper)] border border-[var(--border-color)] rounded-xl pl-10 pr-10 py-2 text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary-500/50 transition-all text-sm placeholder-[var(--text-muted)]"
/> />
{filterText && ( {filterText && (
<button <button
onClick={() => setFilterText('')} onClick={() => setFilterText('')}
className="absolute right-3 top-1/2 transform -translate-y-1/2 text-white/50 hover:text-white" className="absolute right-3 top-1/2 transform -translate-y-1/2 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
> >
<XCircle className="w-4 h-4" /> <XCircle className="w-4 h-4" />
</button> </button>
)} )}
</div> </div>
<div className="flex items-center gap-1 bg-[var(--bg-paper)] px-3 py-1.5 rounded-lg border border-[var(--border-color)]">
<span className="text-[var(--text-primary)] font-bold text-sm">{filteredList.length}</span>
<span className="text-[var(--text-secondary)] text-xs"></span>
{kuntaeList.length !== filteredList.length && <span className="text-[var(--text-muted)] text-xs ml-1">( {kuntaeList.length})</span>}
</div>
</div> </div>
</div> </div>
</div>
{/* 통계 카드 (잔량 정보) */}
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
{balances.length > 0 ? (
balances.map((bal, idx) => {
// 잔량 계산
const remainDays = bal.TotalGenDays - bal.TotalUseDays;
const remainHours = bal.TotalGenHours - bal.TotalUseHours;
// 아이콘 및 색상 결정
let icon = <Clock className="w-6 h-6" />;
let color = "text-white";
if (bal.cate === '연차') {
icon = <Calendar className="w-6 h-6 text-primary-400" />;
color = "text-primary-400";
} else if (bal.cate === '대체') {
icon = <RefreshCw className="w-6 h-6 text-success-400" />;
color = "text-success-400";
} else if (bal.cate === '휴가') {
icon = <CheckCircle className="w-6 h-6 text-warning-400" />;
color = "text-warning-400";
}
return (
<StatCard
key={idx}
title={`${bal.cate} 잔량`}
value={`${remainDays}${remainHours > 0 ? `(${remainHours}h)` : ''}`}
subValue={`발생: ${bal.TotalGenDays} / 사용: ${bal.TotalUseDays}`}
icon={icon}
color={color}
/>
);
})
) : (
// 데이터 없을 때 기본 카드 표시
<>
<StatCard title="연차 잔량" value="-" icon={<Calendar className="w-6 h-6 text-white/30" />} color="text-white/30" />
<StatCard title="대체 잔량" value="-" icon={<RefreshCw className="w-6 h-6 text-white/30" />} color="text-white/30" />
</>
)}
</div>
{/* 데이터 테이블 */}
<div className="glass-effect rounded-2xl overflow-hidden">
<div className="px-6 py-4 border-b border-white/10 flex justify-between items-center">
<h3 className="text-lg font-semibold text-white"> </h3>
<span className="text-white/50 text-sm">
{filteredList.length}
{kuntaeList.length !== filteredList.length && ` (전체 ${kuntaeList.length}건 중)`}
</span>
</div>
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="w-full"> <table className="w-full">
<thead className="bg-white/10"> <thead className="bg-[var(--bg-paper)]/50">
<tr> <tr>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th> <th className="px-4 py-3 text-left text-xs font-medium text-[var(--text-secondary)] uppercase w-[80px]"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th> <th className="px-4 py-3 text-left text-xs font-medium text-[var(--text-secondary)] uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th> <th className="px-4 py-3 text-left text-xs font-medium text-[var(--text-secondary)] uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">()</th> <th className="px-4 py-3 text-left text-xs font-medium text-[var(--text-secondary)] uppercase">()</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">()</th> <th className="px-4 py-3 text-left text-xs font-medium text-[var(--text-secondary)] uppercase">()</th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th> <th className="px-4 py-3 text-left text-xs font-medium text-[var(--text-secondary)] uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th> <th className="px-4 py-3 text-left text-xs font-medium text-[var(--text-secondary)] uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th> <th className="px-4 py-3 text-left text-xs font-medium text-[var(--text-secondary)] uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th> <th className="px-4 py-3 text-left text-xs font-medium text-[var(--text-secondary)] uppercase"></th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-white/10"> <tbody className="divide-y divide-[var(--border-color)]">
{loading ? ( {loading ? (
<tr> <tr>
<td colSpan={9} className="px-4 py-8 text-center"> <td colSpan={9} className="px-4 py-8 text-center">
<div className="flex items-center justify-center"> <div className="flex items-center justify-center">
<RefreshCw className="w-5 h-5 mr-2 animate-spin text-white/50" /> <RefreshCw className="w-5 h-5 mr-2 animate-spin text-[var(--text-secondary)]" />
<span className="text-white/50"> ...</span> <span className="text-[var(--text-secondary)]"> ...</span>
</div> </div>
</td> </td>
</tr> </tr>
) : filteredList.length === 0 ? ( ) : filteredList.length === 0 ? (
<tr> <tr>
<td colSpan={9} className="px-4 py-8 text-center text-white/50"> <td colSpan={9} className="px-4 py-8 text-center text-[var(--text-secondary)]">
{filterText ? '검색 결과가 없습니다.' : '조회된 데이터가 없습니다.'} {filterText ? '검색 결과가 없습니다.' : '조회된 데이터가 없습니다.'}
</td> </td>
</tr> </tr>
) : ( ) : (
filteredList.map((item) => ( filteredList.map((item) => (
<tr key={item.idx} className={`hover:bg-white/5 transition-colors ${item.extidx ? 'bg-black/20' : ''}`}> <tr key={item.idx} className={`hover:bg-[var(--bg-paper)]/30 transition-colors ${item.extidx ? 'bg-black/20' : ''}`}>
<td className="px-4 py-3 text-white text-sm"> <td className="px-4 py-3 text-[var(--text-primary)] text-sm">
<span className={`px-2 py-1 rounded text-xs ${item.cate === '연차' ? 'bg-primary-500/20 text-primary-300' : <span className={`px-2 py-1 rounded text-xs ${item.cate === '연차' ? 'bg-primary-500/20 text-primary-300' :
item.cate === '대체' ? 'bg-success-500/20 text-success-300' : item.cate === '대체' ? 'bg-success-500/20 text-success-300' :
'bg-white/10 text-white/70' 'bg-[var(--bg-paper)] text-[var(--text-secondary)]'
}`}> }`}>
{item.cate || '-'} {item.cate || '-'}
</span> </span>
</td> </td>
<td className="px-4 py-3 text-white text-sm">{formatDate(item.sdate)}</td> <td className="px-4 py-3 text-[var(--text-primary)] text-sm">{formatDate(item.sdate)}</td>
<td className="px-4 py-3 text-white text-sm">{formatDate(item.edate)}</td> <td className="px-4 py-3 text-[var(--text-primary)] text-sm">{formatDate(item.edate)}</td>
<td className="px-4 py-3 text-white text-sm">{item.term > 0 ? item.term : '-'}</td> <td className="px-4 py-3 text-[var(--text-primary)] text-sm">{item.term > 0 ? item.term : '-'}</td>
<td className="px-4 py-3 text-white text-sm">{item.crtime > 0 ? item.crtime : '-'}</td> <td className="px-4 py-3 text-[var(--text-primary)] text-sm">{item.crtime > 0 ? item.crtime : '-'}</td>
<td className="px-4 py-3 text-white/80 text-sm max-w-xs truncate" title={item.contents}> <td className="px-4 py-3 text-[var(--text-primary)]/80 text-sm max-w-xs truncate" title={item.contents}>
{item.contents || '-'} {item.contents || '-'}
</td> </td>
<td className="px-4 py-3 text-white text-sm"> <td className="px-4 py-3 text-[var(--text-primary)] text-sm">
{item.UserName || item.uid} {item.UserName || item.uid}
</td> </td>
<td className="px-4 py-3 text-white/50 text-xs"> <td className="px-4 py-3 text-[var(--text-secondary)] text-xs">
{item.extcate ? `${item.extcate}` : '-'} {item.extcate ? `${item.extcate}` : '-'}
</td> </td>
<td className="px-4 py-3 text-sm"> <td className="px-4 py-3 text-sm">
@@ -468,11 +472,11 @@ export function Kuntae() {
</button> </button>
<button <button
onClick={() => handleDelete(item.idx)} onClick={() => handleDelete(item.idx)}
className={`text-danger-400 hover:text-danger-300 transition-colors ${item.extidx ? 'opacity-50 cursor-not-allowed' : ''}`} className={`text-danger-400 hover:text-danger-300 transition-colors ${item.extidx || processing ? 'opacity-50 cursor-not-allowed' : ''}`}
title={item.extidx ? "외부 연동 데이터는 삭제할 수 없습니다" : "삭제"} title={item.extidx ? "외부 연동 데이터는 삭제할 수 없습니다" : "삭제"}
disabled={!!item.extidx} disabled={!!item.extidx || processing}
> >
<Trash2 className="w-4 h-4" /> {processing ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Trash2 className="w-4 h-4" />}
</button> </button>
</div> </div>
</td> </td>
@@ -496,28 +500,4 @@ export function Kuntae() {
); );
} }
// 통계 카드 컴포넌트
interface StatCardProps {
title: string;
value: string | number;
subValue?: string;
icon: React.ReactNode;
color: string;
}
function StatCard({ title, value, subValue, icon, color }: StatCardProps) {
return (
<div className="glass-effect rounded-xl p-4 card-hover">
<div className="flex items-center">
<div className={`p-3 rounded-lg ${color.replace('text-', 'bg-').replace('-400', '-500/20')}`}>
{icon}
</div>
<div className="ml-4">
<p className="text-sm font-medium text-white/70">{title}</p>
<p className={`text-xl font-bold ${color}`}>{value}</p>
{subValue && <p className="text-xs text-white/40 mt-1">{subValue}</p>}
</div>
</div>
</div>
);
}

View File

@@ -4,14 +4,18 @@ import {
Plus, Plus,
Edit2, Edit2,
Trash2, Trash2,
Save,
X, X,
Loader2,
RefreshCw, RefreshCw,
Search, Search,
Type,
FileText,
User,
CheckCircle2
} from 'lucide-react'; } from 'lucide-react';
import { comms } from '@/communication'; import { comms } from '@/communication';
import { MailFormItem } from '@/types'; import { MailFormItem } from '@/types';
import { clsx } from 'clsx';
import { DevelopmentNotice } from '@/components/DevelopmentNotice';
const initialFormData: Partial<MailFormItem> = { const initialFormData: Partial<MailFormItem> = {
cate: '', cate: '',
@@ -165,305 +169,400 @@ export function MailFormPage() {
}; };
return ( return (
<div className="space-y-6"> <div className="space-y-6 animate-fade-in pb-4 h-full">
{/* 헤더 */} <DevelopmentNotice />
<div className="glass-effect rounded-2xl p-6"> {/* 메일양식 메인 카드 */}
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4"> <div className="glass-effect rounded-3xl overflow-hidden shadow-2xl border border-white/10 flex flex-col h-full max-h-[calc(100vh-140px)]">
<div className="flex items-center space-x-3"> <div className="px-6 py-4 border-b border-white/10 flex flex-col md:flex-row items-center justify-between gap-4 bg-white/[0.02] shrink-0">
<div className="p-3 bg-primary-500/20 rounded-xl"> <div className="flex items-center gap-3">
<Mail className="w-6 h-6 text-primary-400" /> <div className="p-2 bg-primary-500/20 rounded-lg">
<Mail className="w-5 h-5 text-primary-400" />
</div> </div>
<div> <div>
<h1 className="text-2xl font-bold text-white"></h1> <h3 className="text-lg font-bold text-white tracking-tight"> </h3>
<p className="text-white/60 text-sm"> 릿 </p> <p className="text-white/30 text-[10px] uppercase font-bold tracking-widest mt-0.5">
Email Template System
</p>
</div> </div>
</div> </div>
<div className="flex items-center space-x-3"> <div className="flex items-center gap-3">
{/* 검색 */} {/* 검색 */}
<div className="relative"> <div className="relative group w-48 md:w-64">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-white/40" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-white/40 group-focus-within:text-primary-400 transition-colors" />
<input <input
type="text" type="text"
value={searchKey} value={searchKey}
onChange={(e) => setSearchKey(e.target.value)} onChange={(e) => setSearchKey(e.target.value)}
placeholder="검색..." placeholder="양식명, 제목, 분류 검색..."
className="pl-10 pr-4 py-2 bg-white/10 border border-white/10 rounded-lg text-white text-sm focus:outline-none focus:border-primary-500 w-48" className="w-full bg-white/5 border border-white/10 rounded-xl pl-9 pr-4 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 h-[40px]"
/> />
</div> </div>
{/* 개수 */}
<div className="flex items-center gap-2 bg-white/5 px-3 py-1.5 rounded-xl border border-white/10 h-[40px]">
<span className="text-primary-400 font-bold text-sm">{filteredItems.length}</span>
<span className="text-white/40 text-[10px] uppercase"></span>
</div>
{/* 새로고침 */}
<button <button
onClick={loadData} onClick={loadData}
disabled={loading} disabled={loading}
className="flex items-center space-x-2 px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white transition-colors disabled:opacity-50" 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 h-[40px] w-[40px] flex items-center justify-center"
title="새로고침"
> >
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} /> <RefreshCw className={clsx("w-4 h-4", loading && "animate-spin")} />
</button> </button>
{/* 추가 버튼 */}
<button <button
onClick={openAddModal} onClick={openAddModal}
className="flex items-center space-x-2 px-4 py-2 bg-primary-500 hover:bg-primary-600 rounded-lg text-white transition-colors" 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 h-[40px] w-[40px] flex items-center justify-center group"
title="새 양식 추가"
> >
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4 group-hover:rotate-90 transition-transform" />
<span> </span>
</button> </button>
</div> </div>
</div> </div>
{/* 리스트 헤더 */}
<div className="bg-white/5 px-6 py-3 border-b border-white/5 flex items-center text-list-header font-list-header text-white/opacity-header-muted uppercase shrink-0">
<div className="w-24 px-4 text-center"></div>
<div className="flex-1 px-4"> </div>
<div className="w-64 px-4"> </div>
<div className="w-32 px-4 text-center"> </div>
<div className="w-20 text-center"></div>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar divide-y divide-white/5">
{loading ? (
<div className="py-20 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>
) : filteredItems.length === 0 ? (
<div className="py-32 text-center">
<Mail className="w-16 h-16 mx-auto text-white/10 mb-4" />
<p className="text-white/30 text-base font-bold"> </p>
<p className="text-white/10 text-[10px] mt-2 uppercase tracking-[0.2em]">No email templates available</p>
</div>
) : (
filteredItems.map((item) => (
<div
key={item.idx}
className="px-6 py-3 hover:bg-white/[0.03] transition-all group flex items-center"
>
{/* 분류 */}
<div className="w-24 px-4 text-center">
<span className="px-2 py-0.5 bg-white/5 border border-white/5 rounded-md text-[10px] text-white/40 font-bold uppercase truncate block">
{item.cate || '미분류'}
</span>
</div>
{/* 양식명 */}
<div className="flex-1 px-4 flex flex-col">
<div className="text-sm font-bold text-white group-hover:text-primary-300 transition-colors">
{item.title}
</div>
<div className="text-[10px] text-white/20 mt-0.5 flex items-center gap-1.5">
<FileText className="w-2.5 h-2.5" />
ID: {item.idx}
</div>
</div>
{/* 제목 */}
<div className="w-64 px-4 text-xs text-white/50 truncate italic">
{item.subject || '(제목 없음)'}
</div>
{/* 발신 옵션 (To/CC/BCC Self) */}
<div className="w-32 px-4 flex justify-center gap-1.5">
{[
{ label: 'T', active: item.selfTo, color: 'text-primary-400', bg: 'bg-primary-500/10' },
{ label: 'C', active: item.selfCC, color: 'text-amber-400', bg: 'bg-amber-500/10' },
{ label: 'B', active: item.selfBCC, color: 'text-emerald-400', bg: 'bg-emerald-500/10' }
].map((opt, i) => (
<div
key={i}
className={clsx(
"w-6 h-6 rounded flex items-center justify-center text-[10px] font-bold border border-white/5",
opt.active ? `${opt.bg} ${opt.color} border-${opt.color}/20` : "bg-white/5 text-white/10"
)}
title={`${opt.label === 'T' ? 'To' : opt.label === 'C' ? 'CC' : 'BCC'} Self-include`}
>
{opt.label}
</div>
))}
</div>
{/* 작업 버튼 */}
<div className="w-20 flex justify-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<button
onClick={() => openEditModal(item)}
className="p-1.5 hover:bg-white/10 rounded-lg text-white/40 hover:text-white transition-colors"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(item)}
className="p-1.5 hover:bg-red-500/10 rounded-lg text-white/40 hover:text-red-400 transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
))
)}
</div>
{/* 푸터 */}
<div className="px-6 py-2 flex items-center justify-between bg-white/[0.02] border-t border-white/5 shrink-0">
<div className="text-white/20 text-[9px] font-bold uppercase tracking-[0.2em] py-2">
Template Management Hub <span className="text-white/5 mx-2">/</span>
Total <span className="text-primary-400/50 font-mono tracking-normal">{filteredItems.length}</span>
</div>
<div className="text-[9px] text-white/10 italic">
Reference date: {new Date().toLocaleDateString()}
</div>
</div>
</div> </div>
{/* 목록 */}
<div className="glass-effect rounded-2xl overflow-hidden">
{loading ? (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-8 h-8 text-white animate-spin" />
</div>
) : filteredItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 text-white/50">
<Mail className="w-12 h-12 mb-4 opacity-50" />
<p> .</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-white/10">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase w-24"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th>
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-20">To</th>
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-20">CC</th>
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-20">BCC</th>
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-24"></th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{filteredItems.map((item) => (
<tr key={item.idx} className="hover:bg-white/5 transition-colors">
<td className="px-4 py-3 text-white/70 text-sm">{item.cate || '-'}</td>
<td className="px-4 py-3 text-white text-sm font-medium">{item.title}</td>
<td className="px-4 py-3 text-white/70 text-sm">{item.subject || '-'}</td>
<td className="px-4 py-3 text-center">
{item.selfTo && (
<span className="inline-block w-5 h-5 bg-success-500/20 text-success-400 rounded text-xs leading-5">S</span>
)}
</td>
<td className="px-4 py-3 text-center">
{item.selfCC && (
<span className="inline-block w-5 h-5 bg-warning-500/20 text-warning-400 rounded text-xs leading-5">S</span>
)}
</td>
<td className="px-4 py-3 text-center">
{item.selfBCC && (
<span className="inline-block w-5 h-5 bg-primary-500/20 text-primary-400 rounded text-xs leading-5">S</span>
)}
</td>
<td className="px-4 py-3">
<div className="flex items-center justify-center space-x-2">
<button
onClick={() => openEditModal(item)}
className="p-1.5 hover:bg-white/10 rounded text-white/70 hover:text-white transition-colors"
title="수정"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => handleDelete(item)}
className="p-1.5 hover:bg-danger-500/20 rounded text-white/70 hover:text-danger-400 transition-colors"
title="삭제"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
{/* 편집 모달 */} {/* 편집 모달 */}
{showModal && ( {showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"> <div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
<div className="bg-slate-800 rounded-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden flex flex-col"> <div
className="absolute inset-0 bg-black/60 backdrop-blur-sm animate-fade-in"
onClick={() => setShowModal(false)}
/>
<div className="relative w-full max-w-4xl bg-[#1a1c1e] border border-white/10 rounded-3xl shadow-2xl overflow-hidden animate-scale-in flex flex-col max-h-[90vh]">
{/* 모달 헤더 */} {/* 모달 헤더 */}
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10"> <div className="px-6 py-5 border-b border-white/10 flex items-center justify-between bg-white/[0.02] shrink-0">
<h2 className="text-xl font-bold text-white"> <div className="flex items-center gap-3">
{editingItem ? '메일양식 수정' : '새 메일양식'} <div className="p-2 bg-primary-500/20 rounded-xl text-primary-400">
</h2> <Mail className="w-5 h-5" />
</div>
<div>
<h2 className="text-xl font-bold text-white tracking-tight">
{editingItem ? '메일양식 수정' : '새 메일양식 등록'}
</h2>
<p className="text-white/30 text-[10px] uppercase font-bold tracking-widest mt-0.5">
Template Configuration
</p>
</div>
</div>
<button <button
onClick={() => setShowModal(false)} onClick={() => setShowModal(false)}
className="p-2 hover:bg-white/10 rounded-lg text-white/70 hover:text-white transition-colors" className="p-2 hover:bg-white/10 rounded-xl text-white/40 hover:text-white transition-colors"
> >
<X className="w-5 h-5" /> <X className="w-5 h-5" />
</button> </button>
</div> </div>
{/* 모달 내용 */} {/* 모달 내용 */}
<div className="flex-1 overflow-y-auto p-6 space-y-4"> <div className="flex-1 overflow-y-auto p-6 space-y-8 custom-scrollbar">
{/* 1행: 분류, 양식명 */} {/* 기본 정보 */}
<div className="grid grid-cols-2 gap-4"> <section className="space-y-4">
<div> <div className="flex items-center gap-3 mb-2">
<label className="block text-white/70 text-sm mb-1"></label> <Type className="w-4 h-4 text-primary-500" />
<input <h4 className="text-sm font-bold text-white/70 uppercase tracking-tighter"> </h4>
type="text" <div className="flex-1 h-px bg-white/5"></div>
value={formData.cate || ''}
onChange={(e) => setFormData({ ...formData, cate: e.target.value })}
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary-500"
/>
</div> </div>
<div>
<label className="block text-white/70 text-sm mb-1"> *</label>
<input
type="text"
value={formData.title || ''}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary-500"
/>
</div>
</div>
{/* 2행: 제목 */} <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div> <div className="space-y-1.5 px-3 py-2 bg-white/5 rounded-2xl border border-white/5 focus-within:border-primary-500/30 transition-colors">
<label className="block text-white/70 text-sm mb-1"> </label> <label className="text-[10px] font-bold text-white/30 uppercase pl-1"> (Category)</label>
<input
type="text"
value={formData.subject || ''}
onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary-500"
/>
</div>
{/* 3행: 수신자 */}
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-white/70 text-sm mb-1">To ()</label>
<textarea
value={formData.tolist || ''}
onChange={(e) => setFormData({ ...formData, tolist: e.target.value })}
rows={2}
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white text-sm focus:outline-none focus:border-primary-500 resize-none"
placeholder="이메일 주소 (줄바꿈으로 구분)"
/>
<label className="flex items-center mt-1 text-sm text-white/60">
<input <input
type="checkbox" type="text"
checked={formData.selfTo || false} value={formData.cate || ''}
onChange={(e) => setFormData({ ...formData, selfTo: e.target.checked })} onChange={(e) => setFormData({ ...formData, cate: e.target.value })}
className="mr-2" className="w-full bg-transparent border-none text-sm text-white focus:outline-none placeholder:text-white/10"
placeholder="업무, 공지 등"
/> />
</div>
</label> <div className="space-y-1.5 px-3 py-2 bg-white/5 rounded-2xl border border-white/5 focus-within:border-primary-500/30 transition-colors">
</div> <label className="text-[10px] font-bold text-white/30 uppercase pl-1"> *</label>
<div>
<label className="block text-white/70 text-sm mb-1">CC ()</label>
<textarea
value={formData.cc || ''}
onChange={(e) => setFormData({ ...formData, cc: e.target.value })}
rows={2}
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white text-sm focus:outline-none focus:border-primary-500 resize-none"
placeholder="이메일 주소 (줄바꿈으로 구분)"
/>
<label className="flex items-center mt-1 text-sm text-white/60">
<input <input
type="checkbox" type="text"
checked={formData.selfCC || false} value={formData.title || ''}
onChange={(e) => setFormData({ ...formData, selfCC: e.target.checked })} onChange={(e) => setFormData({ ...formData, title: e.target.value })}
className="mr-2" className="w-full bg-transparent border-none text-sm text-white focus:outline-none placeholder:text-white/10"
placeholder="구분이 쉬운 양식 이름을 입력하세요"
/> />
</div>
</label>
</div> </div>
<div>
<label className="block text-white/70 text-sm mb-1">BCC ()</label>
<textarea
value={formData.bcc || ''}
onChange={(e) => setFormData({ ...formData, bcc: e.target.value })}
rows={2}
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white text-sm focus:outline-none focus:border-primary-500 resize-none"
placeholder="이메일 주소 (줄바꿈으로 구분)"
/>
<label className="flex items-center mt-1 text-sm text-white/60">
<input
type="checkbox"
checked={formData.selfBCC || false}
onChange={(e) => setFormData({ ...formData, selfBCC: e.target.checked })}
className="mr-2"
/>
</label>
</div>
</div>
{/* 4행: 제외 메일 */} <div className="space-y-1.5 px-3 py-2 bg-white/5 rounded-2xl border border-white/5 focus-within:border-primary-500/30 transition-colors">
<div className="grid grid-cols-2 gap-4"> <label className="text-[10px] font-bold text-white/30 uppercase pl-1"> (Subject)</label>
<div>
<label className="block text-white/70 text-sm mb-1">To </label>
<input <input
type="text" type="text"
value={formData.exceptmail || ''} value={formData.subject || ''}
onChange={(e) => setFormData({ ...formData, exceptmail: e.target.value })} onChange={(e) => setFormData({ ...formData, subject: e.target.value })}
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white text-sm focus:outline-none focus:border-primary-500" className="w-full bg-transparent border-none text-sm text-white focus:outline-none placeholder:text-white/10"
placeholder="제외할 이메일 주소" placeholder="발송 시 자동으로 채워질 제목"
/> />
</div> </div>
<div> </section>
<label className="block text-white/70 text-sm mb-1">CC </label>
<input {/* 수신자 설정 */}
type="text" <section className="space-y-4">
value={formData.exceptmailcc || ''} <div className="flex items-center gap-3 mb-2">
onChange={(e) => setFormData({ ...formData, exceptmailcc: e.target.value })} <User className="w-4 h-4 text-amber-500" />
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white text-sm focus:outline-none focus:border-primary-500" <h4 className="text-sm font-bold text-white/70 uppercase tracking-tighter"> </h4>
placeholder="제외할 이메일 주소" <div className="flex-1 h-px bg-white/5"></div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{/* To */}
<div className="space-y-3">
<div className="space-y-1.5 px-3 py-2 bg-white/5 rounded-2xl border border-white/5 focus-within:border-primary-500/30 transition-colors">
<label className="text-[10px] font-bold text-white/30 uppercase pl-1 flex items-center justify-between">
To ()
<span className="flex items-center gap-1.5 cursor-pointer text-primary-400">
<input
type="checkbox"
checked={formData.selfTo || false}
onChange={(e) => setFormData({ ...formData, selfTo: e.target.checked })}
className="w-3 h-3 rounded"
/>
Self
</span>
</label>
<textarea
value={formData.tolist || ''}
onChange={(e) => setFormData({ ...formData, tolist: e.target.value })}
rows={3}
className="w-full bg-transparent border-none text-xs text-white focus:outline-none resize-none placeholder:text-white/10"
placeholder="주소를 입력하세요 (줄바꿈 구분)"
/>
</div>
</div>
{/* CC */}
<div className="space-y-3">
<div className="space-y-1.5 px-3 py-2 bg-white/5 rounded-2xl border border-white/5 focus-within:border-primary-500/30 transition-colors">
<label className="text-[10px] font-bold text-white/30 uppercase pl-1 flex items-center justify-between">
CC ()
<span className="flex items-center gap-2 cursor-pointer text-amber-400">
<input
type="checkbox"
checked={formData.selfCC || false}
onChange={(e) => setFormData({ ...formData, selfCC: e.target.checked })}
className="w-3 h-3 rounded"
/>
Self
</span>
</label>
<textarea
value={formData.cc || ''}
onChange={(e) => setFormData({ ...formData, cc: e.target.value })}
rows={3}
className="w-full bg-transparent border-none text-xs text-white focus:outline-none resize-none placeholder:text-white/10"
placeholder="주소를 입력하세요 (줄바꿈 구분)"
/>
</div>
</div>
{/* BCC */}
<div className="space-y-3">
<div className="space-y-1.5 px-3 py-2 bg-white/5 rounded-2xl border border-white/5 focus-within:border-primary-500/30 transition-colors">
<label className="text-[10px] font-bold text-white/30 uppercase pl-1 flex items-center justify-between">
BCC ()
<span className="flex items-center gap-2 cursor-pointer text-emerald-400">
<input
type="checkbox"
checked={formData.selfBCC || false}
onChange={(e) => setFormData({ ...formData, selfBCC: e.target.checked })}
className="w-3 h-3 rounded"
/>
Self
</span>
</label>
<textarea
value={formData.bcc || ''}
onChange={(e) => setFormData({ ...formData, bcc: e.target.value })}
rows={3}
className="w-full bg-transparent border-none text-xs text-white focus:outline-none resize-none placeholder:text-white/10"
placeholder="주소를 입력하세요 (줄바꿈 구분)"
/>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1.5 px-3 py-2 bg-white/5 rounded-2xl border border-white/5 focus-within:border-primary-500/30 transition-colors">
<label className="text-[10px] font-bold text-white/30 uppercase pl-1 italic">Expect Mail (To )</label>
<input
type="text"
value={formData.exceptmail || ''}
onChange={(e) => setFormData({ ...formData, exceptmail: e.target.value })}
className="w-full bg-transparent border-none text-sm text-white focus:outline-none placeholder:text-white/10"
/>
</div>
<div className="space-y-1.5 px-3 py-2 bg-white/5 rounded-2xl border border-white/5 focus-within:border-primary-500/30 transition-colors">
<label className="text-[10px] font-bold text-white/30 uppercase pl-1 italic">Expect Mail CC ( )</label>
<input
type="text"
value={formData.exceptmailcc || ''}
onChange={(e) => setFormData({ ...formData, exceptmailcc: e.target.value })}
className="w-full bg-transparent border-none text-sm text-white focus:outline-none placeholder:text-white/10"
/>
</div>
</div>
</section>
{/* 본문 에디터 영역 */}
<section className="space-y-4">
<div className="flex items-center gap-3 mb-2">
<FileText className="w-4 h-4 text-emerald-500" />
<h4 className="text-sm font-bold text-white/70 uppercase tracking-tighter"> </h4>
<div className="flex-1 h-px bg-white/5"></div>
</div>
<div className="space-y-1.5 px-3 py-3 bg-white/5 rounded-2xl border border-white/5 focus-within:border-primary-500/30 transition-colors">
<label className="text-[10px] font-bold text-white/30 uppercase pl-1"> (HTML )</label>
<textarea
value={formData.body || ''}
onChange={(e) => setFormData({ ...formData, body: e.target.value })}
rows={8}
className="w-full bg-transparent border-none text-sm text-white focus:outline-none resize-none placeholder:text-white/10 mt-1 font-mono"
placeholder="메일 본문 내용을 입력하세요"
/> />
</div> </div>
</div>
{/* 5행: 본문 */} <div className="space-y-1.5 px-3 py-3 bg-white/5 rounded-2xl border border-white/5 focus-within:border-primary-500/30 transition-colors">
<div> <label className="text-[10px] font-bold text-white/30 uppercase pl-1"> (Tail/Signature)</label>
<label className="block text-white/70 text-sm mb-1"> </label> <textarea
<textarea value={formData.tail || ''}
value={formData.body || ''} onChange={(e) => setFormData({ ...formData, tail: e.target.value })}
onChange={(e) => setFormData({ ...formData, body: e.target.value })} rows={3}
rows={6} className="w-full bg-transparent border-none text-sm text-white focus:outline-none resize-none placeholder:text-white/10 mt-1"
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary-500 resize-none" placeholder="하단에 공통으로 표시될 꼬리말"
placeholder="메일 본문 내용..." />
/> </div>
</div> </section>
{/* 6행: 꼬리말 */}
<div>
<label className="block text-white/70 text-sm mb-1"></label>
<textarea
value={formData.tail || ''}
onChange={(e) => setFormData({ ...formData, tail: e.target.value })}
rows={3}
className="w-full px-3 py-2 bg-white/10 border border-white/10 rounded-lg text-white focus:outline-none focus:border-primary-500 resize-none"
placeholder="메일 꼬리말..."
/>
</div>
</div> </div>
{/* 모달 푸터 */} {/* 모달 푸터 */}
<div className="flex items-center justify-end space-x-3 px-6 py-4 border-t border-white/10"> <div className="px-6 py-5 bg-white/[0.02] border-t border-white/10 flex items-center justify-end gap-3 shrink-0">
<button <button
onClick={() => setShowModal(false)} onClick={() => setShowModal(false)}
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white transition-colors" className="px-5 py-2 text-white/40 hover:text-white font-bold text-xs transition-colors"
> >
</button> </button>
<button <button
onClick={handleSave} onClick={handleSave}
disabled={saving} disabled={saving}
className="flex items-center space-x-2 px-4 py-2 bg-primary-500 hover:bg-primary-600 rounded-lg text-white transition-colors disabled:opacity-50" className="flex items-center gap-2 px-6 py-2 bg-primary-500 hover:bg-primary-600 text-white rounded-xl font-bold text-xs shadow-lg shadow-primary-500/20 transition-all disabled:opacity-50 active:scale-95"
> >
{saving ? ( {saving ? (
<Loader2 className="w-4 h-4 animate-spin" /> <RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : ( ) : (
<Save className="w-4 h-4" /> <CheckCircle2 className="w-3.5 h-3.5" />
)} )}
<span></span> <span>{editingItem ? '양식 변경 사항 저장' : '새 메일양식 저장'}</span>
</button> </button>
</div> </div>
</div> </div>

View File

@@ -1,8 +1,10 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Mail, Search, RefreshCw, Calendar, ChevronLeft, ChevronRight } from 'lucide-react'; import { Mail, Search, RefreshCw, Calendar, ChevronLeft, ChevronRight, X } from 'lucide-react';
import { comms } from '@/communication'; import { comms } from '@/communication';
import { MailItem, UserInfo } from '@/types'; import { MailItem, UserInfo } from '@/types';
import { MailTestDialog } from '@/components/mail/MailTestDialog'; import { MailTestDialog } from '@/components/mail/MailTestDialog';
import { DateRangePicker } from '@/components/DateRangePicker';
import { clsx } from 'clsx';
export function MailList() { export function MailList() {
const [mailList, setMailList] = useState<MailItem[]>([]); const [mailList, setMailList] = useState<MailItem[]>([]);
@@ -42,10 +44,10 @@ export function MailList() {
const start = formatDateLocal(tenDaysAgo); const start = formatDateLocal(tenDaysAgo);
const end = formatDateLocal(now); const end = formatDateLocal(now);
setStartDate(start); setStartDate(start);
setEndDate(end); setEndDate(end);
// 날짜 설정 후 바로 데이터 로드 // 날짜 설정 후 바로 데이터 로드
setTimeout(() => { setTimeout(() => {
loadDataWithDates(start, end); loadDataWithDates(start, end);
@@ -119,117 +121,137 @@ export function MailList() {
return ( return (
<div className="space-y-6 animate-fade-in"> <div className="space-y-6 animate-fade-in">
{/* 검색 필터 */}
<div className="glass-effect rounded-2xl p-6">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-white/70 text-sm font-medium whitespace-nowrap"></label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-36 h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
/>
<span className="text-white/70">~</span>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-36 h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
/>
</div>
<div className="flex items-center gap-2 flex-1">
<label className="text-white/70 text-sm font-medium whitespace-nowrap"></label>
<input
type="text"
value={searchKey}
onChange={(e) => setSearchKey(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="제목, 발신자, 수신자 등"
className="flex-1 h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400"
/>
</div>
<button
onClick={handleSearch}
disabled={loading}
className="h-10 bg-primary-500 hover:bg-primary-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center disabled:opacity-50"
>
{loading ? (
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
) : (
<Search className="w-4 h-4 mr-2" />
)}
</button>
{currentUser && currentUser.Level >= 9 && (
<button
onClick={() => setShowTestDialog(true)}
className="h-10 bg-green-500 hover:bg-green-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center"
>
<Mail className="w-4 h-4 mr-2" />
</button>
)}
</div>
</div>
{/* 메일 내역 목록 */} {/* 메일 내역 목록 */}
<div className="glass-effect rounded-2xl overflow-hidden"> <div className="glass-effect rounded-3xl overflow-hidden shadow-2xl border border-white/10">
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between"> <div className="px-6 py-4 border-b border-white/10 flex flex-col xl:flex-row items-center justify-between gap-4 bg-white/5">
<h3 className="text-lg font-semibold text-white flex items-center"> <div className="flex items-center gap-3">
<Mail className="w-5 h-5 mr-2" /> <div className="p-2 bg-primary-500/20 rounded-lg">
<Mail className="w-5 h-5 text-primary-400" />
</h3> </div>
<span className="text-white/60 text-sm">{mailList.length}</span> <h3 className="text-lg font-bold text-white tracking-tight"> </h3>
</div>
<div className="flex flex-wrap items-center gap-3">
{/* 공용 날짜 컨트롤 */}
<DateRangePicker
startDate={startDate}
endDate={endDate}
onChange={(start, end) => {
setStartDate(start);
setEndDate(end);
loadDataWithDates(start, end);
}}
/>
{/* 검색창 */}
<div className="relative group w-48 md:w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-white/40 group-focus-within:text-primary-400 transition-colors" />
<input
type="text"
value={searchKey}
onChange={(e) => setSearchKey(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
placeholder="검색어..."
className="w-full bg-white/5 border border-white/10 rounded-xl pl-9 pr-8 py-1.5 text-xs text-white placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all backdrop-blur-sm"
/>
{searchKey && (
<button
onClick={() => {
setSearchKey('');
loadData();
}}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-white/20 hover:text-white transition-colors"
>
<X className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* 개수 */}
<div className="flex items-center gap-2 bg-white/5 px-3 py-1.5 rounded-xl border border-white/10 h-[38px]">
<span className="text-primary-400 font-bold text-sm">{mailList.length}</span>
<span className="text-white/40 text-[10px] uppercase"></span>
</div>
{/* 새로고침 */}
<button
onClick={handleSearch}
disabled={loading}
className="p-2 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl text-white/70 hover:text-white transition-all disabled:opacity-50"
title="새로고침"
>
<RefreshCw className={clsx("w-4 h-4", loading && "animate-spin")} />
</button>
{/* 테스트 버튼 */}
{currentUser && currentUser.Level >= 9 && (
<button
onClick={() => setShowTestDialog(true)}
className="px-4 py-1.5 bg-success-500 hover:bg-success-600 border border-white/20 rounded-xl text-white text-xs font-bold transition-all shadow-lg shadow-success-500/20 active:scale-95 flex items-center gap-2"
title="메일 발송 테스트"
>
<Mail className="w-3.5 h-3.5" />
</button>
)}
</div>
</div> </div>
<div className="divide-y divide-white/10 max-h-[calc(100vh-380px)] overflow-y-auto"> <div className="divide-y divide-white/5 max-h-[calc(100vh-280px)] overflow-y-auto custom-scrollbar">
{loading ? ( {loading ? (
<div className="px-6 py-8 text-center"> <div className="px-6 py-12 text-center">
<div className="flex items-center justify-center"> <RefreshCw className="w-10 h-10 mx-auto mb-4 animate-spin text-primary-500/50" />
<RefreshCw className="w-5 h-5 mr-2 animate-spin text-white/50" /> <p className="text-white/50 font-medium text-sm"> ...</p>
<span className="text-white/50"> ...</span>
</div>
</div> </div>
) : mailList.length === 0 ? ( ) : mailList.length === 0 ? (
<div className="px-6 py-8 text-center"> <div className="px-6 py-20 text-center">
<Mail className="w-12 h-12 mx-auto mb-3 text-white/30" /> <div className="relative inline-block mb-4">
<p className="text-white/50"> .</p> <Mail className="w-16 h-16 mx-auto text-white/10" />
</div>
<p className="text-white/30 font-medium"> .</p>
</div> </div>
) : ( ) : (
paginatedList.map((item) => ( paginatedList.map((item) => (
<div <div
key={item.idx} key={item.idx}
className={`px-6 py-4 transition-colors ${currentUser && currentUser.Level >= 9 ? 'hover:bg-white/5 cursor-pointer' : 'cursor-default'}`} className={clsx(
"group px-6 py-3.5 transition-all relative border-b border-white/[0.02]",
currentUser && currentUser.Level >= 9 ? 'hover:bg-white/[0.03] cursor-pointer' : 'cursor-default'
)}
onClick={() => handleRowClick(item)} onClick={() => handleRowClick(item)}
> >
<div className="flex items-center justify-between gap-4"> <div className="flex items-center justify-between gap-6">
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1"> <h4 className="text-sm font-bold text-white group-hover:text-primary-400 transition-colors mb-2 truncate">
{item.subject}
</h4>
<div className="flex items-center gap-4 text-xs font-medium uppercase tracking-tight">
{item.cate && ( {item.cate && (
<span className="px-2 py-0.5 bg-primary-500/20 text-primary-400 text-xs rounded"> <span className="px-1.5 py-0.5 bg-primary-500/10 text-primary-400 font-bold rounded border border-primary-500/20 mr-1">
{item.cate} {item.cate}
</span> </span>
)} )}
{item.project && ( {item.project && (
<span className="px-2 py-0.5 bg-white/10 text-white/70 text-xs rounded"> <span className="px-1.5 py-0.5 bg-white/5 text-white/40 font-bold rounded border border-white/10 mr-1">
{item.project} {item.project}
</span> </span>
)} )}
</div> <div className="flex items-center gap-1.5 text-white/30">
<h4 className="text-white font-medium mb-1">{item.subject}</h4> <span className="text-white/20 italic font-bold">FROM:</span>
<div className="flex items-center gap-4 text-white/60 text-sm"> <span className="text-white/60 truncate max-w-[180px]">{item.fromlist}</span>
<div>: {item.fromlist}</div> </div>
<div>: {item.tolist}</div> <div className="flex items-center gap-1.5 text-white/30">
<span className="text-white/20 italic font-bold">TO:</span>
<span className="text-white/60 truncate max-w-[180px]">{item.tolist}</span>
</div>
</div> </div>
</div> </div>
<div className="flex flex-col items-end gap-1 flex-shrink-0"> <div className="flex flex-col items-end gap-2 shrink-0">
<div className="flex items-center text-white/60 text-xs"> <div className="flex items-center gap-2 px-3 py-1.5 bg-white/5 rounded-lg border border-white/5">
<Calendar className="w-3 h-3 mr-1" /> <Calendar className="w-3.5 h-3.5 text-white/30" />
{formatDate(item.wdate)} <span className="text-sm text-white/50 font-mono">{formatDate(item.wdate)}</span>
</div> </div>
</div> </div>
</div> </div>
@@ -264,63 +286,70 @@ export function MailList() {
{/* 상세 모달 */} {/* 상세 모달 */}
{showModal && selectedItem && ( {showModal && selectedItem && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50 backdrop-blur-sm"> <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md animate-fade-in">
<div className="bg-gray-900 rounded-2xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-hidden border border-white/10"> <div className="bg-[#1a1b2e]/90 rounded-3xl shadow-2xl w-full max-w-5xl max-h-[90vh] overflow-hidden border border-white/10 flex flex-col backdrop-blur-xl">
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10"> {/* 헤더 */}
<div className="flex items-center gap-2"> <div className="flex items-center justify-between px-8 py-6 border-b border-white/10 bg-white/5">
{selectedItem.cate && ( <div className="flex items-center gap-4">
<span className="px-2 py-1 bg-primary-500/20 text-primary-400 text-sm rounded"> <div className="flex items-center gap-2">
{selectedItem.cate} {selectedItem.cate && (
</span> <span className="px-2.5 py-1 bg-primary-500/10 text-primary-400 text-[10px] font-bold rounded-md border border-primary-500/20 uppercase tracking-wider">
)} {selectedItem.cate}
<h2 className="text-xl font-bold text-white ml-2">{selectedItem.subject}</h2> </span>
)}
</div>
<h2 className="text-xl font-bold text-white tracking-tight">{selectedItem.subject}</h2>
</div> </div>
<button <button
onClick={() => setShowModal(false)} onClick={() => setShowModal(false)}
className="text-white/50 hover:text-white transition-colors" className="p-2 hover:bg-white/10 rounded-full text-white/40 hover:text-white transition-all transform hover:rotate-90"
> >
<span className="text-2xl">×</span> <X className="w-6 h-6" />
</button> </button>
</div> </div>
<div className="px-6 py-4 border-b border-white/10 space-y-2 text-sm"> {/* 메타 정보 */}
<div className="flex items-start gap-2 text-white/70"> <div className="px-8 py-6 border-b border-white/5 bg-white/[0.02] space-y-3">
<span className="font-medium w-16">:</span> <div className="flex items-start gap-4">
<span className="text-white">{selectedItem.fromlist}</span> <span className="text-[10px] font-bold text-white/20 uppercase tracking-widest w-20 pt-1"></span>
<span className="text-sm text-white/80 font-medium">{selectedItem.fromlist}</span>
</div> </div>
<div className="flex items-start gap-2 text-white/70"> <div className="flex items-start gap-4 border-t border-white/5 pt-3">
<span className="font-medium w-16">:</span> <span className="text-[10px] font-bold text-white/20 uppercase tracking-widest w-20 pt-1"></span>
<span className="text-white">{selectedItem.tolist}</span> <span className="text-sm text-white/80 font-medium">{selectedItem.tolist}</span>
</div> </div>
{selectedItem.cclist && ( {selectedItem.cclist && (
<div className="flex items-start gap-2 text-white/70"> <div className="flex items-start gap-4 border-t border-white/5 pt-3">
<span className="font-medium w-16">:</span> <span className="text-[10px] font-bold text-white/20 uppercase tracking-widest w-20 pt-1"></span>
<span className="text-white">{selectedItem.cclist}</span> <span className="text-sm text-white/80 font-medium">{selectedItem.cclist}</span>
</div> </div>
)} )}
{selectedItem.bcclist && ( {selectedItem.bcclist && (
<div className="flex items-start gap-2 text-white/70"> <div className="flex items-start gap-4 border-t border-white/5 pt-3">
<span className="font-medium w-16">:</span> <span className="text-[10px] font-bold text-white/20 uppercase tracking-widest w-20 pt-1"></span>
<span className="text-white">{selectedItem.bcclist}</span> <span className="text-sm text-white/80 font-medium">{selectedItem.bcclist}</span>
</div> </div>
)} )}
<div className="flex items-center gap-2 text-white/60"> <div className="flex items-center gap-2 pt-3 text-[10px] font-bold text-white/30 uppercase tracking-widest border-t border-white/5">
<Calendar className="w-4 h-4" /> <Calendar className="w-3.5 h-3.5" />
{formatDate(selectedItem.wdate)} <span> :</span>
<span className="text-white/50">{formatDate(selectedItem.wdate)}</span>
</div> </div>
</div> </div>
<div className="overflow-y-auto max-h-[calc(90vh-280px)] p-6"> {/* 본문 */}
<div <div className="flex-1 overflow-y-auto p-8 bg-white/5 custom-scrollbar">
className="prose prose-invert max-w-none" <div
className="prose prose-invert max-w-none text-white/90 leading-relaxed text-[15px]"
dangerouslySetInnerHTML={{ __html: selectedItem.htmlbody }} dangerouslySetInnerHTML={{ __html: selectedItem.htmlbody }}
/> />
</div> </div>
<div className="flex items-center justify-end px-6 py-4 border-t border-white/10 bg-white/5"> {/* 푸터 */}
<div className="flex items-center justify-end px-8 py-6 border-t border-white/10 bg-white/5">
<button <button
onClick={() => setShowModal(false)} onClick={() => setShowModal(false)}
className="px-4 py-2 rounded-lg bg-white/10 hover:bg-white/20 text-white transition-colors" className="px-6 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-white/70 hover:text-white text-sm font-bold transition-all active:scale-95"
> >
</button> </button>

View File

@@ -1,14 +1,20 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { import {
Calendar, CalendarDays,
Save, Save,
RefreshCw, RefreshCw,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
Loader2, Loader2,
Calendar,
CheckCircle2,
XCircle,
FileText,
Clock
} from 'lucide-react'; } from 'lucide-react';
import { comms } from '@/communication'; import { comms } from '@/communication';
import { HolidayItem } from '@/types'; import { HolidayItem } from '@/types';
import { clsx } from 'clsx';
interface DayInfo extends HolidayItem { interface DayInfo extends HolidayItem {
dayOfWeek: number; dayOfWeek: number;
@@ -104,144 +110,197 @@ export function MonthlyWorkPage() {
const freeDays = holidays.filter(h => h.free).length; const freeDays = holidays.filter(h => h.free).length;
return ( return (
<div className="space-y-6"> <div className="space-y-6 animate-fade-in pb-4 h-full">
{/* 헤더 */} {/* 월별근무표 메인 카드 */}
<div className="glass-effect rounded-2xl p-6"> <div className="glass-effect rounded-3xl overflow-hidden shadow-2xl border border-white/10 flex flex-col h-full max-h-[calc(100vh-140px)]">
<div className="flex flex-col md:flex-row md:items-center md:justify-between gap-4"> <div className="px-6 py-4 border-b border-white/10 flex flex-col xl:flex-row items-center justify-between gap-4 bg-white/[0.02] shrink-0">
<div className="flex items-center space-x-3"> <div className="flex items-center gap-3">
<div className="p-3 bg-primary-500/20 rounded-xl"> <div className="p-2 bg-primary-500/20 rounded-lg">
<Calendar className="w-6 h-6 text-primary-400" /> <CalendarDays className="w-5 h-5 text-primary-400" />
</div> </div>
<div> <div>
<h1 className="text-2xl font-bold text-white"></h1> <h3 className="text-lg font-bold text-white tracking-tight"></h3>
<p className="text-white/60 text-sm"> </p>
</div> </div>
</div> </div>
<div className="flex items-center space-x-4"> <div className="flex flex-wrap items-center justify-center gap-3">
{/* 월 선택 */} {/* 월 선택 컨트롤 */}
<div className="flex items-center space-x-2 bg-white/10 rounded-lg px-3 py-2"> <div className="flex items-center gap-2 bg-white/5 border border-white/10 rounded-xl px-2 py-1 h-[40px]">
<button <button
onClick={() => handleMonthChange(-1)} onClick={() => handleMonthChange(-1)}
className="p-1 hover:bg-white/10 rounded transition-colors" className="p-1.5 hover:bg-white/10 rounded-lg text-white/50 hover:text-white transition-all"
> >
<ChevronLeft className="w-5 h-5 text-white" /> <ChevronLeft className="w-4 h-4" />
</button> </button>
<input
type="month" <div className="relative">
value={month} <Calendar className="absolute left-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-primary-400" />
onChange={(e) => setMonth(e.target.value)} <input
className="bg-transparent text-white text-center w-32 focus:outline-none" type="month"
/> value={month}
onChange={(e) => setMonth(e.target.value)}
className="bg-transparent border-none text-xs font-bold text-white pl-7 pr-2 focus:outline-none focus:ring-0 w-28 h-full cursor-pointer"
/>
</div>
<button <button
onClick={() => handleMonthChange(1)} onClick={() => handleMonthChange(1)}
className="p-1 hover:bg-white/10 rounded transition-colors" className="p-1.5 hover:bg-white/10 rounded-lg text-white/50 hover:text-white transition-all"
> >
<ChevronRight className="w-5 h-5 text-white" /> <ChevronRight className="w-4 h-4" />
</button> </button>
</div> </div>
{/* 버튼들 */} {/* 빠른 통계 */}
<button <div className="flex items-center gap-1.5 px-3 h-[40px] bg-white/5 border border-white/10 rounded-xl">
onClick={loadData} <div className="flex items-center gap-1.5">
disabled={loading} <div className="w-1.5 h-1.5 rounded-full bg-primary-400" />
className="flex items-center space-x-2 px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white transition-colors disabled:opacity-50" <span className="text-[10px] text-white/30 uppercase font-bold">Duty</span>
> <span className="text-xs font-bold text-white ml-0.5">{workDays}</span>
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} /> </div>
<span className="hidden sm:inline"></span> <div className="w-px h-3 bg-white/10 mx-1" />
</button> <div className="flex items-center gap-1.5">
<div className="w-1.5 h-1.5 rounded-full bg-danger-400" />
<span className="text-[10px] text-white/30 uppercase font-bold">Free</span>
<span className="text-xs font-bold text-white ml-0.5">{freeDays}</span>
</div>
</div>
<button <div className="flex items-center gap-2">
onClick={handleSave} {/* 새로고침 */}
disabled={saving || !hasChanges} <button
className="flex items-center space-x-2 px-4 py-2 bg-primary-500 hover:bg-primary-600 rounded-lg text-white transition-colors disabled:opacity-50" onClick={loadData}
> disabled={loading}
{saving ? ( 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 h-[40px] w-[40px] flex items-center justify-center"
<Loader2 className="w-4 h-4 animate-spin" /> title="새로고침"
) : ( >
<Save className="w-4 h-4" /> <RefreshCw className={clsx("w-4 h-4", loading && "animate-spin")} />
)} </button>
<span></span>
</button> {/* 저장 버튼 */}
<button
onClick={handleSave}
disabled={saving || !hasChanges}
className={clsx(
"flex items-center gap-2 px-4 py-2 rounded-xl font-bold text-xs transition-all active:scale-95 h-[40px]",
hasChanges
? "bg-primary-500 hover:bg-primary-600 text-white shadow-lg shadow-primary-500/20 border border-white/20"
: "bg-white/5 text-white/20 border border-white/5 cursor-not-allowed"
)}
>
{saving ? (
<Loader2 className="w-3.5 h-3.5 animate-spin" />
) : (
<Save className="w-3.5 h-3.5" />
)}
<span> </span>
</button>
</div>
</div> </div>
</div> </div>
{/* 통계 */} {/* 리스트 헤더 */}
<div className="mt-4 flex items-center space-x-6 text-sm"> <div className="bg-white/5 px-6 py-3 border-b border-white/5 flex items-center text-list-header font-list-header text-white/opacity-header-muted uppercase shrink-0">
<span className="text-white/70"> <div className="w-32 px-4"> (Date)</div>
: <span className="text-white font-semibold">{workDays}</span> <div className="w-20 px-4 text-center"></div>
</span> <div className="w-24 px-4 text-center"> </div>
<span className="text-white/70"> <div className="flex-1 px-4"> (Memo)</div>
: <span className="text-danger-400 font-semibold">{freeDays}</span>
</span>
<span className="text-white/70">
: <span className="text-white font-semibold">{holidays.length}</span>
</span>
</div> </div>
</div>
{/* 테이블 */} <div className="flex-1 overflow-y-auto custom-scrollbar divide-y divide-white/5">
<div className="glass-effect rounded-2xl overflow-hidden"> {loading ? (
{loading ? ( <div className="py-20 text-center">
<div className="flex items-center justify-center py-20"> <RefreshCw className="w-10 h-10 mx-auto mb-4 animate-spin text-primary-500/50" />
<Loader2 className="w-8 h-8 text-white animate-spin" /> <p className="text-white/50 font-medium text-sm"> ...</p>
</div> </div>
) : ( ) : holidays.length === 0 ? (
<div className="overflow-x-auto"> <div className="py-32 text-center">
<table className="w-full"> <Calendar className="w-16 h-16 mx-auto text-white/10 mb-4" />
<thead className="bg-white/10"> <p className="text-white/30 text-base font-bold"> </p>
<tr> <p className="text-white/10 text-[10px] mt-2 uppercase tracking-[0.2em]">No schedule data found</p>
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase w-32"></th> </div>
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-20"></th> ) : (
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-24"></th> holidays.map((day) => (
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase"></th> <div
</tr> key={day.idx}
</thead> className={clsx(
<tbody className="divide-y divide-white/5"> "px-6 py-2.5 hover:bg-white/[0.03] transition-all group flex items-center",
{holidays.map((day) => ( day.dayOfWeek === 0 && "bg-danger-500/[0.03]",
<tr day.dayOfWeek === 6 && "bg-blue-500/[0.03]"
key={day.idx} )}
className={`hover:bg-white/5 transition-colors ${ >
day.dayOfWeek === 0 ? 'bg-danger-500/10' : {/* 날짜 */}
day.dayOfWeek === 6 ? 'bg-primary-500/10' : '' <div className="w-32 px-4">
}`} <div className="flex items-center gap-2">
> <Clock className="w-3 h-3 text-white/20" />
<td className="px-4 py-3 text-white text-sm"> <span className="text-sm font-mono tracking-tight text-white/70">
{day.pdate} {day.pdate}
</td> </span>
<td className={`px-4 py-3 text-center text-sm font-medium ${ </div>
day.dayOfWeek === 0 ? 'text-danger-400' : </div>
day.dayOfWeek === 6 ? 'text-primary-400' : 'text-white/70'
}`}> {/* 요일 */}
{day.dayName} <div className="w-20 px-4 text-center">
</td> <span className={clsx(
<td className="px-4 py-3 text-center"> "text-xs font-bold",
<button day.dayOfWeek === 0 ? "text-danger-400" :
onClick={() => handleToggleFree(day.idx)} day.dayOfWeek === 6 ? "text-blue-400" : "text-white/30"
className={`w-8 h-8 rounded-lg transition-colors ${ )}>
day.free {day.dayName}
? 'bg-danger-500/20 text-danger-400 hover:bg-danger-500/30' </span>
: 'bg-white/10 text-white/40 hover:bg-white/20' </div>
}`}
> {/* 휴일지정 */}
{day.free ? 'O' : '-'} <div className="w-24 px-4 flex justify-center">
</button> <button
</td> onClick={() => handleToggleFree(day.idx)}
<td className="px-4 py-3"> className={clsx(
<input "flex items-center justify-center w-8 h-8 rounded-xl border transition-all active:scale-90",
type="text" day.free
value={day.memo || ''} ? "bg-danger-500/10 border-danger-500/30 text-danger-400 shadow-lg shadow-danger-500/10"
onChange={(e) => handleMemoChange(day.idx, e.target.value)} : "bg-white/5 border-white/5 text-white/10 hover:border-white/10 hover:text-white/30"
placeholder="메모 입력..." )}
className="w-full bg-white/5 border border-white/10 rounded-lg px-3 py-1.5 text-white text-sm focus:outline-none focus:border-primary-500" title={day.free ? "휴일 해제" : "휴일 지정"}
/> >
</td> {day.free ? <CheckCircle2 className="w-4 h-4" /> : <XCircle className="w-4 h-4" />}
</tr> </button>
))} </div>
</tbody>
</table> {/* 메모 */}
<div className="flex-1 px-4">
<div className="relative group/input">
<FileText className="absolute left-3 top-1/2 -translate-y-1/2 w-3 h-3 text-white/10 group-focus-within/input:text-primary-500 transition-colors" />
<input
type="text"
value={day.memo || ''}
onChange={(e) => handleMemoChange(day.idx, e.target.value)}
placeholder="메모를 입력하세요..."
className="w-full bg-white/5 border border-white/5 rounded-xl pl-9 pr-4 py-1.5 text-xs text-white placeholder:text-white/5 focus:outline-none focus:bg-white/10 focus:border-primary-500/30 transition-all font-medium"
/>
</div>
</div>
</div>
))
)}
</div>
{/* 푸터 */}
<div className="px-6 py-2 flex items-center justify-between bg-white/[0.02] border-t border-white/5 shrink-0">
<div className="text-white/20 text-[9px] font-bold uppercase tracking-[0.2em] py-2">
Company Schedule Hub <span className="text-white/5 mx-2">/</span>
Month <span className="text-primary-400/50 font-mono tracking-normal">{month}</span>
</div> </div>
)} <div className="text-white/20 text-[9px] flex items-center gap-4">
<div className="flex items-center gap-1.5">
<div className="w-1 h-1 rounded-full bg-danger-400" />
<span>Sunday ()</span>
</div>
<div className="flex items-center gap-1.5">
<div className="w-1 h-1 rounded-full bg-blue-400" />
<span>Saturday ()</span>
</div>
</div>
</div>
</div> </div>
</div> </div>
); );

View File

@@ -8,22 +8,19 @@ import {
Trash2, Trash2,
Share2, Share2,
Lock, Lock,
XCircle,
} from 'lucide-react'; } from 'lucide-react';
import { comms } from '@/communication'; import { comms } from '@/communication';
import { NoteItem } from '@/types'; import { NoteItem } from '@/types';
import { NoteEditModal } from '@/components/note/NoteEditModal'; import { NoteEditModal } from '@/components/note/NoteEditModal';
import { NoteViewModal } from '@/components/note/NoteViewModal'; import { NoteViewModal } from '@/components/note/NoteViewModal';
import { clsx } from 'clsx';
export function Note() { export function Note() {
const [noteList, setNoteList] = useState<NoteItem[]>([]); const [noteList, setNoteList] = useState<NoteItem[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
// 검색 조건
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [searchKey, setSearchKey] = useState(''); const [searchKey, setSearchKey] = useState('');
// 모달 상태 // 모달 상태
const [showModal, setShowModal] = useState(false); const [showModal, setShowModal] = useState(false);
const [showViewModal, setShowViewModal] = useState(false); const [showViewModal, setShowViewModal] = useState(false);
@@ -32,53 +29,26 @@ export function Note() {
// 페이징 상태 // 페이징 상태
const [currentPage, setCurrentPage] = useState(1); const [currentPage, setCurrentPage] = useState(1);
const pageSize = 10; const pageSize = 100;
// 날짜 포맷 헬퍼 함수 (로컬 시간 기준) // 날짜 포맷 헬퍼 함수 (로컬 시간 기준)
const formatDateLocal = (date: Date) => { const formatDateLocal = (date: Date) => {
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`; return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
}; };
// 초기화 완료 플래그
const [initialized, setInitialized] = useState(false);
// 날짜 초기화
useEffect(() => {
const now = new Date();
// 2000년부터 현재까지 데이터 조회
const startOfPeriod = new Date(2000, 0, 1);
const sd = formatDateLocal(startOfPeriod);
const ed = formatDateLocal(now);
setStartDate(sd);
setEndDate(ed);
// 초기화 완료 표시
setInitialized(true);
}, []);
// 초기화 완료 후 조회 실행 (최초 1회만)
useEffect(() => {
if (initialized && startDate && endDate) {
handleSearch();
}
}, [initialized]);
// 데이터 로드 // 데이터 로드
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
if (!startDate || !endDate) return;
setLoading(true); setLoading(true);
try { try {
console.log('메모장 조회 요청:', { startDate, endDate }); const startOfPeriod = '2000-01-01';
const response = await comms.getNoteList(startDate, endDate, ''); const today = formatDateLocal(new Date());
console.log('메모장 전수 조회 요청');
const response = await comms.getNoteList(startOfPeriod, today, '');
console.log('메모장 조회 응답:', response); console.log('메모장 조회 응답:', response);
if (response.Success && response.Data) { if (response.Success && response.Data) {
console.log('메모장 데이터 개수:', response.Data.length);
setNoteList(response.Data); setNoteList(response.Data);
} else { } else {
console.log('메모장 조회 실패 또는 데이터 없음:', response);
setNoteList([]); setNoteList([]);
} }
} catch (error) { } catch (error) {
@@ -87,16 +57,12 @@ export function Note() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [startDate, endDate]); }, []);
// 검색 // 초기화 완료 후 조회 실행 (최초 1회만)
const handleSearch = async () => { useEffect(() => {
if (new Date(startDate) > new Date(endDate)) { loadData();
alert('시작일은 종료일보다 늦을 수 없습니다.'); }, [loadData]);
return;
}
await loadData();
};
// 새 메모 추가 모달 // 새 메모 추가 모달
const openAddModal = () => { const openAddModal = () => {
@@ -225,6 +191,7 @@ export function Note() {
} }
}; };
// 필터링된 목록 (검색어 적용) // 필터링된 목록 (검색어 적용)
const filteredList = noteList.filter(item => { const filteredList = noteList.filter(item => {
if (!searchKey.trim()) return true; if (!searchKey.trim()) return true;
@@ -243,121 +210,157 @@ export function Note() {
currentPage * pageSize currentPage * pageSize
); );
// 검색 시 페이지 초기화
const handleSearchWithReset = () => {
setCurrentPage(1);
handleSearch();
};
return ( return (
<div className="space-y-6 animate-fade-in"> <div className="space-y-6 animate-fade-in pb-4">
{/* 검색 필터 */}
<div className="glass-effect rounded-2xl p-6">
<div className="flex items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-white/70 text-sm font-medium whitespace-nowrap"></label>
<input
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
className="w-36 h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
/>
<span className="text-white/70">~</span>
<input
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
className="w-36 h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white focus:outline-none focus:ring-2 focus:ring-primary-400"
/>
</div>
<div className="flex items-center gap-2">
<label className="text-white/70 text-sm font-medium whitespace-nowrap"></label>
<input
type="text"
value={searchKey}
onChange={(e) => setSearchKey(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSearchWithReset()}
placeholder="제목, 내용, 작성자 등"
className="w-60 h-10 bg-white/20 border border-white/30 rounded-lg px-3 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400"
/>
</div>
<button
onClick={handleSearchWithReset}
disabled={loading}
className="h-10 bg-primary-500 hover:bg-primary-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center disabled:opacity-50"
>
{loading ? (
<RefreshCw className="w-4 h-4 mr-2 animate-spin" />
) : (
<Search className="w-4 h-4 mr-2" />
)}
</button>
<button
onClick={openAddModal}
className="h-10 bg-success-500 hover:bg-success-600 text-white px-6 rounded-lg transition-colors flex items-center justify-center"
>
<Plus className="w-4 h-4 mr-2" />
</button>
</div>
</div>
{/* 메모 리스트 */} {/* 메모 리스트 */}
<div className="glass-effect rounded-2xl overflow-hidden"> <div className="glass-effect rounded-3xl overflow-hidden shadow-2xl border border-white/10">
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between"> <div className="px-6 py-4 border-b border-white/10 flex flex-col md:flex-row items-center justify-between gap-4">
<h3 className="text-lg font-semibold text-white flex items-center"> <div className="flex items-center gap-3">
<FileText className="w-5 h-5 mr-2" /> <div className="p-2 bg-primary-500/20 rounded-lg">
<FileText className="w-5 h-5 text-primary-400" />
</h3> </div>
<span className="text-white/60 text-sm">{filteredList.length}</span> <h3 className="text-lg font-bold text-white tracking-tight"> </h3>
</div>
<div className="flex items-center gap-3">
{/* 검색창 */}
<div className="relative group w-48 md:w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-white/40 group-focus-within:text-primary-400 transition-colors" />
<input
type="text"
value={searchKey}
onChange={(e) => {
setSearchKey(e.target.value);
setCurrentPage(1); // 검색 시 1페이지로 이동
}}
placeholder="검색..."
className="w-full bg-white/5 border border-white/10 rounded-xl pl-9 pr-8 py-1.5 text-xs text-white placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all backdrop-blur-sm"
/>
{searchKey && (
<button
onClick={() => setSearchKey('')}
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-white/20 hover:text-white transition-colors"
>
<XCircle className="w-3.5 h-3.5" />
</button>
)}
</div>
{/* 개수 */}
<div className="flex items-center gap-2 bg-white/5 px-3 py-1.5 rounded-xl border border-white/10 h-[38px]">
<span className="text-primary-400 font-bold text-sm">{filteredList.length}</span>
<span className="text-white/40 text-[10px] uppercase"></span>
</div>
{/* 새로고침 */}
<button
onClick={loadData}
disabled={loading}
className="p-2 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl text-white/70 hover:text-white transition-all disabled:opacity-50"
title="새로고침"
>
<RefreshCw className={clsx("w-4 h-4", loading && "animate-spin")} />
</button>
{/* 새 메모 작성 */}
<button
onClick={openAddModal}
className="p-2 bg-success-500 hover:bg-success-600 border border-white/20 rounded-xl text-white transition-all shadow-lg shadow-success-500/20 active:scale-95"
title="새 메모 작성"
>
<Plus className="w-4 h-4" />
</button>
</div>
</div> </div>
<div className="divide-y divide-white/10 max-h-[calc(100vh-300px)] overflow-y-auto"> {/* 컬럼 헤더 (업무일지 디자인 통일) */}
<div className="bg-white/10 px-6 py-3 border-b border-white/5 flex items-center gap-4 text-list-header text-white/opacity-header-muted font-list-header uppercase">
<div className="w-8 text-center uppercase"></div>
<div className="flex-1 uppercase"></div>
<div className="flex items-center gap-6 shrink-0">
<div className="flex items-center gap-4">
<div className="w-20 text-right uppercase"></div>
<div className="w-24 text-center uppercase"></div>
<div className="w-16 text-right uppercase"></div>
</div>
<div className="w-[88px]"></div> {/* 액션 버튼 공간 */}
</div>
</div>
<div className="divide-y divide-white/5 max-h-[calc(100vh-280px)] overflow-y-auto custom-scrollbar">
{loading ? ( {loading ? (
<div className="px-6 py-8 text-center"> <div className="px-6 py-12 text-center">
<div className="flex items-center justify-center"> <RefreshCw className="w-10 h-10 mx-auto mb-4 animate-spin text-primary-500/50" />
<RefreshCw className="w-5 h-5 mr-2 animate-spin text-white/50" /> <p className="text-white/50 font-medium text-sm"> ...</p>
<span className="text-white/50"> ...</span>
</div>
</div> </div>
) : filteredList.length === 0 ? ( ) : filteredList.length === 0 ? (
<div className="px-6 py-8 text-center"> <div className="px-6 py-20 text-center">
<FileText className="w-12 h-12 mx-auto mb-3 text-white/30" /> <div className="relative inline-block mb-4">
<p className="text-white/50"> .</p> <FileText className="w-16 h-16 mx-auto text-white/10" />
<div className="absolute inset-0 flex items-center justify-center">
<Search className="w-6 h-6 text-primary-500/20" />
</div>
</div>
<p className="text-white/30 text-base"> </p>
<button
onClick={openAddModal}
className="mt-4 text-primary-400 hover:text-primary-300 text-sm font-medium underline underline-offset-4"
>
</button>
</div> </div>
) : ( ) : (
paginatedList.map((item) => ( paginatedList.map((item) => (
<div <div
key={item.idx} key={item.idx}
className="px-6 py-3 hover:bg-white/5 transition-colors cursor-pointer group" className="px-6 py-2.5 hover:bg-white/[0.03] transition-all cursor-pointer group relative"
onClick={() => handleNoteClick(item)} onClick={() => handleNoteClick(item)}
> >
<div className="flex items-center justify-between gap-4"> <div className="flex items-center gap-4">
<div className="flex items-center gap-3 flex-1 min-w-0"> {/* 공유/잠금 아이콘 - 컬럼 너비 8에 맞춤 */}
<div className={clsx(
"w-8 h-8 rounded-lg flex items-center justify-center shrink-0 transition-all group-hover:scale-110",
item.share ? "bg-success-500/20 text-success-400 shadow-[0_0_10px_rgba(34,197,94,0.1)]" : "bg-blue-500/20 text-blue-400 shadow-[0_0_10px_rgba(59,130,246,0.1)]"
)}>
{item.share ? ( {item.share ? (
<Share2 className="w-4 h-4 text-green-400 flex-shrink-0" /> <Share2 className="w-4 h-4" />
) : ( ) : (
<Lock className="w-4 h-4 text-blue-400 flex-shrink-0" /> <Lock className="w-4 h-4" />
)} )}
<p className="text-white text-sm font-medium truncate flex-1">
{(item.title || '제목 없음').length > 15 ? `${(item.title || '제목 없음').substring(0, 15)}...` : (item.title || '제목 없음')}
</p>
</div> </div>
<div className="flex items-center gap-4 flex-shrink-0">
<span className="text-white/60 text-xs">{item.uid || '-'}</span> {/* 제목 - flex-1 로 확장 */}
<span className="text-white/60 text-xs">{formatDate(item.pdate)}</span> <div className="flex-1 min-w-0">
<span className="text-white/50 text-xs"> {item.viewcount || 0}</span> <h4 className="text-[var(--text-primary)] font-medium group-hover:text-primary-300 transition-colors truncate text-sm">
<div className="flex items-center gap-2 opacity-0 group-hover:opacity-100 transition-opacity"> {item.title || '제목 없음'}
</h4>
</div>
{/* 작성자, 일자, 조회수 - 헤더 컬럼과 동일 간격 배치 */}
<div className="flex items-center gap-6 shrink-0">
<div className="flex items-center gap-4">
<span className="text-white/60 text-sm w-20 truncate text-right">
{item.uid || 'ADMIN'}
</span>
<span className="text-white/50 text-sm w-24 text-center">
{formatDate(item.pdate)}
</span>
<span className="text-white/30 text-xs w-16 text-right font-medium">
{item.viewcount || 0}
</span>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-all translate-x-2 group-hover:translate-x-0 w-[88px] justify-end">
<button <button
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
openEditModal(item); openEditModal(item);
}} }}
className="text-white/40 hover:text-primary-400 transition-colors" className="p-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-primary-400 transition-all border border-white/10"
title="편집" title="편집"
> >
<Edit className="w-4 h-4" /> <Edit className="w-4 h-4" />
@@ -367,7 +370,7 @@ export function Note() {
e.stopPropagation(); e.stopPropagation();
handleDelete(item.idx); handleDelete(item.idx);
}} }}
className="text-white/40 hover:text-red-400 transition-colors" className="p-1.5 rounded-lg bg-white/5 hover:bg-white/10 text-danger-400 transition-all border border-white/10"
title="삭제" title="삭제"
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
@@ -382,39 +385,42 @@ export function Note() {
{/* 페이징 */} {/* 페이징 */}
{totalPages > 1 && ( {totalPages > 1 && (
<div className="px-6 py-4 border-t border-white/10 flex items-center justify-between"> <div className="px-6 py-4 border-t border-white/10 flex items-center justify-between bg-white/[0.02]">
<div className="text-white/50 text-sm"> <div className="text-white/40 text-xs font-medium">
{filteredList.length} {(currentPage - 1) * pageSize + 1}-{Math.min(currentPage * pageSize, filteredList.length)} <span className="text-white">{filteredList.length}</span>
<span className="text-white ml-2">{(currentPage - 1) * pageSize + 1}-{Math.min(currentPage * pageSize, filteredList.length)}</span>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={() => setCurrentPage(1)} onClick={() => setCurrentPage(1)}
disabled={currentPage === 1} disabled={currentPage === 1}
className="px-3 py-1 rounded bg-white/10 text-white/70 hover:bg-white/20 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" className="w-8 h-8 flex items-center justify-center rounded-lg bg-white/5 text-white/70 hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed transition-all border border-white/10 text-xs"
> >
« «
</button> </button>
<button <button
onClick={() => setCurrentPage(p => Math.max(1, p - 1))} onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
disabled={currentPage === 1} disabled={currentPage === 1}
className="px-3 py-1 rounded bg-white/10 text-white/70 hover:bg-white/20 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" className="w-8 h-8 flex items-center justify-center rounded-lg bg-white/5 text-white/70 hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed transition-all border border-white/10 text-xs"
> >
</button> </button>
<span className="text-white/70 px-3"> <div className="flex items-center bg-white/5 px-3 h-8 rounded-lg border border-white/10 text-xs font-bold">
{currentPage} / {totalPages} <span className="text-primary-400">{currentPage}</span>
</span> <span className="text-white/30 mx-1.5">/</span>
<span className="text-white/70">{totalPages}</span>
</div>
<button <button
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))} onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages} disabled={currentPage === totalPages}
className="px-3 py-1 rounded bg-white/10 text-white/70 hover:bg-white/20 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" className="w-8 h-8 flex items-center justify-center rounded-lg bg-white/5 text-white/70 hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed transition-all border border-white/10 text-xs"
> >
</button> </button>
<button <button
onClick={() => setCurrentPage(totalPages)} onClick={() => setCurrentPage(totalPages)}
disabled={currentPage === totalPages} disabled={currentPage === totalPages}
className="px-3 py-1 rounded bg-white/10 text-white/70 hover:bg-white/20 disabled:opacity-30 disabled:cursor-not-allowed transition-colors" className="w-8 h-8 flex items-center justify-center rounded-lg bg-white/5 text-white/70 hover:bg-white/10 disabled:opacity-30 disabled:cursor-not-allowed transition-all border border-white/10 text-xs"
> >
» »
</button> </button>

View File

@@ -11,11 +11,12 @@ import {
ClipboardList, ClipboardList,
Mail, Mail,
Edit2, Edit2,
XCircle,
} from 'lucide-react'; } from 'lucide-react';
import { comms } from '@/communication'; import { comms } from '@/communication';
import { ProjectListItem, ProjectListResponse } from '@/types'; import { ProjectListItem, ProjectListResponse } from '@/types';
import { ProjectDetailDialog } from '@/components/project'; import { ProjectDetailDialog } from '@/components/project';
import { DevelopmentNotice } from '@/components/common/DevelopmentNotice'; import { DevelopmentNotice } from '@/components/DevelopmentNotice';
import clsx from 'clsx'; import clsx from 'clsx';
// 상태별 색상 매핑 // 상태별 색상 매핑
@@ -302,11 +303,6 @@ export function Project() {
{/* 헤더 */} {/* 헤더 */}
<div className="glass-effect rounded-xl p-4"> <div className="glass-effect rounded-xl p-4">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<FolderKanban className="w-6 h-6 text-primary-400" />
<h1 className="text-xl font-bold text-white"> </h1>
<span className="text-white/50 text-sm">({filteredProjects.length})</span>
</div>
<button <button
onClick={loadProjects} onClick={loadProjects}
disabled={loading} disabled={loading}
@@ -414,234 +410,267 @@ export function Project() {
</div> </div>
{/* 메인 콘텐츠 */} {/* 메인 콘텐츠 */}
<div className="glass-effect rounded-xl overflow-hidden"> <div className="glass-effect rounded-2xl overflow-hidden shadow-2xl transition-all duration-300">
<div className="overflow-x-auto"> <div className="px-6 py-4 flex flex-col md:flex-row items-center justify-between gap-4">
<table className="w-full text-sm"> <div className="flex items-center gap-3">
<thead className="bg-white/5 sticky top-0"> <div className="p-2 bg-primary-500/20 rounded-lg">
<tr className="text-white/60 text-left"> <FolderKanban className="w-5 h-5 text-primary-400" />
<th className="px-3 py-2 w-16"></th> </div>
<th className="px-3 py-2"></th> <h3 className="text-lg font-bold text-[var(--text-primary)] tracking-tight">
<th className="px-3 py-2 w-20"></th>
<th className="px-3 py-2 w-28"></th> </h3>
<th className="px-3 py-2 w-20 text-center"></th> </div>
<th className="px-3 py-2 w-24"></th>
<th className="px-3 py-2 w-24">/</th>
<th className="px-3 py-2 w-10"></th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{loading ? (
<tr>
<td colSpan={8} className="px-3 py-8 text-center text-white/50">
<RefreshCw className="w-6 h-6 animate-spin mx-auto mb-2" />
...
</td>
</tr>
) : paginatedProjects.length === 0 ? (
<tr>
<td colSpan={8} className="px-3 py-8 text-center text-white/50">
.
</td>
</tr>
) : (
paginatedProjects.map((project) => {
const statusColor = statusColors[project.status] || { text: 'text-white', bg: 'bg-white/10' };
const isExpanded = expandedProject === project.idx;
return ( <div className="flex items-center gap-4 w-full md:w-auto">
<> {/* 검색 필터 */}
<tr <div className="relative flex-1 md:w-80 group">
key={project.idx} <Search className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-[var(--text-secondary)] group-focus-within:text-primary-400 transition-colors" />
className={clsx( <input
'border-b border-white/10 cursor-pointer hover:bg-white/5', type="text"
isExpanded && 'bg-primary-900/30' value={searchKey}
)} onChange={(e) => setSearchKey(e.target.value)}
onClick={() => toggleHistory(project.idx)} placeholder="검색..."
> className="w-full bg-[var(--bg-paper)] border border-[var(--border-color)] rounded-xl pl-10 pr-10 py-2 text-[var(--text-primary)] focus:outline-none focus:ring-2 focus:ring-primary-500/50 transition-all text-sm placeholder-[var(--text-muted)]"
<td className="px-3 py-2"> />
<span className={`px-2 py-0.5 rounded text-xs ${statusColor.bg} ${statusColor.text}`}> {searchKey && (
{project.status} <button
</span> onClick={() => setSearchKey('')}
</td> className="absolute right-3 top-1/2 transform -translate-y-1/2 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
<td className={`px-3 py-2 ${statusColor.text}`}> >
<div className="truncate max-w-xs" title={project.name}> <XCircle className="w-4 h-4" />
<div className="flex items-center gap-2"> </button>
<button )}
onClick={e => { </div>
e.stopPropagation(); <div className="flex items-center gap-1 bg-[var(--bg-paper)] px-3 py-1.5 rounded-lg border border-[var(--border-color)]">
handleSelectProject(project); <span className="text-[var(--text-primary)] font-bold text-sm">{filteredProjects.length}</span>
}} <span className="text-[var(--text-secondary)] text-xs"></span>
className="text-primary-300 hover:text-primary-200 transition-colors" </div>
title="편집" </div>
> </div>
<Edit2 className="w-4 h-4" /> <table className="w-full text-sm">
</button> <thead className="bg-white/5 sticky top-0">
<span className="font-regular text-white/90">{project.name}</span> <tr className="text-white/60 text-left">
</div> <th className="px-3 py-2 w-16"></th>
</div> <th className="px-3 py-2"></th>
</td> <th className="px-3 py-2 w-20"></th>
<td className="px-3 py-2 text-white/70">{project.name_champion || project.userManager}</td> <th className="px-3 py-2 w-28"></th>
<td className="px-3 py-2 text-white/70 text-xs"> <th className="px-3 py-2 w-20 text-center"></th>
<div>{project.ReqLine}</div> <th className="px-3 py-2 w-24"></th>
<div className="text-white/50">{project.reqstaff}</div> <th className="px-3 py-2 w-24">/</th>
</td> <th className="px-3 py-2 w-10"></th>
<td className="px-3 py-2"> </tr>
</thead>
<tbody className="divide-y divide-white/5">
{loading ? (
<tr>
<td colSpan={8} className="px-3 py-8 text-center text-white/50">
<RefreshCw className="w-6 h-6 animate-spin mx-auto mb-2" />
...
</td>
</tr>
) : paginatedProjects.length === 0 ? (
<tr>
<td colSpan={8} className="px-3 py-8 text-center text-white/50">
.
</td>
</tr>
) : (
paginatedProjects.map((project) => {
const statusColor = statusColors[project.status] || { text: 'text-white', bg: 'bg-white/10' };
const isExpanded = expandedProject === project.idx;
return (
<>
<tr
key={project.idx}
className={clsx(
'border-b border-white/10 cursor-pointer hover:bg-white/5',
isExpanded && 'bg-primary-900/30'
)}
onClick={() => toggleHistory(project.idx)}
>
<td className="px-3 py-2">
<span className={`px-2 py-0.5 rounded text-xs ${statusColor.bg} ${statusColor.text}`}>
{project.status}
</span>
</td>
<td className={`px-3 py-2 ${statusColor.text}`}>
<div className="truncate max-w-xs" title={project.name}>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-white/10 rounded-full overflow-hidden"> <button
<div onClick={e => {
className="h-full bg-primary-500 transition-all" e.stopPropagation();
style={{ width: `${project.progress || 0}%` }} handleSelectProject(project);
/> }}
</div> className="text-primary-300 hover:text-primary-200 transition-colors"
<span className="text-xs text-white/50">{project.progress || 0}%</span> title="편집"
</div>
</td>
<td className="px-3 py-2 text-white/50">{formatDate(project.sdate)}</td>
<td className="px-3 py-2 text-white/50 text-xs">
<div>{formatDate(project.ddate)}</div>
<div className="text-white/40">{formatDate(project.edate)}</div>
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-2">
{project.jasmin && project.jasmin > 0 && (
<button
onClick={(e) => {
e.stopPropagation();
openJasmin(project.jasmin);
}}
className="text-primary-400 hover:text-primary-300"
title="자스민 열기"
>
<ExternalLink className="w-4 h-4" />
</button>
)}
{(userLevel >= 9 || userCode === '395552') && (
<button
onClick={(e) => {
e.stopPropagation();
const w = window as any;
if (w.CefSharp) {
w.CefSharp.BindObjectAsync('bridge').then(() => {
w.bridge?.OpenMailHistory();
});
}
}}
className="text-cyan-400 hover:text-cyan-300"
title="메일내역"
>
<Mail className="w-4 h-4" />
</button>
)}
<a
href={`#/partlist?idx=${project.idx}&name=${encodeURIComponent(project.name)}`}
onClick={(e) => e.stopPropagation()}
className="text-amber-400 hover:text-amber-300"
title="파트리스트"
> >
<ClipboardList className="w-4 h-4" /> <Edit2 className="w-4 h-4" />
</a> </button>
<span className="font-regular text-white/90">{project.name}</span>
</div>
</div>
</td>
<td className="px-3 py-2 text-white/70">{project.name_champion || project.userManager}</td>
<td className="px-3 py-2 text-white/70 text-xs">
<div>{project.ReqLine}</div>
<div className="text-white/50">{project.reqstaff}</div>
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-white/10 rounded-full overflow-hidden">
<div
className="h-full bg-primary-500 transition-all"
style={{ width: `${project.progress || 0}%` }}
/>
</div>
<span className="text-xs text-white/50">{project.progress || 0}%</span>
</div>
</td>
<td className="px-3 py-2 text-white/50">{formatDate(project.sdate)}</td>
<td className="px-3 py-2 text-white/50 text-xs">
<div>{formatDate(project.ddate)}</div>
<div className="text-white/40">{formatDate(project.edate)}</div>
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-2">
{project.jasmin && project.jasmin > 0 && (
<button
onClick={(e) => {
e.stopPropagation();
openJasmin(project.jasmin);
}}
className="text-primary-400 hover:text-primary-300"
title="자스민 열기"
>
<ExternalLink className="w-4 h-4" />
</button>
)}
{(userLevel >= 9 || userCode === '395552') && (
<button
onClick={(e) => {
e.stopPropagation();
const w = window as any;
if (w.CefSharp) {
w.CefSharp.BindObjectAsync('bridge').then(() => {
w.bridge?.OpenMailHistory();
});
}
}}
className="text-cyan-400 hover:text-cyan-300"
title="메일내역"
>
<Mail className="w-4 h-4" />
</button>
)}
<a
href={`#/partlist?idx=${project.idx}&name=${encodeURIComponent(project.name)}`}
onClick={(e) => e.stopPropagation()}
className="text-amber-400 hover:text-amber-300"
title="파트리스트"
>
<ClipboardList className="w-4 h-4" />
</a>
</div>
</td>
</tr>
{isExpanded && (
<tr key={`history-${project.idx}`}>
<td colSpan={8} className="px-3 py-2 bg-primary-950/50">
<div className="p-4">
<div className="flex items-center justify-between mb-3">
<div className="text-sm font-semibold text-primary-300"> </div>
<button
onClick={() => startAddHistory(project.idx)}
className="text-xs px-3 py-1 bg-primary-500/20 hover:bg-primary-500/30 text-primary-400 rounded transition-colors"
>
+
</button>
</div>
{loadingHistory ? (
<div className="text-white/50 text-sm"> ...</div>
) : editingHistory ? (
<div className="bg-white/10 rounded p-3 space-y-3">
<div className="flex gap-4 text-xs text-white/60">
<span className="text-primary-400 font-semibold">{formatDate(editingHistory.pdate)}</span>
<span>: {editingHistory.progress || 0}%</span>
</div>
<textarea
value={editRemark}
onChange={(e) => setEditRemark(e.target.value)}
className="w-full h-32 px-3 py-2 bg-white/5 border border-white/10 rounded text-white text-sm resize-none"
placeholder="업무 내용을 입력하세요..."
/>
<div className="flex gap-2 justify-end">
<button
onClick={cancelEdit}
className="px-3 py-1 bg-white/5 hover:bg-white/10 text-white/70 rounded text-sm transition-colors"
>
</button>
<button
onClick={saveHistory}
className="px-3 py-1 bg-primary-500/20 hover:bg-primary-500/30 text-primary-400 rounded text-sm transition-colors"
>
</button>
</div>
</div>
) : projectHistory.length > 0 ? (
<div
className="bg-white/5 rounded p-3 border-l-2 border-primary-500 cursor-pointer hover:bg-white/10 transition-colors"
onClick={() => startEditHistory(projectHistory[0])}
>
<div className="flex gap-4 mb-2 text-xs">
<span className="text-primary-400 font-semibold">{formatDate(projectHistory[0].pdate)}</span>
<span className="text-white/60">: {projectHistory[0].progress || 0}%</span>
<span className="text-white/40">{projectHistory[0].wname || ''}</span>
</div>
{projectHistory[0].remark ? (
<div className="text-sm text-white/80 whitespace-pre-wrap">{projectHistory[0].remark}</div>
) : (
<div className="text-sm text-white/40 italic"> . .</div>
)}
</div>
) : (
<div className="text-white/50 text-sm text-center py-4">
. .
</div>
)}
</div> </div>
</td> </td>
</tr> </tr>
{isExpanded && ( )}
<tr key={`history-${project.idx}`}> </>
<td colSpan={8} className="px-3 py-2 bg-primary-950/50"> );
<div className="p-4"> })
<div className="flex items-center justify-between mb-3"> )}
<div className="text-sm font-semibold text-primary-300"> </div> </tbody>
<button </table>
onClick={() => startAddHistory(project.idx)}
className="text-xs px-3 py-1 bg-primary-500/20 hover:bg-primary-500/30 text-primary-400 rounded transition-colors"
>
+
</button>
</div>
{loadingHistory ? (
<div className="text-white/50 text-sm"> ...</div>
) : editingHistory ? (
<div className="bg-white/10 rounded p-3 space-y-3">
<div className="flex gap-4 text-xs text-white/60">
<span className="text-primary-400 font-semibold">{formatDate(editingHistory.pdate)}</span>
<span>: {editingHistory.progress || 0}%</span>
</div>
<textarea
value={editRemark}
onChange={(e) => setEditRemark(e.target.value)}
className="w-full h-32 px-3 py-2 bg-white/5 border border-white/10 rounded text-white text-sm resize-none"
placeholder="업무 내용을 입력하세요..."
/>
<div className="flex gap-2 justify-end">
<button
onClick={cancelEdit}
className="px-3 py-1 bg-white/5 hover:bg-white/10 text-white/70 rounded text-sm transition-colors"
>
</button>
<button
onClick={saveHistory}
className="px-3 py-1 bg-primary-500/20 hover:bg-primary-500/30 text-primary-400 rounded text-sm transition-colors"
>
</button>
</div>
</div>
) : projectHistory.length > 0 ? (
<div
className="bg-white/5 rounded p-3 border-l-2 border-primary-500 cursor-pointer hover:bg-white/10 transition-colors"
onClick={() => startEditHistory(projectHistory[0])}
>
<div className="flex gap-4 mb-2 text-xs">
<span className="text-primary-400 font-semibold">{formatDate(projectHistory[0].pdate)}</span>
<span className="text-white/60">: {projectHistory[0].progress || 0}%</span>
<span className="text-white/40">{projectHistory[0].wname || ''}</span>
</div>
{projectHistory[0].remark ? (
<div className="text-sm text-white/80 whitespace-pre-wrap">{projectHistory[0].remark}</div>
) : (
<div className="text-sm text-white/40 italic"> . .</div>
)}
</div>
) : (
<div className="text-white/50 text-sm text-center py-4">
. .
</div>
)}
</div>
</td>
</tr>
)}
</>
);
})
)}
</tbody>
</table>
</div>
{/* 페이징 */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 p-3 border-t border-white/10">
<button
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="p-1 rounded hover:bg-white/10 disabled:opacity-30"
>
<ChevronLeft className="w-5 h-5 text-white/70" />
</button>
<span className="text-white/70 text-sm">
{currentPage} / {totalPages}
</span>
<button
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="p-1 rounded hover:bg-white/10 disabled:opacity-30"
>
<ChevronRight className="w-5 h-5 text-white/70" />
</button>
</div>
)}
</div> </div>
{/* 페이징 */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 p-3 border-t border-white/10">
<button
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="p-1 rounded hover:bg-white/10 disabled:opacity-30"
>
<ChevronLeft className="w-5 h-5 text-white/70" />
</button>
<span className="text-white/70 text-sm">
{currentPage} / {totalPages}
</span>
<button
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="p-1 rounded hover:bg-white/10 disabled:opacity-30"
>
<ChevronRight className="w-5 h-5 text-white/70" />
</button>
</div>
)}
{/* 프로젝트 상세 다이얼로그 */} {/* 프로젝트 상세 다이얼로그 */}
{showDetailDialog && selectedProject && ( {showDetailDialog && selectedProject && (
<ProjectDetailDialog <ProjectDetailDialog
@@ -649,8 +678,6 @@ export function Project() {
onClose={handleCloseDialog} onClose={handleCloseDialog}
/> />
)} )}
</div> </div>
); );
} }

View File

@@ -2,15 +2,20 @@ import { useState, useEffect, useCallback } from 'react';
import { import {
Plus, Plus,
Edit2, Edit2,
Edit3,
Trash2, Trash2,
Flag, Flag,
Zap, Zap,
CheckCircle, CheckCircle,
X, X,
Loader2, Loader2,
RefreshCw,
Calendar,
Check,
} from 'lucide-react'; } from 'lucide-react';
import { comms } from '@/communication'; import { comms } from '@/communication';
import { TodoModel, TodoStatus, TodoPriority } from '@/types'; import { TodoModel, TodoStatus, TodoPriority } from '@/types';
import { clsx } from 'clsx';
// 상태/중요도 유틸리티 함수들 // 상태/중요도 유틸리티 함수들
const getStatusText = (status: string): string => { const getStatusText = (status: string): string => {
@@ -26,12 +31,12 @@ const getStatusText = (status: string): string => {
const getStatusClass = (status: string): string => { const getStatusClass = (status: string): string => {
switch (status) { switch (status) {
case '0': return 'bg-gray-500/20 text-gray-300 border-gray-500/30'; case '0': return 'bg-white/5 text-white/40 border-white/10';
case '1': return 'bg-primary-500/20 text-primary-300 border-primary-500/30'; case '1': return 'bg-primary-500/10 text-primary-400 border-primary-500/20';
case '2': return 'bg-danger-500/20 text-danger-300 border-danger-500/30'; case '2': return 'bg-danger-500/10 text-danger-400 border-danger-500/20';
case '3': return 'bg-warning-500/20 text-warning-300 border-warning-500/30'; case '3': return 'bg-warning-500/10 text-warning-400 border-warning-500/20';
case '5': return 'bg-success-500/20 text-success-300 border-success-500/30'; case '5': return 'bg-success-500/10 text-success-400 border-success-500/20';
default: return 'bg-white/10 text-white/50 border-white/20'; default: return 'bg-white/5 text-white/30 border-white/5';
} }
}; };
@@ -47,11 +52,11 @@ const getPriorityText = (seqno: number): string => {
const getPriorityClass = (seqno: number): string => { const getPriorityClass = (seqno: number): string => {
switch (seqno) { switch (seqno) {
case -1: return 'bg-white/5 text-white/40'; case -1: return 'text-white/20';
case 1: return 'bg-primary-500/20 text-primary-300'; case 1: return 'text-primary-400 font-bold';
case 2: return 'bg-warning-500/20 text-warning-300'; case 2: return 'text-warning-400 font-bold';
case 3: return 'bg-danger-500/20 text-danger-300'; case 3: return 'text-danger-400 font-bold';
default: return 'bg-white/10 text-white/50'; default: return 'text-white/40';
} }
}; };
@@ -265,111 +270,93 @@ export function Todo() {
} }
return ( return (
<div className="space-y-6 animate-fade-in"> <div className="space-y-6 animate-fade-in pb-10">
{/* 헤더 */} {/* 할일 요약 & 컨트롤 */}
<div className="glass-effect rounded-2xl overflow-hidden"> <div className="glass-effect rounded-3xl overflow-hidden shadow-2xl border border-white/10">
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between"> <div className="px-6 py-4 border-b border-white/10 flex flex-col md:flex-row items-center justify-between gap-4 bg-white/5">
<h2 className="text-xl font-semibold text-white flex items-center"> <div className="flex items-center gap-3">
<svg className="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <div className="p-2 bg-primary-500/20 rounded-lg">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5H7a2 2 0 00-2 2v10a2 2 0 002 2h8a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2" /> <CheckCircle className="w-5 h-5 text-primary-400" />
</svg> </div>
<h3 className="text-lg font-bold text-white tracking-tight"> </h3>
</h2> </div>
<button
onClick={() => {
setFormData(initialFormData);
setShowAddModal(true);
}}
className="bg-primary-500 hover:bg-primary-600 text-white px-4 py-2 rounded-lg transition-colors flex items-center text-sm"
>
<Plus className="w-4 h-4 mr-1" />
</button>
</div>
{/* 탭 메뉴 */} <div className="flex items-center gap-3">
<div className="px-6 py-2 border-b border-white/10"> {/* 개수 표시 */}
<div className="flex space-x-1 bg-white/5 rounded-lg p-1"> <div className="flex items-center gap-2 bg-white/5 px-3 py-1.5 rounded-xl border border-white/10 h-[38px]">
<span className="text-primary-400 font-bold text-sm tracking-tighter">{todos.length}</span>
<span className="text-white/40 text-[10px] uppercase font-bold">Total</span>
</div>
{/* 새로고침 */}
<button <button
onClick={() => setActiveTab('active')} onClick={loadTodos}
className={`flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 ${ disabled={loading}
activeTab === 'active' className="p-2 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl text-white/70 hover:text-white transition-all active:scale-95 disabled:opacity-50"
? 'text-white bg-white/20 shadow-sm' title="새로고침"
: 'text-white/60 hover:text-white hover:bg-white/10'
}`}
> >
<div className="flex items-center justify-center space-x-2"> <RefreshCw className={clsx("w-4 h-4", loading && "animate-spin")} />
<Zap className="w-4 h-4" />
<span></span>
<span className="px-2 py-0.5 text-xs bg-primary-500/30 text-primary-200 rounded-full">
{activeTodos.length}
</span>
</div>
</button> </button>
{/* 추가 버튼 */}
<button <button
onClick={() => setActiveTab('hold')} onClick={() => {
className={`flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 ${ setFormData(initialFormData);
activeTab === 'hold' setShowAddModal(true);
? 'text-white bg-white/20 shadow-sm' }}
: 'text-white/60 hover:text-white hover:bg-white/10' className="p-2 bg-primary-500 hover:bg-primary-600 border border-white/20 rounded-xl text-white transition-all shadow-lg shadow-primary-500/20 active:scale-95 flex items-center justify-center"
}`} title="새 할일 추가"
> >
<div className="flex items-center justify-center space-x-2"> <Plus className="w-5 h-5" />
<span></span>
<span className="px-2 py-0.5 text-xs bg-warning-500/30 text-warning-200 rounded-full">
{holdTodos.length}
</span>
</div>
</button>
<button
onClick={() => setActiveTab('completed')}
className={`flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 ${
activeTab === 'completed'
? 'text-white bg-white/20 shadow-sm'
: 'text-white/60 hover:text-white hover:bg-white/10'
}`}
>
<div className="flex items-center justify-center space-x-2">
<CheckCircle className="w-4 h-4" />
<span></span>
<span className="px-2 py-0.5 text-xs bg-success-500/30 text-success-200 rounded-full">
{completedTodos.length}
</span>
</div>
</button>
<button
onClick={() => setActiveTab('cancelled')}
className={`flex-1 px-4 py-2 text-sm font-medium rounded-md transition-all duration-200 ${
activeTab === 'cancelled'
? 'text-white bg-white/20 shadow-sm'
: 'text-white/60 hover:text-white hover:bg-white/10'
}`}
>
<div className="flex items-center justify-center space-x-2">
<X className="w-4 h-4" />
<span></span>
<span className="px-2 py-0.5 text-xs bg-danger-500/30 text-danger-200 rounded-full">
{cancelledTodos.length}
</span>
</div>
</button> </button>
</div> </div>
</div> </div>
{/* 할일 테이블 */} {/* 탭 메뉴 */}
<div className="overflow-x-auto"> <div className="px-6 py-2 bg-white/[0.02]">
<table className="w-full"> <div className="flex space-x-1 p-1">
<thead className="bg-white/10"> {[
<tr> { id: 'active', label: '진행중', icon: Zap, count: activeTodos.length, color: 'primary' },
{activeTab === 'active' && ( { id: 'hold', label: '보류', icon: Loader2, count: holdTodos.length, color: 'warning' },
<th className="px-2 py-4 text-center text-xs font-medium text-white/70 uppercase tracking-wider w-10 border-r border-white/10"></th> { id: 'completed', label: '완료', icon: CheckCircle, count: completedTodos.length, color: 'success' },
{ id: 'cancelled', label: '취소', icon: X, count: cancelledTodos.length, color: 'danger' },
].map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
className={clsx(
"flex-1 px-4 py-2.5 text-xs font-bold rounded-xl transition-all duration-300 flex items-center justify-center gap-2 border",
activeTab === tab.id
? `text-white bg-${tab.color}-500/20 border-${tab.color}-500/30 shadow-lg shadow-${tab.color}-500/10`
: "text-white/30 border-transparent hover:text-white/60 hover:bg-white/5"
)} )}
<th className="px-3 py-4 text-center text-xs font-medium text-white/70 uppercase tracking-wider w-16 border-r border-white/10"></th> >
<th className="px-3 py-4 text-center text-xs font-medium text-white/70 uppercase tracking-wider w-16 border-r border-white/10"></th> <tab.icon className={clsx("w-3.5 h-3.5", activeTab === tab.id ? `text-${tab.color}-400` : "opacity-50")} />
<th className="px-4 py-4 text-center text-xs font-medium text-white/70 uppercase tracking-wider border-r border-white/10"></th> <span>{tab.label}</span>
<th className="px-3 py-4 text-center text-xs font-medium text-white/70 uppercase tracking-wider w-24 border-r border-white/10"></th> <span className={clsx(
<th className="px-3 py-4 text-center text-xs font-medium text-white/70 uppercase tracking-wider w-20 border-r border-white/10"></th> "px-1.5 py-0.5 rounded-md text-[10px] min-w-[1.5rem]",
<th className="px-3 py-4 text-center text-xs font-medium text-white/70 uppercase tracking-wider w-24"> activeTab === tab.id ? `bg-${tab.color}-500/20 text-${tab.color}-200` : "bg-white/5 text-white/20"
)}>
{tab.count}
</span>
</button>
))}
</div>
</div>
{/* 할일 테이블 */}
<div className="overflow-x-auto custom-scrollbar max-h-[calc(100vh-320px)] overflow-y-auto">
<table className="w-full border-collapse">
<thead className="sticky top-0 z-10 bg-white/[0.05] backdrop-blur-md">
<tr className="border-b border-white/10">
{activeTab === 'active' && (
<th className="px-3 py-3.5 text-center text-[11px] font-bold text-white/30 uppercase tracking-widest w-12"></th>
)}
<th className="px-4 py-3.5 text-center text-[11px] font-bold text-white/30 uppercase tracking-widest w-20"></th>
<th className="px-6 py-3.5 text-left text-[11px] font-bold text-white/30 uppercase tracking-widest"> </th>
<th className="px-4 py-3.5 text-center text-[11px] font-bold text-white/30 uppercase tracking-widest w-28"></th>
<th className="px-4 py-3.5 text-center text-[11px] font-bold text-white/30 uppercase tracking-widest w-20"></th>
<th className="px-4 py-3.5 text-center text-[11px] font-bold text-white/30 uppercase tracking-widest w-28">
{activeTab === 'completed' ? '완료일' : '만료일'} {activeTab === 'completed' ? '완료일' : '만료일'}
</th> </th>
</tr> </tr>
@@ -387,7 +374,7 @@ export function Todo() {
))} ))}
{(activeTab === 'active' ? activeTodos : activeTab === 'hold' ? holdTodos : activeTab === 'completed' ? completedTodos : cancelledTodos).length === 0 && ( {(activeTab === 'active' ? activeTodos : activeTab === 'hold' ? holdTodos : activeTab === 'completed' ? completedTodos : cancelledTodos).length === 0 && (
<tr> <tr>
<td colSpan={activeTab === 'active' ? 7 : 6} className="px-6 py-8 text-center text-white/50"> <td colSpan={activeTab === 'active' ? 6 : 5} className="px-6 py-8 text-center text-white/50">
{activeTab === 'active' ? '진행중인 할일이 없습니다' : activeTab === 'hold' ? '보류된 할일이 없습니다' : activeTab === 'completed' ? '완료된 할일이 없습니다' : '취소된 할일이 없습니다'} {activeTab === 'active' ? '진행중인 할일이 없습니다' : activeTab === 'hold' ? '보류된 할일이 없습니다' : activeTab === 'completed' ? '완료된 할일이 없습니다' : '취소된 할일이 없습니다'}
</td> </td>
</tr> </tr>
@@ -470,45 +457,61 @@ function TodoRow({ todo, showOkdate, showCompleteButton = true, onEdit, onComple
return ( return (
<tr <tr
className="hover:bg-white/5 transition-colors cursor-pointer" className="group hover:bg-white/[0.03] transition-all cursor-pointer border-b border-white/[0.02]"
onClick={onEdit} onClick={onEdit}
> >
{showCompleteButton && ( {showCompleteButton && (
<td className="px-2 py-4 text-center border-r border-white/10"> <td className="px-3 py-3 text-center">
<button <button
onClick={handleComplete} onClick={handleComplete}
className="p-1.5 bg-success-500/20 hover:bg-success-500/40 text-success-300 rounded-full transition-colors" className="p-1.5 bg-success-500/10 hover:bg-success-500/20 text-success-400 rounded-lg transition-all border border-success-500/20 active:scale-90"
title="완료 처리" title="완료 처리"
> >
<CheckCircle className="w-4 h-4" /> <CheckCircle className="w-3.5 h-3.5" />
</button> </button>
</td> </td>
)} )}
<td className="px-3 py-4 text-center whitespace-nowrap border-r border-white/10"> <td className="px-4 py-3 text-center whitespace-nowrap">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${getStatusClass(todo.status)}`}> <span className={clsx(
"inline-flex items-center px-2 py-0.5 rounded text-xs font-bold border uppercase tracking-widest",
getStatusClass(todo.status)
)}>
{getStatusText(todo.status)} {getStatusText(todo.status)}
</span> </span>
</td> </td>
<td className="px-3 py-4 text-center whitespace-nowrap border-r border-white/10"> <td className="px-6 py-3 text-left">
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${ <div className="flex items-center gap-2">
todo.flag ? 'bg-warning-500/20 text-warning-300' : 'bg-white/10 text-white/50' {todo.flag && (
}`}> <Flag className="w-3.5 h-3.5 text-warning-400 fill-warning-400/20 shrink-0" />
{todo.flag ? <Flag className="w-3 h-3 mr-1" /> : null} )}
{todo.flag ? '고정' : '일반'} <span className="text-sm font-bold text-white group-hover:text-primary-400 transition-colors truncate">
</span> {todo.title || '제목 없음'}
</span>
</div>
</td> </td>
<td className="px-4 py-4 text-left text-white border-r border-white/10">{todo.title || '제목 없음'}</td> <td className="px-4 py-3 text-center text-sm font-medium text-white/70">{todo.request || '-'}</td>
<td className="px-3 py-4 text-center text-white/80 border-r border-white/10">{todo.request || '-'}</td> <td className="px-4 py-3 text-center whitespace-nowrap">
<td className="px-3 py-4 text-center whitespace-nowrap border-r border-white/10"> <span className={clsx(
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${getPriorityClass(todo.seqno)}`}> "inline-flex items-center px-2 py-0.5 rounded text-xs font-bold uppercase tracking-widest",
getPriorityClass(todo.seqno)
)}>
<Zap className={clsx("w-3.5 h-3.5 mr-1", todo.seqno > 0 ? "fill-current" : "opacity-20")} />
{getPriorityText(todo.seqno)} {getPriorityText(todo.seqno)}
</span> </span>
</td> </td>
<td className={`px-3 py-4 text-center whitespace-nowrap ${showOkdate ? 'text-success-400' : (isExpired ? 'text-danger-400' : 'text-white/80')}`}> <td className="px-4 py-3 text-center whitespace-nowrap">
{showOkdate <div className={clsx(
? (todo.okdate ? new Date(todo.okdate).toLocaleDateString('ko-KR', { year: '2-digit', month: '2-digit', day: '2-digit' }) : '-') "inline-flex items-center gap-2 px-3 py-1 bg-white/5 rounded-lg border border-white/5",
: (todo.expire ? new Date(todo.expire).toLocaleDateString('ko-KR', { year: '2-digit', month: '2-digit', day: '2-digit' }) : '-') showOkdate ? 'text-success-400' : (isExpired ? 'text-danger-400' : 'text-white/40')
} )}>
<Calendar className="w-3.5 h-3.5 opacity-30" />
<span className="text-sm font-mono font-medium">
{showOkdate
? (todo.okdate ? new Date(todo.okdate).toLocaleDateString('ko-KR', { year: '2-digit', month: '2-digit', day: '2-digit' }) : '-')
: (todo.expire ? new Date(todo.expire).toLocaleDateString('ko-KR', { year: '2-digit', month: '2-digit', day: '2-digit' }) : '-')
}
</span>
</div>
</td> </td>
</tr> </tr>
); );
@@ -551,159 +554,192 @@ function TodoModal({
]; ];
return ( return (
<div className="fixed inset-0 bg-black/50 backdrop-blur-sm z-50" onClick={onClose}> <div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md animate-fade-in" onClick={onClose}>
<div className="flex items-center justify-center min-h-screen p-4"> <div className="dialog-container w-full max-w-2xl" onClick={(e) => e.stopPropagation()}>
<div {/* 헤더 */}
className="glass-effect rounded-2xl w-full max-w-2xl animate-slide-up" <div className="dialog-header">
onClick={(e) => e.stopPropagation()} <div className="flex items-center gap-4">
> <div className={`p-2 rounded-lg ${isEdit ? 'bg-primary-500/20' : 'bg-primary-500/20'}`}>
{/* 헤더 */} {isEdit ? <Edit3 className="w-5 h-5 text-primary-400" /> : <Plus className="w-5 h-5 text-primary-400" />}
<div className="px-6 py-4 border-b border-white/10 flex items-center justify-between">
<h2 className="text-xl font-semibold text-white flex items-center">
<Plus className="w-5 h-5 mr-2" />
{title}
</h2>
<div className="flex items-center space-x-2">
{isEdit && onComplete && currentStatus !== '5' && (
<button
type="button"
onClick={onComplete}
disabled={processing}
className="bg-success-500 hover:bg-success-600 text-white px-3 py-1.5 rounded-lg transition-colors flex items-center disabled:opacity-50 text-sm"
>
<CheckCircle className="w-4 h-4 mr-1" />
</button>
)}
<button onClick={onClose} className="text-white/70 hover:text-white transition-colors">
<X className="w-6 h-6" />
</button>
</div> </div>
<h2 className="dialog-title">{title}</h2>
</div> </div>
<div className="flex items-center gap-3">
{isEdit && onComplete && currentStatus !== '5' && (
<button
onClick={onComplete}
disabled={processing}
className="px-4 py-1.5 bg-success-500 hover:bg-success-600 border border-white/20 rounded-xl text-white text-xs font-bold transition-all shadow-lg shadow-success-500/20 active:scale-95 flex items-center gap-2"
>
<Check className="w-3.5 h-3.5" />
</button>
)}
<button
onClick={onClose}
className="p-2 hover:bg-white/10 rounded-full text-white/40 hover:text-white transition-all transform hover:rotate-90"
>
<X className="w-6 h-6" />
</button>
</div>
</div>
{/* 내 */} {/* 내 */}
<div className="p-6 space-y-4"> <div className="p-8 space-y-6 overflow-y-auto max-h-[70vh] custom-scrollbar">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-6">
<div> <div className="space-y-2">
<label className="block text-white/70 text-sm font-medium mb-2"> ()</label> <label className="text-[10px] font-bold text-white/20 uppercase tracking-widest ml-1"> ()</label>
<input <input
type="text" type="text"
value={formData.title} value={formData.title}
onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))} onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all" className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm text-white placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all"
placeholder="할일 제목을 입력하세요" placeholder="제목입력..."
/> />
</div> </div>
<div> <div className="space-y-2">
<label className="block text-white/70 text-sm font-medium mb-2"> ()</label> <label className="text-[10px] font-bold text-white/20 uppercase tracking-widest ml-1"> ()</label>
<div className="relative">
<Calendar className="absolute left-4 top-1/2 -translate-y-1/2 w-4 h-4 text-white/20 pointer-events-none" />
<input <input
type="date" type="date"
value={formData.expire} value={formData.expire}
onChange={(e) => setFormData(prev => ({ ...prev, expire: e.target.value }))} onChange={(e) => setFormData(prev => ({ ...prev, expire: e.target.value }))}
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all" className="w-full bg-white/5 border border-white/10 rounded-xl pl-12 pr-4 py-3 text-sm text-white focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all [color-scheme:dark]"
/> />
</div> </div>
</div> </div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2"> *</label>
<textarea
value={formData.remark}
onChange={(e) => setFormData(prev => ({ ...prev, remark: e.target.value }))}
rows={3}
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
placeholder="할일 내용을 입력하세요 (필수)"
required
/>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2"></label>
<input
type="text"
value={formData.request}
onChange={(e) => setFormData(prev => ({ ...prev, request: e.target.value }))}
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
placeholder="업무 요청자를 입력하세요"
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label className="block text-white/70 text-sm font-medium mb-2"></label>
<div className="flex flex-wrap gap-2">
{statusOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setFormData(prev => ({ ...prev, status: option.value }))}
className={`px-3 py-1 rounded-lg text-xs font-medium border transition-all ${
formData.status === option.value
? getStatusClass(option.value)
: 'bg-white/10 text-white/50 border-white/20 hover:bg-white/20'
}`}
>
{option.label}
</button>
))}
</div>
</div>
<div>
<label className="block text-white/70 text-sm font-medium mb-2"></label>
<select
value={formData.seqno}
onChange={(e) => setFormData(prev => ({ ...prev, seqno: parseInt(e.target.value) as TodoPriority }))}
className="w-full bg-white/20 backdrop-blur-sm border border-white/30 rounded-lg px-4 py-2 text-white focus:outline-none focus:ring-2 focus:ring-primary-400 transition-all"
>
<option value={3}></option>
<option value={2}> </option>
<option value={1}></option>
<option value={0}></option>
<option value={-1}></option>
</select>
</div>
<div className="flex items-end">
<label className="flex items-center text-white/70 text-sm font-medium cursor-pointer">
<input
type="checkbox"
checked={formData.flag}
onChange={(e) => setFormData(prev => ({ ...prev, flag: e.target.checked }))}
className="mr-2 text-primary-500 focus:ring-primary-400 focus:ring-offset-0 rounded"
/>
( )
</label>
</div>
</div>
</div> </div>
{/* 푸터 */} <div className="space-y-2">
<div className="px-6 py-4 border-t border-white/10 flex justify-end"> <label className="text-[10px] font-bold text-white/20 uppercase tracking-widest ml-1"> *</label>
<div className="flex space-x-3"> <textarea
value={formData.remark}
onChange={(e) => setFormData(prev => ({ ...prev, remark: e.target.value }))}
rows={4}
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm text-white placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all resize-none"
placeholder="내용을 입력하세요..."
required
/>
</div>
<div className="space-y-2">
<label className="text-[10px] font-bold text-white/20 uppercase tracking-widest ml-1"> </label>
<input
type="text"
value={formData.request}
onChange={(e) => setFormData(prev => ({ ...prev, request: e.target.value }))}
className="w-full bg-white/5 border border-white/10 rounded-xl px-4 py-3 text-sm text-white placeholder-white/20 focus:outline-none focus:ring-1 focus:ring-primary-500/50 transition-all font-medium"
placeholder="요청자 성명..."
/>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 pt-2">
<div className="space-y-4">
<label className="text-[10px] font-bold text-white/20 uppercase tracking-widest ml-1"> </label>
<div className="flex flex-wrap gap-2">
{statusOptions.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setFormData(prev => ({ ...prev, status: option.value }))}
className={clsx(
"px-3 py-1.5 rounded-lg text-[10px] font-bold border transition-all uppercase tracking-widest",
formData.status === option.value
? getStatusClass(option.value)
: "bg-white/5 text-white/20 border-white/5 hover:bg-white/10"
)}
>
{option.label}
</button>
))}
</div>
</div>
<div className="space-y-4">
<div className="flex items-center justify-between">
<label className="text-[10px] font-bold text-white/20 uppercase tracking-widest ml-1"> & FLAG</label>
<label className="flex items-center gap-2 cursor-pointer group">
<div className={clsx(
"w-8 h-4 rounded-full relative transition-all duration-300 border",
formData.flag ? "bg-warning-500/40 border-warning-500/50" : "bg-white/10 border-white/10"
)}>
<div className={clsx(
"absolute top-0.5 w-2.5 h-2.5 rounded-full bg-white transition-all duration-300 shadow-sm",
formData.flag ? "left-4.5 bg-warning-400" : "left-0.5 opacity-30"
)} />
</div>
<input
type="checkbox"
className="hidden"
checked={formData.flag}
onChange={(e) => setFormData(prev => ({ ...prev, flag: e.target.checked }))}
/>
<span className={clsx(
"text-[10px] font-bold uppercase tracking-widest",
formData.flag ? "text-warning-400" : "text-white/20"
)}></span>
</label>
</div>
<div className="flex gap-1.5">
{[
{ value: 3, label: 'URG', color: 'danger' },
{ value: 2, label: 'HIGH', color: 'warning' },
{ value: 1, label: 'MID', color: 'primary' },
{ value: 0, label: 'LOW', color: 'white' },
{ value: -1, label: 'MINI', color: 'white' },
].map((p) => (
<button
key={p.value}
type="button"
onClick={() => setFormData(prev => ({ ...prev, seqno: p.value as TodoPriority }))}
className={clsx(
"flex-1 py-2 rounded-lg text-[9px] font-extrabold border transition-all tracking-tighter",
formData.seqno === p.value
? `bg-${p.color}-500/20 text-${p.color === 'white' ? 'white' : p.color + '-400'} border-${p.color === 'white' ? 'white/20' : p.color + '-500/30'}`
: "bg-white/5 text-white/20 border-white/5 hover:bg-white/10"
)}
>
{p.label}
</button>
))}
</div>
</div>
</div>
</div>
{/* 푸터 */}
<div className="dialog-footer">
<div>
{isEdit && onDelete && (
<button <button
type="button" type="button"
onClick={onSubmit} onClick={onDelete}
disabled={processing} disabled={processing}
className="bg-primary-500 hover:bg-primary-600 text-white px-6 py-2 rounded-lg transition-colors flex items-center disabled:opacity-50" className="px-5 py-2.5 rounded-xl bg-danger-500/10 hover:bg-danger-500/20 border border-danger-500/20 text-danger-400 text-sm font-bold transition-all active:scale-95 flex items-center gap-2"
> >
{processing ? ( <Trash2 className="w-4 h-4" />
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
) : (
<Edit2 className="w-4 h-4 mr-2" />
)}
{submitText}
</button> </button>
{isEdit && onDelete && ( )}
<button </div>
type="button" <div className="flex items-center gap-3">
onClick={onDelete} <button
disabled={processing} type="button"
className="bg-danger-500 hover:bg-danger-600 text-white px-4 py-2 rounded-lg transition-colors flex items-center disabled:opacity-50" onClick={onClose}
> className="px-6 py-2.5 rounded-xl bg-white/5 hover:bg-white/10 border border-white/10 text-white/70 hover:text-white text-sm font-bold transition-all active:scale-95"
<Trash2 className="w-4 h-4 mr-2" /> >
</button> </button>
)} <button
</div> type="button"
onClick={onSubmit}
disabled={processing}
className="px-8 py-2.5 bg-primary-500 hover:bg-primary-600 border border-white/20 rounded-xl text-white text-sm font-bold transition-all shadow-lg shadow-primary-500/20 active:scale-95 flex items-center gap-2"
>
{processing ? <Loader2 className="w-4 h-4 animate-spin" /> : <Edit2 className="w-4 h-4" />}
{submitText}
</button>
</div> </div>
</div> </div>
</div> </div>

View File

@@ -1,5 +1,5 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import { Search, RefreshCw, Users, Check, X, User, Save } from 'lucide-react'; import { Search, RefreshCw, Users, Check, X, User as UserIcon, Save, Settings, Shield, Mail } from 'lucide-react';
import { clsx } from 'clsx'; import { clsx } from 'clsx';
import { comms } from '@/communication'; import { comms } from '@/communication';
import { GroupUser, UserLevelInfo, UserFullData } from '@/types'; import { GroupUser, UserLevelInfo, UserFullData } from '@/types';
@@ -64,284 +64,252 @@ function UserDetailDialog({ user, levelInfo, onClose, onSave }: UserDetailDialog
}; };
return ( return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}> <div className="fixed inset-0 z-[100] flex items-center justify-center p-4">
{/* 배경 오버레이 */}
<div <div
className="glass-effect rounded-xl w-full max-w-2xl max-h-[90vh] overflow-hidden" className="absolute inset-0 bg-black/60 backdrop-blur-sm animate-fade-in"
onClick={(e) => e.stopPropagation()} onClick={onClose}
> />
{/* 헤더 */}
<div className="p-4 border-b border-white/10 flex items-center justify-between"> {/* 다이얼로그 콘텐트 */}
<div className="flex items-center gap-2"> <div className="relative w-full max-w-3xl bg-[#1a1c1e] border border-white/10 rounded-3xl shadow-2xl overflow-hidden animate-scale-in flex flex-col max-h-[90vh]">
<User className="w-5 h-5 text-white/70" /> <div className="px-6 py-5 border-b border-white/10 flex items-center justify-between bg-white/[0.02] shrink-0">
<h2 className="text-lg font-semibold text-white"> <div className="flex items-center gap-3">
{canEdit ? '' : '(읽기 전용)'} <div className="p-2 bg-primary-500/20 rounded-xl text-primary-400">
</h2> <UserIcon className="w-5 h-5" />
</div>
<div>
<h3 className="text-xl font-bold text-white tracking-tight">
{canEdit ? '' : '(읽기 전용)'}
</h3>
<p className="text-white/30 text-[10px] uppercase font-bold tracking-widest mt-0.5">
User Profile Management
</p>
</div>
</div> </div>
<button <button
onClick={onClose} onClick={onClose}
className="text-white/60 hover:text-white transition-colors text-xl" className="p-2 hover:bg-white/10 rounded-xl text-white/40 hover:text-white transition-colors"
> >
× <X className="w-5 h-5" />
</button> </button>
</div> </div>
{/* 내용 */} <div className="p-6 overflow-y-auto custom-scrollbar flex-1">
<div className="p-4 overflow-auto max-h-[calc(90vh-120px)]"> <div className="space-y-8">
<div className="grid grid-cols-3 gap-4"> {/* 기본 인사 정보 */}
{/* 사번 (읽기 전용) */} <section className="space-y-4">
<div> <div className="flex items-center gap-3 mb-2">
<label className="block text-sm text-white/70 mb-1"></label> <UserIcon className="w-4 h-4 text-primary-500" />
<input <h4 className="text-sm font-bold text-white/70 uppercase tracking-tighter"> </h4>
type="text" <div className="flex-1 h-px bg-white/5"></div>
value={formData.id}
disabled
className="w-full px-3 py-2 bg-white/5 border border-white/20 rounded-lg text-white/50"
/>
</div>
{/* 성명 */}
<div>
<label className="block text-sm text-white/70 mb-1"></label>
<input
type="text"
value={formData.name}
onChange={(e) => handleChange('name', e.target.value)}
disabled={!canEdit}
className={clsx(
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
)}
/>
</div>
{/* 영문명 */}
<div>
<label className="block text-sm text-white/70 mb-1"></label>
<input
type="text"
value={formData.nameE}
onChange={(e) => handleChange('nameE', e.target.value)}
disabled={!canEdit}
className={clsx(
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
)}
/>
</div>
{/* 직책 */}
<div>
<label className="block text-sm text-white/70 mb-1"></label>
<input
type="text"
value={formData.grade}
onChange={(e) => handleChange('grade', e.target.value)}
disabled={!canEdit}
className={clsx(
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
)}
/>
</div>
{/* 공정 */}
<div>
<label className="block text-sm text-white/70 mb-1"></label>
<input
type="text"
value={formData.processs}
onChange={(e) => handleChange('processs', e.target.value)}
disabled={!canEdit}
className={clsx(
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
)}
/>
</div>
{/* 상태 */}
<div>
<label className="block text-sm text-white/70 mb-1"></label>
<input
type="text"
value={formData.state}
onChange={(e) => handleChange('state', e.target.value)}
disabled={!canEdit}
className={clsx(
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
)}
/>
</div>
{/* 이메일 */}
<div>
<label className="block text-sm text-white/70 mb-1"></label>
<input
type="email"
value={formData.email}
onChange={(e) => handleChange('email', e.target.value)}
disabled={!canEdit}
className={clsx(
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
)}
/>
</div>
{/* 전화 */}
<div>
<label className="block text-sm text-white/70 mb-1"></label>
<input
type="text"
value={formData.tel}
onChange={(e) => handleChange('tel', e.target.value)}
disabled={!canEdit}
className={clsx(
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
)}
/>
</div>
{/* 입사일 */}
<div>
<label className="block text-sm text-white/70 mb-1"></label>
<input
type="text"
value={formData.indate}
onChange={(e) => handleChange('indate', e.target.value)}
disabled={!canEdit}
placeholder="YYYY-MM-DD"
className={clsx(
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
)}
/>
</div>
{/* 퇴사일 */}
<div>
<label className="block text-sm text-white/70 mb-1"></label>
<input
type="text"
value={formData.outdate}
onChange={(e) => handleChange('outdate', e.target.value)}
disabled={!canEdit}
placeholder="YYYY-MM-DD"
className={clsx(
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
)}
/>
</div>
{/* 레벨 */}
<div>
<label className="block text-sm text-white/70 mb-1"></label>
<select
value={formData.level}
onChange={(e) => handleChange('level', parseInt(e.target.value) || 0)}
disabled={!canEditAdmin}
className={clsx(
"w-full px-3 py-2 border border-white/20 rounded-lg text-white",
canEditAdmin ? "bg-white/10" : "bg-white/5 text-white/50"
)}
>
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((lv) => (
<option key={lv} value={lv} className="bg-gray-800 text-white">
{lv}
</option>
))}
</select>
</div>
{/* 메모 */}
<div className="col-span-3">
<label className="block text-sm text-white/70 mb-1"></label>
<textarea
value={formData.memo}
onChange={(e) => handleChange('memo', e.target.value)}
disabled={!canEdit}
rows={2}
className={clsx(
"w-full px-3 py-2 border border-white/20 rounded-lg text-white resize-none",
canEdit ? "bg-white/10" : "bg-white/5 text-white/50"
)}
/>
</div>
{/* 관리자 전용 설정 */}
<div className="col-span-3 border-t border-white/10 pt-4 mt-2">
<h3 className="text-sm font-medium text-white/80 mb-3">
{!canEditAdmin && '(관리자만 수정 가능)'}
</h3>
<div className="flex items-center gap-6">
{/* 계정 사용 */}
<label className={clsx(
"flex items-center gap-2 cursor-pointer",
!canEditAdmin && "opacity-50 cursor-not-allowed"
)}>
<input
type="checkbox"
checked={formData.useUserState}
onChange={(e) => handleChange('useUserState', e.target.checked)}
disabled={!canEditAdmin}
className="w-4 h-4 rounded"
/>
<span className="text-sm text-white"> </span>
</label>
{/* 일지 사용 */}
<label className={clsx(
"flex items-center gap-2 cursor-pointer",
!canEditAdmin && "opacity-50 cursor-not-allowed"
)}>
<input
type="checkbox"
checked={formData.useJobReport}
onChange={(e) => handleChange('useJobReport', e.target.checked)}
disabled={!canEditAdmin}
className="w-4 h-4 rounded"
/>
<span className="text-sm text-white"> </span>
</label>
{/* 휴가 제외 */}
<label className={clsx(
"flex items-center gap-2 cursor-pointer",
!canEditAdmin && "opacity-50 cursor-not-allowed"
)}>
<input
type="checkbox"
checked={formData.exceptHoly}
onChange={(e) => handleChange('exceptHoly', e.target.checked)}
disabled={!canEditAdmin}
className="w-4 h-4 rounded"
/>
<span className="text-sm text-white"> </span>
</label>
</div> </div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-1.5 px-3 py-2 bg-white/5 rounded-2xl border border-white/5">
<label className="text-[10px] font-bold text-white/30 uppercase pl-1"></label>
<input
type="text"
value={formData.id}
disabled
className="w-full bg-transparent border-none text-sm text-white/50 focus:outline-none"
/>
</div>
<div className="space-y-1.5 px-3 py-2 bg-white/5 rounded-2xl border border-white/5 focus-within:border-primary-500/30 transition-colors">
<label className="text-[10px] font-bold text-white/30 uppercase pl-1"></label>
<input
type="text"
value={formData.name}
onChange={(e) => handleChange('name', e.target.value)}
disabled={!canEdit}
className="w-full bg-transparent border-none text-sm text-white focus:outline-none placeholder:text-white/10"
/>
</div>
<div className="space-y-1.5 px-3 py-2 bg-white/5 rounded-2xl border border-white/5 focus-within:border-primary-500/30 transition-colors">
<label className="text-[10px] font-bold text-white/30 uppercase pl-1"></label>
<input
type="text"
value={formData.nameE}
onChange={(e) => handleChange('nameE', e.target.value)}
disabled={!canEdit}
className="w-full bg-transparent border-none text-sm text-white focus:outline-none placeholder:text-white/10"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-1.5 px-3 py-2 bg-white/5 rounded-2xl border border-white/5 focus-within:border-primary-500/30 transition-colors">
<label className="text-[10px] font-bold text-white/30 uppercase pl-1"></label>
<input
type="text"
value={formData.grade}
onChange={(e) => handleChange('grade', e.target.value)}
disabled={!canEdit}
className="w-full bg-transparent border-none text-sm text-white focus:outline-none placeholder:text-white/10"
/>
</div>
<div className="space-y-1.5 px-3 py-2 bg-white/5 rounded-2xl border border-white/5 focus-within:border-primary-500/30 transition-colors">
<label className="text-[10px] font-bold text-white/30 uppercase pl-1"></label>
<input
type="text"
value={formData.processs}
onChange={(e) => handleChange('processs', e.target.value)}
disabled={!canEdit}
className="w-full bg-transparent border-none text-sm text-white focus:outline-none placeholder:text-white/10"
/>
</div>
<div className="space-y-1.5 px-3 py-2 bg-white/5 rounded-2xl border border-white/5 focus-within:border-primary-500/30 transition-colors">
<label className="text-[10px] font-bold text-white/30 uppercase pl-1"></label>
<input
type="text"
value={formData.state}
onChange={(e) => handleChange('state', e.target.value)}
disabled={!canEdit}
className="w-full bg-transparent border-none text-sm text-white focus:outline-none placeholder:text-white/10"
/>
</div>
</div>
</section>
{/* 연락처 및 일정 */}
<section className="space-y-4">
<div className="flex items-center gap-3 mb-2">
<Shield className="w-4 h-4 text-primary-500" />
<h4 className="text-sm font-bold text-white/70 uppercase tracking-tighter"> </h4>
<div className="flex-1 h-px bg-white/5"></div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1.5 px-3 py-2 bg-white/5 rounded-2xl border border-white/5 focus-within:border-primary-500/30 transition-colors">
<label className="text-[10px] font-bold text-white/30 uppercase pl-1"></label>
<input
type="email"
value={formData.email}
onChange={(e) => handleChange('email', e.target.value)}
disabled={!canEdit}
className="w-full bg-transparent border-none text-sm text-white focus:outline-none placeholder:text-white/10"
/>
</div>
<div className="space-y-1.5 px-3 py-2 bg-white/5 rounded-2xl border border-white/5 focus-within:border-primary-500/30 transition-colors">
<label className="text-[10px] font-bold text-white/30 uppercase pl-1"></label>
<input
type="text"
value={formData.tel}
onChange={(e) => handleChange('tel', e.target.value)}
disabled={!canEdit}
className="w-full bg-transparent border-none text-sm text-white focus:outline-none placeholder:text-white/10"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1.5 px-3 py-2 bg-white/5 rounded-2xl border border-white/5 focus-within:border-primary-500/30 transition-colors">
<label className="text-[10px] font-bold text-white/30 uppercase pl-1"></label>
<input
type="text"
value={formData.indate}
onChange={(e) => handleChange('indate', e.target.value)}
disabled={!canEdit}
placeholder="YYYY-MM-DD"
className="w-full bg-transparent border-none text-sm text-white focus:outline-none placeholder:text-white/10"
/>
</div>
<div className="space-y-1.5 px-3 py-2 bg-white/5 rounded-2xl border border-white/5 focus-within:border-primary-500/30 transition-colors">
<label className="text-[10px] font-bold text-white/30 uppercase pl-1"></label>
<input
type="text"
value={formData.outdate}
onChange={(e) => handleChange('outdate', e.target.value)}
disabled={!canEdit}
placeholder="YYYY-MM-DD"
className="w-full bg-transparent border-none text-sm text-white focus:outline-none placeholder:text-white/10"
/>
</div>
</div>
</section>
{/* 관리자 권한 및 메모 */}
<section className="space-y-4">
<div className="flex items-center gap-3 mb-2">
<Settings className="w-4 h-4 text-primary-500" />
<h4 className="text-sm font-bold text-white/70 uppercase tracking-tighter"> </h4>
<div className="flex-1 h-px bg-white/5"></div>
</div>
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div className="space-y-1.5 px-3 py-2 bg-white/5 rounded-2xl border border-white/5">
<label className="text-[10px] font-bold text-white/30 uppercase pl-1"> </label>
<select
value={formData.level}
onChange={(e) => handleChange('level', parseInt(e.target.value) || 0)}
disabled={!canEditAdmin}
className="w-full bg-transparent border-none text-sm text-white focus:outline-none cursor-pointer"
>
{[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10].map((lv) => (
<option key={lv} value={lv} className="bg-[#1a1c1e] text-white">
Level {lv}
</option>
))}
</select>
</div>
<div className="md:col-span-3 flex items-center gap-6 px-4">
{[
{ id: 'useUserState', label: '계정 활성', checked: formData.useUserState },
{ id: 'useJobReport', label: '일지 사용', checked: formData.useJobReport },
{ id: 'exceptHoly', label: '휴가 제외', checked: formData.exceptHoly },
].map((item) => (
<label
key={item.id}
className={clsx(
"flex items-center gap-2 cursor-pointer transition-opacity",
!canEditAdmin && "opacity-40 cursor-not-allowed"
)}
>
<input
type="checkbox"
checked={item.checked}
onChange={(e) => handleChange(item.id as any, e.target.checked)}
disabled={!canEditAdmin}
className="w-4 h-4 rounded border-white/20 bg-white/5 text-primary-500 focus:ring-primary-500/50 focus:ring-offset-0"
/>
<span className="text-sm text-white/70 font-medium">{item.label}</span>
</label>
))}
</div>
</div>
<div className="space-y-1.5 px-3 py-3 bg-white/5 rounded-2xl border border-white/5 focus-within:border-primary-500/30 transition-colors">
<label className="text-[10px] font-bold text-white/30 uppercase pl-1"> </label>
<textarea
value={formData.memo}
onChange={(e) => handleChange('memo', e.target.value)}
disabled={!canEdit}
rows={3}
className="w-full bg-transparent border-none text-sm text-white focus:outline-none resize-none placeholder:text-white/10 mt-1"
placeholder="사용자 관련 특이사항을 입력하세요"
/>
</div>
</section>
</div> </div>
</div> </div>
{/* 푸터 */} {/* 푸터 버튼 */}
<div className="p-4 border-t border-white/10 flex justify-end gap-2"> <div className="px-6 py-5 bg-white/[0.02] border-t border-white/10 flex items-center justify-end gap-3 shrink-0">
<button <button
onClick={onClose} onClick={onClose}
className="px-4 py-2 bg-white/10 hover:bg-white/20 rounded-lg text-white transition-colors" className="px-5 py-2 text-white/40 hover:text-white font-bold text-xs transition-colors"
> >
</button> </button>
{canEdit && ( {canEdit && (
<button <button
onClick={handleSave} onClick={handleSave}
disabled={saving} disabled={saving}
className="flex items-center gap-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 rounded-lg text-white transition-colors" className="flex items-center gap-2 px-6 py-2 bg-primary-500 hover:bg-primary-600 text-white rounded-xl font-bold text-xs shadow-lg shadow-primary-500/20 transition-all disabled:opacity-50 active:scale-95"
> >
<Save className="w-4 h-4" /> <Save className="w-3.5 h-3.5" />
{saving ? '저장 중...' : '저장'} {saving ? '저장 중...' : '사용자 정보 저장'}
</button> </button>
)} )}
</div> </div>
@@ -380,14 +348,7 @@ export function UserListPage() {
const result = await comms.getUserList(process); const result = await comms.getUserList(process);
if (Array.isArray(result)) { if (Array.isArray(result)) {
setUsers(result); setUsers(result);
} else if (result && typeof result === 'object') {
const r = result as { Success?: boolean; Message?: string };
if (r.Success === false) {
console.error('사용자 목록 조회 실패:', r.Message);
}
setUsers([]);
} else { } else {
console.error('사용자 목록 응답이 배열이 아님:', result);
setUsers([]); setUsers([]);
} }
} catch (error) { } catch (error) {
@@ -415,130 +376,170 @@ export function UserListPage() {
); );
return ( return (
<div className="h-full flex flex-col"> <div className="space-y-6 animate-fade-in pb-4 h-full">
{/* 헤더 */} {/* 사용자 목록 단일 카드 */}
<div className="glass-effect rounded-xl p-4 mb-4"> <div className="glass-effect rounded-3xl overflow-hidden shadow-2xl border border-white/10 flex flex-col h-full max-h-[calc(100vh-140px)]">
<div className="flex items-center gap-4 flex-wrap"> <div className="px-6 py-4 border-b border-white/10 flex flex-col md:flex-row items-center justify-between gap-4 bg-white/[0.02]">
<div className="flex items-center gap-2"> <div className="flex items-center gap-3">
<label className="text-sm text-white/70"></label> <div className="p-2 bg-primary-500/20 rounded-lg">
<input <Users className="w-5 h-5 text-primary-400" />
type="text" </div>
value={process} <div>
onChange={(e) => setProcess(e.target.value)} <h3 className="text-lg font-bold text-white tracking-tight"> </h3>
onKeyDown={(e) => e.key === 'Enter' && handleRefresh()} <p className="text-white/30 text-[10px] uppercase font-bold tracking-widest mt-0.5">
className="px-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white w-24 text-center" Organizational Directory
/> </p>
</div>
</div> </div>
<button <div className="flex items-center gap-3">
onClick={handleRefresh} {/* 공정 필터 */}
className="flex items-center gap-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 rounded-lg text-white transition-colors" <div className="flex items-center gap-2 bg-white/5 px-3 py-1.5 rounded-xl border border-white/10">
> <span className="text-[10px] text-white/30 font-bold uppercase whitespace-nowrap"></span>
<RefreshCw className="w-4 h-4" /> <input
type="text"
</button> value={process}
onChange={(e) => setProcess(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleRefresh()}
className="bg-transparent border-none text-xs text-white p-0 w-16 text-center focus:outline-none focus:ring-0 font-bold"
/>
</div>
<div className="flex-1" /> {/* 검색창 */}
<div className="relative group w-48 md:w-64">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-white/40 group-focus-within:text-primary-400 transition-colors" />
<input
type="text"
value={filter}
onChange={(e) => setFilter(e.target.value)}
placeholder="ID, 성명, 정보 검색..."
className="w-full bg-white/5 border border-white/10 rounded-xl pl-9 pr-4 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 h-[40px]"
/>
</div>
<div className="relative"> {/* 개수 */}
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-white/40" /> <div className="flex items-center gap-2 bg-white/5 px-3 py-1.5 rounded-xl border border-white/10 h-[40px]">
<input <span className="text-primary-400 font-bold text-sm">{filteredUsers.length}</span>
type="text" <span className="text-white/40 text-[10px] uppercase"></span>
value={filter} </div>
onChange={(e) => setFilter(e.target.value)}
placeholder="검색..." {/* 새로고침 */}
className="pl-9 pr-3 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/40 w-48" <button
/> onClick={handleRefresh}
disabled={loading}
className="p-2 bg-white/5 hover:bg-white/10 border border-white/10 rounded-xl text-white/70 hover:text-white transition-all disabled:opacity-50 h-[40px] w-[40px] flex items-center justify-center"
title="새로고침"
>
<RefreshCw className={clsx("w-4 h-4", loading && "animate-spin")} />
</button>
</div> </div>
</div> </div>
</div>
{/* 테이블 */} {/* 테이블 헤더 */}
<div className="glass-effect rounded-xl flex-1 overflow-hidden flex flex-col"> <div className="bg-white/5 px-6 py-3 border-b border-white/5 flex items-center text-list-header font-list-header text-white/opacity-header-muted uppercase shrink-0">
<div className="p-4 border-b border-white/10 flex items-center gap-2"> <div className="w-12 text-center">ID</div>
<Users className="w-5 h-5 text-white/70" /> <div className="w-32 px-4">/</div>
<h2 className="text-lg font-semibold text-white"> </h2> <div className="flex-1 px-4">/</div>
<span className="text-sm text-white/50">({filteredUsers.length})</span> <div className="w-24 px-4 text-center"></div>
<div className="w-20 text-center"></div>
<div className="w-16 text-center"></div>
<div className="w-16 text-center"></div>
</div> </div>
<div className="flex-1 overflow-auto"> <div className="flex-1 overflow-y-auto custom-scrollbar divide-y divide-white/5">
{loading ? ( {loading ? (
<div className="flex items-center justify-center h-32"> <div className="py-20 text-center">
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-white"></div> <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>
) : filteredUsers.length === 0 ? (
<div className="py-32 text-center">
<Users className="w-16 h-16 mx-auto text-white/10 mb-4" />
<p className="text-white/30 text-base font-bold"> </p>
<p className="text-white/10 text-[10px] mt-2 uppercase tracking-[0.2em]">No personnel records found</p>
</div> </div>
) : ( ) : (
<table className="w-full text-sm"> filteredUsers.map((user) => (
<thead className="bg-white/5 sticky top-0"> <div
<tr> key={user.id}
<th className="px-3 py-2 text-left font-medium text-white/70 w-24"></th> onClick={() => handleRowClick(user)}
<th className="px-3 py-2 text-left font-medium text-white/70 w-24"></th> className={clsx(
<th className="px-3 py-2 text-left font-medium text-white/70 w-20"></th> "px-6 py-3 hover:bg-white/[0.03] transition-all cursor-pointer group flex items-center",
<th className="px-3 py-2 text-left font-medium text-white/70"></th> !user.useUserState && "opacity-40 grayscale-[0.5]"
<th className="px-3 py-2 text-left font-medium text-white/70 w-28"></th>
<th className="px-3 py-2 text-left font-medium text-white/70 w-20"></th>
<th className="px-3 py-2 text-left font-medium text-white/70 w-16"></th>
<th className="px-3 py-2 text-center font-medium text-white/70 w-12">Lv</th>
<th className="px-3 py-2 text-center font-medium text-white/70 w-16"></th>
<th className="px-3 py-2 text-center font-medium text-white/70 w-16"></th>
</tr>
</thead>
<tbody className="divide-y divide-white/5">
{filteredUsers.map((user) => (
<tr
key={user.id}
onClick={() => handleRowClick(user)}
className={clsx(
'hover:bg-white/5 transition-colors cursor-pointer',
!user.useUserState && 'opacity-50'
)}
>
<td className="px-3 py-2 text-white font-mono">{user.id}</td>
<td className="px-3 py-2 text-white font-medium">{user.name}</td>
<td className="px-3 py-2 text-white/70">{user.grade}</td>
<td className="px-3 py-2">
{user.email ? (
<a
href={`mailto:${user.email}`}
className="text-blue-400 hover:text-blue-300 hover:underline"
onClick={(e) => e.stopPropagation()}
>
{user.email}
</a>
) : (
<span className="text-white/70">-</span>
)}
</td>
<td className="px-3 py-2 text-white/70">{user.tel}</td>
<td className="px-3 py-2 text-white/70">{user.processs}</td>
<td className="px-3 py-2 text-white/70">{user.state}</td>
<td className="px-3 py-2 text-white text-center">{user.level}</td>
<td className="px-3 py-2 text-center">
{user.useUserState ? (
<Check className="w-4 h-4 text-green-400 mx-auto" />
) : (
<X className="w-4 h-4 text-red-400 mx-auto" />
)}
</td>
<td className="px-3 py-2 text-center">
{user.useJobReport ? (
<Check className="w-4 h-4 text-green-400 mx-auto" />
) : (
<X className="w-4 h-4 text-red-400 mx-auto" />
)}
</td>
</tr>
))}
{filteredUsers.length === 0 && (
<tr>
<td colSpan={10} className="px-4 py-8 text-center text-white/50">
{users.length === 0 ? '공정을 입력하고 새로고침하세요.' : '검색 결과가 없습니다.'}
</td>
</tr>
)} )}
</tbody> >
</table> {/* ID */}
<div className="w-12 text-center text-xs font-mono text-white/40 group-hover:text-primary-400 Transition-colors">
{user.id}
</div>
{/* 성명/직책 */}
<div className="w-32 px-4 flex flex-col">
<div className="text-sm font-bold text-white group-hover:text-primary-300 transition-colors">
{user.name}
</div>
<div className="text-[10px] text-white/30 font-medium uppercase tracking-tighter">
{user.grade || '-'}
</div>
</div>
{/* 정보 */}
<div className="flex-1 px-4 flex flex-col">
<div className="flex items-center gap-1.5 text-xs text-white/60">
<Mail className="w-3 h-3 text-white/20" />
{user.email || '-'}
</div>
<div className="text-[10px] text-white/30 mt-0.5">
{user.tel || user.hp || ''}
</div>
</div>
{/* 공정 */}
<div className="w-24 px-4 text-center">
<span className="px-2 py-0.5 bg-white/5 border border-white/5 rounded-md text-[10px] text-white/50 font-bold uppercase">
{user.processs || '-'}
</span>
</div>
{/* 레벨 */}
<div className="w-20 text-center font-mono text-xs text-white/40">
Lv.{user.level}
</div>
{/* 계정 상태 */}
<div className="w-16 flex justify-center">
<div className={clsx(
"w-6 h-6 rounded-lg flex items-center justify-center transition-colors",
user.useUserState ? "bg-green-500/10 text-green-400" : "bg-red-500/10 text-red-500"
)}>
{user.useUserState ? <Check className="w-3.5 h-3.5" /> : <X className="w-3.5 h-3.5" />}
</div>
</div>
{/* 일지 사용 */}
<div className="w-16 flex justify-center">
<div className={clsx(
"w-6 h-6 rounded-lg flex items-center justify-center transition-colors",
user.useJobReport ? "bg-primary-500/10 text-primary-400" : "bg-white/5 text-white/10"
)}>
{user.useJobReport ? <Check className="w-3.5 h-3.5" /> : <X className="w-3.5 h-3.5" />}
</div>
</div>
</div>
))
)} )}
</div> </div>
{/* 푸터 */}
<div className="px-6 py-2 flex items-center justify-between bg-white/[0.02] border-t border-white/5 shrink-0">
<div className="text-white/20 text-[9px] font-bold uppercase tracking-[0.2em] py-2">
System Directory Access <span className="text-white/5 mx-2">/</span>
Level <span className="text-primary-400/50 font-mono tracking-normal">{levelInfo?.CurrentUserId || 'Unknown'}</span>
</div>
<div className="text-[9px] text-white/10 italic">
Reference date: {new Date().toLocaleDateString()}
</div>
</div>
</div> </div>
{/* 사용자 상세 다이얼로그 */} {/* 사용자 상세 다이얼로그 */}

View File

@@ -176,6 +176,7 @@ export interface JobReportItem {
export interface JobReportUser { export interface JobReportUser {
id: string; id: string;
name: string; name: string;
process?: string;
} }
// 로그인 관련 타입 // 로그인 관련 타입
@@ -492,6 +493,10 @@ export interface MachineBridgeInterface {
// HolidayRequest API (휴가/외출 신청) // HolidayRequest API (휴가/외출 신청)
HolidayRequest_GetList(startDate: string, endDate: string, userId: string, userLevel: number): Promise<string>; HolidayRequest_GetList(startDate: string, endDate: string, userId: string, userLevel: number): Promise<string>;
HolidayRequest_Save(idx: number, uid: string, cate: string, sdate: string, edate: string, remark: string, response: string, conf: number, holyReason: string, holyBackup: string, holyLocation: string, holyDays: number, holyTimes: number, stime: string, etime: string): Promise<string>; HolidayRequest_Save(idx: number, uid: string, cate: string, sdate: string, edate: string, remark: string, response: string, conf: number, holyReason: string, holyBackup: string, holyLocation: string, holyDays: number, holyTimes: number, stime: string, etime: string): Promise<string>;
// Settings API
GetSettings(): Promise<string>;
SaveSettings(jsonSettings: string): Promise<string>;
} }
// 사용자 권한 정보 타입 // 사용자 권한 정보 타입
@@ -808,6 +813,27 @@ export interface ProjectListResponse {
CurrentUser?: string; CurrentUser?: string;
} }
// 설정(Settings.cs) 관련 타입
export interface SettingsModel {
Disable8HourOver: boolean;
startForm: number; // enum (0:없음, 1:NR구매, 2:프로젝트, 3:업무일지, 4:재고, 5:재고현황, 6:근태, 7:품목)
DupWindow: boolean;
Language: string;
FullScreen: boolean;
Showbuyerror: boolean;
NotShowJobreportPRewView: boolean;
Barcode: string;
CamIndex: number;
HideToolbar: number; // enum (0:Left, 1:Right, 2:Top, 3:Bottom, 4:Hide)
Theme: string;
}
export interface SettingsResponse {
Success: boolean;
Message?: string;
Data?: SettingsModel;
}
// 프로젝트 히스토리 타입 // 프로젝트 히스토리 타입
export interface ProjectHistory { export interface ProjectHistory {
idx: number; idx: number;

View File

@@ -7,18 +7,32 @@ export default {
theme: { theme: {
extend: { extend: {
colors: { colors: {
// Semantic Colors (Mapped to CSS Variables)
primary: { primary: {
50: '#eff6ff', DEFAULT: 'rgb(var(--color-primary) / <alpha-value>)',
100: '#dbeafe', 400: 'rgb(var(--color-primary-light) / <alpha-value>)',
200: '#bfdbfe', 500: 'rgb(var(--color-primary) / <alpha-value>)',
300: '#93c5fd', 600: 'rgb(var(--color-primary-dark) / <alpha-value>)',
400: '#60a5fa',
500: '#3b82f6',
600: '#2563eb',
700: '#1d4ed8',
800: '#1e40af',
900: '#1e3a8a',
}, },
bg: {
main: 'var(--bg-main)',
paper: 'var(--bg-paper)',
surface: 'rgba(255, 255, 255, 0.1)', // fallback or common
},
text: {
primary: 'var(--text-primary)',
secondary: 'var(--text-secondary)',
muted: 'var(--text-muted)',
},
border: {
DEFAULT: 'var(--border-color)',
base: 'var(--border-base)',
},
accent: {
DEFAULT: 'rgb(var(--color-accent) / <alpha-value>)',
},
// Legacy Support (Optional - keep generic scales if needed for specific overrides)
success: { success: {
50: '#f0fdf4', 50: '#f0fdf4',
100: '#dcfce7', 100: '#dcfce7',
@@ -56,6 +70,17 @@ export default {
900: '#7f1d1d', 900: '#7f1d1d',
} }
}, },
fontSize: {
'xs': 'var(--fs-list-header)',
'list-header': 'var(--fs-list-header)',
'list-item': 'var(--fs-list-item)',
},
opacity: {
'header-muted': 'var(--header-muted-opacity)',
},
fontWeight: {
'list-header': 'var(--fw-list-header)',
},
animation: { animation: {
'fade-in': 'fadeIn 0.5s ease-in-out', 'fade-in': 'fadeIn 0.5s ease-in-out',
'slide-up': 'slideUp 0.3s ease-out', 'slide-up': 'slideUp 0.3s ease-out',