merge: develop → main 전체 반영

- 전표/분개, 급여관리, 카드사용내역, 거래처 등 재무/회계 기능
- PMIS 시공관리, BIM 뷰어
- 결재, 근로계약서, HR 기능 개선
- 방화셔터 도면, 명함신청 등
This commit is contained in:
김보곤
2026-03-12 16:11:23 +09:00
7 changed files with 652 additions and 16 deletions

View File

@@ -0,0 +1,79 @@
<?php
namespace App\Http\Controllers\Juil;
use App\Http\Controllers\Controller;
use App\Models\Juil\PmisWorkVolume;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class PmisWorkVolumeController extends Controller
{
private function tenantId(): int
{
return (int) session('current_tenant_id', 1);
}
public function list(Request $request): JsonResponse
{
$query = PmisWorkVolume::tenant($this->tenantId())
->orderByDesc('id');
if ($request->filled('search')) {
$s = $request->search;
$query->where(function ($q) use ($s) {
$q->where('work_type', 'like', "%{$s}%")
->orWhere('sub_work_type', 'like', "%{$s}%");
});
}
$perPage = $request->integer('per_page', 15);
$workVolumes = $query->paginate($perPage);
return response()->json($workVolumes);
}
public function store(Request $request): JsonResponse
{
$validated = $request->validate([
'work_type' => 'required|string|max:200',
'sub_work_type' => 'required|string|max:200',
'unit' => 'required|string|max:50',
'design_quantity' => 'nullable|numeric|min:0',
'daily_report_applied' => 'nullable|boolean',
]);
$validated['tenant_id'] = $this->tenantId();
$validated['design_quantity'] = $validated['design_quantity'] ?? 0;
$validated['daily_report_applied'] = $validated['daily_report_applied'] ?? false;
$workVolume = PmisWorkVolume::create($validated);
return response()->json($workVolume, 201);
}
public function update(Request $request, int $id): JsonResponse
{
$workVolume = PmisWorkVolume::tenant($this->tenantId())->findOrFail($id);
$validated = $request->validate([
'work_type' => 'sometimes|required|string|max:200',
'sub_work_type' => 'sometimes|required|string|max:200',
'unit' => 'sometimes|required|string|max:50',
'design_quantity' => 'nullable|numeric|min:0',
'daily_report_applied' => 'nullable|boolean',
]);
$workVolume->update($validated);
return response()->json($workVolume);
}
public function destroy(int $id): JsonResponse
{
$workVolume = PmisWorkVolume::tenant($this->tenantId())->findOrFail($id);
$workVolume->delete();
return response()->json(['message' => '삭제되었습니다.']);
}
}

View File

@@ -0,0 +1,34 @@
<?php
namespace App\Models\Juil;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
class PmisWorkVolume extends Model
{
use SoftDeletes;
protected $table = 'pmis_work_volumes';
protected $fillable = [
'tenant_id',
'work_type',
'sub_work_type',
'unit',
'design_quantity',
'daily_report_applied',
'options',
];
protected $casts = [
'design_quantity' => 'decimal:2',
'daily_report_applied' => 'boolean',
'options' => 'array',
];
public function scopeTenant($query, $tenantId)
{
return $query->where('tenant_id', $tenantId);
}
}

View File

@@ -0,0 +1,29 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
Schema::create('pmis_work_volumes', function (Blueprint $table) {
$table->id();
$table->unsignedBigInteger('tenant_id')->index();
$table->string('work_type', 200)->comment('공종');
$table->string('sub_work_type', 200)->comment('세부공종');
$table->string('unit', 50)->comment('단위');
$table->decimal('design_quantity', 14, 2)->default(0)->comment('설계량');
$table->boolean('daily_report_applied')->default(false)->comment('일보적용');
$table->json('options')->nullable();
$table->timestamps();
$table->softDeletes();
});
}
public function down(): void
{
Schema::dropIfExists('pmis_work_volumes');
}
};

View File

