feat:달력 휴일 관리 기능 추가

- 달력/목록 뷰 전환, 단일/기간/대량 등록 지원
- 공휴일/회사지정/대체휴일/임시휴일 유형 관리
- 시스템 관리 메뉴에 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-05 19:59:35 +09:00
parent d169e75544
commit e68a4c9cad
5 changed files with 807 additions and 0 deletions

View File

@@ -0,0 +1,154 @@
<?php
namespace App\Http\Controllers\System;
use App\Http\Controllers\Controller;
use App\Models\System\Holiday;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
class HolidayController extends Controller
{
public function index(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('system.holidays.index'));
}
return view('system.holidays.index');
}
public function list(Request $request): JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
$year = $request->input('year', now()->year);
$holidays = Holiday::forTenant($tenantId)
->forYear($year)
->orderBy('start_date')
->get()
->map(function ($h) {
return [
'id' => $h->id,
'startDate' => $h->start_date->format('Y-m-d'),
'endDate' => $h->end_date->format('Y-m-d'),
'name' => $h->name,
'type' => $h->type,
'isRecurring' => $h->is_recurring,
'memo' => $h->memo,
'days' => $h->start_date->diffInDays($h->end_date) + 1,
];
});
return response()->json([
'success' => true,
'data' => $holidays,
]);
}
public function store(Request $request): JsonResponse
{
$request->validate([
'name' => 'required|string|max:100',
'startDate' => 'required|date',
'endDate' => 'required|date|after_or_equal:startDate',
'type' => 'required|in:public,company,alternative,temporary',
'isRecurring' => 'boolean',
'memo' => 'nullable|string|max:500',
]);
$tenantId = session('selected_tenant_id', 1);
$holiday = Holiday::create([
'tenant_id' => $tenantId,
'start_date' => $request->input('startDate'),
'end_date' => $request->input('endDate'),
'name' => $request->input('name'),
'type' => $request->input('type', 'public'),
'is_recurring' => $request->input('isRecurring', false),
'memo' => $request->input('memo'),
]);
return response()->json([
'success' => true,
'message' => '휴일이 등록되었습니다.',
'data' => ['id' => $holiday->id],
]);
}
public function update(Request $request, int $id): JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
$holiday = Holiday::forTenant($tenantId)->findOrFail($id);
$request->validate([
'name' => 'required|string|max:100',
'startDate' => 'required|date',
'endDate' => 'required|date|after_or_equal:startDate',
'type' => 'required|in:public,company,alternative,temporary',
'isRecurring' => 'boolean',
'memo' => 'nullable|string|max:500',
]);
$holiday->update([
'start_date' => $request->input('startDate'),
'end_date' => $request->input('endDate'),
'name' => $request->input('name'),
'type' => $request->input('type'),
'is_recurring' => $request->input('isRecurring', false),
'memo' => $request->input('memo'),
]);
return response()->json([
'success' => true,
'message' => '휴일이 수정되었습니다.',
]);
}
public function destroy(int $id): JsonResponse
{
$tenantId = session('selected_tenant_id', 1);
$holiday = Holiday::forTenant($tenantId)->findOrFail($id);
$holiday->delete();
return response()->json([
'success' => true,
'message' => '휴일이 삭제되었습니다.',
]);
}
public function bulkStore(Request $request): JsonResponse
{
$request->validate([
'holidays' => 'required|array|min:1',
'holidays.*.name' => 'required|string|max:100',
'holidays.*.startDate' => 'required|date',
'holidays.*.endDate' => 'required|date|after_or_equal:holidays.*.startDate',
'holidays.*.type' => 'required|in:public,company,alternative,temporary',
'holidays.*.isRecurring' => 'boolean',
]);
$tenantId = session('selected_tenant_id', 1);
$count = 0;
foreach ($request->input('holidays') as $item) {
Holiday::create([
'tenant_id' => $tenantId,
'start_date' => $item['startDate'],
'end_date' => $item['endDate'],
'name' => $item['name'],
'type' => $item['type'] ?? 'public',
'is_recurring' => $item['isRecurring'] ?? false,
'memo' => $item['memo'] ?? null,
]);
$count++;
}
return response()->json([
'success' => true,
'message' => "{$count}건의 휴일이 등록되었습니다.",
]);
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace App\Models\System;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class Holiday extends Model
{
use SoftDeletes;
protected $table = 'holidays';
protected $fillable = [
'tenant_id',
'start_date',
'end_date',
'name',
'type',
'is_recurring',
'memo',
'created_by',
'updated_by',
];
protected $casts = [
'start_date' => 'date',
'end_date' => 'date',
'is_recurring' => 'boolean',
];
public function scopeForTenant($query, int $tenantId)
{
return $query->where('tenant_id', $tenantId);
}
public function scopeForYear($query, int $year)
{
return $query->whereYear('start_date', $year);
}
}

View File

@@ -0,0 +1,63 @@
<?php
namespace Database\Seeders;
use App\Models\Commons\Menu;
use Illuminate\Database\Seeder;
class HolidayMenuSeeder extends Seeder
{
public function run(): void
{
$tenantId = 1;
// 시스템 관리 부모 메뉴 찾기
$parentMenu = Menu::where('tenant_id', $tenantId)
->where('name', '시스템 관리')
->first();
if (!$parentMenu) {
$this->command->error('시스템 관리 메뉴를 찾을 수 없습니다.');
return;
}
// 이미 존재하는지 확인
$existingMenu = Menu::where('tenant_id', $tenantId)
->where('name', '달력 휴일 관리')
->where('parent_id', $parentMenu->id)
->first();
if ($existingMenu) {
$this->command->info('달력 휴일 관리 메뉴가 이미 존재합니다.');
return;
}
// 현재 자식 메뉴 최대 sort_order 확인
$maxSort = Menu::where('parent_id', $parentMenu->id)
->max('sort_order') ?? 0;
// 메뉴 생성
$menu = Menu::create([
'tenant_id' => $tenantId,
'parent_id' => $parentMenu->id,
'name' => '달력 휴일 관리',
'url' => '/system/holidays',
'icon' => 'calendar',
'sort_order' => $maxSort + 1,
'is_active' => true,
]);
$this->command->info("메뉴 생성 완료: {$menu->name} (sort_order: {$menu->sort_order})");
// 하위 메뉴 목록 출력
$this->command->info('');
$this->command->info('=== 시스템 관리 하위 메뉴 ===');
$children = Menu::where('parent_id', $parentMenu->id)
->orderBy('sort_order')
->get(['name', 'url', 'sort_order']);
foreach ($children as $child) {
$this->command->info("{$child->sort_order}. {$child->name} ({$child->url})");
}
}
}

View File

@@ -0,0 +1,538 @@
@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')
<script src="https://unpkg.com/react@18/umd/react.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js?v={{ time() }}"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js?v={{ time() }}"></script>
<script src="https://unpkg.com/lucide@latest?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(() => {
if (ref.current && lucide.icons[name]) {
ref.current.innerHTML = '';
const svg = lucide.createElement(lucide.icons[name]);
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 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>
);
})}
</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

View File

@@ -34,6 +34,7 @@
use App\Http\Controllers\RolePermissionController;
use App\Http\Controllers\Sales\SalesProductController;
use App\Http\Controllers\System\AiConfigController;
use App\Http\Controllers\System\HolidayController;
use App\Http\Controllers\Stats\StatDashboardController;
use App\Http\Controllers\System\SystemAlertController;
use App\Http\Controllers\TenantController;
@@ -392,6 +393,16 @@
Route::post('/test-gcs', [AiConfigController::class, 'testGcs'])->name('test-gcs');
});
// 달력 휴일 관리
Route::prefix('system/holidays')->name('system.holidays.')->group(function () {
Route::get('/', [HolidayController::class, 'index'])->name('index');
Route::get('/list', [HolidayController::class, 'list'])->name('list');
Route::post('/', [HolidayController::class, 'store'])->name('store');
Route::put('/{id}', [HolidayController::class, 'update'])->name('update');
Route::delete('/{id}', [HolidayController::class, 'destroy'])->name('destroy');
Route::post('/bulk', [HolidayController::class, 'bulkStore'])->name('bulk');
});
// 명함 OCR API
Route::post('/api/business-card-ocr', [BusinessCardOcrController::class, 'process'])->name('api.business-card-ocr');