- 24개 Blade 파일의 수동 SVG 생성 코드를 lucide.createElement(_def)로 통일 - 불필요한 quote-stripping regex(/^"|"$/g) 제거 - Lucide 공식 API 사용으로 SVG viewBox/path 속성 에러 해결
570 lines
31 KiB
PHP
570 lines
31 KiB
PHP
@extends('layouts.app')
|
|
|
|
@section('title', '달력 휴일 관리')
|
|
|
|
@push('styles')
|
|
<style>
|
|
@media print { .no-print { display: none !important; } }
|
|
</style>
|
|
@endpush
|
|
|
|
@section('content')
|
|
<meta name="csrf-token" content="{{ csrf_token() }}">
|
|
<div id="holiday-root"></div>
|
|
@endsection
|
|
|
|
@push('scripts')
|
|
@include('partials.react-cdn')
|
|
<script src="https://unpkg.com/lucide@0.469.0?v={{ time() }}"></script>
|
|
@verbatim
|
|
<script type="text/babel">
|
|
const { useState, useRef, useEffect, useMemo } = React;
|
|
|
|
const createIcon = (name) => ({ className = "w-5 h-5", ...props }) => {
|
|
const ref = useRef(null);
|
|
useEffect(() => {
|
|
const _def=((n)=>{const a={'check-circle':'CircleCheck','alert-circle':'CircleAlert','alert-triangle':'TriangleAlert','clipboard-check':'ClipboardCheck'};if(a[n]&&lucide[a[n]])return lucide[a[n]];const p=n.split('-').map(w=>w.charAt(0).toUpperCase()+w.slice(1)).join('');return lucide[p]||(lucide.icons&&lucide.icons[n])||null;})(name);
|
|
if (ref.current && _def) {
|
|
ref.current.innerHTML = '';
|
|
const svg = lucide.createElement(_def);
|
|
svg.setAttribute('class', className);
|
|
ref.current.appendChild(svg);
|
|
}
|
|
}, [className]);
|
|
return <span ref={ref} className="inline-flex items-center" {...props} />;
|
|
};
|
|
|
|
const CalendarIcon = createIcon('calendar');
|
|
const Plus = createIcon('plus');
|
|
const X = createIcon('x');
|
|
const Edit = createIcon('edit');
|
|
const Trash2 = createIcon('trash-2');
|
|
const ChevronLeft = createIcon('chevron-left');
|
|
const ChevronRight = createIcon('chevron-right');
|
|
const Upload = createIcon('upload');
|
|
const ListIcon = createIcon('list');
|
|
const LayoutGrid = createIcon('layout-grid');
|
|
|
|
const HOLIDAY_TYPES = {
|
|
public: { label: '공휴일', color: 'bg-red-100 text-red-700', dot: 'bg-red-500' },
|
|
company: { label: '회사지정', color: 'bg-blue-100 text-blue-700', dot: 'bg-blue-500' },
|
|
alternative: { label: '대체휴일', color: 'bg-amber-100 text-amber-700', dot: 'bg-amber-500' },
|
|
temporary: { label: '임시휴일', color: 'bg-purple-100 text-purple-700', dot: 'bg-purple-500' },
|
|
};
|
|
|
|
const MONTHS = ['1월','2월','3월','4월','5월','6월','7월','8월','9월','10월','11월','12월'];
|
|
const WEEKDAYS = ['일','월','화','수','목','금','토'];
|
|
|
|
function HolidayManagement() {
|
|
const [holidays, setHolidays] = useState([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [saving, setSaving] = useState(false);
|
|
const [selectedYear, setSelectedYear] = useState(new Date().getFullYear());
|
|
const [viewMode, setViewMode] = useState('calendar');
|
|
|
|
const [showModal, setShowModal] = useState(false);
|
|
const [modalMode, setModalMode] = useState('add');
|
|
const [editingItem, setEditingItem] = useState(null);
|
|
|
|
const [showBulkModal, setShowBulkModal] = useState(false);
|
|
const [bulkText, setBulkText] = useState('');
|
|
|
|
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
|
|
|
const initialFormState = {
|
|
name: '',
|
|
startDate: '',
|
|
endDate: '',
|
|
type: 'public',
|
|
isRecurring: false,
|
|
memo: '',
|
|
};
|
|
const [formData, setFormData] = useState(initialFormState);
|
|
|
|
const formatCurrency = (num) => num ? num.toLocaleString() : '0';
|
|
|
|
const fetchHolidays = async (year) => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await fetch(`/system/holidays/list?year=${year || selectedYear}`);
|
|
const data = await res.json();
|
|
if (data.success) setHolidays(data.data);
|
|
} catch (err) {
|
|
console.error('휴일 조회 실패:', err);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => { fetchHolidays(selectedYear); }, [selectedYear]);
|
|
|
|
// 달력에 휴일 매핑
|
|
const holidayMap = useMemo(() => {
|
|
const map = {};
|
|
holidays.forEach(h => {
|
|
const start = new Date(h.startDate);
|
|
const end = new Date(h.endDate);
|
|
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
|
|
const key = d.toISOString().split('T')[0];
|
|
if (!map[key]) map[key] = [];
|
|
map[key].push(h);
|
|
}
|
|
});
|
|
return map;
|
|
}, [holidays]);
|
|
|
|
const handleAdd = (dateStr) => {
|
|
setModalMode('add');
|
|
setEditingItem(null);
|
|
setFormData({ ...initialFormState, startDate: dateStr || '', endDate: dateStr || '' });
|
|
setShowModal(true);
|
|
};
|
|
|
|
const handleEdit = (item) => {
|
|
setModalMode('edit');
|
|
setEditingItem(item);
|
|
setFormData({
|
|
name: item.name,
|
|
startDate: item.startDate,
|
|
endDate: item.endDate,
|
|
type: item.type,
|
|
isRecurring: item.isRecurring,
|
|
memo: item.memo || '',
|
|
});
|
|
setShowModal(true);
|
|
};
|
|
|
|
const handleSave = async () => {
|
|
if (!formData.name || !formData.startDate || !formData.endDate) {
|
|
alert('휴일명, 시작일, 종료일을 입력해주세요.');
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
const url = modalMode === 'add' ? '/system/holidays' : `/system/holidays/${editingItem.id}`;
|
|
const res = await fetch(url, {
|
|
method: modalMode === 'add' ? 'POST' : 'PUT',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
|
|
body: JSON.stringify(formData),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
const errors = data.errors ? Object.values(data.errors).flat().join('\n') : data.message;
|
|
alert(errors || '저장에 실패했습니다.');
|
|
return;
|
|
}
|
|
setShowModal(false);
|
|
fetchHolidays(selectedYear);
|
|
} catch (err) {
|
|
console.error('저장 실패:', err);
|
|
alert('저장에 실패했습니다.');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const handleDelete = async (id) => {
|
|
if (!confirm('정말 삭제하시겠습니까?')) return;
|
|
try {
|
|
const res = await fetch(`/system/holidays/${id}`, {
|
|
method: 'DELETE',
|
|
headers: { 'X-CSRF-TOKEN': csrfToken },
|
|
});
|
|
if (res.ok) {
|
|
setShowModal(false);
|
|
fetchHolidays(selectedYear);
|
|
}
|
|
} catch (err) {
|
|
console.error('삭제 실패:', err);
|
|
}
|
|
};
|
|
|
|
const handleDeleteYear = async () => {
|
|
if (!confirm(`${selectedYear}년도의 모든 휴일(${totalHolidays}건)을 삭제하시겠습니까?\n\n이 작업은 되돌릴 수 없습니다.`)) return;
|
|
try {
|
|
const res = await fetch('/system/holidays/destroy-year', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
'X-CSRF-TOKEN': csrfToken,
|
|
},
|
|
body: JSON.stringify({ year: selectedYear }),
|
|
});
|
|
const data = await res.json();
|
|
if (res.ok) {
|
|
alert(data.message);
|
|
fetchHolidays(selectedYear);
|
|
} else {
|
|
alert(data.message || '삭제에 실패했습니다.');
|
|
}
|
|
} catch (err) {
|
|
console.error('연도별 삭제 실패:', err);
|
|
alert('삭제에 실패했습니다.');
|
|
}
|
|
};
|
|
|
|
// 대량 등록 파싱
|
|
const parseBulkText = (text) => {
|
|
const lines = text.trim().split('\n').filter(l => l.trim());
|
|
const results = [];
|
|
for (const line of lines) {
|
|
// 형식: YYYY-MM-DD 또는 YYYY-MM-DD~YYYY-MM-DD 휴일명 [유형]
|
|
const match = line.match(/^(\d{4}-\d{2}-\d{2})(?:\s*~\s*(\d{4}-\d{2}-\d{2}))?\s+(.+?)(?:\s+\[(공휴일|회사지정|대체휴일|임시휴일)\])?\s*$/);
|
|
if (match) {
|
|
const typeMap = { '공휴일': 'public', '회사지정': 'company', '대체휴일': 'alternative', '임시휴일': 'temporary' };
|
|
results.push({
|
|
startDate: match[1],
|
|
endDate: match[2] || match[1],
|
|
name: match[3].trim(),
|
|
type: typeMap[match[4]] || 'public',
|
|
isRecurring: false,
|
|
});
|
|
}
|
|
}
|
|
return results;
|
|
};
|
|
|
|
const handleBulkSave = async () => {
|
|
const parsed = parseBulkText(bulkText);
|
|
if (parsed.length === 0) {
|
|
alert('유효한 데이터가 없습니다.\n형식: YYYY-MM-DD 휴일명\n또는: YYYY-MM-DD~YYYY-MM-DD 휴일명 [유형]');
|
|
return;
|
|
}
|
|
setSaving(true);
|
|
try {
|
|
const res = await fetch('/system/holidays/bulk', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json', 'X-CSRF-TOKEN': csrfToken },
|
|
body: JSON.stringify({ holidays: parsed }),
|
|
});
|
|
const data = await res.json();
|
|
if (!res.ok) {
|
|
alert(data.message || '등록에 실패했습니다.');
|
|
return;
|
|
}
|
|
alert(data.message);
|
|
setShowBulkModal(false);
|
|
setBulkText('');
|
|
fetchHolidays(selectedYear);
|
|
} catch (err) {
|
|
console.error('대량 등록 실패:', err);
|
|
alert('대량 등록에 실패했습니다.');
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const getDefaultBulkText = () => {
|
|
const y = selectedYear;
|
|
return `${y}-01-01 신정
|
|
${y}-01-28~${y}-01-30 설날연휴
|
|
${y}-03-01 삼일절
|
|
${y}-05-05 어린이날
|
|
${y}-05-15 부처님오신날
|
|
${y}-06-06 현충일
|
|
${y}-08-15 광복절
|
|
${y}-10-03 개천절
|
|
${y}-10-05~${y}-10-07 추석연휴
|
|
${y}-10-09 한글날
|
|
${y}-12-25 크리스마스`;
|
|
};
|
|
|
|
// 월별 달력 렌더링
|
|
const renderMonth = (month) => {
|
|
const year = selectedYear;
|
|
const firstDay = new Date(year, month, 1).getDay();
|
|
const daysInMonth = new Date(year, month + 1, 0).getDate();
|
|
const days = [];
|
|
|
|
for (let i = 0; i < firstDay; i++) days.push(null);
|
|
for (let d = 1; d <= daysInMonth; d++) days.push(d);
|
|
|
|
return (
|
|
<div key={month} className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
|
<div className="px-4 py-2 bg-gray-50 border-b border-gray-200">
|
|
<h3 className="font-semibold text-gray-800 text-center">{MONTHS[month]}</h3>
|
|
</div>
|
|
<div className="p-2">
|
|
<div className="grid grid-cols-7 mb-1">
|
|
{WEEKDAYS.map((w, i) => (
|
|
<div key={w} className={`text-center text-xs font-medium py-1 ${i === 0 ? 'text-red-500' : i === 6 ? 'text-blue-500' : 'text-gray-500'}`}>{w}</div>
|
|
))}
|
|
</div>
|
|
<div className="grid grid-cols-7 gap-px">
|
|
{days.map((day, idx) => {
|
|
if (!day) return <div key={`empty-${idx}`} className="h-9" />;
|
|
const dateStr = `${year}-${String(month + 1).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
|
const dayHolidays = holidayMap[dateStr] || [];
|
|
const dayOfWeek = new Date(year, month, day).getDay();
|
|
const isSunday = dayOfWeek === 0;
|
|
const isSaturday = dayOfWeek === 6;
|
|
const isHoliday = dayHolidays.length > 0;
|
|
const today = new Date();
|
|
const isToday = today.getFullYear() === year && today.getMonth() === month && today.getDate() === day;
|
|
|
|
return (
|
|
<div
|
|
key={day}
|
|
onClick={() => isHoliday ? handleEdit(dayHolidays[0]) : handleAdd(dateStr)}
|
|
className={`relative h-9 flex flex-col items-center justify-center rounded cursor-pointer transition-colors
|
|
${isToday ? 'ring-2 ring-indigo-400' : ''}
|
|
${isHoliday ? 'bg-red-50 hover:bg-red-100' : 'hover:bg-gray-100'}
|
|
`}
|
|
title={dayHolidays.map(h => h.name).join(', ')}
|
|
>
|
|
<span className={`text-sm leading-none
|
|
${isHoliday || isSunday ? 'text-red-600 font-bold' : isSaturday ? 'text-blue-600' : 'text-gray-700'}
|
|
`}>{day}</span>
|
|
{isHoliday && (
|
|
<div className="flex gap-0.5 mt-0.5">
|
|
{dayHolidays.slice(0, 2).map((h, i) => (
|
|
<span key={i} className={`w-1 h-1 rounded-full ${HOLIDAY_TYPES[h.type]?.dot || 'bg-red-500'}`} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|
|
|
|
// 리스트 뷰
|
|
const renderListView = () => (
|
|
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
|
<table className="w-full">
|
|
<thead className="bg-gray-50 border-b border-gray-200">
|
|
<tr>
|
|
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">유형</th>
|
|
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">휴일명</th>
|
|
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">시작일</th>
|
|
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">종료일</th>
|
|
<th className="px-6 py-3 text-center text-xs font-semibold text-gray-600">일수</th>
|
|
<th className="px-6 py-3 text-center text-xs font-semibold text-gray-600">반복</th>
|
|
<th className="px-6 py-3 text-left text-xs font-semibold text-gray-600">메모</th>
|
|
<th className="px-6 py-3 text-center text-xs font-semibold text-gray-600">관리</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-100">
|
|
{loading ? (
|
|
<tr><td colSpan="8" className="px-6 py-12 text-center text-gray-400">
|
|
<div className="flex items-center justify-center gap-2">
|
|
<svg className="animate-spin h-5 w-5 text-gray-400" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path></svg>
|
|
불러오는 중...
|
|
</div>
|
|
</td></tr>
|
|
) : holidays.length === 0 ? (
|
|
<tr><td colSpan="8" className="px-6 py-12 text-center text-gray-400">등록된 휴일이 없습니다.</td></tr>
|
|
) : holidays.map(item => {
|
|
const typeInfo = HOLIDAY_TYPES[item.type] || HOLIDAY_TYPES.public;
|
|
return (
|
|
<tr key={item.id} className="hover:bg-gray-50">
|
|
<td className="px-6 py-3"><span className={`px-2 py-1 rounded text-xs font-medium ${typeInfo.color}`}>{typeInfo.label}</span></td>
|
|
<td className="px-6 py-3 text-sm font-medium text-gray-900">{item.name}</td>
|
|
<td className="px-6 py-3 text-sm text-gray-600">{item.startDate}</td>
|
|
<td className="px-6 py-3 text-sm text-gray-600">{item.endDate}</td>
|
|
<td className="px-6 py-3 text-sm text-center text-gray-600">{item.days}일</td>
|
|
<td className="px-6 py-3 text-center">
|
|
{item.isRecurring && <span className="px-2 py-0.5 bg-green-100 text-green-700 text-xs rounded-full">매년</span>}
|
|
</td>
|
|
<td className="px-6 py-3 text-sm text-gray-500">{item.memo || '-'}</td>
|
|
<td className="px-6 py-3 text-center">
|
|
<div className="flex items-center justify-center gap-1">
|
|
<button onClick={() => handleEdit(item)} className="p-1.5 text-gray-500 hover:text-blue-600 hover:bg-blue-50 rounded-lg"><Edit className="w-4 h-4" /></button>
|
|
<button onClick={() => handleDelete(item.id)} className="p-1.5 text-gray-500 hover:text-red-600 hover:bg-red-50 rounded-lg"><Trash2 className="w-4 h-4" /></button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
|
|
// 통계
|
|
const totalHolidays = holidays.length;
|
|
const totalDays = holidays.reduce((sum, h) => sum + h.days, 0);
|
|
const byType = {};
|
|
holidays.forEach(h => { byType[h.type] = (byType[h.type] || 0) + 1; });
|
|
|
|
return (
|
|
<div className="bg-gray-50 min-h-screen">
|
|
<header className="bg-white border-b border-gray-200 rounded-t-xl mb-6">
|
|
<div className="px-6 py-4 flex items-center justify-between">
|
|
<div className="flex items-center gap-4">
|
|
<div className="p-2 bg-red-100 rounded-xl"><CalendarIcon className="w-6 h-6 text-red-600" /></div>
|
|
<div><h1 className="text-xl font-bold text-gray-900">달력 휴일 관리</h1><p className="text-sm text-gray-500">Calendar Holiday Management</p></div>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<div className="flex items-center bg-gray-100 rounded-lg p-0.5">
|
|
<button onClick={() => setViewMode('calendar')} className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${viewMode === 'calendar' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}>
|
|
<span className="flex items-center gap-1.5"><LayoutGrid className="w-4 h-4" />달력</span>
|
|
</button>
|
|
<button onClick={() => setViewMode('list')} className={`px-3 py-1.5 rounded-md text-sm font-medium transition-colors ${viewMode === 'list' ? 'bg-white text-gray-900 shadow-sm' : 'text-gray-500 hover:text-gray-700'}`}>
|
|
<span className="flex items-center gap-1.5"><ListIcon className="w-4 h-4" />목록</span>
|
|
</button>
|
|
</div>
|
|
<button onClick={() => { setBulkText(getDefaultBulkText()); setShowBulkModal(true); }} className="flex items-center gap-2 px-4 py-2 text-gray-700 hover:bg-gray-100 rounded-lg border border-gray-300">
|
|
<Upload className="w-4 h-4" /><span className="text-sm">대량 등록</span>
|
|
</button>
|
|
<button onClick={() => handleAdd('')} className="flex items-center gap-2 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg">
|
|
<Plus className="w-4 h-4" /><span className="text-sm font-medium">휴일 등록</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* 연도 선택 + 통계 */}
|
|
<div className="bg-white rounded-xl border border-gray-200 p-6 mb-6">
|
|
<div className="flex flex-wrap items-center gap-6">
|
|
<div className="flex items-center gap-2">
|
|
<button onClick={() => setSelectedYear(y => y - 1)} className="p-2 hover:bg-gray-100 rounded-lg"><ChevronLeft className="w-5 h-5 text-gray-600" /></button>
|
|
<span className="text-2xl font-bold text-gray-900 min-w-[80px] text-center">{selectedYear}년</span>
|
|
<button onClick={() => setSelectedYear(y => y + 1)} className="p-2 hover:bg-gray-100 rounded-lg"><ChevronRight className="w-5 h-5 text-gray-600" /></button>
|
|
</div>
|
|
<div className="flex-1 flex flex-wrap items-center gap-4">
|
|
<div className="bg-gray-50 rounded-lg px-4 py-2">
|
|
<span className="text-sm text-gray-500">등록 건수</span>
|
|
<span className="ml-2 text-lg font-bold text-gray-900">{totalHolidays}건</span>
|
|
</div>
|
|
<div className="bg-red-50 rounded-lg px-4 py-2">
|
|
<span className="text-sm text-red-600">총 휴일 일수</span>
|
|
<span className="ml-2 text-lg font-bold text-red-600">{totalDays}일</span>
|
|
</div>
|
|
{Object.entries(byType).map(([type, count]) => {
|
|
const info = HOLIDAY_TYPES[type] || HOLIDAY_TYPES.public;
|
|
return (
|
|
<div key={type} className="flex items-center gap-1.5">
|
|
<span className={`w-2.5 h-2.5 rounded-full ${info.dot}`} />
|
|
<span className="text-sm text-gray-600">{info.label} {count}건</span>
|
|
</div>
|
|
);
|
|
})}
|
|
{totalHolidays > 0 && (
|
|
<button
|
|
onClick={handleDeleteYear}
|
|
className="ml-auto px-3 py-1.5 text-sm text-red-600 hover:text-white hover:bg-red-600 border border-red-300 rounded-lg transition-colors"
|
|
>
|
|
해당연도 삭제
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 달력/리스트 뷰 */}
|
|
{viewMode === 'calendar' ? (
|
|
<div className="grid grid-cols-1 md:grid-cols-3 lg:grid-cols-4 gap-4 mb-6">
|
|
{Array.from({ length: 12 }, (_, i) => renderMonth(i))}
|
|
</div>
|
|
) : renderListView()}
|
|
|
|
{/* 등록/수정 모달 */}
|
|
{showModal && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
<div className="bg-white rounded-xl p-6 w-full max-w-lg mx-4 max-h-[90vh] overflow-y-auto">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h3 className="text-lg font-bold text-gray-900">{modalMode === 'add' ? '휴일 등록' : '휴일 수정'}</h3>
|
|
<button onClick={() => setShowModal(false)} className="p-1 hover:bg-gray-100 rounded-lg"><X className="w-5 h-5 text-gray-500" /></button>
|
|
</div>
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">휴일명 *</label>
|
|
<input type="text" value={formData.name} onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))} placeholder="예: 설날, 추석연휴" className="w-full px-3 py-2 border border-gray-300 rounded-lg" />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">시작일 *</label>
|
|
<input type="date" value={formData.startDate} onChange={(e) => {
|
|
const v = e.target.value;
|
|
setFormData(prev => ({ ...prev, startDate: v, endDate: prev.endDate < v ? v : prev.endDate }));
|
|
}} className="w-full px-3 py-2 border border-gray-300 rounded-lg" />
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">종료일 * <span className="text-xs text-gray-400">(연휴는 기간 지정)</span></label>
|
|
<input type="date" value={formData.endDate} min={formData.startDate} onChange={(e) => setFormData(prev => ({ ...prev, endDate: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg" />
|
|
</div>
|
|
</div>
|
|
{formData.startDate && formData.endDate && formData.startDate !== formData.endDate && (
|
|
<div className="bg-blue-50 rounded-lg px-3 py-2 text-sm text-blue-700">
|
|
{Math.floor((new Date(formData.endDate) - new Date(formData.startDate)) / 86400000) + 1}일간의 연휴로 등록됩니다.
|
|
</div>
|
|
)}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">유형</label>
|
|
<select value={formData.type} onChange={(e) => setFormData(prev => ({ ...prev, type: e.target.value }))} className="w-full px-3 py-2 border border-gray-300 rounded-lg">
|
|
{Object.entries(HOLIDAY_TYPES).map(([key, info]) => (
|
|
<option key={key} value={key}>{info.label}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="flex items-end pb-1">
|
|
<label className="flex items-center gap-2 cursor-pointer">
|
|
<input type="checkbox" checked={formData.isRecurring} onChange={(e) => setFormData(prev => ({ ...prev, isRecurring: e.target.checked }))} className="w-4 h-4 text-blue-600 rounded border-gray-300" />
|
|
<span className="text-sm text-gray-700">매년 반복</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium text-gray-700 mb-1">메모</label>
|
|
<input type="text" value={formData.memo} onChange={(e) => setFormData(prev => ({ ...prev, memo: e.target.value }))} placeholder="메모" className="w-full px-3 py-2 border border-gray-300 rounded-lg" />
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-3 mt-6">
|
|
{modalMode === 'edit' && <button onClick={() => handleDelete(editingItem.id)} className="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg">삭제</button>}
|
|
<button onClick={() => setShowModal(false)} className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">취소</button>
|
|
<button onClick={handleSave} disabled={saving} className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:opacity-50">{saving ? '저장 중...' : (modalMode === 'add' ? '등록' : '저장')}</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* 대량 등록 모달 */}
|
|
{showBulkModal && (
|
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
|
<div className="bg-white rounded-xl p-6 w-full max-w-2xl mx-4 max-h-[90vh] overflow-y-auto">
|
|
<div className="flex items-center justify-between mb-6">
|
|
<h3 className="text-lg font-bold text-gray-900">대량 등록</h3>
|
|
<button onClick={() => setShowBulkModal(false)} className="p-1 hover:bg-gray-100 rounded-lg"><X className="w-5 h-5 text-gray-500" /></button>
|
|
</div>
|
|
<div className="space-y-4">
|
|
<div className="bg-gray-50 rounded-lg p-4 text-sm text-gray-600 space-y-1">
|
|
<p className="font-medium text-gray-800">입력 형식:</p>
|
|
<p><code className="bg-gray-200 px-1.5 py-0.5 rounded text-xs">YYYY-MM-DD 휴일명</code> - 단일 일자</p>
|
|
<p><code className="bg-gray-200 px-1.5 py-0.5 rounded text-xs">YYYY-MM-DD~YYYY-MM-DD 휴일명</code> - 기간 (연휴)</p>
|
|
<p><code className="bg-gray-200 px-1.5 py-0.5 rounded text-xs">YYYY-MM-DD 휴일명 [유형]</code> - 유형 지정 (공휴일/회사지정/대체휴일/임시휴일)</p>
|
|
</div>
|
|
<textarea
|
|
value={bulkText}
|
|
onChange={(e) => setBulkText(e.target.value)}
|
|
rows={14}
|
|
className="w-full px-3 py-2 border border-gray-300 rounded-lg font-mono text-sm"
|
|
placeholder="2026-01-01 신정 2026-01-28~2026-01-30 설날연휴 2026-03-01 삼일절"
|
|
/>
|
|
<div className="text-sm text-gray-500">
|
|
파싱 결과: <span className="font-medium text-gray-900">{parseBulkText(bulkText).length}건</span> 인식됨
|
|
</div>
|
|
</div>
|
|
<div className="flex gap-3 mt-6">
|
|
<button onClick={() => setShowBulkModal(false)} className="flex-1 px-4 py-2 border border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50">취소</button>
|
|
<button onClick={handleBulkSave} disabled={saving || parseBulkText(bulkText).length === 0} className="flex-1 px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg disabled:opacity-50">
|
|
{saving ? '등록 중...' : `${parseBulkText(bulkText).length}건 등록`}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const rootElement = document.getElementById('holiday-root');
|
|
if (rootElement) { ReactDOM.createRoot(rootElement).render(<HolidayManagement />); }
|
|
</script>
|
|
@endverbatim
|
|
@endpush
|