@@ -1674,7 +1674,7 @@ className="w-full px-3 py-2 text-sm border border-stone-200 rounded-lg focus:rin
<div>
<h4 className="text-sm font-semibold text-stone-700 mb-3">분개 내역</h4>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<table className="w-full text-sm table-fixed">
<thead>
<tr className="bg-stone-50 border-b border-stone-200">
<th className="px-3 py-2 text-center font-medium text-stone-600 w-[70px]">구분</th>
@@ -2096,7 +2096,7 @@ className="w-full px-3 py-2 text-sm border border-stone-200 rounded-lg focus:rin
<div>
<h4 className="text-sm font-semibold text-stone-700 mb-3">분개 내역</h4>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<table className="w-full text-sm table-fixed">
<thead>
<tr className="bg-stone-50 border-b border-stone-200">
<th className="px-3 py-2 text-center font-medium text-stone-600 w-[70px]">구분</th>
@@ -2503,7 +2503,7 @@ className="w-full px-3 py-2 text-sm border border-stone-200 rounded-lg focus:rin
<div>
<h4 className="text-sm font-semibold text-stone-700 mb-3">분개 내역</h4>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<table className="w-full text-sm table-fixed">
<thead>
<tr className="bg-stone-50 border-b border-stone-200">
<th className="px-3 py-2 text-center font-medium text-stone-600 w-[70px]">구분</th>

View File

