Files
sam-manage/resources/views/system/holidays/index.blade.php
김보곤 e58b3438e9 fix: [icons] 커스텀 SVG 생성 코드를 lucide.createElement API로 교체
- 24개 Blade 파일의 수동 SVG 생성 코드를 lucide.createElement(_def)로 통일
- 불필요한 quote-stripping regex(/^"|"$/g) 제거
- Lucide 공식 API 사용으로 SVG viewBox/path 속성 에러 해결
2026-02-23 17:21:40 +09:00

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 신정&#10;2026-01-28~2026-01-30 설날연휴&#10;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