feat: 휴가 신청 다이얼로그 개선 (관리자 상용구, UI 가시성, 색상 통일, 공통 알림 컴포넌트 추가)
This commit is contained in:
12
Project/frontend/src/components/common/DevelopmentNotice.tsx
Normal file
12
Project/frontend/src/components/common/DevelopmentNotice.tsx
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
|
||||||
|
|
||||||
|
export function DevelopmentNotice() {
|
||||||
|
return (
|
||||||
|
<div className="bg-yellow-500/10 border border-yellow-500/20 rounded-lg p-4 flex items-center justify-center animate-pulse">
|
||||||
|
<span className="text-yellow-400 font-medium flex items-center gap-2">
|
||||||
|
<span className="text-xl">⚠️</span>
|
||||||
|
현재 개발중인 페이지입니다. 데이터가 정확하지 않을 수 있습니다.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +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 { HolidayRequest, CommonCode } from '@/types';
|
import { HolidayRequest, CommonCode } from '@/types';
|
||||||
|
|
||||||
interface HolidayRequestDialogProps {
|
interface HolidayRequestDialogProps {
|
||||||
@@ -35,6 +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>({
|
||||||
@@ -59,7 +61,9 @@ export function HolidayRequestDialog({
|
|||||||
sendmail: false
|
sendmail: false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [balanceMessage, setBalanceMessage] = useState('');
|
||||||
const [requestType, setRequestType] = useState<'day' | 'time' | 'out'>('day'); // day: 휴가, time: 대체, out: 외출
|
const [requestType, setRequestType] = useState<'day' | 'time' | 'out'>('day'); // day: 휴가, time: 대체, out: 외출
|
||||||
|
const isReadOnly = formData.conf === 1 && userLevel < 5;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isOpen) {
|
if (isOpen) {
|
||||||
@@ -69,7 +73,38 @@ export function HolidayRequestDialog({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (initialData) {
|
if (initialData) {
|
||||||
setFormData({ ...initialData });
|
const confValue = initialData.conf;
|
||||||
|
const convertedConf = Number(initialData.conf ?? 0);
|
||||||
|
console.log('Dialog Debug:', {
|
||||||
|
initialData,
|
||||||
|
rawConf: confValue,
|
||||||
|
typeOfConf: typeof confValue,
|
||||||
|
convertedConf,
|
||||||
|
finalFormDataConf: convertedConf
|
||||||
|
});
|
||||||
|
setFormData({
|
||||||
|
idx: initialData.idx,
|
||||||
|
gcode: initialData.gcode || '',
|
||||||
|
uid: initialData.uid || currentUserId || '',
|
||||||
|
cate: initialData.cate || '',
|
||||||
|
sdate: initialData.sdate ? initialData.sdate.split('T')[0] : new Date().toISOString().split('T')[0],
|
||||||
|
edate: initialData.edate ? initialData.edate.split('T')[0] : new Date().toISOString().split('T')[0],
|
||||||
|
HolyDays: initialData.HolyDays || 0,
|
||||||
|
HolyTimes: initialData.HolyTimes || 0,
|
||||||
|
HolyReason: initialData.HolyReason || (initialData as any).holyReason || '',
|
||||||
|
HolyLocation: initialData.HolyLocation || (initialData as any).holyLocation || '',
|
||||||
|
HolyBackup: initialData.HolyBackup || (initialData as any).holyBackup || '',
|
||||||
|
Remark: initialData.Remark || (initialData as any).remark || '',
|
||||||
|
wuid: initialData.wuid || '',
|
||||||
|
wdate: initialData.wdate || '',
|
||||||
|
Response: initialData.Response || (initialData as any).response || '',
|
||||||
|
conf: convertedConf,
|
||||||
|
stime: initialData.stime || '09:00',
|
||||||
|
etime: initialData.etime || '18:00',
|
||||||
|
sendmail: initialData.sendmail || false,
|
||||||
|
conf_id: initialData.conf_id || '',
|
||||||
|
conf_time: initialData.conf_time || ''
|
||||||
|
});
|
||||||
// Determine request type based on data
|
// Determine request type based on data
|
||||||
if (initialData.cate === '외출') {
|
if (initialData.cate === '외출') {
|
||||||
setRequestType('out');
|
setRequestType('out');
|
||||||
@@ -106,14 +141,32 @@ export function HolidayRequestDialog({
|
|||||||
}
|
}
|
||||||
}, [isOpen, initialData, currentUserId]);
|
}, [isOpen, initialData, currentUserId]);
|
||||||
|
|
||||||
|
// Handle ESC key
|
||||||
|
useEffect(() => {
|
||||||
|
const handleEsc = (e: KeyboardEvent) => {
|
||||||
|
if (isOpen && e.key === 'Escape') {
|
||||||
|
onClose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('keydown', handleEsc);
|
||||||
|
return () => window.removeEventListener('keydown', handleEsc);
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
const loadCodes = async () => {
|
const loadCodes = async () => {
|
||||||
try {
|
try {
|
||||||
const [cateRes, reasonRes, locationRes, backupRes] = await Promise.all([
|
// Execute sequentially to avoid WebSocket response race conditions
|
||||||
comms.getCommonList('50'),
|
const cateRes = await comms.getCommonList('50');
|
||||||
comms.getCommonList('51'),
|
const reasonRes = await comms.getCommonList('51');
|
||||||
comms.getCommonList('52'),
|
const locationRes = await comms.getCommonList('52');
|
||||||
comms.getCommonList('53')
|
const backupRes = await comms.getCommonList('53');
|
||||||
]);
|
|
||||||
|
console.log('Fetched Common Codes:', {
|
||||||
|
cate: cateRes,
|
||||||
|
reason: reasonRes,
|
||||||
|
location: locationRes,
|
||||||
|
backup: backupRes
|
||||||
|
});
|
||||||
|
|
||||||
setCodes({
|
setCodes({
|
||||||
cate: cateRes || [],
|
cate: cateRes || [],
|
||||||
reason: reasonRes || [],
|
reason: reasonRes || [],
|
||||||
@@ -145,12 +198,104 @@ export function HolidayRequestDialog({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchBalance = async () => {
|
||||||
|
// Only for new requests
|
||||||
|
if (formData.idx !== 0 || !isOpen || !formData.sdate || !formData.uid) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const year = formData.sdate.substring(0, 4);
|
||||||
|
const response = await comms.getHolydayBalance(year, formData.uid);
|
||||||
|
|
||||||
|
// C# logic replication for message formatting
|
||||||
|
// [기준:YYYY-MM-DD] => [분류] N일 남음(N%사용), ...
|
||||||
|
const basedate = new Date(formData.sdate);
|
||||||
|
basedate.setDate(basedate.getDate() - 1);
|
||||||
|
const baseDateStr = basedate.toISOString().split('T')[0]; // Format manually if needed, but ISO YYYY-MM-DD ok
|
||||||
|
|
||||||
|
if (!response.Success || !response.Data) {
|
||||||
|
setBalanceMessage(`[기준:${baseDateStr}] => 등록된 근태자료가 없습니다`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const items: string[] = [];
|
||||||
|
response.Data.forEach(item => {
|
||||||
|
// Days
|
||||||
|
if (item.TotalGenDays !== 0 || item.TotalUseDays !== 0) { // Actually checks if Total != 0 or Remain != 0 in C#
|
||||||
|
const remain = item.TotalGenDays - item.TotalUseDays;
|
||||||
|
// C# checks: if (val[0] != "0" || val[2] != "0") (Total or Remain)
|
||||||
|
// Here checking Total != 0 is usually sufficient.
|
||||||
|
if (item.TotalGenDays > 0) {
|
||||||
|
const perc = (item.TotalUseDays / item.TotalGenDays) * 100;
|
||||||
|
items.push(`[${item.cate}] ${remain.toFixed(1)}일 남음(${perc.toFixed(1)}%사용)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Times
|
||||||
|
if (item.TotalGenHours !== 0) {
|
||||||
|
const remain = item.TotalGenHours - item.TotalUseHours;
|
||||||
|
if (item.TotalGenHours > 0) {
|
||||||
|
const perc = (item.TotalUseHours / item.TotalGenHours) * 100;
|
||||||
|
items.push(`[${item.cate}] ${remain.toFixed(1)}시간 남음(${perc.toFixed(1)}%사용)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
setBalanceMessage(`[기준:${baseDateStr}] => 등록된 근태자료가 없습니다`);
|
||||||
|
} else {
|
||||||
|
setBalanceMessage(`[기준:${baseDateStr}] => ${items.join(',')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch balance:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
fetchBalance();
|
||||||
|
}, [formData.sdate, formData.uid, formData.idx, isOpen]);
|
||||||
|
|
||||||
|
|
||||||
|
const handleTypeChange = (type: 'day' | 'time' | 'out') => {
|
||||||
|
setRequestType(type);
|
||||||
|
setFormData(prev => {
|
||||||
|
let newCate = prev.cate;
|
||||||
|
if (type === 'time') newCate = '대체';
|
||||||
|
else if (type === 'out') newCate = '외출';
|
||||||
|
// If switching back to 'day', we might want to reset cate if it was fixed to '대체' or '외출',
|
||||||
|
// or just leave it and let the user change it via Select.
|
||||||
|
// But typically '대체'/'외출' are not valid options for 'day' (general holiday).
|
||||||
|
// So let's reset to empty or first option if we have codes.
|
||||||
|
else if (prev.cate === '대체' || prev.cate === '외출') newCate = codes.cate[0]?.svalue || '';
|
||||||
|
|
||||||
|
return { ...prev, cate: newCate };
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
// Validation
|
// Validation
|
||||||
if (!formData.cate && requestType === 'day') {
|
if (!formData.cate && requestType === 'day') {
|
||||||
alert('구분을 선택하세요.');
|
alert('구분을 선택하세요.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (requestType === 'day' && (!formData.HolyDays || formData.HolyDays <= 0)) {
|
||||||
|
alert('일수를 입력하세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if ((requestType === 'time' || requestType === 'out') && (!formData.HolyTimes || formData.HolyTimes <= 0)) {
|
||||||
|
alert('시간을 입력하세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (requestType === 'out') {
|
||||||
|
if (!formData.stime) {
|
||||||
|
alert('시작 시간을 입력하세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!formData.etime) {
|
||||||
|
alert('종료 시간을 입력하세요.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (formData.sdate > formData.edate) {
|
if (formData.sdate > formData.edate) {
|
||||||
alert('종료일이 시작일보다 빠를 수 없습니다.');
|
alert('종료일이 시작일보다 빠를 수 없습니다.');
|
||||||
return;
|
return;
|
||||||
@@ -161,8 +306,7 @@ export function HolidayRequestDialog({
|
|||||||
if (requestType === 'out') {
|
if (requestType === 'out') {
|
||||||
dataToSave.cate = '외출';
|
dataToSave.cate = '외출';
|
||||||
dataToSave.HolyDays = 0;
|
dataToSave.HolyDays = 0;
|
||||||
// Calculate times if needed, or rely on user input?
|
// Calculate times if needed
|
||||||
// WinForms doesn't seem to auto-calc times for 'out', just saves stime/etime.
|
|
||||||
} else if (requestType === 'time') {
|
} else if (requestType === 'time') {
|
||||||
dataToSave.cate = '대체';
|
dataToSave.cate = '대체';
|
||||||
dataToSave.HolyDays = 0;
|
dataToSave.HolyDays = 0;
|
||||||
@@ -171,15 +315,24 @@ export function HolidayRequestDialog({
|
|||||||
dataToSave.HolyTimes = 0;
|
dataToSave.HolyTimes = 0;
|
||||||
dataToSave.stime = '';
|
dataToSave.stime = '';
|
||||||
dataToSave.etime = '';
|
dataToSave.etime = '';
|
||||||
|
// dataToSave.HolyDays is already set from formData (manual input)
|
||||||
// Calculate days
|
|
||||||
const start = new Date(dataToSave.sdate);
|
|
||||||
const end = new Date(dataToSave.edate);
|
|
||||||
const diffTime = Math.abs(end.getTime() - start.getTime());
|
|
||||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24)) + 1;
|
|
||||||
dataToSave.HolyDays = diffDays;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// New request specific handling
|
||||||
|
if (formData.idx === 0) {
|
||||||
|
// Validate Remark is not empty (as per user request)
|
||||||
|
if (!dataToSave.Remark || dataToSave.Remark.trim() === '') {
|
||||||
|
alert('비고를 입력해주세요');
|
||||||
|
return; // Needs to focus textarea but we can just return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append balance message if not already present (simplified check)
|
||||||
|
if (balanceMessage && !dataToSave.Remark.includes(balanceMessage)) {
|
||||||
|
dataToSave.Remark = dataToSave.Remark.trim() + '\r\n' + balanceMessage;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const response = await comms.saveHolidayRequest(dataToSave);
|
const response = await comms.saveHolidayRequest(dataToSave);
|
||||||
@@ -199,303 +352,384 @@ export function HolidayRequestDialog({
|
|||||||
|
|
||||||
if (!isOpen) return null;
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
const title = formData.idx === 0 ? '휴가/외출 신청' : '신청 내역 수정';
|
||||||
|
|
||||||
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 animate-fade-in">
|
||||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-2xl max-h-[90vh] overflow-y-auto">
|
<div className="bg-[#1e1e2e] rounded-2xl shadow-2xl w-full max-w-4xl max-h-[90vh] overflow-y-auto border border-white/10">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-100">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 bg-white/5">
|
||||||
<h2 className="text-xl font-bold text-gray-800">
|
<h2 className="text-xl font-bold text-white flex items-center">
|
||||||
{formData.idx === 0 ? '휴가/외출 신청' : '신청 내역 수정'}
|
<Calendar className="w-5 h-5 mr-2 text-primary-400" />
|
||||||
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-full transition-colors">
|
<button onClick={onClose} className="text-white/50 hover:text-white transition-colors">
|
||||||
<X className="w-5 h-5 text-gray-500" />
|
<X className="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 개발중 알림 */}
|
||||||
|
<div className="px-6 pt-6">
|
||||||
|
<DevelopmentNotice />
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="p-6 space-y-6">
|
<div className="p-6 grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
{/* Request Type */}
|
{/* Left Column: Inputs */}
|
||||||
<div className="flex gap-4 p-4 bg-gray-50 rounded-lg">
|
<div className="space-y-6">
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
{/* Request Type */}
|
||||||
<input
|
<div className="flex gap-4 p-4 bg-white/5 rounded-lg border border-white/5">
|
||||||
type="radio"
|
<label className={`flex items-center gap-2 ${(isReadOnly || (formData.idx > 0 && initialData?.cate !== '대체' && initialData?.cate !== '외출')) ? 'cursor-not-allowed' : 'cursor-pointer'}`}>
|
||||||
name="type"
|
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${requestType === 'day' ? 'border-green-400' : 'border-white/30'}`}>
|
||||||
checked={requestType === 'day'}
|
{requestType === 'day' && <div className="w-2 h-2 rounded-full bg-green-400" />}
|
||||||
onChange={() => setRequestType('day')}
|
</div>
|
||||||
className="w-4 h-4 text-blue-600"
|
|
||||||
disabled={formData.idx > 0 && initialData?.cate !== '대체' && initialData?.cate !== '외출'}
|
|
||||||
/>
|
|
||||||
<span className="font-medium text-gray-700">일반휴가</span>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="type"
|
|
||||||
checked={requestType === 'time'}
|
|
||||||
onChange={() => setRequestType('time')}
|
|
||||||
className="w-4 h-4 text-blue-600"
|
|
||||||
disabled={formData.idx > 0 && initialData?.cate !== '대체'}
|
|
||||||
/>
|
|
||||||
<span className="font-medium text-gray-700">대체휴가(시간)</span>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="type"
|
|
||||||
checked={requestType === 'out'}
|
|
||||||
onChange={() => setRequestType('out')}
|
|
||||||
className="w-4 h-4 text-blue-600"
|
|
||||||
disabled={formData.idx > 0 && initialData?.cate !== '외출'}
|
|
||||||
/>
|
|
||||||
<span className="font-medium text-gray-700">외출</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* User Selection (Admin only) */}
|
|
||||||
{userLevel >= 5 && (
|
|
||||||
<div className="grid grid-cols-1 gap-2">
|
|
||||||
<label className="text-sm font-medium text-gray-700 flex items-center gap-2">
|
|
||||||
<User className="w-4 h-4" /> 신청자
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={formData.uid}
|
|
||||||
onChange={(e) => setFormData({ ...formData, uid: e.target.value })}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
||||||
disabled={formData.idx > 0}
|
|
||||||
>
|
|
||||||
{users.map(user => (
|
|
||||||
<option key={user.id} value={user.id}>{user.name} ({user.id})</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Date & Time */}
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-gray-700 flex items-center gap-2">
|
|
||||||
<Calendar className="w-4 h-4" /> 시작일
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={formData.sdate}
|
|
||||||
onChange={(e) => setFormData({ ...formData, sdate: e.target.value })}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-gray-700 flex items-center gap-2">
|
|
||||||
<Calendar className="w-4 h-4" /> 종료일
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={formData.edate}
|
|
||||||
onChange={(e) => setFormData({ ...formData, edate: e.target.value })}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(requestType === 'time' || requestType === 'out') && (
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-gray-700 flex items-center gap-2">
|
|
||||||
<Clock className="w-4 h-4" /> 시작 시간
|
|
||||||
</label>
|
|
||||||
<input
|
<input
|
||||||
type="time"
|
type="radio"
|
||||||
value={formData.stime}
|
name="type"
|
||||||
onChange={(e) => setFormData({ ...formData, stime: e.target.value })}
|
checked={requestType === 'day'}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
onChange={() => handleTypeChange('day')}
|
||||||
|
className="hidden"
|
||||||
|
disabled={isReadOnly || (formData.idx > 0 && initialData?.cate !== '대체' && initialData?.cate !== '외출')}
|
||||||
/>
|
/>
|
||||||
</div>
|
<span className="font-medium text-white/90">일반휴가</span>
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-gray-700 flex items-center gap-2">
|
|
||||||
<Clock className="w-4 h-4" /> 종료 시간
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="time"
|
|
||||||
value={formData.etime}
|
|
||||||
onChange={(e) => setFormData({ ...formData, etime: e.target.value })}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Category & Reason */}
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-gray-700 flex items-center gap-2">
|
|
||||||
<FileText className="w-4 h-4" /> 구분
|
|
||||||
</label>
|
</label>
|
||||||
{requestType === 'day' ? (
|
<label className={`flex items-center gap-2 ${(isReadOnly || (formData.idx > 0 && initialData?.cate !== '대체')) ? 'cursor-not-allowed' : 'cursor-pointer'}`}>
|
||||||
|
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${requestType === 'time' ? 'border-green-400' : 'border-white/30'}`}>
|
||||||
|
{requestType === 'time' && <div className="w-2 h-2 rounded-full bg-green-400" />}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="type"
|
||||||
|
checked={requestType === 'time'}
|
||||||
|
onChange={() => handleTypeChange('time')}
|
||||||
|
className="hidden"
|
||||||
|
disabled={isReadOnly || (formData.idx > 0 && initialData?.cate !== '대체')}
|
||||||
|
/>
|
||||||
|
<span className="font-medium text-white/90">대체휴가</span>
|
||||||
|
</label>
|
||||||
|
<label className={`flex items-center gap-2 ${isReadOnly ? 'cursor-not-allowed' : 'cursor-pointer'}`}>
|
||||||
|
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${requestType === 'out' ? 'border-green-400' : 'border-white/30'}`}>
|
||||||
|
{requestType === 'out' && <div className="w-2 h-2 rounded-full bg-green-400" />}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="type"
|
||||||
|
checked={requestType === 'out'}
|
||||||
|
onChange={() => handleTypeChange('out')}
|
||||||
|
className="hidden"
|
||||||
|
disabled={isReadOnly}
|
||||||
|
/>
|
||||||
|
<span className="font-medium text-white/90">외출</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* User Selection (Admin only) */}
|
||||||
|
{userLevel >= 5 && (
|
||||||
|
<div className="grid grid-cols-1 gap-2">
|
||||||
|
<label className="text-sm font-medium text-white/70 flex items-center gap-2">
|
||||||
|
<User className="w-4 h-4" /> 신청자
|
||||||
|
</label>
|
||||||
<select
|
<select
|
||||||
value={formData.cate}
|
value={formData.uid}
|
||||||
onChange={(e) => setFormData({ ...formData, cate: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, uid: e.target.value })}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
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={isReadOnly || formData.idx > 0}
|
||||||
>
|
>
|
||||||
{codes.cate.map(code => (
|
{users.map(user => (
|
||||||
<option key={code.code} value={code.svalue}>{code.svalue}</option>
|
<option key={user.id} value={user.id} className="bg-[#1e1e2e]">{user.name} ({user.id})</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
) : (
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Date & Time */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-white/70 flex items-center gap-2">
|
||||||
|
<Calendar className="w-4 h-4" /> 시작일
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="date"
|
||||||
value={requestType === 'out' ? '외출' : '대체'}
|
value={formData.sdate}
|
||||||
disabled
|
onChange={(e) => setFormData({ ...formData, sdate: e.target.value })}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg bg-gray-100 text-gray-500"
|
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={isReadOnly}
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-gray-700 flex items-center gap-2">
|
|
||||||
<AlertCircle className="w-4 h-4" /> 사유
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={formData.HolyReason}
|
|
||||||
onChange={(e) => setFormData({ ...formData, HolyReason: e.target.value })}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
||||||
>
|
|
||||||
<option value="">선택하세요</option>
|
|
||||||
{codes.reason.map(code => (
|
|
||||||
<option key={code.code} value={code.svalue}>{code.svalue}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Location & Backup */}
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-gray-700 flex items-center gap-2">
|
|
||||||
<MapPin className="w-4 h-4" /> 행선지
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={formData.HolyLocation}
|
|
||||||
onChange={(e) => setFormData({ ...formData, HolyLocation: e.target.value })}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
||||||
>
|
|
||||||
<option value="">선택하세요</option>
|
|
||||||
{codes.location.map(code => (
|
|
||||||
<option key={code.code} value={code.svalue}>{code.svalue}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-gray-700 flex items-center gap-2">
|
|
||||||
<User className="w-4 h-4" /> 업무대행
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={formData.HolyBackup}
|
|
||||||
onChange={(e) => setFormData({ ...formData, HolyBackup: e.target.value })}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
||||||
>
|
|
||||||
<option value="">선택하세요</option>
|
|
||||||
{codes.backup.map(code => (
|
|
||||||
<option key={code.code} value={code.svalue}>{code.svalue}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Days & Times (Manual Override) */}
|
|
||||||
<div className="grid grid-cols-2 gap-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-gray-700">일수</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.5"
|
|
||||||
value={formData.HolyDays}
|
|
||||||
onChange={(e) => setFormData({ ...formData, HolyDays: parseFloat(e.target.value) })}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
||||||
disabled={requestType !== 'day'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-gray-700">시간</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
step="0.5"
|
|
||||||
value={formData.HolyTimes}
|
|
||||||
onChange={(e) => setFormData({ ...formData, HolyTimes: parseFloat(e.target.value) })}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
|
||||||
disabled={requestType === 'day'}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Remark */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-sm font-medium text-gray-700">비고</label>
|
|
||||||
<textarea
|
|
||||||
value={formData.Remark}
|
|
||||||
onChange={(e) => setFormData({ ...formData, Remark: e.target.value })}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent h-20 resize-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Admin Response & Confirmation */}
|
|
||||||
{userLevel >= 5 && (
|
|
||||||
<div className="p-4 bg-blue-50 rounded-lg space-y-4 border border-blue-100">
|
|
||||||
<h3 className="font-semibold text-blue-800">관리자 승인</h3>
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="conf"
|
|
||||||
checked={formData.conf === 0}
|
|
||||||
onChange={() => setFormData({ ...formData, conf: 0 })}
|
|
||||||
className="w-4 h-4 text-blue-600"
|
|
||||||
/>
|
|
||||||
<span className="text-gray-700">미승인</span>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="conf"
|
|
||||||
checked={formData.conf === 1}
|
|
||||||
onChange={() => setFormData({ ...formData, conf: 1 })}
|
|
||||||
className="w-4 h-4 text-green-600"
|
|
||||||
/>
|
|
||||||
<span className="text-gray-700">승인</span>
|
|
||||||
</label>
|
|
||||||
<label className="flex items-center gap-2 cursor-pointer">
|
|
||||||
<input
|
|
||||||
type="radio"
|
|
||||||
name="conf"
|
|
||||||
checked={formData.conf === 2}
|
|
||||||
onChange={() => setFormData({ ...formData, conf: 2 })}
|
|
||||||
className="w-4 h-4 text-red-600"
|
|
||||||
/>
|
|
||||||
<span className="text-gray-700">반려</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<label className="text-sm font-medium text-gray-700">관리자 메모</label>
|
<label className="text-sm font-medium text-white/70 flex items-center gap-2">
|
||||||
|
<Calendar className="w-4 h-4" /> 종료일
|
||||||
|
</label>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="date"
|
||||||
value={formData.Response}
|
value={formData.edate}
|
||||||
onChange={(e) => setFormData({ ...formData, Response: e.target.value })}
|
onChange={(e) => setFormData({ ...formData, edate: e.target.value })}
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent"
|
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={isReadOnly}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
|
|
||||||
|
{/* Category & Reason */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-white/70 flex items-center gap-2">
|
||||||
|
<FileText className="w-4 h-4" /> 구분
|
||||||
|
</label>
|
||||||
|
{requestType === 'day' ? (
|
||||||
|
<select
|
||||||
|
value={formData.cate}
|
||||||
|
onChange={(e) => setFormData({ ...formData, cate: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:bg-white/10"
|
||||||
|
disabled={isReadOnly}
|
||||||
|
>
|
||||||
|
{codes.cate.map(code => {
|
||||||
|
const val = code.memo || (code as any).Memo || code.svalue || (code as any).SValue || (code as any).value || (code as any).Value || (code as any).name || (code as any).Name;
|
||||||
|
const key = code.code || (code as any).Code || (code as any).key || (code as any).Key || val;
|
||||||
|
return (
|
||||||
|
<option key={key} value={val} className="bg-[#1e1e2e]">
|
||||||
|
{val}
|
||||||
|
</option>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</select>
|
||||||
|
) : (
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.cate}
|
||||||
|
readOnly
|
||||||
|
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white/50 cursor-not-allowed"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-white/70 flex items-center gap-2">
|
||||||
|
<AlertCircle className="w-4 h-4" /> 사유
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
list="reason-list"
|
||||||
|
value={formData.HolyReason || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, HolyReason: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:bg-white/10"
|
||||||
|
placeholder="입력 또는 선택"
|
||||||
|
disabled={isReadOnly}
|
||||||
|
/>
|
||||||
|
<datalist id="reason-list">
|
||||||
|
{codes.reason.map(code => {
|
||||||
|
const val = code.memo || (code as any).Memo || code.svalue || (code as any).SValue || (code as any).value || (code as any).Value || (code as any).name || (code as any).Name;
|
||||||
|
const key = code.code || (code as any).Code || (code as any).key || (code as any).Key || val;
|
||||||
|
return (
|
||||||
|
<option key={key} value={val} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Location & Backup */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-white/70 flex items-center gap-2">
|
||||||
|
<MapPin className="w-4 h-4" /> 행선지
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
list="location-list"
|
||||||
|
value={formData.HolyLocation || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, HolyLocation: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:bg-white/10"
|
||||||
|
placeholder="입력 또는 선택"
|
||||||
|
disabled={isReadOnly}
|
||||||
|
/>
|
||||||
|
<datalist id="location-list">
|
||||||
|
{codes.location.map(code => {
|
||||||
|
const val = code.memo || (code as any).Memo || code.svalue || (code as any).SValue || (code as any).value || (code as any).Value || (code as any).name || (code as any).Name;
|
||||||
|
const key = code.code || (code as any).Code || (code as any).key || (code as any).Key || val;
|
||||||
|
return (
|
||||||
|
<option key={key} value={val} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-white/70 flex items-center gap-2">
|
||||||
|
<User className="w-4 h-4" /> 업무대행
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
list="backup-list"
|
||||||
|
value={formData.HolyBackup || ''}
|
||||||
|
onChange={(e) => setFormData({ ...formData, HolyBackup: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:bg-white/10"
|
||||||
|
placeholder="입력 또는 선택"
|
||||||
|
disabled={isReadOnly}
|
||||||
|
/>
|
||||||
|
<datalist id="backup-list">
|
||||||
|
{codes.backup.map(code => {
|
||||||
|
const val = code.memo || (code as any).Memo || code.svalue || (code as any).SValue || (code as any).value || (code as any).Value || (code as any).name || (code as any).Name;
|
||||||
|
const key = code.code || (code as any).Code || (code as any).key || (code as any).Key || val;
|
||||||
|
return (
|
||||||
|
<option key={key} value={val} />
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</datalist>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Days & Times (Manual Override) */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-white/70">일수</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.5"
|
||||||
|
value={formData.HolyDays}
|
||||||
|
onChange={(e) => setFormData({ ...formData, HolyDays: parseFloat(e.target.value) })}
|
||||||
|
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:bg-white/10 disabled:text-white/30 disabled:cursor-not-allowed"
|
||||||
|
disabled={isReadOnly || requestType !== 'day'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-white/70">시간</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
step="0.5"
|
||||||
|
value={formData.HolyTimes}
|
||||||
|
onChange={(e) => setFormData({ ...formData, HolyTimes: parseFloat(e.target.value) })}
|
||||||
|
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:bg-white/10 disabled:text-white/30 disabled:cursor-not-allowed"
|
||||||
|
disabled={isReadOnly || requestType === 'day'}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Outing Time (Only for 'out') */}
|
||||||
|
{requestType === 'out' && (
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-white/70 flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4" /> 시작 시간
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={formData.stime}
|
||||||
|
onChange={(e) => setFormData({ ...formData, stime: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:bg-white/10"
|
||||||
|
disabled={isReadOnly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-white/70 flex items-center gap-2">
|
||||||
|
<Clock className="w-4 h-4" /> 종료 시간
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="time"
|
||||||
|
value={formData.etime}
|
||||||
|
onChange={(e) => setFormData({ ...formData, etime: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:bg-white/10"
|
||||||
|
disabled={isReadOnly}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right Column: Remark */}
|
||||||
|
<div className="md:col-span-1 h-full">
|
||||||
|
<div className="flex flex-col h-full space-y-2">
|
||||||
|
<label className="text-sm font-medium text-white/70">비고</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.Remark}
|
||||||
|
onChange={(e) => setFormData({ ...formData, Remark: e.target.value })}
|
||||||
|
className="w-full flex-1 px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400 resize-none min-h-[200px] disabled:bg-white/10"
|
||||||
|
placeholder="비고 사항을 입력하세요..."
|
||||||
|
disabled={isReadOnly}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Admin Response & Confirmation (Moved to Right) */}
|
||||||
|
<div className="p-4 bg-primary-500/10 rounded-lg space-y-4 border border-primary-500/20 mt-4">
|
||||||
|
<h3 className="font-semibold text-primary-400">관리자 승인</h3>
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<label className={`flex items-center gap-2 ${userLevel < 5 ? 'cursor-not-allowed' : 'cursor-pointer'}`}>
|
||||||
|
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${formData.conf === 0 ? 'border-primary-400' : 'border-white/30'}`}>
|
||||||
|
{formData.conf === 0 && <div className="w-2 h-2 rounded-full bg-primary-400" />}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="conf"
|
||||||
|
checked={formData.conf === 0}
|
||||||
|
onChange={() => setFormData({ ...formData, conf: 0 })}
|
||||||
|
className="hidden"
|
||||||
|
disabled={userLevel < 5}
|
||||||
|
/>
|
||||||
|
<span className="text-white/70">미승인</span>
|
||||||
|
</label>
|
||||||
|
<label className={`flex items-center gap-2 ${userLevel < 5 ? 'cursor-not-allowed' : 'cursor-pointer'}`}>
|
||||||
|
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${formData.conf === 1 ? 'border-green-400' : 'border-white/30'}`}>
|
||||||
|
{formData.conf === 1 && <div className="w-2 h-2 rounded-full bg-green-400" />}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="conf"
|
||||||
|
checked={formData.conf === 1}
|
||||||
|
onChange={() => setFormData({ ...formData, conf: 1 })}
|
||||||
|
className="hidden"
|
||||||
|
disabled={userLevel < 5}
|
||||||
|
/>
|
||||||
|
<span className="text-white/70">승인</span>
|
||||||
|
</label>
|
||||||
|
<label className={`flex items-center gap-2 ${userLevel < 5 ? 'cursor-not-allowed' : 'cursor-pointer'}`}>
|
||||||
|
<div className={`w-4 h-4 rounded-full border-2 flex items-center justify-center ${formData.conf === 2 ? 'border-red-400' : 'border-white/30'}`}>
|
||||||
|
{formData.conf === 2 && <div className="w-2 h-2 rounded-full bg-red-400" />}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="conf"
|
||||||
|
checked={formData.conf === 2}
|
||||||
|
onChange={() => setFormData({ ...formData, conf: 2 })}
|
||||||
|
className="hidden"
|
||||||
|
disabled={userLevel < 5}
|
||||||
|
/>
|
||||||
|
<span className="text-white/70">반려</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="text-sm font-medium text-white/70">관리자 메모</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
list="adminCommentsList"
|
||||||
|
value={formData.Response}
|
||||||
|
onChange={(e) => setFormData({ ...formData, Response: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 bg-white/5 border border-white/10 rounded-lg text-white focus:outline-none focus:ring-2 focus:ring-primary-400 disabled:bg-white/10"
|
||||||
|
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>
|
||||||
|
|
||||||
{/* Footer */}
|
{/* Footer */}
|
||||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-gray-100 bg-gray-50 rounded-b-xl">
|
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t border-white/10 bg-white/5">
|
||||||
<button
|
<button
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="px-4 py-2 text-gray-600 hover:bg-gray-200 rounded-lg transition-colors font-medium"
|
className="px-4 py-2 text-white/70 hover:text-white hover:bg-white/10 rounded-lg transition-colors font-medium"
|
||||||
>
|
>
|
||||||
취소
|
취소
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={handleSave}
|
onClick={handleSave}
|
||||||
disabled={loading}
|
disabled={loading || isReadOnly}
|
||||||
className="flex items-center gap-2 px-6 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg shadow-lg shadow-blue-500/30 transition-all font-medium disabled:opacity-50"
|
className="flex items-center gap-2 px-6 py-2 bg-primary-500 hover:bg-primary-600 text-white rounded-lg shadow-lg shadow-primary-500/30 transition-all font-medium disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
>
|
>
|
||||||
<Save className="w-4 h-4" />
|
<Save className="w-4 h-4" />
|
||||||
{loading ? '저장 중...' : '저장'}
|
{loading ? '저장 중...' : '저장'}
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from 'react';
|
||||||
import { Calendar, Search, User, RefreshCw, ChevronLeft, ChevronRight, Plus } from 'lucide-react';
|
import {
|
||||||
|
Calendar,
|
||||||
|
Search,
|
||||||
|
User,
|
||||||
|
RefreshCw,
|
||||||
|
ChevronLeft,
|
||||||
|
ChevronRight,
|
||||||
|
Plus,
|
||||||
|
CheckCircle,
|
||||||
|
AlertCircle,
|
||||||
|
} from 'lucide-react';
|
||||||
import { comms } from '../communication';
|
import { comms } from '../communication';
|
||||||
import { HolidayRequest, HolidayRequestSummary } from '../types';
|
import { HolidayRequest, HolidayRequestSummary } from '../types';
|
||||||
import { HolidayRequestDialog } from '../components/holiday/HolidayRequestDialog';
|
import { HolidayRequestDialog } from '../components/holiday/HolidayRequestDialog';
|
||||||
|
import { DevelopmentNotice } from '@/components/common/DevelopmentNotice';
|
||||||
|
|
||||||
export default function HolidayRequestPage() {
|
export default function HolidayRequestPage() {
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
@@ -11,8 +22,10 @@ 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 [startDate, setStartDate] = useState('');
|
const [startDate, setStartDate] = useState('');
|
||||||
@@ -23,7 +36,7 @@ export default function HolidayRequestPage() {
|
|||||||
const [currentUserName, setCurrentUserName] = useState('');
|
const [currentUserName, setCurrentUserName] = useState('');
|
||||||
|
|
||||||
// 사용자 목록
|
// 사용자 목록
|
||||||
const [users, setUsers] = useState<Array<{id: string, name: string}>>([]);
|
const [users, setUsers] = useState<Array<{ id: string, name: string }>>([]);
|
||||||
|
|
||||||
// Dialog State
|
// Dialog State
|
||||||
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
const [isDialogOpen, setIsDialogOpen] = useState(false);
|
||||||
@@ -113,6 +126,26 @@ export default function HolidayRequestPage() {
|
|||||||
setSummary(data.Summary);
|
setSummary(data.Summary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Fetch Balance
|
||||||
|
// If viewing all, show current user's balance. If viewing specific user, show that user's balance.
|
||||||
|
const targetUid = userId === '%' ? currentUserId : userId;
|
||||||
|
if (targetUid) {
|
||||||
|
const year = startDate.substring(0, 4);
|
||||||
|
const balanceResp = await comms.getHolydayBalance(year, targetUid);
|
||||||
|
if (balanceResp.Success && balanceResp.Data) {
|
||||||
|
let d = 0, t = 0;
|
||||||
|
balanceResp.Data.forEach(item => {
|
||||||
|
d += (item.TotalGenDays - item.TotalUseDays);
|
||||||
|
t += (item.TotalGenHours - item.TotalUseHours);
|
||||||
|
});
|
||||||
|
setBalance({ days: d, times: t });
|
||||||
|
} else {
|
||||||
|
setBalance({ days: 0, times: 0 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to load holiday requests:', error);
|
console.error('Failed to load holiday requests:', error);
|
||||||
alert('데이터를 불러오는 중 오류가 발생했습니다.');
|
alert('데이터를 불러오는 중 오류가 발생했습니다.');
|
||||||
@@ -151,224 +184,280 @@ export default function HolidayRequestPage() {
|
|||||||
return conf === 1 ? '승인' : '미승인';
|
return conf === 1 ? '승인' : '미승인';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 카운팅: 미래 휴가 (예정일 + 건수)
|
||||||
|
const scheduledStats = requests.reduce((acc, req) => {
|
||||||
|
if (!req.sdate) return acc;
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const startDate = new Date(req.sdate);
|
||||||
|
// 오늘 이후에 시작하는 휴가
|
||||||
|
if (startDate > today) {
|
||||||
|
return {
|
||||||
|
count: acc.count + 1,
|
||||||
|
days: acc.days + (req.HolyDays || 0)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return acc;
|
||||||
|
}, { count: 0, days: 0 });
|
||||||
|
|
||||||
|
const isFuture = (dateStr?: string) => {
|
||||||
|
if (!dateStr) return false;
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
const startDate = new Date(dateStr);
|
||||||
|
return startDate > today;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col h-full bg-gradient-to-br from-slate-50 via-blue-50/30 to-slate-50">
|
<div className="space-y-6 animate-fade-in">
|
||||||
{/* 헤더 */}
|
<div className="max-w-[1920px] mx-auto space-y-6">
|
||||||
<div className="glass-effect-solid border-b border-white/20">
|
<DevelopmentNotice />
|
||||||
<div className="flex items-center justify-between px-6 py-4">
|
{/* 상단 컨트롤 바 */}
|
||||||
<div className="flex items-center space-x-3">
|
<div className="glass-effect rounded-2xl p-6">
|
||||||
<div className="p-2 bg-gradient-to-br from-blue-500 to-blue-600 rounded-xl shadow-lg">
|
<div className="flex flex-col space-y-4">
|
||||||
<Calendar className="w-5 h-5 text-white" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-bold text-white">휴가/외출 신청</h1>
|
|
||||||
<p className="text-sm text-white/70">Holiday Request Management</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setSelectedRequest(null);
|
|
||||||
setIsDialogOpen(true);
|
|
||||||
}}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-all shadow-lg hover:shadow-blue-500/30"
|
|
||||||
>
|
|
||||||
<Plus className="w-4 h-4" />
|
|
||||||
<span className="text-sm font-medium">신청</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={loadData}
|
|
||||||
disabled={loading}
|
|
||||||
className="flex items-center gap-2 px-4 py-2 glass-effect-solid hover:bg-white/20 rounded-lg transition-all text-white disabled:opacity-50"
|
|
||||||
>
|
|
||||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
|
||||||
<span className="text-sm font-medium">새로고침</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 필터 영역 */}
|
<div className="flex flex-col md:flex-row gap-4 items-end md:items-center justify-between">
|
||||||
<div className="glass-effect-solid border-b border-white/20">
|
{/* Date Move & Pick */}
|
||||||
<div className="px-6 py-4">
|
<div className="flex items-center gap-2 w-full md:w-auto">
|
||||||
<div className="flex items-center gap-4 flex-wrap">
|
<button
|
||||||
{/* 월 이동 버튼 */}
|
onClick={() => moveMonth(-1)}
|
||||||
<div className="flex items-center gap-2">
|
className="p-2 rounded-lg hover:bg-white/10 text-white/70 hover:text-white transition-colors"
|
||||||
<button
|
title="이전 달"
|
||||||
onClick={() => moveMonth(-1)}
|
|
||||||
className="p-2 glass-effect hover:bg-white/30 rounded-lg transition-all"
|
|
||||||
>
|
|
||||||
<ChevronLeft className="w-4 h-4 text-white" />
|
|
||||||
</button>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Calendar className="w-4 h-4 text-white/80" />
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={startDate}
|
|
||||||
onChange={(e) => setStartDate(e.target.value)}
|
|
||||||
className="px-3 py-2 glass-effect text-white text-sm rounded-lg focus:ring-2 focus:ring-blue-400/50 focus:outline-none"
|
|
||||||
/>
|
|
||||||
<span className="text-white/60">~</span>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={endDate}
|
|
||||||
onChange={(e) => setEndDate(e.target.value)}
|
|
||||||
className="px-3 py-2 glass-effect text-white text-sm rounded-lg focus:ring-2 focus:ring-blue-400/50 focus:outline-none"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => moveMonth(1)}
|
|
||||||
className="p-2 glass-effect hover:bg-white/30 rounded-lg transition-all"
|
|
||||||
>
|
|
||||||
<ChevronRight className="w-4 h-4 text-white" />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 담당자 선택 (레벨 5 이상만) */}
|
|
||||||
{userLevel >= 5 && (
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<User className="w-4 h-4 text-white/80" />
|
|
||||||
<select
|
|
||||||
value={selectedUserId}
|
|
||||||
onChange={(e) => setSelectedUserId(e.target.value)}
|
|
||||||
className="px-3 py-2 glass-effect text-white text-sm rounded-lg focus:ring-2 focus:ring-blue-400/50 focus:outline-none"
|
|
||||||
>
|
>
|
||||||
{users.map(user => (
|
<ChevronLeft className="w-5 h-5" />
|
||||||
<option key={user.id} value={user.id}>{user.name}</option>
|
</button>
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 조회 버튼 */}
|
<div className="grid grid-cols-2 gap-2">
|
||||||
<button
|
<input
|
||||||
onClick={loadData}
|
type="date"
|
||||||
disabled={loading}
|
value={startDate}
|
||||||
className="flex items-center gap-2 px-4 py-2 bg-gradient-to-r from-blue-500 to-blue-600 hover:from-blue-600 hover:to-blue-700 text-white text-sm font-medium rounded-lg shadow-lg transition-all disabled:opacity-50"
|
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"
|
||||||
<Search className="w-4 h-4" />
|
/>
|
||||||
조회
|
<input
|
||||||
</button>
|
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"
|
||||||
|
title="다음 달"
|
||||||
|
>
|
||||||
|
<ChevronRight className="w-5 h-5" />
|
||||||
|
</button>
|
||||||
|
</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">
|
||||||
|
{/* 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 */}
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedRequest(null);
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
신청
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 합계 표시 */}
|
<div className="border-t border-white/10 my-4"></div>
|
||||||
<div className="mt-4 flex gap-6 text-sm">
|
|
||||||
<div className="glass-effect px-4 py-2 rounded-lg">
|
{/* Summary Stats Cards (Merged) */}
|
||||||
<span className="font-medium text-white/90">합계(일) = </span>
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<span className="text-blue-300 font-semibold">승인: {summary.ApprovedDays}</span>
|
|
||||||
<span className="mx-1 text-white/60">/</span>
|
<StatCard
|
||||||
<span className={summary.PendingDays === 0 ? 'text-white/90' : 'text-red-400 font-semibold'}>
|
title="휴가 예정"
|
||||||
미승인: {summary.PendingDays}
|
value={`${scheduledStats.count}건 (${scheduledStats.days}일)`}
|
||||||
</span>
|
icon={<Calendar className="w-6 h-6 text-blue-400" />}
|
||||||
</div>
|
color={scheduledStats.count > 0 ? "text-blue-400" : "text-white/50"}
|
||||||
<div className="glass-effect px-4 py-2 rounded-lg">
|
/>
|
||||||
<span className="font-medium text-white/90">합계(시간) = </span>
|
<StatCard
|
||||||
<span className="text-blue-300 font-semibold">승인: {summary.ApprovedTimes}</span>
|
title="휴가 잔량 (일)"
|
||||||
<span className="mx-1 text-white/60">/</span>
|
value={`${balance.days.toFixed(1)}일`}
|
||||||
<span className={summary.PendingTimes === 0 ? 'text-white/90' : 'text-red-400 font-semibold'}>
|
icon={<RefreshCw className="w-6 h-6 text-green-400" />}
|
||||||
미승인: {summary.PendingTimes}
|
color="text-green-400"
|
||||||
</span>
|
/>
|
||||||
</div>
|
<StatCard
|
||||||
|
title="휴가 잔량 (시간)"
|
||||||
|
value={`${balance.times.toFixed(1)}시간`}
|
||||||
|
icon={<RefreshCw className="w-6 h-6 text-purple-400" />}
|
||||||
|
color="text-purple-400"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 테이블 */}
|
{/* List Table */}
|
||||||
<div className="flex-1 overflow-auto px-6 py-4">
|
<div className="glass-effect rounded-2xl overflow-hidden">
|
||||||
<div className="glass-effect-solid rounded-xl 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">
|
||||||
|
총 {requests.length}건
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full">
|
||||||
<thead>
|
<thead className="bg-white/10">
|
||||||
<tr className="border-b border-white/10">
|
<tr>
|
||||||
<th className="px-4 py-3 text-left font-semibold text-white/90">No</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase w-[50px]">No</th>
|
||||||
<th className="px-4 py-3 text-left font-semibold text-white/90">신청일</th>
|
{/* <th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">부서</th> <- Removed */}
|
||||||
<th className="px-4 py-3 text-left font-semibold text-white/90">부서</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 font-semibold text-white/90">신청자</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase w-[80px]">종류</th>
|
||||||
<th className="px-4 py-3 text-left font-semibold text-white/90">종류</th>
|
<th className="px-4 py-3 text-center text-xs font-medium text-white/70 uppercase w-[80px]">상태</th>
|
||||||
<th className="px-4 py-3 text-left font-semibold text-white/90">시작일</th>
|
<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-left font-semibold text-white/90">종료일</th>
|
<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 font-semibold text-white/90">일수</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 font-semibold text-white/90">시간</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 font-semibold text-white/90">사유</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-center font-semibold text-white/90">승인</th>
|
<th className="px-4 py-3 text-left text-xs font-medium text-white/70 uppercase">사유</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody className="divide-y divide-white/10">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={11} className="px-4 py-12 text-center">
|
<td colSpan={10} className="px-4 py-8 text-center bg-transparent">
|
||||||
<div className="flex items-center justify-center gap-3">
|
<div className="flex items-center justify-center">
|
||||||
<RefreshCw className="w-5 h-5 text-blue-400 animate-spin" />
|
<RefreshCw className="w-5 h-5 mr-2 animate-spin text-white/50" />
|
||||||
<span className="text-white/60">데이터를 불러오는 중...</span>
|
<span className="text-white/50">데이터를 불러오는 중...</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : requests.length === 0 ? (
|
) : requests.length === 0 ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={11} className="px-4 py-12 text-center">
|
<td colSpan={9} className="px-4 py-8 text-center text-white/50 bg-transparent">
|
||||||
<div className="flex flex-col items-center justify-center gap-3">
|
조회된 데이터가 없습니다.
|
||||||
<Calendar className="w-12 h-12 text-white/30" />
|
|
||||||
<span className="text-white/60">조회된 데이터가 없습니다.</span>
|
|
||||||
</div>
|
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : (
|
) : (
|
||||||
requests.map((req, index) => (
|
requests.map((req, index) => {
|
||||||
<tr
|
// 미래 휴가 배경색 처리
|
||||||
key={req.idx}
|
const isFutureRequest = isFuture(req.sdate);
|
||||||
className="border-b border-white/5 hover:bg-white/5 transition-colors cursor-pointer"
|
const rowClass = isFutureRequest
|
||||||
onClick={() => {
|
? "bg-blue-500/10 hover:bg-blue-500/20 transition-colors cursor-pointer"
|
||||||
setSelectedRequest(req);
|
: "hover:bg-white/5 transition-colors cursor-pointer";
|
||||||
setIsDialogOpen(true);
|
|
||||||
}}
|
return (
|
||||||
>
|
<tr
|
||||||
<td className="px-4 py-3 text-white/70">{index + 1}</td>
|
key={req.idx}
|
||||||
<td className="px-4 py-3 text-white/90">{formatDateShort(req.wdate)}</td>
|
className={rowClass}
|
||||||
<td className="px-4 py-3 text-white/80">{req.dept || ''}</td>
|
onClick={() => {
|
||||||
<td className="px-4 py-3 text-white font-medium">{req.name || ''}</td>
|
setSelectedRequest(req);
|
||||||
<td className="px-4 py-3">
|
setIsDialogOpen(true);
|
||||||
<span className="px-2 py-1 bg-blue-500/20 text-blue-300 rounded text-xs font-medium">
|
}}
|
||||||
{getCategoryName(req.cate)}
|
>
|
||||||
</span>
|
<td className="px-4 py-3 text-white/50 text-sm">{index + 1}</td>
|
||||||
</td>
|
{/* <td className="px-4 py-3 text-white/70 text-sm">{req.dept || '-'}</td> <- Removed */}
|
||||||
<td className="px-4 py-3 text-white/90">{formatDateShort(req.sdate)}</td>
|
<td className="px-4 py-3 text-white text-sm font-medium">{req.name || '-'}</td>
|
||||||
<td className="px-4 py-3 text-white/90">{formatDateShort(req.edate)}</td>
|
<td className="px-4 py-3 text-sm">
|
||||||
<td className="px-4 py-3 text-center text-white/90">{req.HolyDays || 0}</td>
|
<span className={`px-2 py-1 rounded text-xs ${req.cate === '1' ? 'bg-primary-500/20 text-primary-300' : // 연차
|
||||||
<td className="px-4 py-3 text-center text-white/90">{req.HolyTimes || 0}</td>
|
req.cate === '2' ? 'bg-blue-500/20 text-blue-300' : // 반차
|
||||||
<td className="px-4 py-3 text-white/70 max-w-xs truncate" title={req.HolyReason || ''}>
|
req.cate === '5' ? 'bg-yellow-500/20 text-yellow-300' : // 외출
|
||||||
{req.HolyReason || '-'}
|
'bg-white/10 text-white/70'
|
||||||
</td>
|
}`}>
|
||||||
<td className="px-4 py-3 text-center">
|
{getCategoryName(req.cate)}
|
||||||
<span className={`px-2 py-1 rounded text-xs font-semibold ${
|
</span>
|
||||||
req.conf === 1
|
</td>
|
||||||
? 'bg-green-500/20 text-green-300'
|
<td className="px-4 py-3 text-center">
|
||||||
: 'bg-red-500/20 text-red-300'
|
<span className={`px-2 py-1 rounded text-xs font-semibold ${req.conf === 1
|
||||||
}`}>
|
? 'bg-success-500/20 text-success-300'
|
||||||
{getConfirmStatusText(req.conf)}
|
: 'bg-danger-500/20 text-danger-300'
|
||||||
</span>
|
}`}>
|
||||||
</td>
|
{getConfirmStatusText(req.conf)}
|
||||||
</tr>
|
</span>
|
||||||
))
|
</td>
|
||||||
|
<td className="px-4 py-3 text-white text-sm">
|
||||||
|
{formatDateShort(req.sdate)}
|
||||||
|
{isFutureRequest && <span className="ml-2 text-[10px] bg-blue-500 text-white px-1.5 py-0.5 rounded-full">예정</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-white text-sm">{formatDateShort(req.edate)}</td>
|
||||||
|
<td className="px-4 py-3 text-center text-white text-sm">{req.HolyDays || 0}</td>
|
||||||
|
<td className="px-4 py-3 text-center text-white text-sm">{req.HolyTimes || 0}</td>
|
||||||
|
<td className="px-4 py-3 text-white text-sm max-w-[150px] truncate">{req.HolyLocation || '-'}</td>
|
||||||
|
<td className="px-4 py-3 text-white/70 text-sm max-w-xs truncate" title={req.HolyReason || ''}>
|
||||||
|
{req.HolyReason || '-'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
</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 >
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 통계 카드 컴포넌트 (Local)
|
||||||
|
interface StatCardProps {
|
||||||
|
title: string;
|
||||||
|
value: string | number;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function StatCard({ title, value, 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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function Jobreport() {
|
|||||||
|
|
||||||
// 페이징 상태
|
// 페이징 상태
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const pageSize = 10;
|
const pageSize = 30;
|
||||||
|
|
||||||
// 권한 상태
|
// 권한 상태
|
||||||
const [canViewOT, setCanViewOT] = useState(false);
|
const [canViewOT, setCanViewOT] = useState(false);
|
||||||
@@ -105,17 +105,17 @@ export function Jobreport() {
|
|||||||
|
|
||||||
// 어제부터 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 = formatDateLocal(d);
|
const dStr = formatDateLocal(d);
|
||||||
|
|
||||||
// 주말(토:6, 일:0) 제외
|
// 주말(토:6, 일:0) 제외
|
||||||
if (d.getDay() === 0 || d.getDay() === 6) continue;
|
if (d.getDay() === 0 || d.getDay() === 6) continue;
|
||||||
|
|
||||||
const hrs = dailyWork[dStr] || 0;
|
const hrs = dailyWork[dStr] || 0;
|
||||||
if (hrs < 8) {
|
if (hrs < 8) {
|
||||||
insufficientDays.push({ date: dStr, hrs });
|
insufficientDays.push({ date: dStr, hrs });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setUnregisteredJobReportCount(insufficientDays.length);
|
setUnregisteredJobReportCount(insufficientDays.length);
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
import { comms } from '@/communication';
|
import { comms } from '@/communication';
|
||||||
import { KuntaeModel, HolydayPermission, HolydayUser, HolydayBalance } from '@/types';
|
import { KuntaeModel, HolydayPermission, HolydayUser, HolydayBalance } from '@/types';
|
||||||
import { KuntaeEditModal, KuntaeFormData } from '@/components/kuntae/KuntaeEditModal';
|
import { KuntaeEditModal, KuntaeFormData } from '@/components/kuntae/KuntaeEditModal';
|
||||||
|
import { DevelopmentNotice } from '@/components/common/DevelopmentNotice';
|
||||||
|
|
||||||
export function Kuntae() {
|
export function Kuntae() {
|
||||||
const [kuntaeList, setKuntaeList] = useState<KuntaeModel[]>([]);
|
const [kuntaeList, setKuntaeList] = useState<KuntaeModel[]>([]);
|
||||||
@@ -234,6 +235,9 @@ export function Kuntae() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6 animate-fade-in">
|
<div className="space-y-6 animate-fade-in">
|
||||||
|
{/* 개발중 알림 */}
|
||||||
|
<DevelopmentNotice />
|
||||||
|
|
||||||
{/* 상단 컨트롤 바 */}
|
{/* 상단 컨트롤 바 */}
|
||||||
<div className="glass-effect rounded-2xl p-6">
|
<div className="glass-effect rounded-2xl p-6">
|
||||||
<div className="flex flex-col space-y-4">
|
<div className="flex flex-col space-y-4">
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
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 clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
|
|
||||||
// 상태별 색상 매핑
|
// 상태별 색상 매핑
|
||||||
@@ -294,7 +295,10 @@ export function Project() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 space-y-4">
|
<div className="p-4 space-y-4 relative">
|
||||||
|
{/* 개발중 알림 */}
|
||||||
|
<DevelopmentNotice />
|
||||||
|
|
||||||
{/* 헤더 */}
|
{/* 헤더 */}
|
||||||
<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">
|
||||||
@@ -321,11 +325,10 @@ export function Project() {
|
|||||||
<button
|
<button
|
||||||
key={status}
|
key={status}
|
||||||
onClick={() => toggleStatus(status)}
|
onClick={() => toggleStatus(status)}
|
||||||
className={`px-3 py-1 rounded-lg text-sm transition-all ${
|
className={`px-3 py-1 rounded-lg text-sm transition-all ${checked
|
||||||
checked
|
? `${statusColors[status]?.bg || 'bg-white/20'} ${statusColors[status]?.text || 'text-white'} font-semibold`
|
||||||
? `${statusColors[status]?.bg || 'bg-white/20'} ${statusColors[status]?.text || 'text-white'} font-semibold`
|
: 'bg-white/5 text-white/50'
|
||||||
: 'bg-white/5 text-white/50'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{status}
|
{status}
|
||||||
<span className="ml-1 text-xs">({statusCounts[status] || 0})</span>
|
<span className="ml-1 text-xs">({statusCounts[status] || 0})</span>
|
||||||
@@ -337,9 +340,8 @@ export function Project() {
|
|||||||
<div className="flex flex-wrap items-center gap-3">
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
<button
|
<button
|
||||||
onClick={toggleUserFilter}
|
onClick={toggleUserFilter}
|
||||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm transition-all ${
|
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg text-sm transition-all ${userFilter ? 'bg-primary-500/20 text-primary-400' : 'bg-white/5 text-white/50'
|
||||||
userFilter ? 'bg-primary-500/20 text-primary-400' : 'bg-white/5 text-white/50'
|
}`}
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
<User className="w-4 h-4" />
|
<User className="w-4 h-4" />
|
||||||
<span>{userFilter || '전체'}</span>
|
<span>{userFilter || '전체'}</span>
|
||||||
@@ -413,71 +415,71 @@ export function Project() {
|
|||||||
|
|
||||||
{/* 메인 콘텐츠 */}
|
{/* 메인 콘텐츠 */}
|
||||||
<div className="glass-effect rounded-xl overflow-hidden">
|
<div className="glass-effect rounded-xl overflow-hidden">
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="w-full text-sm">
|
<table className="w-full text-sm">
|
||||||
<thead className="bg-white/5 sticky top-0">
|
<thead className="bg-white/5 sticky top-0">
|
||||||
<tr className="text-white/60 text-left">
|
<tr className="text-white/60 text-left">
|
||||||
<th className="px-3 py-2 w-16">상태</th>
|
<th className="px-3 py-2 w-16">상태</th>
|
||||||
<th className="px-3 py-2">프로젝트명</th>
|
<th className="px-3 py-2">프로젝트명</th>
|
||||||
<th className="px-3 py-2 w-20">챔피언</th>
|
<th className="px-3 py-2 w-20">챔피언</th>
|
||||||
<th className="px-3 py-2 w-28">요청자</th>
|
<th className="px-3 py-2 w-28">요청자</th>
|
||||||
<th className="px-3 py-2 w-20 text-center">진행률</th>
|
<th className="px-3 py-2 w-20 text-center">진행률</th>
|
||||||
<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-24">만료/완료</th>
|
<th className="px-3 py-2 w-24">만료/완료</th>
|
||||||
<th className="px-3 py-2 w-10"></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>
|
</tr>
|
||||||
</thead>
|
) : paginatedProjects.length === 0 ? (
|
||||||
<tbody className="divide-y divide-white/5">
|
<tr>
|
||||||
{loading ? (
|
<td colSpan={8} className="px-3 py-8 text-center text-white/50">
|
||||||
<tr>
|
프로젝트가 없습니다.
|
||||||
<td colSpan={8} className="px-3 py-8 text-center text-white/50">
|
</td>
|
||||||
<RefreshCw className="w-6 h-6 animate-spin mx-auto mb-2" />
|
</tr>
|
||||||
로딩중...
|
) : (
|
||||||
</td>
|
paginatedProjects.map((project) => {
|
||||||
</tr>
|
const statusColor = statusColors[project.status] || { text: 'text-white', bg: 'bg-white/10' };
|
||||||
) : paginatedProjects.length === 0 ? (
|
const isExpanded = expandedProject === project.idx;
|
||||||
<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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<tr
|
<tr
|
||||||
key={project.idx}
|
key={project.idx}
|
||||||
className={clsx(
|
className={clsx(
|
||||||
'border-b border-white/10 cursor-pointer hover:bg-white/5',
|
'border-b border-white/10 cursor-pointer hover:bg-white/5',
|
||||||
isExpanded && 'bg-primary-900/30'
|
isExpanded && 'bg-primary-900/30'
|
||||||
)}
|
)}
|
||||||
onClick={() => toggleHistory(project.idx)}
|
onClick={() => toggleHistory(project.idx)}
|
||||||
>
|
>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
<span className={`px-2 py-0.5 rounded text-xs ${statusColor.bg} ${statusColor.text}`}>
|
<span className={`px-2 py-0.5 rounded text-xs ${statusColor.bg} ${statusColor.text}`}>
|
||||||
{project.status}
|
{project.status}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</td>
|
||||||
<td className={`px-3 py-2 ${statusColor.text}`}>
|
<td className={`px-3 py-2 ${statusColor.text}`}>
|
||||||
<div className="truncate max-w-xs" title={project.name}>
|
<div className="truncate max-w-xs" title={project.name}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
onClick={e => {
|
onClick={e => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleSelectProject(project);
|
handleSelectProject(project);
|
||||||
}}
|
}}
|
||||||
className="text-primary-300 hover:text-primary-200 transition-colors"
|
className="text-primary-300 hover:text-primary-200 transition-colors"
|
||||||
title="편집"
|
title="편집"
|
||||||
>
|
>
|
||||||
<Edit2 className="w-4 h-4" />
|
<Edit2 className="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
<span className="font-regular text-white/90">{project.name}</span>
|
<span className="font-regular text-white/90">{project.name}</span>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</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">{project.name_champion || project.userManager}</td>
|
||||||
<td className="px-3 py-2 text-white/70 text-xs">
|
<td className="px-3 py-2 text-white/70 text-xs">
|
||||||
<div>{project.ReqLine}</div>
|
<div>{project.ReqLine}</div>
|
||||||
@@ -608,36 +610,36 @@ export function Project() {
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
)}
|
)}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 페이징 */}
|
{/* 페이징 */}
|
||||||
{totalPages > 1 && (
|
{totalPages > 1 && (
|
||||||
<div className="flex items-center justify-center gap-2 p-3 border-t border-white/10">
|
<div className="flex items-center justify-center gap-2 p-3 border-t border-white/10">
|
||||||
<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="p-1 rounded hover:bg-white/10 disabled:opacity-30"
|
className="p-1 rounded hover:bg-white/10 disabled:opacity-30"
|
||||||
>
|
>
|
||||||
<ChevronLeft className="w-5 h-5 text-white/70" />
|
<ChevronLeft className="w-5 h-5 text-white/70" />
|
||||||
</button>
|
</button>
|
||||||
<span className="text-white/70 text-sm">
|
<span className="text-white/70 text-sm">
|
||||||
{currentPage} / {totalPages}
|
{currentPage} / {totalPages}
|
||||||
</span>
|
</span>
|
||||||
<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="p-1 rounded hover:bg-white/10 disabled:opacity-30"
|
className="p-1 rounded hover:bg-white/10 disabled:opacity-30"
|
||||||
>
|
>
|
||||||
<ChevronRight className="w-5 h-5 text-white/70" />
|
<ChevronRight className="w-5 h-5 text-white/70" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 프로젝트 상세 다이얼로그 */}
|
{/* 프로젝트 상세 다이얼로그 */}
|
||||||
|
|||||||
Reference in New Issue
Block a user