diff --git a/app/Http/Controllers/Juil/PmisDailyAttendanceController.php b/app/Http/Controllers/Juil/PmisDailyAttendanceController.php new file mode 100644 index 00000000..6059dd29 --- /dev/null +++ b/app/Http/Controllers/Juil/PmisDailyAttendanceController.php @@ -0,0 +1,212 @@ +input('date', now()->toDateString()); + $company = $request->input('company', ''); + + $attendance = PmisDailyAttendance::tenant($this->tenantId()) + ->where('date', $date) + ->when($company, fn ($q) => $q->where('company_name', $company)) + ->first(); + + if (! $attendance) { + $attendance = PmisDailyAttendance::create([ + 'tenant_id' => $this->tenantId(), + 'date' => $date, + 'company_name' => $company, + 'weather' => '맑음', + 'status' => 'draft', + ]); + } + + $attendance->load(['workers', 'equipments']); + + return response()->json($attendance); + } + + /** + * 해당 월의 일별 상태 조회 (캘린더 닷 표시용) + */ + public function monthStatus(Request $request): JsonResponse + { + $year = $request->integer('year', now()->year); + $month = $request->integer('month', now()->month); + $company = $request->input('company', ''); + + $attendances = PmisDailyAttendance::tenant($this->tenantId()) + ->whereYear('date', $year) + ->whereMonth('date', $month) + ->when($company, fn ($q) => $q->where('company_name', $company)) + ->withCount(['workers', 'equipments']) + ->get(); + + $result = []; + foreach ($attendances as $att) { + $day = (int) $att->date->format('d'); + if ($att->workers_count > 0 || $att->equipments_count > 0) { + $result[$day] = $att->status; + } + } + + return response()->json($result); + } + + /** + * 출면일보 메타 업데이트 (날씨, 특이사항, 상태) + */ + public function update(Request $request, int $id): JsonResponse + { + $attendance = PmisDailyAttendance::tenant($this->tenantId())->findOrFail($id); + + $validated = $request->validate([ + 'weather' => 'sometimes|string|max:50', + 'notes' => 'sometimes|nullable|string', + 'status' => 'sometimes|in:draft,review,approved', + 'options' => 'sometimes|nullable|array', + ]); + + $attendance->update($validated); + + return response()->json($attendance); + } + + /** + * 검토자 저장 + */ + public function saveReviewers(Request $request, int $id): JsonResponse + { + $attendance = PmisDailyAttendance::tenant($this->tenantId())->findOrFail($id); + + $reviewers = $request->input('reviewers', []); + $options = $attendance->options ?? []; + $options['reviewers'] = $reviewers; + $attendance->update(['options' => $options]); + + return response()->json(['message' => '검토자가 저장되었습니다.']); + } + + // ─── 인원(Worker) CRUD ─── + + public function workerStore(Request $request): JsonResponse + { + $validated = $request->validate([ + 'attendance_id' => 'required|integer|exists:pmis_daily_attendances,id', + 'work_type' => 'required|string|max:200', + 'job_type' => 'required|string|max:200', + 'name' => 'required|string|max:100', + 'man_days' => 'nullable|numeric|min:0', + 'amount' => 'nullable|numeric|min:0', + 'work_content' => 'nullable|string|max:500', + ]); + + $validated['tenant_id'] = $this->tenantId(); + $validated['man_days'] = $validated['man_days'] ?? 1.0; + $validated['amount'] = $validated['amount'] ?? 0; + $validated['work_content'] = $validated['work_content'] ?? ''; + + $maxSort = PmisAttendanceWorker::where('attendance_id', $validated['attendance_id'])->max('sort_order') ?? 0; + $validated['sort_order'] = $maxSort + 1; + + $worker = PmisAttendanceWorker::create($validated); + + return response()->json($worker, 201); + } + + public function workerUpdate(Request $request, int $id): JsonResponse + { + $worker = PmisAttendanceWorker::where('tenant_id', $this->tenantId())->findOrFail($id); + + $validated = $request->validate([ + 'work_type' => 'sometimes|string|max:200', + 'job_type' => 'sometimes|string|max:200', + 'name' => 'sometimes|string|max:100', + 'man_days' => 'nullable|numeric|min:0', + 'amount' => 'nullable|numeric|min:0', + 'work_content' => 'nullable|string|max:500', + ]); + + $worker->update($validated); + + return response()->json($worker); + } + + public function workerDestroy(int $id): JsonResponse + { + $worker = PmisAttendanceWorker::where('tenant_id', $this->tenantId())->findOrFail($id); + $worker->delete(); + + return response()->json(['message' => '삭제되었습니다.']); + } + + // ─── 장비(Equipment) CRUD ─── + + public function equipmentStore(Request $request): JsonResponse + { + $validated = $request->validate([ + 'attendance_id' => 'required|integer|exists:pmis_daily_attendances,id', + 'equipment_name' => 'required|string|max:200', + 'specification' => 'nullable|string|max:300', + 'equipment_number' => 'nullable|string|max:100', + 'operator' => 'nullable|string|max:100', + 'man_days' => 'nullable|numeric|min:0', + 'work_content' => 'nullable|string|max:500', + ]); + + $validated['tenant_id'] = $this->tenantId(); + $validated['man_days'] = $validated['man_days'] ?? 1.0; + $validated['work_content'] = $validated['work_content'] ?? ''; + + $maxSort = PmisAttendanceEquipment::where('attendance_id', $validated['attendance_id'])->max('sort_order') ?? 0; + $validated['sort_order'] = $maxSort + 1; + + $equipment = PmisAttendanceEquipment::create($validated); + + return response()->json($equipment, 201); + } + + public function equipmentUpdate(Request $request, int $id): JsonResponse + { + $equipment = PmisAttendanceEquipment::where('tenant_id', $this->tenantId())->findOrFail($id); + + $validated = $request->validate([ + 'equipment_name' => 'sometimes|string|max:200', + 'specification' => 'nullable|string|max:300', + 'equipment_number' => 'nullable|string|max:100', + 'operator' => 'nullable|string|max:100', + 'man_days' => 'nullable|numeric|min:0', + 'work_content' => 'nullable|string|max:500', + ]); + + $equipment->update($validated); + + return response()->json($equipment); + } + + public function equipmentDestroy(int $id): JsonResponse + { + $equipment = PmisAttendanceEquipment::where('tenant_id', $this->tenantId())->findOrFail($id); + $equipment->delete(); + + return response()->json(['message' => '삭제되었습니다.']); + } +} diff --git a/app/Models/Juil/PmisAttendanceEquipment.php b/app/Models/Juil/PmisAttendanceEquipment.php new file mode 100644 index 00000000..1cd03939 --- /dev/null +++ b/app/Models/Juil/PmisAttendanceEquipment.php @@ -0,0 +1,37 @@ + 'decimal:1', + 'options' => 'array', + ]; + + public function attendance(): BelongsTo + { + return $this->belongsTo(PmisDailyAttendance::class, 'attendance_id'); + } +} diff --git a/app/Models/Juil/PmisAttendanceWorker.php b/app/Models/Juil/PmisAttendanceWorker.php new file mode 100644 index 00000000..0f5234eb --- /dev/null +++ b/app/Models/Juil/PmisAttendanceWorker.php @@ -0,0 +1,38 @@ + 'decimal:1', + 'amount' => 'integer', + 'options' => 'array', + ]; + + public function attendance(): BelongsTo + { + return $this->belongsTo(PmisDailyAttendance::class, 'attendance_id'); + } +} diff --git a/app/Models/Juil/PmisDailyAttendance.php b/app/Models/Juil/PmisDailyAttendance.php new file mode 100644 index 00000000..88b023d8 --- /dev/null +++ b/app/Models/Juil/PmisDailyAttendance.php @@ -0,0 +1,44 @@ + 'date', + 'options' => 'array', + ]; + + public function scopeTenant($query, $tenantId) + { + return $query->where('tenant_id', $tenantId); + } + + public function workers(): HasMany + { + return $this->hasMany(PmisAttendanceWorker::class, 'attendance_id')->orderBy('sort_order'); + } + + public function equipments(): HasMany + { + return $this->hasMany(PmisAttendanceEquipment::class, 'attendance_id')->orderBy('sort_order'); + } +} diff --git a/database/migrations/2026_03_12_180000_create_pmis_daily_attendances_table.php b/database/migrations/2026_03_12_180000_create_pmis_daily_attendances_table.php new file mode 100644 index 00000000..46c703db --- /dev/null +++ b/database/migrations/2026_03_12_180000_create_pmis_daily_attendances_table.php @@ -0,0 +1,31 @@ +id(); + $table->unsignedBigInteger('tenant_id')->index(); + $table->date('date')->comment('일자'); + $table->string('company_name', 200)->default('')->comment('업체명'); + $table->string('weather', 50)->default('맑음')->comment('날씨'); + $table->enum('status', ['draft', 'review', 'approved'])->default('draft')->comment('상태'); + $table->text('notes')->nullable()->comment('특이사항'); + $table->json('options')->nullable()->comment('검토자 등 부가정보'); + $table->timestamps(); + $table->softDeletes(); + + $table->unique(['tenant_id', 'date', 'company_name'], 'pmis_attendance_unique'); + }); + } + + public function down(): void + { + Schema::dropIfExists('pmis_daily_attendances'); + } +}; diff --git a/database/migrations/2026_03_12_180001_create_pmis_attendance_workers_table.php b/database/migrations/2026_03_12_180001_create_pmis_attendance_workers_table.php new file mode 100644 index 00000000..b598e0e4 --- /dev/null +++ b/database/migrations/2026_03_12_180001_create_pmis_attendance_workers_table.php @@ -0,0 +1,34 @@ +id(); + $table->unsignedBigInteger('tenant_id')->index(); + $table->unsignedBigInteger('attendance_id')->index(); + $table->string('work_type', 200)->comment('공종'); + $table->string('job_type', 200)->comment('직종'); + $table->string('name', 100)->comment('성명'); + $table->decimal('man_days', 5, 1)->default(1.0)->comment('공수'); + $table->decimal('amount', 14, 0)->default(0)->comment('금액'); + $table->string('work_content', 500)->default('')->comment('작업내용'); + $table->integer('sort_order')->default(0); + $table->json('options')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->foreign('attendance_id')->references('id')->on('pmis_daily_attendances')->onDelete('cascade'); + }); + } + + public function down(): void + { + Schema::dropIfExists('pmis_attendance_workers'); + } +}; diff --git a/database/migrations/2026_03_12_180002_create_pmis_attendance_equipments_table.php b/database/migrations/2026_03_12_180002_create_pmis_attendance_equipments_table.php new file mode 100644 index 00000000..03dbf852 --- /dev/null +++ b/database/migrations/2026_03_12_180002_create_pmis_attendance_equipments_table.php @@ -0,0 +1,34 @@ +id(); + $table->unsignedBigInteger('tenant_id')->index(); + $table->unsignedBigInteger('attendance_id')->index(); + $table->string('equipment_name', 200)->comment('장비명'); + $table->string('specification', 300)->default('')->comment('규격'); + $table->string('equipment_number', 100)->default('')->comment('장비번호'); + $table->string('operator', 100)->default('')->comment('운전원'); + $table->decimal('man_days', 5, 1)->default(1.0)->comment('공수'); + $table->string('work_content', 500)->default('')->comment('작업내용'); + $table->integer('sort_order')->default(0); + $table->json('options')->nullable(); + $table->timestamps(); + $table->softDeletes(); + + $table->foreign('attendance_id')->references('id')->on('pmis_daily_attendances')->onDelete('cascade'); + }); + } + + public function down(): void + { + Schema::dropIfExists('pmis_attendance_equipments'); + } +}; diff --git a/resources/views/juil/pmis-daily-attendance.blade.php b/resources/views/juil/pmis-daily-attendance.blade.php index 0d02ebbf..2d9b95a6 100644 --- a/resources/views/juil/pmis-daily-attendance.blade.php +++ b/resources/views/juil/pmis-daily-attendance.blade.php @@ -14,6 +14,25 @@ @verbatim const { useState, useEffect, useRef, useCallback, useMemo } = React; +const API_BASE = '/juil/construction-pmis/api'; +const CSRF = document.querySelector('meta[name="csrf-token"]')?.content || ''; + +async function api(path, opts = {}) { + const res = await fetch(`${API_BASE}${path}`, { + headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', 'X-CSRF-TOKEN': CSRF, ...opts.headers }, + ...opts, + }); + if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.message || `HTTP ${res.status}`); } + return res.json(); +} + +const DAY_NAMES = ['일','월','화','수','목','금','토']; +const WEATHERS = ['맑음','흐림','비','눈','안개','구름많음']; + +function getDaysInMonth(y, m) { return new Date(y, m, 0).getDate(); } +function getDayOfWeek(y, m, d) { return new Date(y, m - 1, d).getDay(); } +function formatDate(y, m, d) { return `${y}-${String(m).padStart(2,'0')}-${String(d).padStart(2,'0')}`; } + /* ════════════════════════════════════════════════ PMIS 사이드바 ════════════════════════════════════════════════ */ @@ -37,17 +56,13 @@ function PmisSidebar({ activePage }) { const [profile, setProfile] = useState(null); const [expanded, setExpanded] = useState(() => { - for (const m of PMIS_MENUS) { - if (m.children?.some(c => c.id === activePage)) return m.id; - } + for (const m of PMIS_MENUS) { if (m.children?.some(c => c.id === activePage)) return m.id; } return null; }); - useEffect(() => { fetch('/juil/construction-pmis/profile', { headers: { Accept: 'application/json' } }) .then(r => r.json()).then(d => setProfile(d.worker)).catch(() => {}); }, []); - return (
@@ -55,9 +70,7 @@ function PmisSidebar({ activePage }) {
- {profile?.profile_photo_path - ? - : } + {profile?.profile_photo_path ? : }
{profile?.name || '...'}
{profile?.department || ''}
@@ -65,17 +78,13 @@ function PmisSidebar({ activePage }) {
{PMIS_MENUS.map(m => (
-
setExpanded(expanded === m.id ? null : m.id)} +
setExpanded(expanded === m.id ? null : m.id)} className={`flex items-center gap-2 px-4 py-2.5 text-sm cursor-pointer transition ${expanded === m.id ? 'bg-blue-50 text-blue-700 font-semibold' : 'text-gray-600 hover:bg-gray-50'}`}> {m.label}
{expanded === m.id && m.children?.map(c => ( - - {c.label} - + {c.label} ))}
))} @@ -84,27 +93,883 @@ className={`block pl-10 pr-4 py-2 text-sm transition ${c.id === activePage ? 'bg ); } +/* ════════════════════════════════════════════════ + 캘린더 스트립 (월간 일자 선택) + ════════════════════════════════════════════════ */ +function CalendarStrip({ year, month, selectedDay, onSelectDay, dayStatus }) { + const days = getDaysInMonth(year, month); + const STATUS_COLORS = { draft: '#22c55e', review: '#ef4444', approved: '#3b82f6' }; + + return ( +
+ + {Array.from({ length: days }, (_, i) => { + const d = i + 1; + const dow = getDayOfWeek(year, month, d); + const isWeekend = dow === 0 || dow === 6; + const isSelected = d === selectedDay; + const status = dayStatus[d]; + const dotColor = STATUS_COLORS[status]; + return ( +
onSelectDay(d)} + className={`flex flex-col items-center cursor-pointer shrink-0 transition rounded ${isSelected ? 'bg-blue-600 text-white' : 'hover:bg-gray-100'}`} + style={{ width: 32, padding: '2px 0' }}> +
+ {dotColor &&
} +
+
{d}
+
+ ); + })} + +
+ ); +} + +/* ════════════════════════════════════════════════ + 인원 추가/수정 모달 + ════════════════════════════════════════════════ */ +function WorkerModal({ open, onClose, onSaved, worker, attendanceId }) { + const isEdit = !!worker?.id; + const [form, setForm] = useState({ work_type: '', job_type: '', name: '', man_days: '1.0', amount: '', work_content: '' }); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + if (open) { + if (worker) { + setForm({ + work_type: worker.work_type || '', job_type: worker.job_type || '', + name: worker.name || '', man_days: worker.man_days ?? '1.0', + amount: worker.amount ?? '', work_content: worker.work_content || '', + }); + } else { + setForm({ work_type: '', job_type: '', name: '', man_days: '1.0', amount: '', work_content: '' }); + } + setError(''); + } + }, [open, worker]); + + const set = (k, v) => setForm(f => ({ ...f, [k]: v })); + + async function handleSubmit(e) { + e.preventDefault(); setSaving(true); setError(''); + try { + const body = { ...form, man_days: parseFloat(form.man_days) || 1.0, amount: parseInt(form.amount) || 0 }; + if (isEdit) { + await api(`/attendance-workers/${worker.id}`, { method: 'PUT', body: JSON.stringify(body) }); + } else { + body.attendance_id = attendanceId; + await api('/attendance-workers', { method: 'POST', body: JSON.stringify(body) }); + } + onSaved(); onClose(); + } catch (err) { setError(err.message); } finally { setSaving(false); } + } + + if (!open) return null; + return ( +
+
e.stopPropagation()}> +
+

인원 정보

+ +
+
+ {error &&
{error}
} +
+ + set('work_type', e.target.value)} required className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" /> +
+
+ + set('job_type', e.target.value)} required className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" /> +
+
+ + set('name', e.target.value)} required className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" /> +
+
+ + set('man_days', e.target.value)} className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500" style={{width:100}} /> + + set('amount', e.target.value)} className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500" /> +
+
+ + set('work_content', e.target.value)} className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500" /> +
+
+ + +
+
+
+
+ ); +} + +/* ════════════════════════════════════════════════ + 장비 추가/수정 모달 + ════════════════════════════════════════════════ */ +function EquipmentModal({ open, onClose, onSaved, equipment, attendanceId }) { + const isEdit = !!equipment?.id; + const [form, setForm] = useState({ equipment_name: '', specification: '', equipment_number: '', operator: '', man_days: '1.0', work_content: '' }); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + if (open) { + if (equipment) { + setForm({ + equipment_name: equipment.equipment_name || '', specification: equipment.specification || '', + equipment_number: equipment.equipment_number || '', operator: equipment.operator || '', + man_days: equipment.man_days ?? '1.0', work_content: equipment.work_content || '', + }); + } else { + setForm({ equipment_name: '', specification: '', equipment_number: '', operator: '', man_days: '1.0', work_content: '' }); + } + setError(''); + } + }, [open, equipment]); + + const set = (k, v) => setForm(f => ({ ...f, [k]: v })); + + async function handleSubmit(e) { + e.preventDefault(); setSaving(true); setError(''); + try { + const body = { ...form, man_days: parseFloat(form.man_days) || 1.0 }; + if (isEdit) { + await api(`/attendance-equipments/${equipment.id}`, { method: 'PUT', body: JSON.stringify(body) }); + } else { + body.attendance_id = attendanceId; + await api('/attendance-equipments', { method: 'POST', body: JSON.stringify(body) }); + } + onSaved(); onClose(); + } catch (err) { setError(err.message); } finally { setSaving(false); } + } + + if (!open) return null; + return ( +
+
e.stopPropagation()}> +
+

장비 정보

+ +
+
+ {error &&
{error}
} +
+ + set('equipment_name', e.target.value)} required className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" /> +
+
+ + set('specification', e.target.value)} className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500" /> +
+
+ + set('equipment_number', e.target.value)} className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500" style={{width:140}} /> + + set('operator', e.target.value)} className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500" /> +
+
+ + set('man_days', e.target.value)} className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500" style={{width:100}} /> +
+
+ + set('work_content', e.target.value)} className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500" /> +
+
+ + +
+
+
+
+ ); +} + +/* ════════════════════════════════════════════════ + 검토자 지정 모달 + ════════════════════════════════════════════════ */ +const DEFAULT_REVIEWERS = [ + { line: 1, name: '신승표', position: '부장', confirmed_at: null }, + { line: 2, name: '이성태', position: '대리', confirmed_at: null }, + { line: 3, name: '안성현', position: '과장', confirmed_at: null }, + { line: 4, name: '정영진', position: '과장', confirmed_at: null }, + { line: 5, name: '심준수', position: '부장', confirmed_at: null }, + { line: 6, name: '김정훈', position: '부장', confirmed_at: null }, +]; + +const ORG_TREE = [ + { name: '안성 당목리 물류센터', children: [ + { name: '협력업체', members: [ + { dept: '(주)주일기업', name: '신승표', position: '부장' }, + { dept: '(주)주일기업', name: '이성태', position: '대리' }, + { dept: '(주)주일기업', name: '김국민', position: '반장' }, + ]}, + { name: 'KCC건설', members: [ + { dept: 'KCC건설', name: '안성현', position: '과장' }, + { dept: 'KCC건설', name: '정영진', position: '과장' }, + { dept: 'KCC건설', name: '심준수', position: '부장' }, + { dept: 'KCC건설', name: '김정훈', position: '부장' }, + ]}, + ]}, +]; + +function ReviewerModal({ open, onClose, attendance, onSaved }) { + const [reviewers, setReviewers] = useState([]); + const [selectedOrg, setSelectedOrg] = useState(null); + const [search, setSearch] = useState(''); + + useEffect(() => { + if (open && attendance) { + const saved = attendance.options?.reviewers; + setReviewers(saved?.length ? saved : DEFAULT_REVIEWERS.map(r => ({...r}))); + setSelectedOrg(null); + setSearch(''); + } + }, [open, attendance]); + + const orgMembers = useMemo(() => { + if (!selectedOrg) return []; + for (const root of ORG_TREE) { + for (const child of root.children || []) { + if (child.name === selectedOrg) return child.members || []; + } + } + return []; + }, [selectedOrg]); + + const filteredMembers = useMemo(() => { + if (!search) return orgMembers; + const s = search.toLowerCase(); + return orgMembers.filter(m => m.name.includes(s) || m.dept.includes(s) || m.position.includes(s)); + }, [orgMembers, search]); + + async function handleSave() { + if (!attendance?.id) return; + try { + await api(`/daily-attendances/${attendance.id}/reviewers`, { + method: 'PUT', body: JSON.stringify({ reviewers }), + }); + onSaved(); + onClose(); + } catch {} + } + + function addReviewer(member) { + if (reviewers.length >= 10) return; + setReviewers(prev => [...prev, { line: prev.length + 1, name: member.name, position: member.position, confirmed_at: null }]); + } + + if (!open) return null; + return ( +
+
e.stopPropagation()}> +
+

검토자 지정

+ +
+
+ {/* 왼쪽: 조직 트리 */} +
+ {ORG_TREE.map(root => ( +
+
{root.name}
+ {root.children?.map(child => ( +
setSelectedOrg(child.name)} + className={`px-5 py-2 text-sm cursor-pointer transition ${selectedOrg === child.name ? 'bg-blue-100 text-blue-800 font-semibold' : 'text-gray-600 hover:bg-gray-50'}`}> + {child.name} +
+ ))} +
+ ))} +
+ + {/* 가운데: 인원 목록 */} +
+
+ setSearch(e.target.value)} + className="flex-1 border border-gray-300 rounded px-3 py-1.5 text-sm" /> + +
+
+ + + + + + + + + + + {filteredMembers.map((m, i) => ( + + + + + + + ))} + {filteredMembers.length === 0 && } + +
소속성명직책추가
{m.dept}{m.name}{m.position} + +
{selectedOrg ? '조직을 선택하세요' : '좌측 조직을 선택하세요'}
+
+
+ + {/* 오른쪽: 검토자 라인 */} +
+
+ 라인 선택 +
+ + +
+
+
+ {reviewers.map((r, i) => ( +
+ {String(i+1).padStart(2,'0')} : {r.name} {r.position} + {r.confirmed_at && {r.confirmed_at}} + +
+ ))} +
+
+
+
+ +
+
+
+ ); +} + +/* ════════════════════════════════════════════════ + 양식보기 모달 (출면일보 + 장비일보) + ════════════════════════════════════════════════ */ +function PrintPreviewModal({ open, onClose, attendance, dateStr }) { + if (!open || !attendance) return null; + const workers = attendance.workers || []; + const equipments = attendance.equipments || []; + const reviewers = attendance.options?.reviewers || DEFAULT_REVIEWERS; + + const totalManDays = workers.reduce((s, w) => s + parseFloat(w.man_days || 0), 0); + const totalAmount = workers.reduce((s, w) => s + parseInt(w.amount || 0), 0); + const eqTotalManDays = equipments.reduce((s, e) => s + parseFloat(e.man_days || 0), 0); + + const tdStyle = { border: '1px solid #333', padding: '4px 6px', fontSize: '12px' }; + const thStyle = { ...tdStyle, backgroundColor: '#f0f0f0', fontWeight: 'bold', textAlign: 'center' }; + + function renderApprovalSection() { + const r = (i) => reviewers[i] || {}; + return ( + + + + + + + + + + + + + + + +
공사과장공무과장현장소장
{r(1).name || ''}{r(2).name || ''}{r(3).name || ''}
관리부장
{r(4).name || ''}{r(5).name || ''}
+ ); + } + + return ( +
+
e.stopPropagation()}> +
+

양식보기

+ +
+ + {/* 출면일보 */} +
+
+
+

출 면 일 보

+
+
현 장 명 : 안성 당목리 물류센터
+
업 체 명 : (주)주일기업
+
일     자 : {dateStr}
+
+
+ + + + + + + + + {workers.map((w, i) => ( + + + + + + + + + ))} + {Array.from({length: Math.max(0, 15 - workers.length)}).map((_, i) => ( + + ))} + + + + + + + +
번호직종성명공수금액작업내용
{i+1}{w.job_type}{w.name}{w.man_days}{parseInt(w.amount||0).toLocaleString()}{w.work_content}
 
{totalManDays}{totalAmount.toLocaleString()}
+
+
특이사항 :
+
{attendance.notes || ''}
+
+
작업책임자 : (주)주일기업 {reviewers[0]?.name || ''}
+ {renderApprovalSection()} +
+ + {/* 장비일보 */} +
+
+

장 비 일 보

+
+
현 장 명 : 안성 당목리 물류센터
+
업 체 명 : (주)주일기업
+
일     자 : {dateStr}
+
+
+ + + + + + + + + + {equipments.map((eq, i) => ( + + + + + + + + + + + ))} + {Array.from({length: Math.max(0, 10 - equipments.length)}).map((_, i) => ( + + ))} + + + + + + +
번호장비명규격단위투입량금액운전원작업내용
{i+1}{eq.equipment_name}{eq.specification}{eq.man_days}{eq.operator}{eq.work_content}
 
{eqTotalManDays}
+
+
특이사항 :
+
{attendance.notes || ''}
+
+
작업책임자 : (주)주일기업 {reviewers[0]?.name || ''}
+ {renderApprovalSection()} +
+
+
+
+ ); +} + +/* ════════════════════════════════════════════════ + 랜덤 데이터 생성 + ════════════════════════════════════════════════ */ +const RANDOM_WORKERS = [ + { work_type: '방화셔터공사', job_type: '방화셔터', names: ['김국민','김상수','김성운','김세호','김송민','임용준','조명석'], amounts: [180000,200000] }, + { work_type: '방화셔터공사', job_type: '현장소장', names: ['신승표'], amounts: [0] }, + { work_type: '방화셔터공사', job_type: '화기감시자', names: ['임민서','최흥숙','박동현','이재훈'], amounts: [170000] }, + { work_type: '철근콘크리트공사', job_type: '철근공', names: ['박철수','이강호','최영수','김민준'], amounts: [200000,220000] }, + { work_type: '철근콘크리트공사', job_type: '형틀목공', names: ['한상우','정대호','오진혁'], amounts: [190000,210000] }, + { work_type: '전기공사', job_type: '전기기사', names: ['송재민','윤호진','박성훈'], amounts: [200000,230000] }, + { work_type: '설비공사', job_type: '배관공', names: ['최태호','강민석','이동현'], amounts: [190000,210000] }, +]; +const RANDOM_WORK_CONTENTS = ['B동 1층 셔터설치','A동 1층 셔터설치','A동 3층 셔터설치','B동 2층 배관작업','A동 지하1층 전기배선','B동 3층 철근배근','A동 2층 거푸집설치','지하주차장 방수작업']; +const RANDOM_EQUIPS = [ + { equipment_name: '타워크레인', specification: 'T-50', operators: ['박기사','이기사'] }, + { equipment_name: '굴삭기', specification: '0.7m3', operators: ['김기사','최기사'] }, + { equipment_name: '덤프트럭', specification: '15톤', operators: ['정기사','한기사'] }, + { equipment_name: '레미콘', specification: '6m3', operators: ['윤기사'] }, + { equipment_name: '펌프카', specification: '36m', operators: ['임기사','송기사'] }, + { equipment_name: '지게차', specification: '3톤', operators: ['오기사'] }, +]; + +function randomWorker() { + const pick = arr => arr[Math.floor(Math.random() * arr.length)]; + const grp = pick(RANDOM_WORKERS); + return { + work_type: grp.work_type, job_type: grp.job_type, + name: pick(grp.names), man_days: '1.0', amount: String(pick(grp.amounts)), + work_content: pick(RANDOM_WORK_CONTENTS), + }; +} + +function randomEquip() { + const pick = arr => arr[Math.floor(Math.random() * arr.length)]; + const eq = pick(RANDOM_EQUIPS); + return { + equipment_name: eq.equipment_name, specification: eq.specification, + equipment_number: `${String.fromCharCode(65 + Math.floor(Math.random()*5))}-${String(Math.floor(Math.random()*900)+100)}`, + operator: pick(eq.operators), man_days: '1.0', work_content: pick(RANDOM_WORK_CONTENTS), + }; +} + /* ════════════════════════════════════════════════ 메인 컴포넌트 ════════════════════════════════════════════════ */ function App() { + const today = new Date(); + const [year, setYear] = useState(today.getFullYear()); + const [month, setMonth] = useState(today.getMonth() + 1); + const [selectedDay, setSelectedDay] = useState(today.getDate()); + const [company, setCompany] = useState('(주)주일기업·방화셔터공사'); + const [dayStatus, setDayStatus] = useState({}); + const [tab, setTab] = useState('workers'); + + const [attendance, setAttendance] = useState(null); + const [loading, setLoading] = useState(false); + + const [workerModalOpen, setWorkerModalOpen] = useState(false); + const [editWorker, setEditWorker] = useState(null); + const [equipModalOpen, setEquipModalOpen] = useState(false); + const [editEquip, setEditEquip] = useState(null); + const [reviewerModalOpen, setReviewerModalOpen] = useState(false); + const [printModalOpen, setPrintModalOpen] = useState(false); + + const [selectedWorkers, setSelectedWorkers] = useState(new Set()); + const [selectedEquips, setSelectedEquips] = useState(new Set()); + const [notes, setNotes] = useState(''); + + const dateStr = formatDate(year, month, selectedDay); + const dow = getDayOfWeek(year, month, selectedDay); + const dateTitle = `${year}년 ${String(month).padStart(2,'0')}월 ${String(selectedDay).padStart(2,'0')}일 (${DAY_NAMES[dow]}) ${attendance?.weather || '맑음'}`; + + // 월별 상태 조회 + const fetchMonthStatus = useCallback(async () => { + try { + const data = await api(`/daily-attendances/month-status?year=${year}&month=${month}`); + setDayStatus(data); + } catch {} + }, [year, month]); + + // 해당 날짜 출면일보 조회 + const fetchAttendance = useCallback(async () => { + setLoading(true); + try { + const data = await api(`/daily-attendances?date=${dateStr}`); + setAttendance(data); + setNotes(data.notes || ''); + } catch {} + setLoading(false); + }, [dateStr]); + + useEffect(() => { fetchMonthStatus(); }, [fetchMonthStatus]); + useEffect(() => { fetchAttendance(); }, [fetchAttendance]); + + function handleRefresh() { fetchAttendance(); fetchMonthStatus(); } + + // 특이사항 저장 + async function handleSaveNotes() { + if (!attendance?.id) return; + try { + await api(`/daily-attendances/${attendance.id}`, { method: 'PUT', body: JSON.stringify({ notes }) }); + alert('특이사항이 저장되었습니다.'); + } catch {} + } + + // 인원 삭제 + async function handleDeleteWorkers() { + if (selectedWorkers.size === 0) return; + if (!confirm(`선택한 ${selectedWorkers.size}건을 삭제하시겠습니까?`)) return; + for (const id of selectedWorkers) { + try { await api(`/attendance-workers/${id}`, { method: 'DELETE' }); } catch {} + } + setSelectedWorkers(new Set()); + handleRefresh(); + } + + // 장비 삭제 + async function handleDeleteEquips() { + if (selectedEquips.size === 0) return; + if (!confirm(`선택한 ${selectedEquips.size}건을 삭제하시겠습니까?`)) return; + for (const id of selectedEquips) { + try { await api(`/attendance-equipments/${id}`, { method: 'DELETE' }); } catch {} + } + setSelectedEquips(new Set()); + handleRefresh(); + } + + // 번개 추가 (인원) + async function handleQuickWorker() { + if (!attendance?.id) return; + const data = randomWorker(); + data.attendance_id = attendance.id; + data.man_days = parseFloat(data.man_days); + data.amount = parseInt(data.amount); + try { + await api('/attendance-workers', { method: 'POST', body: JSON.stringify(data) }); + handleRefresh(); + } catch {} + } + + // 번개 추가 (장비) + async function handleQuickEquip() { + if (!attendance?.id) return; + const data = randomEquip(); + data.attendance_id = attendance.id; + data.man_days = parseFloat(data.man_days); + try { + await api('/attendance-equipments', { method: 'POST', body: JSON.stringify(data) }); + handleRefresh(); + } catch {} + } + + const workers = attendance?.workers || []; + const equipments = attendance?.equipments || []; + const totalManDays = workers.reduce((s, w) => s + parseFloat(w.man_days || 0), 0); + const totalAmount = workers.reduce((s, w) => s + parseInt(w.amount || 0), 0); + const eqTotalManDays = equipments.reduce((s, e) => s + parseFloat(e.man_days || 0), 0); + + // 상태 범례 색상 + const statusLegend = [ + { label: '작성중', color: '#22c55e' }, { label: '검토중', color: '#ef4444' }, + { label: '승인', color: '#3b82f6' }, { label: '미작성', color: '#d1d5db' }, + ]; + return (
+ setWorkerModalOpen(false)} onSaved={handleRefresh} worker={editWorker} attendanceId={attendance?.id} /> + setEquipModalOpen(false)} onSaved={handleRefresh} equipment={editEquip} attendanceId={attendance?.id} /> + setReviewerModalOpen(false)} attendance={attendance} onSaved={handleRefresh} /> + setPrintModalOpen(false)} attendance={attendance} dateStr={dateStr} /> +
-
-
- - Home > 시공관리 > 출면일보 + {/* 헤더 */} +
+
+ Home > 시공관리 > 출면일보 +
+ + {/* 필터 바 */} +
+ { const d = new Date(e.target.value); setYear(d.getFullYear()); setMonth(d.getMonth()+1); setSelectedDay(d.getDate()); }} + className="border border-gray-300 rounded px-3 py-1.5 text-sm" /> + + + +
+ {statusLegend.map(s => ( +
+
+ {s.label} +
+ ))} +
+
+ + {/* 날짜 제목 */} +

{dateTitle}

+ + {/* 캘린더 스트립 */} + + + {/* 기능 버튼들 + 탭 */} +
+
+ +
+
+ 선택일자 출면가져오기 + + + +
+
+ + {/* 탭 */} +
+ +
-

출면일보

-
-
- -

출면일보

-

준비 중입니다

-
+ + {/* 탭 컨텐츠 */} +
+ {loading ? ( +
불러오는 중...
+ ) : tab === 'workers' ? ( + /* ─── 인원 탭 ─── */ +
+ {/* 추가/삭제 버튼 */} +
+ + + {selectedWorkers.size > 0 && ( + + )} +
+ +
+ + + + + + + + + + + + + + {workers.map(w => ( + { setEditWorker(w); setWorkerModalOpen(true); }}> + + + + + + + + + ))} + {workers.length === 0 && ( + + )} + {/* 합계 */} + + + + + + + +
+ 0 && selectedWorkers.size === workers.length} + onChange={() => setSelectedWorkers(workers.length > 0 && selectedWorkers.size === workers.length ? new Set() : new Set(workers.map(w=>w.id)))} /> + 공종직종성명공수금액작업내용
e.stopPropagation()}> + setSelectedWorkers(prev => { const s = new Set(prev); s.has(w.id) ? s.delete(w.id) : s.add(w.id); return s; })} /> + {w.work_type}{w.job_type}{w.name}{w.man_days}{parseInt(w.amount||0).toLocaleString()}{w.work_content}
등록된 인원이 없습니다.
합계{totalManDays}{totalAmount.toLocaleString()}
+
+
※ 합계는 저장 후에 반영됩니다.
+ + {/* 특이사항 */} +
+
+ 특이사항 + +
+ +
+
+ ) : ( + /* ─── 장비 탭 ─── */ +
+
+ + + {selectedEquips.size > 0 && ( + + )} +
+ +
+ + + + + + + + + + + + + + {equipments.map(eq => ( + { setEditEquip(eq); setEquipModalOpen(true); }}> + + + + + + + + + ))} + {equipments.length === 0 && ( + + )} + + + + + + +
+ 0 && selectedEquips.size === equipments.length} + onChange={() => setSelectedEquips(equipments.length > 0 && selectedEquips.size === equipments.length ? new Set() : new Set(equipments.map(e=>e.id)))} /> + 장비명규격장비번호운전원공수작업내용
e.stopPropagation()}> + setSelectedEquips(prev => { const s = new Set(prev); s.has(eq.id) ? s.delete(eq.id) : s.add(eq.id); return s; })} /> + {eq.equipment_name}{eq.specification}{eq.equipment_number}{eq.operator}{eq.man_days}{eq.work_content}
등록된 장비가 없습니다.
합계{eqTotalManDays}
+
+
※ 합계는 저장 후에 반영됩니다.
+ + {/* 특이사항 */} +
+
+ 특이사항 + +
+ +
+
+ )}
diff --git a/routes/web.php b/routes/web.php index 23c023ef..386b2a6b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -46,6 +46,7 @@ use App\Http\Controllers\Juil\ConstructionSitePhotoController; use App\Http\Controllers\Juil\MeetingMinuteController; use App\Http\Controllers\Juil\PlanningController; +use App\Http\Controllers\Juil\PmisDailyAttendanceController; use App\Http\Controllers\Juil\PmisEquipmentController; use App\Http\Controllers\Juil\PmisMaterialController; use App\Http\Controllers\Juil\PmisWorkforceController; @@ -1768,6 +1769,18 @@ Route::post('/work-volumes', [PmisWorkVolumeController::class, 'store']); Route::put('/work-volumes/{id}', [PmisWorkVolumeController::class, 'update']); Route::delete('/work-volumes/{id}', [PmisWorkVolumeController::class, 'destroy']); + + // 출면일보 CRUD + Route::get('/daily-attendances', [PmisDailyAttendanceController::class, 'show']); + Route::get('/daily-attendances/month-status', [PmisDailyAttendanceController::class, 'monthStatus']); + Route::put('/daily-attendances/{id}', [PmisDailyAttendanceController::class, 'update']); + Route::put('/daily-attendances/{id}/reviewers', [PmisDailyAttendanceController::class, 'saveReviewers']); + Route::post('/attendance-workers', [PmisDailyAttendanceController::class, 'workerStore']); + Route::put('/attendance-workers/{id}', [PmisDailyAttendanceController::class, 'workerUpdate']); + Route::delete('/attendance-workers/{id}', [PmisDailyAttendanceController::class, 'workerDestroy']); + Route::post('/attendance-equipments', [PmisDailyAttendanceController::class, 'equipmentStore']); + Route::put('/attendance-equipments/{id}', [PmisDailyAttendanceController::class, 'equipmentUpdate']); + Route::delete('/attendance-equipments/{id}', [PmisDailyAttendanceController::class, 'equipmentDestroy']); }); // 공사현장 사진대지