feat:달력 휴일 관리 기능 추가
- 달력/목록 뷰 전환, 단일/기간/대량 등록 지원 - 공휴일/회사지정/대체휴일/임시휴일 유형 관리 - 시스템 관리 메뉴에 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
154
app/Http/Controllers/System/HolidayController.php
Normal file
154
app/Http/Controllers/System/HolidayController.php
Normal 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}건의 휴일이 등록되었습니다.",
|
||||
]);
|
||||
}
|
||||
}
|
||||
41
app/Models/System/Holiday.php
Normal file
41
app/Models/System/Holiday.php
Normal 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);
|
||||
}
|
||||
}
|
||||
63
database/seeders/HolidayMenuSeeder.php
Normal file
63
database/seeders/HolidayMenuSeeder.php
Normal 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})");
|
||||
}
|
||||
}
|
||||
}
|
||||
538
resources/views/system/holidays/index.blade.php
Normal file
538
resources/views/system/holidays/index.blade.php
Normal 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 신정 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
|
||||
@@ -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');
|
||||
|
||||
|
||||
Reference in New Issue
Block a user