From 9889658caaf3410d2eabd7a72e7cae5b75e29b97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=B3=B4=EA=B3=A4?= Date: Thu, 12 Mar 2026 14:13:38 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20[pmis]=20=EC=9E=A5=EB=B9=84=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=20=EC=8B=A4=EC=A0=9C=20CRUD=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PmisEquipmentController: 장비 CRUD API - PmisEquipment 모델 추가 - 3개 탭: 장비등록(CRUD), 출역현황, 투입현황 - 장비정보 모달 (저장/수정/삭제) - API 라우트 추가 (equipments) --- .../Juil/PmisEquipmentController.php | 99 +++ app/Models/Juil/PmisEquipment.php | 42 ++ resources/views/juil/pmis-equipment.blade.php | 595 +++++++++++++++++- routes/web.php | 7 + 4 files changed, 735 insertions(+), 8 deletions(-) create mode 100644 app/Http/Controllers/Juil/PmisEquipmentController.php create mode 100644 app/Models/Juil/PmisEquipment.php diff --git a/app/Http/Controllers/Juil/PmisEquipmentController.php b/app/Http/Controllers/Juil/PmisEquipmentController.php new file mode 100644 index 00000000..1bc775b1 --- /dev/null +++ b/app/Http/Controllers/Juil/PmisEquipmentController.php @@ -0,0 +1,99 @@ +tenantId()) + ->orderByDesc('id'); + + if ($request->filled('company')) { + $query->where('company_name', 'like', '%' . $request->company . '%'); + } + if ($request->filled('equipment_name')) { + $query->where('equipment_name', 'like', '%' . $request->equipment_name . '%'); + } + if ($request->filled('search')) { + $s = $request->search; + $query->where(function ($q) use ($s) { + $q->where('equipment_name', 'like', "%{$s}%") + ->orWhere('equipment_code', 'like', "%{$s}%") + ->orWhere('equipment_number', 'like', "%{$s}%") + ->orWhere('operator', 'like', "%{$s}%"); + }); + } + + $perPage = $request->integer('per_page', 15); + $equipments = $query->paginate($perPage); + + return response()->json($equipments); + } + + public function store(Request $request): JsonResponse + { + $validated = $request->validate([ + 'company_name' => 'required|string|max:200', + 'equipment_code' => 'nullable|string|max:50', + 'equipment_name' => 'required|string|max:200', + 'specification' => 'nullable|string|max:300', + 'unit' => 'nullable|string|max:50', + 'equipment_number' => 'required|string|max:100', + 'operator' => 'nullable|string|max:50', + 'inspection_end_date' => 'nullable|date', + 'inspection_not_applicable' => 'nullable|boolean', + 'insurance_end_date' => 'nullable|date', + 'insurance_not_applicable' => 'nullable|boolean', + ]); + + $validated['tenant_id'] = $this->tenantId(); + $validated['inspection_not_applicable'] = $validated['inspection_not_applicable'] ?? false; + $validated['insurance_not_applicable'] = $validated['insurance_not_applicable'] ?? false; + + $equipment = PmisEquipment::create($validated); + + return response()->json($equipment, 201); + } + + public function update(Request $request, int $id): JsonResponse + { + $equipment = PmisEquipment::tenant($this->tenantId())->findOrFail($id); + + $validated = $request->validate([ + 'company_name' => 'sometimes|required|string|max:200', + 'equipment_code' => 'nullable|string|max:50', + 'equipment_name' => 'sometimes|required|string|max:200', + 'specification' => 'nullable|string|max:300', + 'unit' => 'nullable|string|max:50', + 'equipment_number' => 'sometimes|required|string|max:100', + 'operator' => 'nullable|string|max:50', + 'inspection_end_date' => 'nullable|date', + 'inspection_not_applicable' => 'nullable|boolean', + 'insurance_end_date' => 'nullable|date', + 'insurance_not_applicable' => 'nullable|boolean', + ]); + + $equipment->update($validated); + + return response()->json($equipment); + } + + public function destroy(int $id): JsonResponse + { + $equipment = PmisEquipment::tenant($this->tenantId())->findOrFail($id); + $equipment->delete(); + + return response()->json(['message' => '삭제되었습니다.']); + } +} diff --git a/app/Models/Juil/PmisEquipment.php b/app/Models/Juil/PmisEquipment.php new file mode 100644 index 00000000..1a96cf11 --- /dev/null +++ b/app/Models/Juil/PmisEquipment.php @@ -0,0 +1,42 @@ + 'date', + 'inspection_not_applicable' => 'boolean', + 'insurance_end_date' => 'date', + 'insurance_not_applicable' => 'boolean', + 'options' => 'array', + ]; + + public function scopeTenant($query, $tenantId) + { + return $query->where('tenant_id', $tenantId); + } +} diff --git a/resources/views/juil/pmis-equipment.blade.php b/resources/views/juil/pmis-equipment.blade.php index 336a5e24..adfccfa9 100644 --- a/resources/views/juil/pmis-equipment.blade.php +++ b/resources/views/juil/pmis-equipment.blade.php @@ -14,6 +14,26 @@ @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(); +} + /* ════════════════════════════════════════════════ PMIS 사이드바 ════════════════════════════════════════════════ */ @@ -84,28 +104,587 @@ className={`block pl-10 pr-4 py-2 text-sm transition ${c.id === activePage ? 'bg ); } +/* ════════════════════════════════════════════════ + 장비정보 모달 + ════════════════════════════════════════════════ */ +function EquipmentModal({ open, onClose, onSaved, equipment }) { + const isEdit = !!equipment?.id; + const [form, setForm] = useState({ + company_name: '', equipment_code: '', equipment_name: '', + specification: '', unit: '', equipment_number: '', + operator: '', inspection_end_date: '', inspection_not_applicable: false, + insurance_end_date: '', insurance_not_applicable: false, + }); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + + useEffect(() => { + if (open) { + if (equipment) { + setForm({ + company_name: equipment.company_name || '', + equipment_code: equipment.equipment_code || '', + equipment_name: equipment.equipment_name || '', + specification: equipment.specification || '', + unit: equipment.unit || '', + equipment_number: equipment.equipment_number || '', + operator: equipment.operator || '', + inspection_end_date: equipment.inspection_end_date ? equipment.inspection_end_date.slice(0, 10) : '', + inspection_not_applicable: equipment.inspection_not_applicable || false, + insurance_end_date: equipment.insurance_end_date ? equipment.insurance_end_date.slice(0, 10) : '', + insurance_not_applicable: equipment.insurance_not_applicable || false, + }); + } else { + setForm({ + company_name: '', equipment_code: '', equipment_name: '', + specification: '', unit: '', equipment_number: '', + operator: '', inspection_end_date: '', inspection_not_applicable: false, + insurance_end_date: '', insurance_not_applicable: false, + }); + } + 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 }; + if (body.inspection_not_applicable) body.inspection_end_date = null; + if (body.insurance_not_applicable) body.insurance_end_date = null; + if (!body.inspection_end_date) body.inspection_end_date = null; + if (!body.insurance_end_date) body.insurance_end_date = null; + + if (isEdit) { + await api(`/equipments/${equipment.id}`, { method: 'PUT', body: JSON.stringify(body) }); + } else { + await api('/equipments', { method: 'POST', body: JSON.stringify(body) }); + } + onSaved(); + onClose(); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function handleDelete() { + if (!isEdit) return; + if (!confirm('정말 삭제하시겠습니까?')) return; + try { + await api(`/equipments/${equipment.id}`, { method: 'DELETE' }); + onSaved(); + onClose(); + } catch (err) { + setError(err.message); + } + } + + if (!open) return null; + + return ( +
+
e.stopPropagation()}> +
+

장비정보

+ +
+
+ {error && ( +
{error}
+ )} + + {/* 업체 명 */} +
+ + set('company_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('equipment_code', e.target.value)} + className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" style={{width: 160}} /> + + 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)} + 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('unit', 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('equipment_number', 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('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 focus:border-blue-500" /> +
+ + {/* 검사종료일 */} +
+ + set('inspection_end_date', e.target.value)} + disabled={form.inspection_not_applicable} + required={!form.inspection_not_applicable} + className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:text-gray-400" style={{width: 180}} /> + +
+ + {/* 보험종료일 */} +
+ + set('insurance_end_date', e.target.value)} + disabled={form.insurance_not_applicable} + required={!form.insurance_not_applicable} + className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:bg-gray-100 disabled:text-gray-400" style={{width: 180}} /> + +
+ + {/* 버튼 */} +
+ {isEdit && ( + + )} + + +
+
+
+
+ ); +} + +/* ════════════════════════════════════════════════ + 탭 1: 장비등록 + ════════════════════════════════════════════════ */ +function TabRegister() { + const [equipments, setEquipments] = useState([]); + const [loading, setLoading] = useState(true); + const [pagination, setPagination] = useState({ current_page: 1, last_page: 1, total: 0, per_page: 15 }); + + const [search, setSearch] = useState(''); + const [filterCompany, setFilterCompany] = useState(''); + const [perPage, setPerPage] = useState(15); + const [page, setPage] = useState(1); + + const [modalOpen, setModalOpen] = useState(false); + const [editEquip, setEditEquip] = useState(null); + const [selected, setSelected] = useState(new Set()); + + const fetchEquipments = useCallback(async () => { + setLoading(true); + try { + const params = new URLSearchParams(); + params.set('page', page); + params.set('per_page', perPage); + if (search) params.set('search', search); + if (filterCompany) params.set('company', filterCompany); + const data = await api(`/equipments?${params}`); + setEquipments(data.data); + setPagination({ + current_page: data.current_page, + last_page: data.last_page, + total: data.total, + per_page: data.per_page, + }); + } catch {} + setLoading(false); + }, [page, perPage, search, filterCompany]); + + useEffect(() => { fetchEquipments(); }, [fetchEquipments]); + + function handleSearch() { setPage(1); fetchEquipments(); } + function handleAdd() { setEditEquip(null); setModalOpen(true); } + function handleEdit(eq) { setEditEquip(eq); setModalOpen(true); } + + async function handleBulkDelete() { + if (selected.size === 0) return; + if (!confirm(`선택한 ${selected.size}건을 삭제하시겠습니까?`)) return; + for (const id of selected) { + try { await api(`/equipments/${id}`, { method: 'DELETE' }); } catch {} + } + setSelected(new Set()); + fetchEquipments(); + } + + function toggleSelect(id) { + setSelected(prev => { const s = new Set(prev); s.has(id) ? s.delete(id) : s.add(id); return s; }); + } + function toggleAll() { + setSelected(equipments.length > 0 && selected.size === equipments.length ? new Set() : new Set(equipments.map(e => e.id))); + } + + const companies = useMemo(() => [...new Set(equipments.map(e => e.company_name).filter(Boolean))], [equipments]); + + const pageNumbers = useMemo(() => { + const pages = []; + const start = Math.max(1, pagination.current_page - 2); + const end = Math.min(pagination.last_page, pagination.current_page + 2); + for (let i = start; i <= end; i++) pages.push(i); + return pages; + }, [pagination]); + + return ( +
+ setModalOpen(false)} + onSaved={fetchEquipments} + equipment={editEquip} + /> + + {/* 필터 */} +
+ + setSearch(e.target.value)} + onKeyDown={e => e.key === 'Enter' && handleSearch()} + className="border border-gray-300 rounded px-3 py-1.5 text-sm" style={{width: 160}} /> + +
+ + {selected.size > 0 && ( + + )} + + +
+
+ + {/* 테이블 */} +
+ + + + + + + + + + + + + + + + + + {loading ? ( + + ) : equipments.length === 0 ? ( + + ) : equipments.map((eq, idx) => ( + handleEdit(eq)}> + + + + + + + + + + + + + ))} + +
+ 0 && selected.size === equipments.length} onChange={toggleAll} /> + 순번업체명장비코드장비명규격단위장비번호장비번호누적검사종료일보험종료일
불러오는 중...
등록된 장비가 없습니다.
e.stopPropagation()}> + toggleSelect(eq.id)} /> + + {(pagination.current_page - 1) * pagination.per_page + idx + 1} + {eq.company_name}{eq.equipment_code || '-'}{eq.equipment_name}{eq.specification || '-'}{eq.unit || '-'}{eq.equipment_number}{eq.equipment_number} + {eq.inspection_not_applicable ? '해당없음' : (eq.inspection_end_date ? eq.inspection_end_date.slice(0, 10) : '-')} + + {eq.insurance_not_applicable ? '해당없음' : (eq.insurance_end_date ? eq.insurance_end_date.slice(0, 10) : '-')} +
+
+ + {/* 페이지네이션 */} +
+
총 {pagination.total}건
+
+ + {pageNumbers.map(p => ( + + ))} + + +
+
+
+ ); +} + +/* ════════════════════════════════════════════════ + 탭 2: 출역현황 + ════════════════════════════════════════════════ */ +function TabAttendance() { + const today = new Date().toISOString().slice(0, 10); + const [date, setDate] = useState(today); + const [equipments, setEquipments] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + (async () => { + setLoading(true); + try { + const data = await api('/equipments?per_page=100'); + setEquipments(data.data); + } catch {} + setLoading(false); + })(); + }, []); + + return ( +
+
+ + setDate(e.target.value)} + className="border border-gray-300 rounded px-3 py-1.5 text-sm" /> + + +
+ +
+
+ +
+ + + + + + + + + + + + + + + + {loading ? ( + + ) : equipments.length === 0 ? ( + + ) : equipments.map((eq, idx) => ( + + + + + + + + + + + + ))} + +
순번업체명장비코드장비명규격장비번호단위누적급일출역
불러오는 중...
등록된 장비가 없습니다.
{idx + 1}{eq.company_name}{eq.equipment_code || '-'}{eq.equipment_name}{eq.specification || '-'}{eq.equipment_number}---
+
+
+ ); +} + +/* ════════════════════════════════════════════════ + 탭 3: 투입현황 + ════════════════════════════════════════════════ */ +function TabDeployment() { + const [viewBy, setViewBy] = useState('day'); + const [period, setPeriod] = useState('1w'); + const [startDate, setStartDate] = useState('2026-03-05'); + const [endDate, setEndDate] = useState('2026-03-12'); + + const dates = useMemo(() => { + const arr = []; + const s = new Date(startDate), e = new Date(endDate); + for (let d = new Date(s); d <= e; d.setDate(d.getDate() + 1)) { + arr.push(new Date(d)); + } + return arr; + }, [startDate, endDate]); + + return ( +
+
+ +
+ +
+
+
+ 보기기준 + {[['day','일'],['week','주'],['month','월']].map(([v,l]) => ( + + ))} + 조회기간 + {[['1w','1주'],['1m','1개월'],['3m','3개월']].map(([v,l]) => ( + + ))} + 날짜 + setStartDate(e.target.value)} className="border border-gray-300 rounded px-2 py-1 text-sm" /> + ~ + setEndDate(e.target.value)} className="border border-gray-300 rounded px-2 py-1 text-sm" /> + + +
+ +
+ {startDate.replace(/-/g, '년 ').replace(/년 (\d+)$/, '월 $1일')} ~ {endDate.replace(/-/g, '년 ').replace(/년 (\d+)$/, '월 $1일')} +
+ +
+ + + + + + + + + + + {dates.map(d => ( + + ))} + + + + + + + +
업체명장비명규격 + {startDate.slice(0, 7)} + 합계
+ {d.getDate()} +
+ +

출역 데이터 기반으로 투입 현황을 집계합니다.

+
+
+
+ ); +} + /* ════════════════════════════════════════════════ 메인 컴포넌트 ════════════════════════════════════════════════ */ +const TABS = [ + { id: 'register', label: '장비등록' }, + { id: 'attendance', label: '출역현황' }, + { id: 'deployment', label: '투입현황' }, +]; + function App() { + const [activeTab, setActiveTab] = useState('register'); + return (
-
+ {/* 헤더 */} +
Home > 시공관리 > 장비관리
-

장비관리

-
-
-
- -

장비관리

-

준비 중입니다

+

장비관리

+ {/* 탭 */} +
+ {TABS.map(tab => ( + + ))}
+ {/* 탭 컨텐츠 */} +
+ {activeTab === 'register' && } + {activeTab === 'attendance' && } + {activeTab === 'deployment' && } +
); diff --git a/routes/web.php b/routes/web.php index db6bdc46..c95b1971 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\PmisEquipmentController; use App\Http\Controllers\Juil\PmisWorkforceController; use App\Http\Controllers\Lab\StrategyController; use App\Http\Controllers\MenuController; @@ -1747,6 +1748,12 @@ Route::delete('/workers/{id}', [PmisWorkforceController::class, 'workerDestroy']); Route::get('/job-types', [PmisWorkforceController::class, 'jobTypeList']); Route::post('/job-types', [PmisWorkforceController::class, 'jobTypeStore']); + + // 장비관리 CRUD + Route::get('/equipments', [PmisEquipmentController::class, 'list']); + Route::post('/equipments', [PmisEquipmentController::class, 'store']); + Route::put('/equipments/{id}', [PmisEquipmentController::class, 'update']); + Route::delete('/equipments/{id}', [PmisEquipmentController::class, 'destroy']); }); // 공사현장 사진대지