Files
sam-manage/resources/views/juil/pmis-materials.blade.php
김보곤 2102f4a398 feat: [pmis] PMIS 자료실/안전관리/품질관리 기능 추가 및 개선
- 자료실 하위 3개 메뉴: 자료보관함, 매뉴얼, 공지사항
- 자료보관함: 폴더 트리 + 파일 업로드/다운로드/삭제
- 매뉴얼/공지사항: 게시판형 CRUD + 첨부파일
- 안전관리: 안전보건교육, TBM현황, 위험성평가, 재해예방조치
- 품질관리: 시정조치 UI 페이지
- 대시보드: 슈퍼관리자 전용 레거시 사이트 참고 카드
- 작업일보/출면일보 오류 수정 및 기능 개선
- 설비 사진 업로드, 근로계약서 종료일 수정
2026-03-12 21:11:21 +09:00

788 lines
43 KiB
PHP

@extends('layouts.app')
@section('title', '자재관리 - 건설PMIS')
@section('content')
<div id="root"></div>
@endsection
@push('scripts')
<script src="https://cdn.tailwindcss.com"></script>
<link href="https://cdn.jsdelivr.net/npm/remixicon@4.2.0/fonts/remixicon.css" rel="stylesheet" />
@include('partials.react-cdn')
<script type="text/babel">
@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 사이드바
════════════════════════════════════════════════ */
const PMIS_MENUS = [
{ icon: 'ri-building-2-line', label: 'BIM 관리', id: 'bim', children: [
{ label: 'BIM 뷰어', id: 'bim-viewer', url: '/juil/construction-pmis/bim-viewer' },
]},
{ icon: 'ri-line-chart-line', label: '시공관리', id: 'construction', children: [
{ label: '인원관리', id: 'workforce', url: '/juil/construction-pmis/workforce' },
{ label: '장비관리', id: 'equipment', url: '/juil/construction-pmis/equipment' },
{ label: '자재관리', id: 'materials', url: '/juil/construction-pmis/materials' },
{ label: '공사량관리', id: 'work-volume', url: '/juil/construction-pmis/work-volume' },
{ label: '출면일보', id: 'daily-attendance', url: '/juil/construction-pmis/daily-attendance' },
{ label: '작업일보', id: 'daily-report', url: '/juil/construction-pmis/daily-report' },
]},
{ icon: 'ri-file-list-3-line', label: '품질관리', id: 'quality', children: [
{ label: '시정조치', id: 'corrective-action', url: '/juil/construction-pmis/corrective-action' },
]},
{ icon: 'ri-shield-check-line', label: '안전관리', id: 'safety', children: [
{ label: '안전보건교육', id: 'safety-education', url: '/juil/construction-pmis/safety-education' },
{ label: 'TBM현장', id: 'tbm', url: '/juil/construction-pmis/tbm' },
{ label: '위험성 평가', id: 'risk-assessment', url: '/juil/construction-pmis/risk-assessment' },
{ label: '재해예방조치', id: 'disaster-prevention', url: '/juil/construction-pmis/disaster-prevention' },
]},
{ icon: 'ri-folder-line', label: '자료실', id: 'archive', children: [
{ label: '자료보관함', id: 'archive-files', url: '/juil/construction-pmis/archive-files' },
{ label: '매뉴얼', id: 'archive-manual', url: '/juil/construction-pmis/archive-manual' },
{ label: '공지사항', id: 'archive-notice', url: '/juil/construction-pmis/archive-notice' },
]},
];
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;
}
return null;
});
useEffect(() => {
fetch('/juil/construction-pmis/profile', { headers: { Accept: 'application/json' } })
.then(r => r.json()).then(d => setProfile(d.worker)).catch(() => {});
}, []);
return (
<div className="bg-white border-r border-gray-200 shadow-sm flex flex-col shrink-0" style={{ width: 200 }}>
<a href="/juil/construction-pmis" className="flex items-center gap-2 px-4 py-3 text-sm text-blue-600 hover:bg-blue-50 border-b border-gray-100 transition">
<i className="ri-arrow-left-s-line text-lg"></i> PMIS 대시보드
</a>
<div className="p-3 border-b border-gray-100 text-center">
<div className="w-12 h-12 mx-auto mb-1 rounded-full bg-gray-100 border-2 border-gray-200 flex items-center justify-center">
{profile?.profile_photo_path
? <img src={profile.profile_photo_path} className="w-full h-full rounded-full object-cover" />
: <i className="ri-user-3-line text-xl text-gray-300"></i>}
</div>
<div className="text-sm font-bold text-gray-800">{profile?.name || '...'}</div>
<div className="text-xs text-gray-500 mt-0.5">{profile?.department || ''}</div>
</div>
<div className="flex-1 overflow-auto py-1">
{PMIS_MENUS.map(m => (
<div key={m.id}>
<div
onClick={() => 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'}`}>
<i className={`${m.icon} text-base`}></i> {m.label}
<i className={`ml-auto ${expanded === m.id ? 'ri-arrow-down-s-line' : 'ri-arrow-right-s-line'} text-gray-400 text-xs`}></i>
</div>
{expanded === m.id && m.children?.map(c => (
<a key={c.id} href={c.url}
className={`block pl-10 pr-4 py-2 text-sm transition ${c.id === activePage ? 'bg-blue-100 text-blue-800 font-semibold border-l-2 border-blue-600' : 'text-gray-500 hover:text-blue-600 hover:bg-gray-50'}`}>
{c.label}
</a>
))}
</div>
))}
</div>
</div>
);
}
/* ════════════════════════════════════════════════
기준자재정보 목록 (선택용)
════════════════════════════════════════════════ */
const BASE_MATERIALS = [
{ code: 'M001', name: '레미콘', spec: '25-24-15', unit: 'm3' },
{ code: 'M002', name: '레미콘', spec: '25-21-15', unit: 'm3' },
{ code: 'M003', name: '레미콘', spec: '25-18-15', unit: 'm3' },
{ code: 'M004', name: '철근', spec: 'HD10', unit: 'ton' },
{ code: 'M005', name: '철근', spec: 'HD13', unit: 'ton' },
{ code: 'M006', name: '철근', spec: 'HD16', unit: 'ton' },
{ code: 'M007', name: '철근', spec: 'HD19', unit: 'ton' },
{ code: 'M008', name: '철근', spec: 'HD22', unit: 'ton' },
{ code: 'M009', name: '철근', spec: 'HD25', unit: 'ton' },
{ code: 'M010', name: '시멘트', spec: '보통포틀랜드', unit: 'ton' },
{ code: 'M011', name: '모래', spec: '세척사', unit: 'm3' },
{ code: 'M012', name: '자갈', spec: '25mm', unit: 'm3' },
{ code: 'M013', name: '거푸집', spec: '합판 12T', unit: 'm2' },
{ code: 'M014', name: '거푸집', spec: '유로폼', unit: 'm2' },
{ code: 'M015', name: '방수재', spec: '도막방수', unit: 'm2' },
{ code: 'M016', name: '방수재', spec: '시트방수', unit: 'm2' },
{ code: 'M017', name: '단열재', spec: '압출법보온판 50T', unit: 'm2' },
{ code: 'M018', name: '단열재', spec: '비드법보온판 100T', unit: 'm2' },
{ code: 'M019', name: '벽돌', spec: '시멘트벽돌 190x90x57', unit: '매' },
{ code: 'M020', name: '타일', spec: '300x300 바닥타일', unit: 'm2' },
{ code: 'M021', name: '타일', spec: '200x200 벽타일', unit: 'm2' },
{ code: 'M022', name: '페인트', spec: '수성페인트', unit: 'L' },
{ code: 'M023', name: '페인트', spec: '유성페인트', unit: 'L' },
{ code: 'M024', name: '석고보드', spec: '9.5T', unit: 'm2' },
{ code: 'M025', name: '석고보드', spec: '12.5T', unit: 'm2' },
{ code: 'M026', name: 'PVC관', spec: 'VG1 100A', unit: 'm' },
{ code: 'M027', name: 'PVC관', spec: 'VG1 150A', unit: 'm' },
{ code: 'M028', name: '전선', spec: 'HIV 2.5sq', unit: 'm' },
{ code: 'M029', name: '전선', spec: 'HIV 4.0sq', unit: 'm' },
{ code: 'M030', name: '철골', spec: 'H형강 300x150', unit: 'ton' },
];
/* ════════════════════════════════════════════════
기준자재정보 모달
════════════════════════════════════════════════ */
function BaseMaterialModal({ open, onClose, onSelect }) {
const [search, setSearch] = useState('');
const [filterName, setFilterName] = useState('');
const [selected, setSelected] = useState(new Set());
useEffect(() => {
if (open) {
setSearch('');
setFilterName('');
setSelected(new Set());
}
}, [open]);
const materialNames = useMemo(() => [...new Set(BASE_MATERIALS.map(m => m.name))], []);
const filtered = useMemo(() => {
return BASE_MATERIALS.filter(m => {
if (filterName && m.name !== filterName) return false;
if (search) {
const s = search.toLowerCase();
return m.code.toLowerCase().includes(s) || m.name.toLowerCase().includes(s) || m.spec.toLowerCase().includes(s);
}
return true;
});
}, [search, filterName]);
function toggleSelect(code) {
setSelected(prev => { const s = new Set(prev); s.has(code) ? s.delete(code) : s.add(code); return s; });
}
function toggleAll() {
setSelected(filtered.length > 0 && selected.size === filtered.length ? new Set() : new Set(filtered.map(m => m.code)));
}
function handleSelect() {
const items = BASE_MATERIALS.filter(m => selected.has(m.code));
onSelect(items);
onClose();
}
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-2xl 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>
<div className="p-4">
{/* 필터 */}
<div className="flex flex-wrap items-center gap-2 mb-3">
<select value={filterName} onChange={e => setFilterName(e.target.value)}
className="border border-gray-300 rounded px-3 py-1.5 text-sm bg-white">
<option value="">자재명 전체</option>
{materialNames.map(n => <option key={n} value={n}>{n}</option>)}
</select>
<input type="text" placeholder="Search" value={search}
onChange={e => setSearch(e.target.value)}
className="border border-gray-300 rounded px-3 py-1.5 text-sm" style={{width: 160}} />
<button onClick={() => { setSearch(''); setFilterName(''); }}
className="bg-gray-200 text-gray-700 px-3 py-1.5 rounded text-sm hover:bg-gray-300">검색초기화</button>
</div>
{/* 테이블 */}
<div className="overflow-y-auto border border-gray-200 rounded-lg" style={{maxHeight: 360}}>
<table className="w-full text-sm">
<thead className="bg-gray-50 border-b border-gray-200 sticky top-0">
<tr>
<th className="px-3 py-2.5 text-left font-semibold text-gray-600">
<input type="checkbox" checked={filtered.length > 0 && selected.size === filtered.length} onChange={toggleAll} />
</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>
</tr>
</thead>
<tbody>
{filtered.map(m => (
<tr key={m.code} className={`border-b border-gray-100 hover:bg-blue-50/30 cursor-pointer transition ${selected.has(m.code) ? 'bg-blue-50' : ''}`}
onClick={() => toggleSelect(m.code)}>
<td className="px-3 py-2">
<input type="checkbox" checked={selected.has(m.code)} onChange={() => toggleSelect(m.code)} />
</td>
<td className="px-3 py-2 text-gray-600">{m.code}</td>
<td className="px-3 py-2 font-medium text-gray-800">{m.name}</td>
<td className="px-3 py-2 text-gray-700">{m.spec}</td>
<td className="px-3 py-2 text-gray-600">{m.unit}</td>
</tr>
))}
{filtered.length === 0 && (
<tr><td colSpan={5} className="text-center py-6 text-gray-400">검색 결과가 없습니다.</td></tr>
)}
</tbody>
</table>
</div>
{/* 버튼 */}
<div className="flex justify-end gap-2 mt-4">
<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 onClick={handleSelect} disabled={selected.size === 0}
className="px-6 py-2 text-sm font-semibold text-white bg-blue-600 hover:bg-blue-700 rounded-lg disabled:opacity-50">
선택 ({selected.size})
</button>
</div>
</div>
</div>
</div>
);
}
/* ════════════════════════════════════════════════
자재 등록/수정 모달
════════════════════════════════════════════════ */
function MaterialModal({ open, onClose, onSaved, material }) {
const isEdit = !!material?.id;
const [form, setForm] = useState({
company_name: '', material_code: '', material_name: '',
specification: '', unit: '', design_quantity: '',
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (open) {
if (material) {
setForm({
company_name: material.company_name || '',
material_code: material.material_code || '',
material_name: material.material_name || '',
specification: material.specification || '',
unit: material.unit || '',
design_quantity: material.design_quantity ?? '',
});
} else {
setForm({
company_name: '', material_code: '', material_name: '',
specification: '', unit: '', design_quantity: '',
});
}
setError('');
}
}, [open, material]);
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
async function handleSubmit(e) {
e.preventDefault();
setSaving(true);
setError('');
try {
const body = { ...form };
body.design_quantity = body.design_quantity ? parseFloat(body.design_quantity) : 0;
if (isEdit) {
await api(`/materials/${material.id}`, { method: 'PUT', body: JSON.stringify(body) });
} else {
await api('/materials', { 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(`/materials/${material.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: 80}}>업체명 <span className="text-red-500">*</span></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: 80}}>자재코드</label>
<input type="text" value={form.material_code} onChange={e => set('material_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: 140}} />
<label className="shrink-0 text-sm font-medium text-gray-700">자재명 <span className="text-red-500">*</span></label>
<input type="text" value={form.material_name} onChange={e => set('material_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: 80}}>규격</label>
<input type="text" value={form.specification} onChange={e => 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 focus:border-blue-500" />
<label className="shrink-0 text-sm font-medium text-gray-700">단위</label>
<input type="text" value={form.unit} onChange={e => set('unit', 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: 100}} />
</div>
{/* 설계량 */}
<div className="flex items-center gap-4">
<label className="shrink-0 text-sm font-medium text-gray-700" style={{width: 80}}>설계량</label>
<input type="number" step="0.01" min="0" value={form.design_quantity} onChange={e => set('design_quantity', 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}} />
</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 [materials, setMaterials] = 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 [filterName, setFilterName] = useState('');
const [perPage, setPerPage] = useState(15);
const [page, setPage] = useState(1);
const [modalOpen, setModalOpen] = useState(false);
const [editMaterial, setEditMaterial] = useState(null);
const [selected, setSelected] = useState(new Set());
const [baseModalOpen, setBaseModalOpen] = useState(false);
const fetchMaterials = 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);
if (filterName) params.set('material_name', filterName);
const data = await api(`/materials?${params}`);
setMaterials(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, filterName]);
useEffect(() => { fetchMaterials(); }, [fetchMaterials]);
function handleSearch() { setPage(1); fetchMaterials(); }
function handleAdd() { setEditMaterial(null); setModalOpen(true); }
function handleEdit(mat) { setEditMaterial(mat); setModalOpen(true); }
function randomMaterialData() {
const companies = ['(주)대림건설','(주)현대건설','(주)삼성물산','(주)GS건설','(주)포스코건설','(주)대우건설'];
const pick = arr => arr[Math.floor(Math.random() * arr.length)];
const baseMat = pick(BASE_MATERIALS);
return {
company_name: pick(companies),
material_code: baseMat.code,
material_name: baseMat.name,
specification: baseMat.spec,
unit: baseMat.unit,
design_quantity: String(Math.floor(Math.random() * 500) + 10),
};
}
function handleQuickAdd() {
setEditMaterial(randomMaterialData());
setModalOpen(true);
}
async function handleBaseMaterialSelect(items) {
for (const item of items) {
try {
await api('/materials', {
method: 'POST',
body: JSON.stringify({
company_name: '(미지정)',
material_code: item.code,
material_name: item.name,
specification: item.spec,
unit: item.unit,
design_quantity: 0,
}),
});
} catch {}
}
fetchMaterials();
}
async function handleBulkDelete() {
if (selected.size === 0) return;
if (!confirm(`선택한 ${selected.size}건을 삭제하시겠습니까?`)) return;
for (const id of selected) {
try { await api(`/materials/${id}`, { method: 'DELETE' }); } catch {}
}
setSelected(new Set());
fetchMaterials();
}
function toggleSelect(id) {
setSelected(prev => { const s = new Set(prev); s.has(id) ? s.delete(id) : s.add(id); return s; });
}
function toggleAll() {
setSelected(materials.length > 0 && selected.size === materials.length ? new Set() : new Set(materials.map(m => m.id)));
}
const companies = useMemo(() => [...new Set(materials.map(m => m.company_name).filter(Boolean))], [materials]);
const matNames = useMemo(() => [...new Set(materials.map(m => m.material_name).filter(Boolean))], [materials]);
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>
<MaterialModal
open={modalOpen}
onClose={() => setModalOpen(false)}
onSaved={fetchMaterials}
material={editMaterial}
/>
<BaseMaterialModal
open={baseModalOpen}
onClose={() => setBaseModalOpen(false)}
onSelect={handleBaseMaterialSelect}
/>
{/* 필터 바 */}
<div className="flex flex-wrap items-center gap-2 mb-2">
<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>
<button onClick={handleQuickAdd} className="bg-amber-500 text-white px-3 py-1.5 rounded text-sm font-semibold hover:bg-amber-600" title="랜덤 데이터로 추가">
<i className="ri-flashlight-line"></i>
</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>
<button onClick={() => setBaseModalOpen(true)} className="flex items-center gap-1 border border-blue-300 bg-blue-50 rounded px-3 py-1.5 text-sm text-blue-700 hover:bg-blue-100">
<i className="ri-database-2-line"></i> 기준자재
</button>
</div>
</div>
{/* 자재명 서브필터 */}
<div className="flex flex-wrap items-center gap-2 mb-4">
<select value={filterName} onChange={e => { setFilterName(e.target.value); setPage(1); }}
className="border border-gray-300 rounded px-3 py-1.5 text-sm bg-white">
<option value="">자재명 전체</option>
{matNames.map(n => <option key={n} value={n}>{n}</option>)}
</select>
</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={materials.length > 0 && selected.size === materials.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-right font-semibold text-gray-600 whitespace-nowrap">설계량</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={8} className="text-center py-8 text-gray-400">불러오는 ...</td></tr>
) : materials.length === 0 ? (
<tr><td colSpan={8} className="text-center py-8 text-gray-400">등록된 자재가 없습니다.</td></tr>
) : materials.map((mat, idx) => (
<tr key={mat.id} className="border-b border-gray-100 hover:bg-blue-50/30 transition cursor-pointer"
onClick={() => handleEdit(mat)}>
<td className="px-3 py-2" onClick={e => e.stopPropagation()}>
<input type="checkbox" checked={selected.has(mat.id)} onChange={() => toggleSelect(mat.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">{mat.company_name}</td>
<td className="px-3 py-2 text-gray-600">{mat.material_code || '-'}</td>
<td className="px-3 py-2 font-medium text-gray-800">{mat.material_name}</td>
<td className="px-3 py-2 text-gray-700">{mat.specification || '-'}</td>
<td className="px-3 py-2 text-gray-600">{mat.unit || '-'}</td>
<td className="px-3 py-2 text-right text-gray-700">{Number(mat.design_quantity || 0).toLocaleString()}</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">&lt;</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">&gt;</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 TabReceiving() {
const today = new Date().toISOString().slice(0, 10);
const [date, setDate] = useState(today);
const [materials, setMaterials] = useState([]);
const [loading, setLoading] = useState(true);
const [filterCompany, setFilterCompany] = useState('');
useEffect(() => {
(async () => {
setLoading(true);
try {
const data = await api('/materials?per_page=100');
setMaterials(data.data);
} catch {}
setLoading(false);
})();
}, []);
const companies = useMemo(() => [...new Set(materials.map(m => m.company_name).filter(Boolean))], [materials]);
const filtered = useMemo(() => {
if (!filterCompany) return materials;
return materials.filter(m => m.company_name === filterCompany);
}, [materials, filterCompany]);
return (
<div>
<div className="flex flex-wrap items-center gap-2 mb-4">
<select value={filterCompany} onChange={e => setFilterCompany(e.target.value)}
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="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 flex items-center gap-2">
<button className="bg-blue-600 text-white px-4 py-1.5 rounded text-sm font-semibold hover:bg-blue-700">저장</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">순번</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-right font-semibold text-gray-600">설계량</th>
<th className="px-3 py-2.5 text-right font-semibold text-gray-600">전일누계</th>
<th className="px-3 py-2.5 text-right font-semibold text-gray-600" style={{backgroundColor: '#FFFDE7'}}>금일</th>
<th className="px-3 py-2.5 text-right font-semibold text-gray-600">총계</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={10} className="text-center py-8 text-gray-400">불러오는 ...</td></tr>
) : filtered.length === 0 ? (
<tr><td colSpan={10} className="text-center py-8 text-gray-400">등록된 자재가 없습니다.</td></tr>
) : filtered.map((mat, idx) => (
<tr key={mat.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">{mat.company_name}</td>
<td className="px-3 py-2 text-gray-600">{mat.material_code || '-'}</td>
<td className="px-3 py-2 font-medium text-gray-800">{mat.material_name}</td>
<td className="px-3 py-2 text-gray-700">{mat.specification || '-'}</td>
<td className="px-3 py-2 text-gray-600">{mat.unit || '-'}</td>
<td className="px-3 py-2 text-right text-gray-700">{Number(mat.design_quantity || 0).toLocaleString()}</td>
<td className="px-3 py-2 text-right text-gray-600">-</td>
<td className="px-3 py-2 text-right" style={{backgroundColor: '#FFFDE7'}}>-</td>
<td className="px-3 py-2 text-right text-gray-600">-</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
/* ════════════════════════════════════════════════
메인 컴포넌트
════════════════════════════════════════════════ */
const TABS = [
{ id: 'register', label: '자재등록' },
{ id: 'receiving', label: '입고현황' },
];
function App() {
const [activeTab, setActiveTab] = useState('register');
return (
<div className="flex bg-gray-100" style={{ height: 'calc(100vh - 56px)' }}>
<PmisSidebar activePage="materials" />
<div className="flex-1 flex flex-col overflow-hidden">
{/* 헤더 */}
<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> &gt; <span>시공관리</span> &gt; <span className="text-gray-600">자재관리</span>
</div>
<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 === 'receiving' && <TabReceiving />}
</div>
</div>
</div>
);
}
ReactDOM.render(<App />, document.getElementById('root'));
@endverbatim
</script>
@endpush