@@ -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,495 @@ className={`block pl-10 pr-4 py-2 text-sm transition ${c.id === activePage ? 'bg
);
}
/* ════════════════════════════════════════════════
공사량정보 모달 (추가/수정)
════════════════════════════════════════════════ */
function WorkVolumeModal({ open, onClose, onSaved, workVolume }) {
const isEdit = !!workVolume?.id;
const [form, setForm] = useState({
work_type: '', sub_work_type: '', unit: '', design_quantity: '',
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (open) {
if (workVolume) {
setForm({
work_type: workVolume.work_type || '',
sub_work_type: workVolume.sub_work_type || '',
unit: workVolume.unit || '',
design_quantity: workVolume.design_quantity ?? '',
});
} else {
setForm({ work_type: '', sub_work_type: '', unit: '', design_quantity: '' });
}
setError('');
}
}, [open, workVolume]);
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(`/work-volumes/${workVolume.id}`, { method: 'PUT', body: JSON.stringify(body) });
} else {
await api('/work-volumes', { 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(`/work-volumes/${workVolume.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-md 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.work_type} onChange={e => 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" />
</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.sub_work_type} onChange={e => set('sub_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" />
</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.unit} onChange={e => set('unit', e.target.value)}
required 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: 120}} />
</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 TabWorkVolume() {
const [items, setItems] = 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 [editItem, setEditItem] = useState(null);
const [selected, setSelected] = useState(new Set());
const fetchItems = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams();
params.set('page', page);
params.set('per_page', perPage);
if (search) params.set('search', search);
const data = await api(`/work-volumes?${params}`);
setItems(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]);
useEffect(() => { fetchItems(); }, [fetchItems]);
function handleSearch() { setPage(1); fetchItems(); }
function handleAdd() { setEditItem(null); setModalOpen(true); }
function handleEdit(item) { setEditItem(item); setModalOpen(true); }
/* 번개 랜덤 데이터 */
const WORK_TYPES = [
{ work_type: '토공사', sub_work_type: '터파기', unit: 'm3' },
{ work_type: '토공사', sub_work_type: '되메우기', unit: 'm3' },
{ work_type: '토공사', sub_work_type: '잔토처리', unit: 'm3' },
{ work_type: '철근콘크리트공사', sub_work_type: '거푸집', unit: 'm2' },
{ work_type: '철근콘크리트공사', sub_work_type: '철근가공조립', unit: 'ton' },
{ work_type: '철근콘크리트공사', sub_work_type: '콘크리트타설', unit: 'm3' },
{ work_type: '철골공사', sub_work_type: '철골제작', unit: 'ton' },
{ work_type: '철골공사', sub_work_type: '철골설치', unit: 'ton' },
{ work_type: '방수공사', sub_work_type: '도막방수', unit: 'm2' },
{ work_type: '방수공사', sub_work_type: '시트방수', unit: 'm2' },
{ work_type: '미장공사', sub_work_type: '시멘트모르타르', unit: 'm2' },
{ work_type: '미장공사', sub_work_type: '자기질타일', unit: 'm2' },
{ work_type: '도장공사', sub_work_type: '수성페인트', unit: 'm2' },
{ work_type: '도장공사', sub_work_type: '유성페인트', unit: 'm2' },
{ work_type: '조적공사', sub_work_type: '시멘트벽돌쌓기', unit: 'm2' },
{ work_type: '조적공사', sub_work_type: '블록쌓기', unit: 'm2' },
{ work_type: '단열공사', sub_work_type: '압출법보온판', unit: 'm2' },
{ work_type: '단열공사', sub_work_type: '비드법보온판', unit: 'm2' },
{ work_type: '창호공사', sub_work_type: '알루미늄창호', unit: '조' },
{ work_type: '창호공사', sub_work_type: 'PVC창호', unit: '조' },
{ work_type: '지붕공사', sub_work_type: '금속기와', unit: 'm2' },
{ work_type: '지붕공사', sub_work_type: '아스팔트슁글', unit: 'm2' },
{ work_type: '설비공사', sub_work_type: '급수배관', unit: 'm' },
{ work_type: '설비공사', sub_work_type: '배수배관', unit: 'm' },
{ work_type: '전기공사', sub_work_type: '전선관배선', unit: 'm' },
{ work_type: '전기공사', sub_work_type: '조명설치', unit: '개' },
];
function randomWorkVolumeData() {
const pick = arr => arr[Math.floor(Math.random() * arr.length)];
const base = pick(WORK_TYPES);
return {
work_type: base.work_type,
sub_work_type: base.sub_work_type,
unit: base.unit,
design_quantity: String(Math.floor(Math.random() * 5000) + 50),
};
}
function handleQuickAdd() {
setEditItem(randomWorkVolumeData());
setModalOpen(true);
}
async function handleBulkDelete() {
if (selected.size === 0) return;
if (!confirm(`선택한 ${selected.size}건을 삭제하시겠습니까?`)) return;
for (const id of selected) {
try { await api(`/work-volumes/${id}`, { method: 'DELETE' }); } catch {}
}
setSelected(new Set());
fetchItems();
}
/* 일보적용 토글 저장 */
async function handleToggleDailyReport(item) {
try {
await api(`/work-volumes/${item.id}`, {
method: 'PUT',
body: JSON.stringify({ daily_report_applied: !item.daily_report_applied }),
});
fetchItems();
} catch {}
}
/* 일괄 저장 (일보적용 변경분) */
async function handleSaveAll() {
alert('일보적용 상태가 저장되었습니다.');
}
function toggleSelect(id) {
setSelected(prev => { const s = new Set(prev); s.has(id) ? s.delete(id) : s.add(id); return s; });
}
function toggleAll() {
setSelected(items.length > 0 && selected.size === items.length ? new Set() : new Set(items.map(m => m.id)));
}
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>
<WorkVolumeModal
open={modalOpen}
onClose={() => setModalOpen(false)}
onSaved={fetchItems}
workVolume={editItem}
/>
{/* 필터 바 */}
<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>
</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>
<button onClick={handleSaveAll} 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={items.length > 0 && selected.size === items.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-right font-semibold text-gray-600 whitespace-nowrap">설계량</th>
<th className="px-3 py-2.5 text-center font-semibold text-gray-600 whitespace-nowrap">일보적용</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={7} className="text-center py-8 text-gray-400">불러오는 ...</td></tr>
) : items.length === 0 ? (
<tr><td colSpan={7} className="text-center py-8 text-gray-400">등록된 공사량이 없습니다.</td></tr>
) : items.map((item, idx) => (
<tr key={item.id} className="border-b border-gray-100 hover:bg-blue-50/30 transition cursor-pointer"
onClick={() => handleEdit(item)}>
<td className="px-3 py-2" onClick={e => e.stopPropagation()}>
<input type="checkbox" checked={selected.has(item.id)} onChange={() => toggleSelect(item.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 font-medium text-gray-800">{item.work_type}</td>
<td className="px-3 py-2 text-gray-700">{item.sub_work_type}</td>
<td className="px-3 py-2 text-gray-600">{item.unit}</td>
<td className="px-3 py-2 text-right text-gray-700">{Number(item.design_quantity || 0).toLocaleString()}</td>
<td className="px-3 py-2 text-center" onClick={e => e.stopPropagation()}>
<input type="checkbox" checked={!!item.daily_report_applied}
onChange={() => handleToggleDailyReport(item)} />
</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 TabPerformance() {
const today = new Date().toISOString().slice(0, 10);
const [date, setDate] = useState(today);
const [items, setItems] = useState([]);
const [loading, setLoading] = useState(true);
const [filterCompany, setFilterCompany] = useState('');
useEffect(() => {
(async () => {
setLoading(true);
try {
const data = await api('/work-volumes?per_page=100');
setItems(data.data);
} catch {}
setLoading(false);
})();
}, []);
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>
</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-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={9} className="text-center py-8 text-gray-400">불러오는 ...</td></tr>
) : items.length === 0 ? (
<tr><td colSpan={9} className="text-center py-8 text-gray-400">등록된 공사량이 없습니다.</td></tr>
) : items.map((item, idx) => (
<tr key={item.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">-</td>
<td className="px-3 py-2 font-medium text-gray-800">{item.work_type}</td>
<td className="px-3 py-2 text-gray-700">{item.sub_work_type}</td>
<td className="px-3 py-2 text-gray-600">{item.unit}</td>
<td className="px-3 py-2 text-right text-gray-700">{Number(item.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: 'work-volume', label: '공사량' },
{ id: 'performance', label: '실적현황' },
];
function App() {
const [activeTab, setActiveTab] = useState('work-volume');
return (
<div className="flex bg-gray-100" style={{ height: 'calc(100vh - 56px)' }}>
<PmisSidebar activePage="work-volume" />
<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> &gt; <span>시공관리</span> &gt; <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 === 'work-volume' && <TabWorkVolume />}
{activeTab === 'performance' && <TabPerformance />}
</div>
</div>
</div>
);

View File

@@ -733,13 +733,13 @@ function renderGrCross() {
const sealT = g.sealThick * sc;
const slatT = Math.max(g.slatThick * sc, 2);
// ── ② 가이드레일 EGI (ㄷ자 1개, 절곡: lip10-flange26-sw80-bw67-sw80-flange26-lip10) ──
// ── ② 가이드레일 EGI (ㄷ자 1개, 절곡: lip10-flange30-sw80-bw67-sw80-flange30-lip10) ──
const bLip = g.lip * sc; // 10mm 립
const bFl = g.flange * sc; // 26mm 플랜지
const bFl = g.flange * sc; // 30mm 플랜지
const bSw = g.sideWall * sc; // 80mm 사이드월
const bBw = g.backWall * sc; // 67mm 백월
const bOuterW = bBw + 2 * t2; // 70mm 외폭 (세로)
const bSlot = bOuterW - 2 * bFl; // 슬롯 개구 (플랜지 팁 간 간격)
const bOuterW = g.width * sc; // 70mm 외폭 (명시된 폭 사용, 부동소수점 방지)
const bSlot = bOuterW - 2 * bFl; // 슬롯 개구 = 70-2*30 = 10mm
// ── ③ 벽연형-C 치수 (절곡: 30-45-30) ──
const c3Lip = 30 * sc; // 립 30mm
@@ -914,7 +914,7 @@ function renderGrCross() {
// ① 코킹립 10mm (벽쪽 라벨)
dimLines += `<text x="${trimL1-m1a-4}" y="${tTop+m1b+t1/2+3}" fill="${cTrim}" font-size="8" font-weight="700" text-anchor="end" font-family="Pretendard">①립${m1a/sc}</text>`;
// 슬롯 개구 (우측 중앙)
dimLines += `<text x="${lipEndX+6}" y="${by+bOuterW/2+16}" fill="#22c55e" font-size="8" font-weight="700" text-anchor="start" font-family="Pretendard">슬롯${bSlot/sc}</text>`;
dimLines += `<text x="${lipEndX+6}" y="${by+bOuterW/2+16}" fill="#22c55e" font-size="8" font-weight="700" text-anchor="start" font-family="Pretendard">슬롯${Math.round(bSlot/sc)}</text>`;
// 립 깊이
dimLines += `<text x="${(swEndX+lipEndX)/2}" y="${by+bFl+14}" fill="${cBody}" font-size="8" font-weight="700" text-anchor="middle" font-family="Pretendard">②립${g.lip}</text>`;
// 두께 (하단, 120mm 치수선 아래)

View File

@@ -49,6 +49,7 @@
use App\Http\Controllers\Juil\PmisEquipmentController;
use App\Http\Controllers\Juil\PmisMaterialController;
use App\Http\Controllers\Juil\PmisWorkforceController;
use App\Http\Controllers\Juil\PmisWorkVolumeController;
use App\Http\Controllers\Lab\StrategyController;
use App\Http\Controllers\MenuController;
use App\Http\Controllers\MenuSyncController;
@@ -1761,6 +1762,12 @@
Route::post('/materials', [PmisMaterialController::class, 'store']);
Route::put('/materials/{id}', [PmisMaterialController::class, 'update']);
Route::delete('/materials/{id}', [PmisMaterialController::class, 'destroy']);
// 공사량관리 CRUD
Route::get('/work-volumes', [PmisWorkVolumeController::class, 'list']);
Route::post('/work-volumes', [PmisWorkVolumeController::class, 'store']);
Route::put('/work-volumes/{id}', [PmisWorkVolumeController::class, 'update']);
Route::delete('/work-volumes/{id}', [PmisWorkVolumeController::class, 'destroy']);
});
// 공사현장 사진대지