feat: [pmis] 장비관리 실제 CRUD 구현
- PmisEquipmentController: 장비 CRUD API - PmisEquipment 모델 추가 - 3개 탭: 장비등록(CRUD), 출역현황, 투입현황 - 장비정보 모달 (저장/수정/삭제) - API 라우트 추가 (equipments)
This commit is contained in:
99
app/Http/Controllers/Juil/PmisEquipmentController.php
Normal file
99
app/Http/Controllers/Juil/PmisEquipmentController.php
Normal file
@@ -0,0 +1,99 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Juil;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Juil\PmisEquipment;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PmisEquipmentController extends Controller
|
||||
{
|
||||
private function tenantId(): int
|
||||
{
|
||||
return (int) session('current_tenant_id', 1);
|
||||
}
|
||||
|
||||
public function list(Request $request): JsonResponse
|
||||
{
|
||||
$query = PmisEquipment::tenant($this->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' => '삭제되었습니다.']);
|
||||
}
|
||||
}
|
||||
42
app/Models/Juil/PmisEquipment.php
Normal file
42
app/Models/Juil/PmisEquipment.php
Normal file
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Juil;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class PmisEquipment extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'pmis_equipments';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'company_name',
|
||||
'equipment_code',
|
||||
'equipment_name',
|
||||
'specification',
|
||||
'unit',
|
||||
'equipment_number',
|
||||
'operator',
|
||||
'inspection_end_date',
|
||||
'inspection_not_applicable',
|
||||
'insurance_end_date',
|
||||
'insurance_not_applicable',
|
||||
'options',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'inspection_end_date' => '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);
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={onClose}>
|
||||
<div className="bg-white rounded-xl shadow-2xl w-full max-w-xl mx-4" onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200">
|
||||
<h2 className="text-lg font-bold text-gray-800">장비정보</h2>
|
||||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||||
<i className="ri-close-line text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={handleSubmit} className="p-6 space-y-4">
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-2 rounded text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
{/* 업체 명 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="shrink-0 text-sm font-medium text-gray-700" style={{width: 90}}>업체 명</label>
|
||||
<input type="text" value={form.company_name} onChange={e => 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" />
|
||||
</div>
|
||||
|
||||
{/* 장비코드 + 장비명 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="shrink-0 text-sm font-medium text-gray-700" style={{width: 90}}>장비코드</label>
|
||||
<input type="text" value={form.equipment_code} onChange={e => 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}} />
|
||||
<label className="shrink-0 text-sm font-medium text-gray-700">장비명 <span className="text-red-500">*</span></label>
|
||||
<input type="text" value={form.equipment_name} onChange={e => 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" />
|
||||
</div>
|
||||
|
||||
{/* 규격 + 단위 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="shrink-0 text-sm font-medium text-gray-700" style={{width: 90}}>규격 <span className="text-red-500">*</span></label>
|
||||
<input type="text" value={form.specification} onChange={e => 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" />
|
||||
<label className="shrink-0 text-sm font-medium text-gray-700">단위 <span className="text-red-500">*</span></label>
|
||||
<input type="text" value={form.unit} onChange={e => 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" />
|
||||
</div>
|
||||
|
||||
{/* 장비번호 + 운전원 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="shrink-0 text-sm font-medium text-gray-700" style={{width: 90}}>장비번호 <span className="text-red-500">*</span></label>
|
||||
<input type="text" value={form.equipment_number} onChange={e => 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" />
|
||||
<label className="shrink-0 text-sm font-medium text-gray-700">운전원</label>
|
||||
<input type="text" value={form.operator} onChange={e => 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" />
|
||||
</div>
|
||||
|
||||
{/* 검사종료일 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="shrink-0 text-sm font-medium text-gray-700" style={{width: 90}}>검사종료일 <span className="text-red-500">*</span></label>
|
||||
<input type="date" value={form.inspection_end_date} onChange={e => 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}} />
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-sm text-gray-600">
|
||||
<input type="checkbox" checked={form.inspection_not_applicable}
|
||||
onChange={e => set('inspection_not_applicable', e.target.checked)}
|
||||
className="rounded" />
|
||||
해당없음
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 보험종료일 */}
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="shrink-0 text-sm font-medium text-gray-700" style={{width: 90}}>보험종료일 <span className="text-red-500">*</span></label>
|
||||
<input type="date" value={form.insurance_end_date} onChange={e => 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}} />
|
||||
<label className="flex items-center gap-1.5 cursor-pointer text-sm text-gray-600">
|
||||
<input type="checkbox" checked={form.insurance_not_applicable}
|
||||
onChange={e => set('insurance_not_applicable', e.target.checked)}
|
||||
className="rounded" />
|
||||
해당없음
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 버튼 */}
|
||||
<div className="flex justify-end gap-2 pt-3">
|
||||
{isEdit && (
|
||||
<button type="button" onClick={handleDelete}
|
||||
className="px-4 py-2 text-sm text-red-600 bg-white border border-gray-300 hover:bg-red-50 rounded-lg">삭제</button>
|
||||
)}
|
||||
<button type="button" onClick={onClose}
|
||||
className="px-4 py-2 text-sm text-gray-700 bg-white border border-gray-300 hover:bg-gray-50 rounded-lg">닫기</button>
|
||||
<button type="submit" disabled={saving}
|
||||
className="px-6 py-2 text-sm font-semibold text-white bg-blue-600 hover:bg-blue-700 rounded-lg disabled:opacity-50">
|
||||
{saving ? '저장 중...' : '저장'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════
|
||||
탭 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 (
|
||||
<div>
|
||||
<EquipmentModal
|
||||
open={modalOpen}
|
||||
onClose={() => setModalOpen(false)}
|
||||
onSaved={fetchEquipments}
|
||||
equipment={editEquip}
|
||||
/>
|
||||
|
||||
{/* 필터 */}
|
||||
<div className="flex flex-wrap items-center gap-2 mb-4">
|
||||
<select value={filterCompany} onChange={e => { setFilterCompany(e.target.value); setPage(1); }}
|
||||
className="border border-gray-300 rounded px-3 py-1.5 text-sm bg-white">
|
||||
<option value="">전체 업체</option>
|
||||
{companies.map(c => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
<input type="text" placeholder="상세검색" value={search}
|
||||
onChange={e => 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}} />
|
||||
<button onClick={handleSearch} className="bg-blue-600 text-white px-4 py-1.5 rounded text-sm font-semibold hover:bg-blue-700">검색</button>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button onClick={handleAdd} className="bg-blue-600 text-white px-4 py-1.5 rounded text-sm font-semibold hover:bg-blue-700">
|
||||
추가
|
||||
</button>
|
||||
{selected.size > 0 && (
|
||||
<button onClick={handleBulkDelete} className="bg-red-500 text-white px-3 py-1.5 rounded text-sm hover:bg-red-600">
|
||||
삭제 ({selected.size})
|
||||
</button>
|
||||
)}
|
||||
<button className="flex items-center gap-1 border border-gray-300 rounded px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50">
|
||||
<i className="ri-file-excel-2-line text-green-600"></i> Excel 입출
|
||||
</button>
|
||||
<button className="flex items-center gap-1 border border-gray-300 rounded px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50">
|
||||
<i className="ri-file-excel-2-line text-green-600"></i> Excel 다운로드
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="overflow-x-auto border border-gray-200 rounded-lg">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="px-3 py-2.5 text-left font-semibold text-gray-600 whitespace-nowrap">
|
||||
<input type="checkbox" checked={equipments.length > 0 && selected.size === equipments.length} onChange={toggleAll} />
|
||||
</th>
|
||||
<th className="px-3 py-2.5 text-left font-semibold text-gray-600 whitespace-nowrap">순번</th>
|
||||
<th className="px-3 py-2.5 text-left font-semibold text-gray-600 whitespace-nowrap">업체명</th>
|
||||
<th className="px-3 py-2.5 text-left font-semibold text-gray-600 whitespace-nowrap">장비코드</th>
|
||||
<th className="px-3 py-2.5 text-left font-semibold text-gray-600 whitespace-nowrap">장비명</th>
|
||||
<th className="px-3 py-2.5 text-left font-semibold text-gray-600 whitespace-nowrap">규격</th>
|
||||
<th className="px-3 py-2.5 text-left font-semibold text-gray-600 whitespace-nowrap">단위</th>
|
||||
<th className="px-3 py-2.5 text-left font-semibold text-gray-600 whitespace-nowrap">장비번호</th>
|
||||
<th className="px-3 py-2.5 text-left font-semibold text-gray-600 whitespace-nowrap">장비번호누적</th>
|
||||
<th className="px-3 py-2.5 text-left font-semibold text-gray-600 whitespace-nowrap">검사종료일</th>
|
||||
<th className="px-3 py-2.5 text-left font-semibold text-gray-600 whitespace-nowrap">보험종료일</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={11} className="text-center py-8 text-gray-400">불러오는 중...</td></tr>
|
||||
) : equipments.length === 0 ? (
|
||||
<tr><td colSpan={11} className="text-center py-8 text-gray-400">등록된 장비가 없습니다.</td></tr>
|
||||
) : equipments.map((eq, idx) => (
|
||||
<tr key={eq.id} className="border-b border-gray-100 hover:bg-blue-50/30 transition cursor-pointer"
|
||||
onClick={() => handleEdit(eq)}>
|
||||
<td className="px-3 py-2" onClick={e => e.stopPropagation()}>
|
||||
<input type="checkbox" checked={selected.has(eq.id)} onChange={() => toggleSelect(eq.id)} />
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-500">
|
||||
{(pagination.current_page - 1) * pagination.per_page + idx + 1}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-700">{eq.company_name}</td>
|
||||
<td className="px-3 py-2 text-gray-600">{eq.equipment_code || '-'}</td>
|
||||
<td className="px-3 py-2 font-medium text-gray-800">{eq.equipment_name}</td>
|
||||
<td className="px-3 py-2 text-gray-700">{eq.specification || '-'}</td>
|
||||
<td className="px-3 py-2 text-gray-600">{eq.unit || '-'}</td>
|
||||
<td className="px-3 py-2 text-gray-600">{eq.equipment_number}</td>
|
||||
<td className="px-3 py-2 text-gray-600">{eq.equipment_number}</td>
|
||||
<td className="px-3 py-2 text-gray-600">
|
||||
{eq.inspection_not_applicable ? '해당없음' : (eq.inspection_end_date ? eq.inspection_end_date.slice(0, 10) : '-')}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-600">
|
||||
{eq.insurance_not_applicable ? '해당없음' : (eq.insurance_end_date ? eq.insurance_end_date.slice(0, 10) : '-')}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 페이지네이션 */}
|
||||
<div className="flex items-center justify-between mt-3 text-sm text-gray-500">
|
||||
<div>총 {pagination.total}건</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button onClick={() => setPage(Math.max(1, page-1))} disabled={page <= 1}
|
||||
className="px-2 py-1 rounded hover:bg-gray-100 disabled:opacity-30"><</button>
|
||||
{pageNumbers.map(p => (
|
||||
<button key={p} onClick={() => setPage(p)}
|
||||
className={`px-2 py-1 rounded text-xs ${p === pagination.current_page ? 'bg-blue-600 text-white' : 'hover:bg-gray-100'}`}>{p}</button>
|
||||
))}
|
||||
<button onClick={() => setPage(Math.min(pagination.last_page, page+1))} disabled={page >= pagination.last_page}
|
||||
className="px-2 py-1 rounded hover:bg-gray-100 disabled:opacity-30">></button>
|
||||
<select value={perPage} onChange={e => { setPerPage(+e.target.value); setPage(1); }}
|
||||
className="border border-gray-300 rounded px-2 py-1 text-xs ml-2">
|
||||
<option value={15}>15</option>
|
||||
<option value={30}>30</option>
|
||||
<option value={50}>50</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════
|
||||
탭 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 (
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2 mb-4">
|
||||
<select className="border border-gray-300 rounded px-3 py-1.5 text-sm bg-white">
|
||||
<option>전체 업체</option>
|
||||
</select>
|
||||
<input type="date" value={date} onChange={e => setDate(e.target.value)}
|
||||
className="border border-gray-300 rounded px-3 py-1.5 text-sm" />
|
||||
<input type="text" placeholder="상세검색" className="border border-gray-300 rounded px-3 py-1.5 text-sm" style={{width: 160}} />
|
||||
<button className="bg-blue-600 text-white px-4 py-1.5 rounded text-sm font-semibold hover:bg-blue-700">검색</button>
|
||||
<div className="ml-auto">
|
||||
<button className="flex items-center gap-1 border border-gray-300 rounded px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50">
|
||||
<i className="ri-file-excel-2-line text-green-600"></i> Excel 다운로드
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto border border-gray-200 rounded-lg">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-gray-50 border-b border-gray-200">
|
||||
<tr>
|
||||
<th className="px-3 py-2.5 text-left font-semibold text-gray-600">순번</th>
|
||||
<th className="px-3 py-2.5 text-left font-semibold text-gray-600">업체명</th>
|
||||
<th className="px-3 py-2.5 text-left font-semibold text-gray-600">장비코드</th>
|
||||
<th className="px-3 py-2.5 text-left font-semibold text-gray-600">장비명</th>
|
||||
<th className="px-3 py-2.5 text-left font-semibold text-gray-600">규격</th>
|
||||
<th className="px-3 py-2.5 text-left font-semibold text-gray-600">장비번호</th>
|
||||
<th className="px-3 py-2.5 text-center font-semibold text-gray-600">단위누적</th>
|
||||
<th className="px-3 py-2.5 text-center font-semibold text-gray-600" style={{backgroundColor: '#FFFDE7'}}>급일</th>
|
||||
<th className="px-3 py-2.5 text-center font-semibold text-gray-600">출역</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr><td colSpan={9} className="text-center py-8 text-gray-400">불러오는 중...</td></tr>
|
||||
) : equipments.length === 0 ? (
|
||||
<tr><td colSpan={9} className="text-center py-8 text-gray-400">등록된 장비가 없습니다.</td></tr>
|
||||
) : equipments.map((eq, idx) => (
|
||||
<tr key={eq.id} className="border-b border-gray-100 hover:bg-blue-50/30 transition">
|
||||
<td className="px-3 py-2 text-gray-500">{idx + 1}</td>
|
||||
<td className="px-3 py-2 text-gray-700">{eq.company_name}</td>
|
||||
<td className="px-3 py-2 text-gray-600">{eq.equipment_code || '-'}</td>
|
||||
<td className="px-3 py-2 font-medium text-gray-800">{eq.equipment_name}</td>
|
||||
<td className="px-3 py-2 text-gray-700">{eq.specification || '-'}</td>
|
||||
<td className="px-3 py-2 text-gray-600">{eq.equipment_number}</td>
|
||||
<td className="px-3 py-2 text-center text-gray-600">-</td>
|
||||
<td className="px-3 py-2 text-center" style={{backgroundColor: '#FFFDE7'}}>-</td>
|
||||
<td className="px-3 py-2 text-center text-gray-600">-</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════
|
||||
탭 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 (
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center gap-2 mb-2">
|
||||
<select className="border border-gray-300 rounded px-3 py-1.5 text-sm bg-white">
|
||||
<option>전체 업체</option>
|
||||
</select>
|
||||
<div className="ml-auto">
|
||||
<button className="flex items-center gap-1 border border-gray-300 rounded px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50">
|
||||
<i className="ri-file-excel-2-line text-green-600"></i> Excel 다운로드
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-3 mb-4 text-sm">
|
||||
<span className="text-gray-600 font-semibold">보기기준</span>
|
||||
{[['day','일'],['week','주'],['month','월']].map(([v,l]) => (
|
||||
<label key={v} className="flex items-center gap-1 cursor-pointer">
|
||||
<input type="radio" name="viewBy" checked={viewBy===v} onChange={()=>setViewBy(v)} />
|
||||
<span className={viewBy===v?'text-gray-800 font-medium':'text-gray-500'}>{l}</span>
|
||||
</label>
|
||||
))}
|
||||
<span className="text-gray-600 font-semibold ml-4">조회기간</span>
|
||||
{[['1w','1주'],['1m','1개월'],['3m','3개월']].map(([v,l]) => (
|
||||
<label key={v} className="flex items-center gap-1 cursor-pointer">
|
||||
<input type="radio" name="period" checked={period===v} onChange={()=>setPeriod(v)} />
|
||||
<span className={period===v?'text-gray-800 font-medium':'text-gray-500'}>{l}</span>
|
||||
</label>
|
||||
))}
|
||||
<span className="text-gray-600 font-semibold ml-4">날짜</span>
|
||||
<input type="date" value={startDate} onChange={e=>setStartDate(e.target.value)} className="border border-gray-300 rounded px-2 py-1 text-sm" />
|
||||
<span className="text-gray-400">~</span>
|
||||
<input type="date" value={endDate} onChange={e=>setEndDate(e.target.value)} className="border border-gray-300 rounded px-2 py-1 text-sm" />
|
||||
<button className="bg-blue-600 text-white px-4 py-1.5 rounded text-sm font-semibold hover:bg-blue-700">검색</button>
|
||||
<button className="bg-gray-200 text-gray-700 px-3 py-1.5 rounded text-sm hover:bg-gray-300">검색초기화</button>
|
||||
</div>
|
||||
|
||||
<div className="text-sm font-bold text-gray-800 mb-2">
|
||||
{startDate.replace(/-/g, '년 ').replace(/년 (\d+)$/, '월 $1일')} ~ {endDate.replace(/-/g, '년 ').replace(/년 (\d+)$/, '월 $1일')}
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto border border-gray-200 rounded-lg">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-100 border-b border-gray-200">
|
||||
<th className="px-3 py-2 text-left font-semibold text-gray-600" rowSpan={2}>업체명</th>
|
||||
<th className="px-3 py-2 text-left font-semibold text-gray-600" rowSpan={2}>장비명</th>
|
||||
<th className="px-3 py-2 text-left font-semibold text-gray-600" rowSpan={2}>규격</th>
|
||||
<th className="px-2 py-1 text-center font-semibold text-gray-600 border-b border-gray-200" colSpan={dates.length}>
|
||||
{startDate.slice(0, 7)}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-center font-semibold text-gray-600" rowSpan={2}>합계</th>
|
||||
</tr>
|
||||
<tr className="bg-gray-100 border-b border-gray-200">
|
||||
{dates.map(d => (
|
||||
<th key={d.toISOString()} className="px-2 py-1.5 text-center font-medium text-gray-500 text-xs min-w-[36px] border-l border-gray-200">
|
||||
{d.getDate()}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td colSpan={3 + dates.length + 1} className="text-center py-8 text-gray-400">
|
||||
<i className="ri-truck-line text-3xl block mb-2"></i>
|
||||
<p className="text-sm">출역 데이터 기반으로 투입 현황을 집계합니다.</p>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════
|
||||
메인 컴포넌트
|
||||
════════════════════════════════════════════════ */
|
||||
const TABS = [
|
||||
{ id: 'register', label: '장비등록' },
|
||||
{ id: 'attendance', label: '출역현황' },
|
||||
{ id: 'deployment', label: '투입현황' },
|
||||
];
|
||||
|
||||
function App() {
|
||||
const [activeTab, setActiveTab] = useState('register');
|
||||
|
||||
return (
|
||||
<div className="flex bg-gray-100" style={{ height: 'calc(100vh - 56px)' }}>
|
||||
<PmisSidebar activePage="equipment" />
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="bg-white border-b border-gray-200 px-6 py-4">
|
||||
{/* 헤더 */}
|
||||
<div className="bg-white border-b border-gray-200 px-6 pt-4 pb-0">
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400 mb-2">
|
||||
<i className="ri-home-4-line"></i>
|
||||
<span>Home</span> > <span>시공관리</span> > <span className="text-gray-600">장비관리</span>
|
||||
</div>
|
||||
<h1 className="text-lg font-bold text-gray-800">장비관리</h1>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center text-gray-400">
|
||||
<i className="ri-tools-line text-5xl block mb-3"></i>
|
||||
<p className="text-lg font-semibold">장비관리</p>
|
||||
<p className="text-sm mt-1">준비 중입니다</p>
|
||||
<h1 className="text-lg font-bold text-gray-800 mb-3">장비관리</h1>
|
||||
{/* 탭 */}
|
||||
<div className="flex gap-0 border-b-0">
|
||||
{TABS.map(tab => (
|
||||
<button key={tab.id}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
className={`px-5 py-2.5 text-sm font-medium border-b-2 transition whitespace-nowrap ${
|
||||
activeTab === tab.id
|
||||
? 'border-blue-600 text-blue-700 bg-blue-50/50'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'
|
||||
}`}>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{/* 탭 컨텐츠 */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{activeTab === 'register' && <TabRegister />}
|
||||
{activeTab === 'attendance' && <TabAttendance />}
|
||||
{activeTab === 'deployment' && <TabDeployment />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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']);
|
||||
});
|
||||
|
||||
// 공사현장 사진대지
|
||||
|
||||
Reference in New Issue
Block a user