feat: [pmis] 출면일보 CRUD 구현
- 일별 출면일보 마스터 + 인원/장비 3테이블 마이그레이션 - 캘린더 스트립 (1~31일) 날짜 선택 및 상태 닷 표시 - 인원/장비 탭 CRUD (추가/수정/삭제/번개 랜덤데이터) - 검토자 확인 모달 (조직도 + 검색 + 검토라인) - 양식보기 모달 (출면일보/장비일보 인쇄 양식) - 날씨/특이사항/상태 업데이트 API
This commit is contained in:
212
app/Http/Controllers/Juil/PmisDailyAttendanceController.php
Normal file
212
app/Http/Controllers/Juil/PmisDailyAttendanceController.php
Normal file
@@ -0,0 +1,212 @@
|
||||
<?php
|
||||
|
||||
namespace App\Http\Controllers\Juil;
|
||||
|
||||
use App\Http\Controllers\Controller;
|
||||
use App\Models\Juil\PmisAttendanceEquipment;
|
||||
use App\Models\Juil\PmisAttendanceWorker;
|
||||
use App\Models\Juil\PmisDailyAttendance;
|
||||
use Illuminate\Http\JsonResponse;
|
||||
use Illuminate\Http\Request;
|
||||
|
||||
class PmisDailyAttendanceController extends Controller
|
||||
{
|
||||
private function tenantId(): int
|
||||
{
|
||||
return (int) session('current_tenant_id', 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 해당 날짜의 출면일보 조회 (없으면 생성)
|
||||
*/
|
||||
public function show(Request $request): JsonResponse
|
||||
{
|
||||
$date = $request->input('date', now()->toDateString());
|
||||
$company = $request->input('company', '');
|
||||
|
||||
$attendance = PmisDailyAttendance::tenant($this->tenantId())
|
||||
->where('date', $date)
|
||||
->when($company, fn ($q) => $q->where('company_name', $company))
|
||||
->first();
|
||||
|
||||
if (! $attendance) {
|
||||
$attendance = PmisDailyAttendance::create([
|
||||
'tenant_id' => $this->tenantId(),
|
||||
'date' => $date,
|
||||
'company_name' => $company,
|
||||
'weather' => '맑음',
|
||||
'status' => 'draft',
|
||||
]);
|
||||
}
|
||||
|
||||
$attendance->load(['workers', 'equipments']);
|
||||
|
||||
return response()->json($attendance);
|
||||
}
|
||||
|
||||
/**
|
||||
* 해당 월의 일별 상태 조회 (캘린더 닷 표시용)
|
||||
*/
|
||||
public function monthStatus(Request $request): JsonResponse
|
||||
{
|
||||
$year = $request->integer('year', now()->year);
|
||||
$month = $request->integer('month', now()->month);
|
||||
$company = $request->input('company', '');
|
||||
|
||||
$attendances = PmisDailyAttendance::tenant($this->tenantId())
|
||||
->whereYear('date', $year)
|
||||
->whereMonth('date', $month)
|
||||
->when($company, fn ($q) => $q->where('company_name', $company))
|
||||
->withCount(['workers', 'equipments'])
|
||||
->get();
|
||||
|
||||
$result = [];
|
||||
foreach ($attendances as $att) {
|
||||
$day = (int) $att->date->format('d');
|
||||
if ($att->workers_count > 0 || $att->equipments_count > 0) {
|
||||
$result[$day] = $att->status;
|
||||
}
|
||||
}
|
||||
|
||||
return response()->json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 출면일보 메타 업데이트 (날씨, 특이사항, 상태)
|
||||
*/
|
||||
public function update(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$attendance = PmisDailyAttendance::tenant($this->tenantId())->findOrFail($id);
|
||||
|
||||
$validated = $request->validate([
|
||||
'weather' => 'sometimes|string|max:50',
|
||||
'notes' => 'sometimes|nullable|string',
|
||||
'status' => 'sometimes|in:draft,review,approved',
|
||||
'options' => 'sometimes|nullable|array',
|
||||
]);
|
||||
|
||||
$attendance->update($validated);
|
||||
|
||||
return response()->json($attendance);
|
||||
}
|
||||
|
||||
/**
|
||||
* 검토자 저장
|
||||
*/
|
||||
public function saveReviewers(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$attendance = PmisDailyAttendance::tenant($this->tenantId())->findOrFail($id);
|
||||
|
||||
$reviewers = $request->input('reviewers', []);
|
||||
$options = $attendance->options ?? [];
|
||||
$options['reviewers'] = $reviewers;
|
||||
$attendance->update(['options' => $options]);
|
||||
|
||||
return response()->json(['message' => '검토자가 저장되었습니다.']);
|
||||
}
|
||||
|
||||
// ─── 인원(Worker) CRUD ───
|
||||
|
||||
public function workerStore(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'attendance_id' => 'required|integer|exists:pmis_daily_attendances,id',
|
||||
'work_type' => 'required|string|max:200',
|
||||
'job_type' => 'required|string|max:200',
|
||||
'name' => 'required|string|max:100',
|
||||
'man_days' => 'nullable|numeric|min:0',
|
||||
'amount' => 'nullable|numeric|min:0',
|
||||
'work_content' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
$validated['tenant_id'] = $this->tenantId();
|
||||
$validated['man_days'] = $validated['man_days'] ?? 1.0;
|
||||
$validated['amount'] = $validated['amount'] ?? 0;
|
||||
$validated['work_content'] = $validated['work_content'] ?? '';
|
||||
|
||||
$maxSort = PmisAttendanceWorker::where('attendance_id', $validated['attendance_id'])->max('sort_order') ?? 0;
|
||||
$validated['sort_order'] = $maxSort + 1;
|
||||
|
||||
$worker = PmisAttendanceWorker::create($validated);
|
||||
|
||||
return response()->json($worker, 201);
|
||||
}
|
||||
|
||||
public function workerUpdate(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$worker = PmisAttendanceWorker::where('tenant_id', $this->tenantId())->findOrFail($id);
|
||||
|
||||
$validated = $request->validate([
|
||||
'work_type' => 'sometimes|string|max:200',
|
||||
'job_type' => 'sometimes|string|max:200',
|
||||
'name' => 'sometimes|string|max:100',
|
||||
'man_days' => 'nullable|numeric|min:0',
|
||||
'amount' => 'nullable|numeric|min:0',
|
||||
'work_content' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
$worker->update($validated);
|
||||
|
||||
return response()->json($worker);
|
||||
}
|
||||
|
||||
public function workerDestroy(int $id): JsonResponse
|
||||
{
|
||||
$worker = PmisAttendanceWorker::where('tenant_id', $this->tenantId())->findOrFail($id);
|
||||
$worker->delete();
|
||||
|
||||
return response()->json(['message' => '삭제되었습니다.']);
|
||||
}
|
||||
|
||||
// ─── 장비(Equipment) CRUD ───
|
||||
|
||||
public function equipmentStore(Request $request): JsonResponse
|
||||
{
|
||||
$validated = $request->validate([
|
||||
'attendance_id' => 'required|integer|exists:pmis_daily_attendances,id',
|
||||
'equipment_name' => 'required|string|max:200',
|
||||
'specification' => 'nullable|string|max:300',
|
||||
'equipment_number' => 'nullable|string|max:100',
|
||||
'operator' => 'nullable|string|max:100',
|
||||
'man_days' => 'nullable|numeric|min:0',
|
||||
'work_content' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
$validated['tenant_id'] = $this->tenantId();
|
||||
$validated['man_days'] = $validated['man_days'] ?? 1.0;
|
||||
$validated['work_content'] = $validated['work_content'] ?? '';
|
||||
|
||||
$maxSort = PmisAttendanceEquipment::where('attendance_id', $validated['attendance_id'])->max('sort_order') ?? 0;
|
||||
$validated['sort_order'] = $maxSort + 1;
|
||||
|
||||
$equipment = PmisAttendanceEquipment::create($validated);
|
||||
|
||||
return response()->json($equipment, 201);
|
||||
}
|
||||
|
||||
public function equipmentUpdate(Request $request, int $id): JsonResponse
|
||||
{
|
||||
$equipment = PmisAttendanceEquipment::where('tenant_id', $this->tenantId())->findOrFail($id);
|
||||
|
||||
$validated = $request->validate([
|
||||
'equipment_name' => 'sometimes|string|max:200',
|
||||
'specification' => 'nullable|string|max:300',
|
||||
'equipment_number' => 'nullable|string|max:100',
|
||||
'operator' => 'nullable|string|max:100',
|
||||
'man_days' => 'nullable|numeric|min:0',
|
||||
'work_content' => 'nullable|string|max:500',
|
||||
]);
|
||||
|
||||
$equipment->update($validated);
|
||||
|
||||
return response()->json($equipment);
|
||||
}
|
||||
|
||||
public function equipmentDestroy(int $id): JsonResponse
|
||||
{
|
||||
$equipment = PmisAttendanceEquipment::where('tenant_id', $this->tenantId())->findOrFail($id);
|
||||
$equipment->delete();
|
||||
|
||||
return response()->json(['message' => '삭제되었습니다.']);
|
||||
}
|
||||
}
|
||||
37
app/Models/Juil/PmisAttendanceEquipment.php
Normal file
37
app/Models/Juil/PmisAttendanceEquipment.php
Normal file
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Juil;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class PmisAttendanceEquipment extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'pmis_attendance_equipments';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'attendance_id',
|
||||
'equipment_name',
|
||||
'specification',
|
||||
'equipment_number',
|
||||
'operator',
|
||||
'man_days',
|
||||
'work_content',
|
||||
'sort_order',
|
||||
'options',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'man_days' => 'decimal:1',
|
||||
'options' => 'array',
|
||||
];
|
||||
|
||||
public function attendance(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PmisDailyAttendance::class, 'attendance_id');
|
||||
}
|
||||
}
|
||||
38
app/Models/Juil/PmisAttendanceWorker.php
Normal file
38
app/Models/Juil/PmisAttendanceWorker.php
Normal file
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Juil;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class PmisAttendanceWorker extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'pmis_attendance_workers';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'attendance_id',
|
||||
'work_type',
|
||||
'job_type',
|
||||
'name',
|
||||
'man_days',
|
||||
'amount',
|
||||
'work_content',
|
||||
'sort_order',
|
||||
'options',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'man_days' => 'decimal:1',
|
||||
'amount' => 'integer',
|
||||
'options' => 'array',
|
||||
];
|
||||
|
||||
public function attendance(): BelongsTo
|
||||
{
|
||||
return $this->belongsTo(PmisDailyAttendance::class, 'attendance_id');
|
||||
}
|
||||
}
|
||||
44
app/Models/Juil/PmisDailyAttendance.php
Normal file
44
app/Models/Juil/PmisDailyAttendance.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models\Juil;
|
||||
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||||
|
||||
class PmisDailyAttendance extends Model
|
||||
{
|
||||
use SoftDeletes;
|
||||
|
||||
protected $table = 'pmis_daily_attendances';
|
||||
|
||||
protected $fillable = [
|
||||
'tenant_id',
|
||||
'date',
|
||||
'company_name',
|
||||
'weather',
|
||||
'status',
|
||||
'notes',
|
||||
'options',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'date' => 'date',
|
||||
'options' => 'array',
|
||||
];
|
||||
|
||||
public function scopeTenant($query, $tenantId)
|
||||
{
|
||||
return $query->where('tenant_id', $tenantId);
|
||||
}
|
||||
|
||||
public function workers(): HasMany
|
||||
{
|
||||
return $this->hasMany(PmisAttendanceWorker::class, 'attendance_id')->orderBy('sort_order');
|
||||
}
|
||||
|
||||
public function equipments(): HasMany
|
||||
{
|
||||
return $this->hasMany(PmisAttendanceEquipment::class, 'attendance_id')->orderBy('sort_order');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<?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_daily_attendances', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('tenant_id')->index();
|
||||
$table->date('date')->comment('일자');
|
||||
$table->string('company_name', 200)->default('')->comment('업체명');
|
||||
$table->string('weather', 50)->default('맑음')->comment('날씨');
|
||||
$table->enum('status', ['draft', 'review', 'approved'])->default('draft')->comment('상태');
|
||||
$table->text('notes')->nullable()->comment('특이사항');
|
||||
$table->json('options')->nullable()->comment('검토자 등 부가정보');
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->unique(['tenant_id', 'date', 'company_name'], 'pmis_attendance_unique');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('pmis_daily_attendances');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
<?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_attendance_workers', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('tenant_id')->index();
|
||||
$table->unsignedBigInteger('attendance_id')->index();
|
||||
$table->string('work_type', 200)->comment('공종');
|
||||
$table->string('job_type', 200)->comment('직종');
|
||||
$table->string('name', 100)->comment('성명');
|
||||
$table->decimal('man_days', 5, 1)->default(1.0)->comment('공수');
|
||||
$table->decimal('amount', 14, 0)->default(0)->comment('금액');
|
||||
$table->string('work_content', 500)->default('')->comment('작업내용');
|
||||
$table->integer('sort_order')->default(0);
|
||||
$table->json('options')->nullable();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->foreign('attendance_id')->references('id')->on('pmis_daily_attendances')->onDelete('cascade');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('pmis_attendance_workers');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
<?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_attendance_equipments', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->unsignedBigInteger('tenant_id')->index();
|
||||
$table->unsignedBigInteger('attendance_id')->index();
|
||||
$table->string('equipment_name', 200)->comment('장비명');
|
||||
$table->string('specification', 300)->default('')->comment('규격');
|
||||
$table->string('equipment_number', 100)->default('')->comment('장비번호');
|
||||
$table->string('operator', 100)->default('')->comment('운전원');
|
||||
$table->decimal('man_days', 5, 1)->default(1.0)->comment('공수');
|
||||
$table->string('work_content', 500)->default('')->comment('작업내용');
|
||||
$table->integer('sort_order')->default(0);
|
||||
$table->json('options')->nullable();
|
||||
$table->timestamps();
|
||||
$table->softDeletes();
|
||||
|
||||
$table->foreign('attendance_id')->references('id')->on('pmis_daily_attendances')->onDelete('cascade');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('pmis_attendance_equipments');
|
||||
}
|
||||
};
|
||||
@@ -14,6 +14,25 @@
|
||||
@verbatim
|
||||
const { useState, useEffect, useRef, useCallback, useMemo } = React;
|
||||
|
||||
const API_BASE = '/juil/construction-pmis/api';
|
||||
const CSRF = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
|
||||
async function api(path, opts = {}) {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
headers: { 'Accept': 'application/json', 'Content-Type': 'application/json', 'X-CSRF-TOKEN': CSRF, ...opts.headers },
|
||||
...opts,
|
||||
});
|
||||
if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error(err.message || `HTTP ${res.status}`); }
|
||||
return res.json();
|
||||
}
|
||||
|
||||
const DAY_NAMES = ['일','월','화','수','목','금','토'];
|
||||
const WEATHERS = ['맑음','흐림','비','눈','안개','구름많음'];
|
||||
|
||||
function getDaysInMonth(y, m) { return new Date(y, m, 0).getDate(); }
|
||||
function getDayOfWeek(y, m, d) { return new Date(y, m - 1, d).getDay(); }
|
||||
function formatDate(y, m, d) { return `${y}-${String(m).padStart(2,'0')}-${String(d).padStart(2,'0')}`; }
|
||||
|
||||
/* ════════════════════════════════════════════════
|
||||
PMIS 사이드바
|
||||
════════════════════════════════════════════════ */
|
||||
@@ -37,17 +56,13 @@
|
||||
function PmisSidebar({ activePage }) {
|
||||
const [profile, setProfile] = useState(null);
|
||||
const [expanded, setExpanded] = useState(() => {
|
||||
for (const m of PMIS_MENUS) {
|
||||
if (m.children?.some(c => c.id === activePage)) return m.id;
|
||||
}
|
||||
for (const m of PMIS_MENUS) { if (m.children?.some(c => c.id === activePage)) return m.id; }
|
||||
return null;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/juil/construction-pmis/profile', { headers: { Accept: 'application/json' } })
|
||||
.then(r => r.json()).then(d => setProfile(d.worker)).catch(() => {});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<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">
|
||||
@@ -55,9 +70,7 @@ function PmisSidebar({ activePage }) {
|
||||
</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>}
|
||||
{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>
|
||||
@@ -65,17 +78,13 @@ function PmisSidebar({ activePage }) {
|
||||
<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)}
|
||||
<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>
|
||||
<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>
|
||||
))}
|
||||
@@ -84,27 +93,883 @@ className={`block pl-10 pr-4 py-2 text-sm transition ${c.id === activePage ? 'bg
|
||||
);
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════
|
||||
캘린더 스트립 (월간 일자 선택)
|
||||
════════════════════════════════════════════════ */
|
||||
function CalendarStrip({ year, month, selectedDay, onSelectDay, dayStatus }) {
|
||||
const days = getDaysInMonth(year, month);
|
||||
const STATUS_COLORS = { draft: '#22c55e', review: '#ef4444', approved: '#3b82f6' };
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-0.5 py-2 overflow-x-auto">
|
||||
<button onClick={() => onSelectDay(Math.max(1, selectedDay - 1))} className="px-2 py-1 text-gray-500 hover:text-gray-800 text-lg shrink-0"><</button>
|
||||
{Array.from({ length: days }, (_, i) => {
|
||||
const d = i + 1;
|
||||
const dow = getDayOfWeek(year, month, d);
|
||||
const isWeekend = dow === 0 || dow === 6;
|
||||
const isSelected = d === selectedDay;
|
||||
const status = dayStatus[d];
|
||||
const dotColor = STATUS_COLORS[status];
|
||||
return (
|
||||
<div key={d} onClick={() => onSelectDay(d)}
|
||||
className={`flex flex-col items-center cursor-pointer shrink-0 transition rounded ${isSelected ? 'bg-blue-600 text-white' : 'hover:bg-gray-100'}`}
|
||||
style={{ width: 32, padding: '2px 0' }}>
|
||||
<div className="h-2 flex items-center justify-center">
|
||||
{dotColor && <div className="rounded-full" style={{ width: 6, height: 6, backgroundColor: dotColor }}></div>}
|
||||
</div>
|
||||
<div className={`text-sm font-medium ${isSelected ? 'text-white' : isWeekend ? 'text-red-500' : 'text-gray-700'}`}>{d}</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<button onClick={() => onSelectDay(Math.min(days, selectedDay + 1))} className="px-2 py-1 text-gray-500 hover:text-gray-800 text-lg shrink-0">></button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════
|
||||
인원 추가/수정 모달
|
||||
════════════════════════════════════════════════ */
|
||||
function WorkerModal({ open, onClose, onSaved, worker, attendanceId }) {
|
||||
const isEdit = !!worker?.id;
|
||||
const [form, setForm] = useState({ work_type: '', job_type: '', name: '', man_days: '1.0', amount: '', work_content: '' });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (worker) {
|
||||
setForm({
|
||||
work_type: worker.work_type || '', job_type: worker.job_type || '',
|
||||
name: worker.name || '', man_days: worker.man_days ?? '1.0',
|
||||
amount: worker.amount ?? '', work_content: worker.work_content || '',
|
||||
});
|
||||
} else {
|
||||
setForm({ work_type: '', job_type: '', name: '', man_days: '1.0', amount: '', work_content: '' });
|
||||
}
|
||||
setError('');
|
||||
}
|
||||
}, [open, worker]);
|
||||
|
||||
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault(); setSaving(true); setError('');
|
||||
try {
|
||||
const body = { ...form, man_days: parseFloat(form.man_days) || 1.0, amount: parseInt(form.amount) || 0 };
|
||||
if (isEdit) {
|
||||
await api(`/attendance-workers/${worker.id}`, { method: 'PUT', body: JSON.stringify(body) });
|
||||
} else {
|
||||
body.attendance_id = attendanceId;
|
||||
await api('/attendance-workers', { method: 'POST', body: JSON.stringify(body) });
|
||||
}
|
||||
onSaved(); onClose();
|
||||
} catch (err) { setError(err.message); } finally { setSaving(false); }
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
<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-lg 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:70}}>공종 <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:70}}>직종 <span className="text-red-500">*</span></label>
|
||||
<input type="text" value={form.job_type} onChange={e => set('job_type', e.target.value)} required className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="shrink-0 text-sm font-medium text-gray-700" style={{width:70}}>성명 <span className="text-red-500">*</span></label>
|
||||
<input type="text" value={form.name} onChange={e => set('name', e.target.value)} required className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" />
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="shrink-0 text-sm font-medium text-gray-700" style={{width:70}}>공수</label>
|
||||
<input type="number" step="0.5" min="0" value={form.man_days} onChange={e => set('man_days', e.target.value)} className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500" style={{width:100}} />
|
||||
<label className="shrink-0 text-sm font-medium text-gray-700">금액</label>
|
||||
<input type="number" min="0" value={form.amount} onChange={e => set('amount', e.target.value)} className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="shrink-0 text-sm font-medium text-gray-700" style={{width:70}}>작업내용</label>
|
||||
<input type="text" value={form.work_content} onChange={e => set('work_content', e.target.value)} className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-3">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════
|
||||
장비 추가/수정 모달
|
||||
════════════════════════════════════════════════ */
|
||||
function EquipmentModal({ open, onClose, onSaved, equipment, attendanceId }) {
|
||||
const isEdit = !!equipment?.id;
|
||||
const [form, setForm] = useState({ equipment_name: '', specification: '', equipment_number: '', operator: '', man_days: '1.0', work_content: '' });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
if (equipment) {
|
||||
setForm({
|
||||
equipment_name: equipment.equipment_name || '', specification: equipment.specification || '',
|
||||
equipment_number: equipment.equipment_number || '', operator: equipment.operator || '',
|
||||
man_days: equipment.man_days ?? '1.0', work_content: equipment.work_content || '',
|
||||
});
|
||||
} else {
|
||||
setForm({ equipment_name: '', specification: '', equipment_number: '', operator: '', man_days: '1.0', work_content: '' });
|
||||
}
|
||||
setError('');
|
||||
}
|
||||
}, [open, equipment]);
|
||||
|
||||
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
|
||||
|
||||
async function handleSubmit(e) {
|
||||
e.preventDefault(); setSaving(true); setError('');
|
||||
try {
|
||||
const body = { ...form, man_days: parseFloat(form.man_days) || 1.0 };
|
||||
if (isEdit) {
|
||||
await api(`/attendance-equipments/${equipment.id}`, { method: 'PUT', body: JSON.stringify(body) });
|
||||
} else {
|
||||
body.attendance_id = attendanceId;
|
||||
await api('/attendance-equipments', { method: 'POST', body: JSON.stringify(body) });
|
||||
}
|
||||
onSaved(); onClose();
|
||||
} catch (err) { setError(err.message); } finally { setSaving(false); }
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
<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-lg 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:70}}>장비명 <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:70}}>규격</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" />
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="shrink-0 text-sm font-medium text-gray-700" style={{width:70}}>장비번호</label>
|
||||
<input type="text" value={form.equipment_number} onChange={e => set('equipment_number', e.target.value)} className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500" style={{width:140}} />
|
||||
<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" />
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="shrink-0 text-sm font-medium text-gray-700" style={{width:70}}>공수</label>
|
||||
<input type="number" step="0.5" min="0" value={form.man_days} onChange={e => set('man_days', e.target.value)} className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500" style={{width:100}} />
|
||||
</div>
|
||||
<div className="flex items-center gap-4">
|
||||
<label className="shrink-0 text-sm font-medium text-gray-700" style={{width:70}}>작업내용</label>
|
||||
<input type="text" value={form.work_content} onChange={e => set('work_content', e.target.value)} className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500" />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-3">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════
|
||||
검토자 지정 모달
|
||||
════════════════════════════════════════════════ */
|
||||
const DEFAULT_REVIEWERS = [
|
||||
{ line: 1, name: '신승표', position: '부장', confirmed_at: null },
|
||||
{ line: 2, name: '이성태', position: '대리', confirmed_at: null },
|
||||
{ line: 3, name: '안성현', position: '과장', confirmed_at: null },
|
||||
{ line: 4, name: '정영진', position: '과장', confirmed_at: null },
|
||||
{ line: 5, name: '심준수', position: '부장', confirmed_at: null },
|
||||
{ line: 6, name: '김정훈', position: '부장', confirmed_at: null },
|
||||
];
|
||||
|
||||
const ORG_TREE = [
|
||||
{ name: '안성 당목리 물류센터', children: [
|
||||
{ name: '협력업체', members: [
|
||||
{ dept: '(주)주일기업', name: '신승표', position: '부장' },
|
||||
{ dept: '(주)주일기업', name: '이성태', position: '대리' },
|
||||
{ dept: '(주)주일기업', name: '김국민', position: '반장' },
|
||||
]},
|
||||
{ name: 'KCC건설', members: [
|
||||
{ dept: 'KCC건설', name: '안성현', position: '과장' },
|
||||
{ dept: 'KCC건설', name: '정영진', position: '과장' },
|
||||
{ dept: 'KCC건설', name: '심준수', position: '부장' },
|
||||
{ dept: 'KCC건설', name: '김정훈', position: '부장' },
|
||||
]},
|
||||
]},
|
||||
];
|
||||
|
||||
function ReviewerModal({ open, onClose, attendance, onSaved }) {
|
||||
const [reviewers, setReviewers] = useState([]);
|
||||
const [selectedOrg, setSelectedOrg] = useState(null);
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (open && attendance) {
|
||||
const saved = attendance.options?.reviewers;
|
||||
setReviewers(saved?.length ? saved : DEFAULT_REVIEWERS.map(r => ({...r})));
|
||||
setSelectedOrg(null);
|
||||
setSearch('');
|
||||
}
|
||||
}, [open, attendance]);
|
||||
|
||||
const orgMembers = useMemo(() => {
|
||||
if (!selectedOrg) return [];
|
||||
for (const root of ORG_TREE) {
|
||||
for (const child of root.children || []) {
|
||||
if (child.name === selectedOrg) return child.members || [];
|
||||
}
|
||||
}
|
||||
return [];
|
||||
}, [selectedOrg]);
|
||||
|
||||
const filteredMembers = useMemo(() => {
|
||||
if (!search) return orgMembers;
|
||||
const s = search.toLowerCase();
|
||||
return orgMembers.filter(m => m.name.includes(s) || m.dept.includes(s) || m.position.includes(s));
|
||||
}, [orgMembers, search]);
|
||||
|
||||
async function handleSave() {
|
||||
if (!attendance?.id) return;
|
||||
try {
|
||||
await api(`/daily-attendances/${attendance.id}/reviewers`, {
|
||||
method: 'PUT', body: JSON.stringify({ reviewers }),
|
||||
});
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function addReviewer(member) {
|
||||
if (reviewers.length >= 10) return;
|
||||
setReviewers(prev => [...prev, { line: prev.length + 1, name: member.name, position: member.position, confirmed_at: null }]);
|
||||
}
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
<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 mx-4" style={{maxWidth: 900, maxHeight: '80vh'}} 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="flex p-4 gap-4" style={{height: 420}}>
|
||||
{/* 왼쪽: 조직 트리 */}
|
||||
<div className="border border-gray-200 rounded-lg overflow-auto" style={{width: 200}}>
|
||||
{ORG_TREE.map(root => (
|
||||
<div key={root.name}>
|
||||
<div className="px-3 py-2 text-sm font-semibold text-blue-700 bg-blue-50">{root.name}</div>
|
||||
{root.children?.map(child => (
|
||||
<div key={child.name} onClick={() => setSelectedOrg(child.name)}
|
||||
className={`px-5 py-2 text-sm cursor-pointer transition ${selectedOrg === child.name ? 'bg-blue-100 text-blue-800 font-semibold' : 'text-gray-600 hover:bg-gray-50'}`}>
|
||||
{child.name}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 가운데: 인원 목록 */}
|
||||
<div className="flex-1 flex flex-col border border-gray-200 rounded-lg overflow-hidden">
|
||||
<div className="p-2 border-b border-gray-200 flex items-center gap-2">
|
||||
<input type="text" placeholder="검색" value={search} onChange={e => setSearch(e.target.value)}
|
||||
className="flex-1 border border-gray-300 rounded px-3 py-1.5 text-sm" />
|
||||
<button className="text-gray-400"><i className="ri-search-line"></i></button>
|
||||
</div>
|
||||
<div className="overflow-auto flex-1">
|
||||
<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 text-left font-semibold text-gray-600">소속</th>
|
||||
<th className="px-3 py-2 text-left font-semibold text-gray-600">성명</th>
|
||||
<th className="px-3 py-2 text-left font-semibold text-gray-600">직책</th>
|
||||
<th className="px-3 py-2 text-center font-semibold text-gray-600">추가</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredMembers.map((m, i) => (
|
||||
<tr key={i} className="border-b border-gray-100 hover:bg-blue-50/30 transition">
|
||||
<td className="px-3 py-2 text-gray-600">{m.dept}</td>
|
||||
<td className="px-3 py-2 font-medium text-gray-800">{m.name}</td>
|
||||
<td className="px-3 py-2 text-gray-600">{m.position}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<button onClick={() => addReviewer(m)} className="text-blue-600 hover:text-blue-800"><i className="ri-add-line"></i></button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{filteredMembers.length === 0 && <tr><td colSpan={4} className="text-center py-6 text-gray-400">{selectedOrg ? '조직을 선택하세요' : '좌측 조직을 선택하세요'}</td></tr>}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 오른쪽: 검토자 라인 */}
|
||||
<div className="border border-gray-200 rounded-lg overflow-auto flex flex-col" style={{width: 260}}>
|
||||
<div className="p-2 border-b border-gray-200 flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-700">라인 선택</span>
|
||||
<div className="ml-auto flex gap-1">
|
||||
<button onClick={handleSave} className="bg-blue-600 text-white px-3 py-1 rounded text-xs font-semibold hover:bg-blue-700">저장</button>
|
||||
<button onClick={() => setReviewers([])} className="bg-gray-200 text-gray-700 px-3 py-1 rounded text-xs hover:bg-gray-300">삭제</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-2">
|
||||
{reviewers.map((r, i) => (
|
||||
<div key={i} className={`flex items-center justify-between px-3 py-2 border-b border-gray-100 text-sm ${r.confirmed_at ? 'text-blue-600' : 'text-gray-700'}`}>
|
||||
<span>{String(i+1).padStart(2,'0')} : {r.name} {r.position}</span>
|
||||
{r.confirmed_at && <span className="text-xs text-blue-500 ml-2">{r.confirmed_at}</span>}
|
||||
<button onClick={() => setReviewers(prev => prev.filter((_, j) => j !== i))} className="text-red-400 hover:text-red-600 ml-1"><i className="ri-close-line text-xs"></i></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end px-6 py-3 border-t border-gray-200">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════
|
||||
양식보기 모달 (출면일보 + 장비일보)
|
||||
════════════════════════════════════════════════ */
|
||||
function PrintPreviewModal({ open, onClose, attendance, dateStr }) {
|
||||
if (!open || !attendance) return null;
|
||||
const workers = attendance.workers || [];
|
||||
const equipments = attendance.equipments || [];
|
||||
const reviewers = attendance.options?.reviewers || DEFAULT_REVIEWERS;
|
||||
|
||||
const totalManDays = workers.reduce((s, w) => s + parseFloat(w.man_days || 0), 0);
|
||||
const totalAmount = workers.reduce((s, w) => s + parseInt(w.amount || 0), 0);
|
||||
const eqTotalManDays = equipments.reduce((s, e) => s + parseFloat(e.man_days || 0), 0);
|
||||
|
||||
const tdStyle = { border: '1px solid #333', padding: '4px 6px', fontSize: '12px' };
|
||||
const thStyle = { ...tdStyle, backgroundColor: '#f0f0f0', fontWeight: 'bold', textAlign: 'center' };
|
||||
|
||||
function renderApprovalSection() {
|
||||
const r = (i) => reviewers[i] || {};
|
||||
return (
|
||||
<table style={{width:'100%', borderCollapse:'collapse', marginTop: 10}}>
|
||||
<tbody>
|
||||
<tr><td style={{...tdStyle, width: 40, textAlign:'center', border:'none'}}></td><td style={{...tdStyle, border:'none'}}></td>
|
||||
<td style={thStyle}>공사과장</td><td style={thStyle}>공무과장</td><td style={thStyle}>현장소장</td></tr>
|
||||
<tr><td style={{...tdStyle, textAlign:'center', writingMode:'vertical-lr'}} rowSpan={2}>결</td>
|
||||
<td style={{...tdStyle, textAlign:'center'}}>{r(1).name || ''}</td>
|
||||
<td style={{...tdStyle, textAlign:'center'}}>{r(2).name || ''}</td>
|
||||
<td style={{...tdStyle, textAlign:'center'}}>{r(3).name || ''}</td>
|
||||
<td style={{...tdStyle, textAlign:'center'}}></td></tr>
|
||||
<tr><td style={thStyle}>관리부장</td><td style={tdStyle}></td><td style={tdStyle}></td><td style={tdStyle}></td></tr>
|
||||
<tr><td style={{...tdStyle, textAlign:'center'}}>재</td>
|
||||
<td style={{...tdStyle, textAlign:'center'}}>{r(4).name || ''}</td>
|
||||
<td style={tdStyle}></td><td style={tdStyle}></td>
|
||||
<td style={{...tdStyle, textAlign:'center'}}>{r(5).name || ''}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
|
||||
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 mx-4 overflow-auto" style={{maxWidth: 800, maxHeight: '90vh'}} onClick={e => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b border-gray-200 sticky top-0 bg-white z-10">
|
||||
<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-6" style={{fontFamily: 'serif'}}>
|
||||
<div style={{border:'2px solid #333', padding:20, marginBottom: 30}}>
|
||||
<div style={{display:'flex', justifyContent:'space-between', marginBottom:10}}>
|
||||
<h3 style={{fontSize:22, fontWeight:'bold', letterSpacing:8}}>출 면 일 보</h3>
|
||||
<div style={{fontSize:12, textAlign:'right', lineHeight:1.8}}>
|
||||
<div>현 장 명 : 안성 당목리 물류센터</div>
|
||||
<div>업 체 명 : (주)주일기업</div>
|
||||
<div>일 자 : {dateStr}</div>
|
||||
</div>
|
||||
</div>
|
||||
<table style={{width:'100%', borderCollapse:'collapse'}}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={thStyle}>번호</th><th style={thStyle}>직종</th><th style={thStyle}>성명</th>
|
||||
<th style={thStyle}>공수</th><th style={thStyle}>금액</th><th style={thStyle}>작업내용</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{workers.map((w, i) => (
|
||||
<tr key={i}>
|
||||
<td style={{...tdStyle, textAlign:'center'}}>{i+1}</td>
|
||||
<td style={tdStyle}>{w.job_type}</td>
|
||||
<td style={{...tdStyle, textAlign:'center'}}>{w.name}</td>
|
||||
<td style={{...tdStyle, textAlign:'center'}}>{w.man_days}</td>
|
||||
<td style={{...tdStyle, textAlign:'right'}}>{parseInt(w.amount||0).toLocaleString()}</td>
|
||||
<td style={tdStyle}>{w.work_content}</td>
|
||||
</tr>
|
||||
))}
|
||||
{Array.from({length: Math.max(0, 15 - workers.length)}).map((_, i) => (
|
||||
<tr key={`e${i}`}><td style={tdStyle}> </td><td style={tdStyle}></td><td style={tdStyle}></td><td style={tdStyle}></td><td style={tdStyle}></td><td style={tdStyle}></td></tr>
|
||||
))}
|
||||
<tr style={{fontWeight:'bold'}}>
|
||||
<td colSpan={3} style={{...tdStyle, textAlign:'center'}}>계</td>
|
||||
<td style={{...tdStyle, textAlign:'center'}}>{totalManDays}</td>
|
||||
<td style={{...tdStyle, textAlign:'right'}}>{totalAmount.toLocaleString()}</td>
|
||||
<td style={tdStyle}></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div style={{marginTop:10, fontSize:12}}>
|
||||
<div style={{fontWeight:'bold'}}>특이사항 :</div>
|
||||
<div style={{minHeight:30, padding:4}}>{attendance.notes || ''}</div>
|
||||
</div>
|
||||
<div style={{marginTop:10, fontSize:12}}>작업책임자 : (주)주일기업 {reviewers[0]?.name || ''}</div>
|
||||
{renderApprovalSection()}
|
||||
</div>
|
||||
|
||||
{/* 장비일보 */}
|
||||
<div style={{border:'2px solid #333', padding:20}}>
|
||||
<div style={{display:'flex', justifyContent:'space-between', marginBottom:10}}>
|
||||
<h3 style={{fontSize:22, fontWeight:'bold', letterSpacing:8}}>장 비 일 보</h3>
|
||||
<div style={{fontSize:12, textAlign:'right', lineHeight:1.8}}>
|
||||
<div>현 장 명 : 안성 당목리 물류센터</div>
|
||||
<div>업 체 명 : (주)주일기업</div>
|
||||
<div>일 자 : {dateStr}</div>
|
||||
</div>
|
||||
</div>
|
||||
<table style={{width:'100%', borderCollapse:'collapse'}}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={thStyle}>번호</th><th style={thStyle}>장비명</th><th style={thStyle}>규격</th>
|
||||
<th style={thStyle}>단위</th><th style={thStyle}>투입량</th><th style={thStyle}>금액</th>
|
||||
<th style={thStyle}>운전원</th><th style={thStyle}>작업내용</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{equipments.map((eq, i) => (
|
||||
<tr key={i}>
|
||||
<td style={{...tdStyle, textAlign:'center'}}>{i+1}</td>
|
||||
<td style={tdStyle}>{eq.equipment_name}</td>
|
||||
<td style={tdStyle}>{eq.specification}</td>
|
||||
<td style={{...tdStyle, textAlign:'center'}}>대</td>
|
||||
<td style={{...tdStyle, textAlign:'center'}}>{eq.man_days}</td>
|
||||
<td style={{...tdStyle, textAlign:'right'}}></td>
|
||||
<td style={{...tdStyle, textAlign:'center'}}>{eq.operator}</td>
|
||||
<td style={tdStyle}>{eq.work_content}</td>
|
||||
</tr>
|
||||
))}
|
||||
{Array.from({length: Math.max(0, 10 - equipments.length)}).map((_, i) => (
|
||||
<tr key={`ee${i}`}><td style={tdStyle}> </td><td style={tdStyle}></td><td style={tdStyle}></td><td style={tdStyle}></td><td style={tdStyle}></td><td style={tdStyle}></td><td style={tdStyle}></td><td style={tdStyle}></td></tr>
|
||||
))}
|
||||
<tr style={{fontWeight:'bold'}}>
|
||||
<td colSpan={4} style={{...tdStyle, textAlign:'center'}}>계</td>
|
||||
<td style={{...tdStyle, textAlign:'center'}}>{eqTotalManDays}</td>
|
||||
<td style={tdStyle}></td><td style={tdStyle}></td><td style={tdStyle}></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div style={{marginTop:10, fontSize:12}}>
|
||||
<div style={{fontWeight:'bold'}}>특이사항 :</div>
|
||||
<div style={{minHeight:30, padding:4}}>{attendance.notes || ''}</div>
|
||||
</div>
|
||||
<div style={{marginTop:10, fontSize:12}}>작업책임자 : (주)주일기업 {reviewers[0]?.name || ''}</div>
|
||||
{renderApprovalSection()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════
|
||||
랜덤 데이터 생성
|
||||
════════════════════════════════════════════════ */
|
||||
const RANDOM_WORKERS = [
|
||||
{ work_type: '방화셔터공사', job_type: '방화셔터', names: ['김국민','김상수','김성운','김세호','김송민','임용준','조명석'], amounts: [180000,200000] },
|
||||
{ work_type: '방화셔터공사', job_type: '현장소장', names: ['신승표'], amounts: [0] },
|
||||
{ work_type: '방화셔터공사', job_type: '화기감시자', names: ['임민서','최흥숙','박동현','이재훈'], amounts: [170000] },
|
||||
{ work_type: '철근콘크리트공사', job_type: '철근공', names: ['박철수','이강호','최영수','김민준'], amounts: [200000,220000] },
|
||||
{ work_type: '철근콘크리트공사', job_type: '형틀목공', names: ['한상우','정대호','오진혁'], amounts: [190000,210000] },
|
||||
{ work_type: '전기공사', job_type: '전기기사', names: ['송재민','윤호진','박성훈'], amounts: [200000,230000] },
|
||||
{ work_type: '설비공사', job_type: '배관공', names: ['최태호','강민석','이동현'], amounts: [190000,210000] },
|
||||
];
|
||||
const RANDOM_WORK_CONTENTS = ['B동 1층 셔터설치','A동 1층 셔터설치','A동 3층 셔터설치','B동 2층 배관작업','A동 지하1층 전기배선','B동 3층 철근배근','A동 2층 거푸집설치','지하주차장 방수작업'];
|
||||
const RANDOM_EQUIPS = [
|
||||
{ equipment_name: '타워크레인', specification: 'T-50', operators: ['박기사','이기사'] },
|
||||
{ equipment_name: '굴삭기', specification: '0.7m3', operators: ['김기사','최기사'] },
|
||||
{ equipment_name: '덤프트럭', specification: '15톤', operators: ['정기사','한기사'] },
|
||||
{ equipment_name: '레미콘', specification: '6m3', operators: ['윤기사'] },
|
||||
{ equipment_name: '펌프카', specification: '36m', operators: ['임기사','송기사'] },
|
||||
{ equipment_name: '지게차', specification: '3톤', operators: ['오기사'] },
|
||||
];
|
||||
|
||||
function randomWorker() {
|
||||
const pick = arr => arr[Math.floor(Math.random() * arr.length)];
|
||||
const grp = pick(RANDOM_WORKERS);
|
||||
return {
|
||||
work_type: grp.work_type, job_type: grp.job_type,
|
||||
name: pick(grp.names), man_days: '1.0', amount: String(pick(grp.amounts)),
|
||||
work_content: pick(RANDOM_WORK_CONTENTS),
|
||||
};
|
||||
}
|
||||
|
||||
function randomEquip() {
|
||||
const pick = arr => arr[Math.floor(Math.random() * arr.length)];
|
||||
const eq = pick(RANDOM_EQUIPS);
|
||||
return {
|
||||
equipment_name: eq.equipment_name, specification: eq.specification,
|
||||
equipment_number: `${String.fromCharCode(65 + Math.floor(Math.random()*5))}-${String(Math.floor(Math.random()*900)+100)}`,
|
||||
operator: pick(eq.operators), man_days: '1.0', work_content: pick(RANDOM_WORK_CONTENTS),
|
||||
};
|
||||
}
|
||||
|
||||
/* ════════════════════════════════════════════════
|
||||
메인 컴포넌트
|
||||
════════════════════════════════════════════════ */
|
||||
function App() {
|
||||
const today = new Date();
|
||||
const [year, setYear] = useState(today.getFullYear());
|
||||
const [month, setMonth] = useState(today.getMonth() + 1);
|
||||
const [selectedDay, setSelectedDay] = useState(today.getDate());
|
||||
const [company, setCompany] = useState('(주)주일기업·방화셔터공사');
|
||||
const [dayStatus, setDayStatus] = useState({});
|
||||
const [tab, setTab] = useState('workers');
|
||||
|
||||
const [attendance, setAttendance] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
const [workerModalOpen, setWorkerModalOpen] = useState(false);
|
||||
const [editWorker, setEditWorker] = useState(null);
|
||||
const [equipModalOpen, setEquipModalOpen] = useState(false);
|
||||
const [editEquip, setEditEquip] = useState(null);
|
||||
const [reviewerModalOpen, setReviewerModalOpen] = useState(false);
|
||||
const [printModalOpen, setPrintModalOpen] = useState(false);
|
||||
|
||||
const [selectedWorkers, setSelectedWorkers] = useState(new Set());
|
||||
const [selectedEquips, setSelectedEquips] = useState(new Set());
|
||||
const [notes, setNotes] = useState('');
|
||||
|
||||
const dateStr = formatDate(year, month, selectedDay);
|
||||
const dow = getDayOfWeek(year, month, selectedDay);
|
||||
const dateTitle = `${year}년 ${String(month).padStart(2,'0')}월 ${String(selectedDay).padStart(2,'0')}일 (${DAY_NAMES[dow]}) ${attendance?.weather || '맑음'}`;
|
||||
|
||||
// 월별 상태 조회
|
||||
const fetchMonthStatus = useCallback(async () => {
|
||||
try {
|
||||
const data = await api(`/daily-attendances/month-status?year=${year}&month=${month}`);
|
||||
setDayStatus(data);
|
||||
} catch {}
|
||||
}, [year, month]);
|
||||
|
||||
// 해당 날짜 출면일보 조회
|
||||
const fetchAttendance = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api(`/daily-attendances?date=${dateStr}`);
|
||||
setAttendance(data);
|
||||
setNotes(data.notes || '');
|
||||
} catch {}
|
||||
setLoading(false);
|
||||
}, [dateStr]);
|
||||
|
||||
useEffect(() => { fetchMonthStatus(); }, [fetchMonthStatus]);
|
||||
useEffect(() => { fetchAttendance(); }, [fetchAttendance]);
|
||||
|
||||
function handleRefresh() { fetchAttendance(); fetchMonthStatus(); }
|
||||
|
||||
// 특이사항 저장
|
||||
async function handleSaveNotes() {
|
||||
if (!attendance?.id) return;
|
||||
try {
|
||||
await api(`/daily-attendances/${attendance.id}`, { method: 'PUT', body: JSON.stringify({ notes }) });
|
||||
alert('특이사항이 저장되었습니다.');
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 인원 삭제
|
||||
async function handleDeleteWorkers() {
|
||||
if (selectedWorkers.size === 0) return;
|
||||
if (!confirm(`선택한 ${selectedWorkers.size}건을 삭제하시겠습니까?`)) return;
|
||||
for (const id of selectedWorkers) {
|
||||
try { await api(`/attendance-workers/${id}`, { method: 'DELETE' }); } catch {}
|
||||
}
|
||||
setSelectedWorkers(new Set());
|
||||
handleRefresh();
|
||||
}
|
||||
|
||||
// 장비 삭제
|
||||
async function handleDeleteEquips() {
|
||||
if (selectedEquips.size === 0) return;
|
||||
if (!confirm(`선택한 ${selectedEquips.size}건을 삭제하시겠습니까?`)) return;
|
||||
for (const id of selectedEquips) {
|
||||
try { await api(`/attendance-equipments/${id}`, { method: 'DELETE' }); } catch {}
|
||||
}
|
||||
setSelectedEquips(new Set());
|
||||
handleRefresh();
|
||||
}
|
||||
|
||||
// 번개 추가 (인원)
|
||||
async function handleQuickWorker() {
|
||||
if (!attendance?.id) return;
|
||||
const data = randomWorker();
|
||||
data.attendance_id = attendance.id;
|
||||
data.man_days = parseFloat(data.man_days);
|
||||
data.amount = parseInt(data.amount);
|
||||
try {
|
||||
await api('/attendance-workers', { method: 'POST', body: JSON.stringify(data) });
|
||||
handleRefresh();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// 번개 추가 (장비)
|
||||
async function handleQuickEquip() {
|
||||
if (!attendance?.id) return;
|
||||
const data = randomEquip();
|
||||
data.attendance_id = attendance.id;
|
||||
data.man_days = parseFloat(data.man_days);
|
||||
try {
|
||||
await api('/attendance-equipments', { method: 'POST', body: JSON.stringify(data) });
|
||||
handleRefresh();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
const workers = attendance?.workers || [];
|
||||
const equipments = attendance?.equipments || [];
|
||||
const totalManDays = workers.reduce((s, w) => s + parseFloat(w.man_days || 0), 0);
|
||||
const totalAmount = workers.reduce((s, w) => s + parseInt(w.amount || 0), 0);
|
||||
const eqTotalManDays = equipments.reduce((s, e) => s + parseFloat(e.man_days || 0), 0);
|
||||
|
||||
// 상태 범례 색상
|
||||
const statusLegend = [
|
||||
{ label: '작성중', color: '#22c55e' }, { label: '검토중', color: '#ef4444' },
|
||||
{ label: '승인', color: '#3b82f6' }, { label: '미작성', color: '#d1d5db' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="flex bg-gray-100" style={{ height: 'calc(100vh - 56px)' }}>
|
||||
<PmisSidebar activePage="daily-attendance" />
|
||||
<WorkerModal open={workerModalOpen} onClose={() => setWorkerModalOpen(false)} onSaved={handleRefresh} worker={editWorker} attendanceId={attendance?.id} />
|
||||
<EquipmentModal open={equipModalOpen} onClose={() => setEquipModalOpen(false)} onSaved={handleRefresh} equipment={editEquip} attendanceId={attendance?.id} />
|
||||
<ReviewerModal open={reviewerModalOpen} onClose={() => setReviewerModalOpen(false)} attendance={attendance} onSaved={handleRefresh} />
|
||||
<PrintPreviewModal open={printModalOpen} onClose={() => setPrintModalOpen(false)} attendance={attendance} dateStr={dateStr} />
|
||||
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
<div className="bg-white border-b border-gray-200 px-6 py-4">
|
||||
<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 className="bg-white border-b border-gray-200 px-6 pt-3 pb-0">
|
||||
<div className="flex items-center gap-2 text-xs text-gray-400 mb-1">
|
||||
<i className="ri-home-4-line"></i> <span>Home</span> > <span>시공관리</span> > <span className="text-gray-600">출면일보</span>
|
||||
</div>
|
||||
|
||||
{/* 필터 바 */}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<input type="date" value={dateStr}
|
||||
onChange={e => { const d = new Date(e.target.value); setYear(d.getFullYear()); setMonth(d.getMonth()+1); setSelectedDay(d.getDate()); }}
|
||||
className="border border-gray-300 rounded px-3 py-1.5 text-sm" />
|
||||
<select value={company} onChange={e => setCompany(e.target.value)} className="border border-gray-300 rounded px-3 py-1.5 text-sm bg-white">
|
||||
<option>(주)주일기업·방화셔터공사</option>
|
||||
<option>KCC건설</option>
|
||||
</select>
|
||||
<button onClick={handleRefresh} className="bg-blue-600 text-white px-4 py-1.5 rounded text-sm font-semibold hover:bg-blue-700">검색</button>
|
||||
<button onClick={() => { setCompany(''); setNotes(''); }} className="bg-gray-200 text-gray-700 px-4 py-1.5 rounded text-sm hover:bg-gray-300">검색초기화</button>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
{statusLegend.map(s => (
|
||||
<div key={s.label} className="flex items-center gap-1">
|
||||
<div className="rounded-full" style={{width:8, height:8, backgroundColor: s.color}}></div>
|
||||
<span className="text-xs text-gray-500">{s.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 날짜 제목 */}
|
||||
<h1 className="text-lg font-bold text-gray-800 text-center mb-1">{dateTitle}</h1>
|
||||
|
||||
{/* 캘린더 스트립 */}
|
||||
<CalendarStrip year={year} month={month} selectedDay={selectedDay} onSelectDay={setSelectedDay} dayStatus={dayStatus} />
|
||||
|
||||
{/* 기능 버튼들 + 탭 */}
|
||||
<div className="flex items-center justify-between mb-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<button className="border border-gray-300 rounded px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50">일괄출력</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">선택일자 출면가져오기</span>
|
||||
<button className="text-gray-400 hover:text-gray-600"><i className="ri-calendar-line"></i></button>
|
||||
<button onClick={() => setReviewerModalOpen(true)} className="border border-gray-300 rounded px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50">검토자 확인</button>
|
||||
<button onClick={() => setPrintModalOpen(true)} className="border border-gray-300 rounded px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50">양식보기</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 탭 */}
|
||||
<div className="flex gap-0 border-b-0 mt-1">
|
||||
<button onClick={() => setTab('workers')}
|
||||
className={`px-5 py-2.5 text-sm font-medium border-b-2 transition whitespace-nowrap ${tab === 'workers' ? 'border-blue-600 text-blue-700 bg-blue-50/50' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}`}>인원</button>
|
||||
<button onClick={() => setTab('equipment')}
|
||||
className={`px-5 py-2.5 text-sm font-medium border-b-2 transition whitespace-nowrap ${tab === 'equipment' ? 'border-blue-600 text-blue-700 bg-blue-50/50' : 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300'}`}>장비</button>
|
||||
</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>
|
||||
</div>
|
||||
|
||||
{/* 탭 컨텐츠 */}
|
||||
<div className="flex-1 overflow-auto p-6">
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-gray-400">불러오는 중...</div>
|
||||
) : tab === 'workers' ? (
|
||||
/* ─── 인원 탭 ─── */
|
||||
<div>
|
||||
{/* 추가/삭제 버튼 */}
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<button onClick={() => { setEditWorker(null); setWorkerModalOpen(true); }} className="bg-blue-600 text-white px-4 py-1.5 rounded text-sm font-semibold hover:bg-blue-700">추가</button>
|
||||
<button onClick={handleQuickWorker} 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>
|
||||
{selectedWorkers.size > 0 && (
|
||||
<button onClick={handleDeleteWorkers} className="bg-red-500 text-white px-3 py-1.5 rounded text-sm hover:bg-red-600">삭제 ({selectedWorkers.size})</button>
|
||||
)}
|
||||
</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">
|
||||
<input type="checkbox" checked={workers.length > 0 && selectedWorkers.size === workers.length}
|
||||
onChange={() => setSelectedWorkers(workers.length > 0 && selectedWorkers.size === workers.length ? new Set() : new Set(workers.map(w=>w.id)))} />
|
||||
</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" style={{backgroundColor:'#FFFDE7'}}>금액</th>
|
||||
<th className="px-3 py-2.5 text-left font-semibold text-gray-600">작업내용</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{workers.map(w => (
|
||||
<tr key={w.id} className="border-b border-gray-100 hover:bg-blue-50/30 transition cursor-pointer"
|
||||
onClick={() => { setEditWorker(w); setWorkerModalOpen(true); }}>
|
||||
<td className="px-3 py-2" onClick={e => e.stopPropagation()}>
|
||||
<input type="checkbox" checked={selectedWorkers.has(w.id)}
|
||||
onChange={() => setSelectedWorkers(prev => { const s = new Set(prev); s.has(w.id) ? s.delete(w.id) : s.add(w.id); return s; })} />
|
||||
</td>
|
||||
<td className="px-3 py-2 text-gray-700">{w.work_type}</td>
|
||||
<td className="px-3 py-2 text-gray-700">{w.job_type}</td>
|
||||
<td className="px-3 py-2 font-medium text-gray-800">{w.name}</td>
|
||||
<td className="px-3 py-2 text-right text-gray-700">{w.man_days}</td>
|
||||
<td className="px-3 py-2 text-right text-gray-700" style={{backgroundColor:'#FFFDE7'}}>{parseInt(w.amount||0).toLocaleString()}</td>
|
||||
<td className="px-3 py-2 text-gray-700">{w.work_content}</td>
|
||||
</tr>
|
||||
))}
|
||||
{workers.length === 0 && (
|
||||
<tr><td colSpan={7} className="text-center py-8 text-gray-400">등록된 인원이 없습니다.</td></tr>
|
||||
)}
|
||||
{/* 합계 */}
|
||||
<tr className="bg-gray-50 font-semibold border-t border-gray-300">
|
||||
<td colSpan={4} className="px-3 py-2 text-center text-gray-700">합계</td>
|
||||
<td className="px-3 py-2 text-right text-gray-800">{totalManDays}</td>
|
||||
<td className="px-3 py-2 text-right text-gray-800" style={{backgroundColor:'#FFFDE7'}}>{totalAmount.toLocaleString()}</td>
|
||||
<td className="px-3 py-2"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-2">※ 합계는 저장 후에 반영됩니다.</div>
|
||||
|
||||
{/* 특이사항 */}
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-semibold text-gray-700">특이사항</span>
|
||||
<button onClick={handleSaveNotes} className="text-xs text-blue-600 hover:text-blue-800">저장</button>
|
||||
</div>
|
||||
<textarea value={notes} onChange={e => setNotes(e.target.value)} rows={3}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" placeholder="특이사항을 입력하세요"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* ─── 장비 탭 ─── */
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<button onClick={() => { setEditEquip(null); setEquipModalOpen(true); }} className="bg-blue-600 text-white px-4 py-1.5 rounded text-sm font-semibold hover:bg-blue-700">추가</button>
|
||||
<button onClick={handleQuickEquip} 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>
|
||||
{selectedEquips.size > 0 && (
|
||||
<button onClick={handleDeleteEquips} className="bg-red-500 text-white px-3 py-1.5 rounded text-sm hover:bg-red-600">삭제 ({selectedEquips.size})</button>
|
||||
)}
|
||||
</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">
|
||||
<input type="checkbox" checked={equipments.length > 0 && selectedEquips.size === equipments.length}
|
||||
onChange={() => setSelectedEquips(equipments.length > 0 && selectedEquips.size === equipments.length ? new Set() : new Set(equipments.map(e=>e.id)))} />
|
||||
</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-left font-semibold text-gray-600">작업내용</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{equipments.map(eq => (
|
||||
<tr key={eq.id} className="border-b border-gray-100 hover:bg-blue-50/30 transition cursor-pointer"
|
||||
onClick={() => { setEditEquip(eq); setEquipModalOpen(true); }}>
|
||||
<td className="px-3 py-2" onClick={e => e.stopPropagation()}>
|
||||
<input type="checkbox" checked={selectedEquips.has(eq.id)}
|
||||
onChange={() => setSelectedEquips(prev => { const s = new Set(prev); s.has(eq.id) ? s.delete(eq.id) : s.add(eq.id); return s; })} />
|
||||
</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-gray-700">{eq.operator}</td>
|
||||
<td className="px-3 py-2 text-right text-gray-700">{eq.man_days}</td>
|
||||
<td className="px-3 py-2 text-gray-700">{eq.work_content}</td>
|
||||
</tr>
|
||||
))}
|
||||
{equipments.length === 0 && (
|
||||
<tr><td colSpan={7} className="text-center py-8 text-gray-400">등록된 장비가 없습니다.</td></tr>
|
||||
)}
|
||||
<tr className="bg-gray-50 font-semibold border-t border-gray-300">
|
||||
<td colSpan={5} className="px-3 py-2 text-center text-gray-700">합계</td>
|
||||
<td className="px-3 py-2 text-right text-gray-800">{eqTotalManDays}</td>
|
||||
<td className="px-3 py-2"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div className="text-xs text-gray-400 mt-2">※ 합계는 저장 후에 반영됩니다.</div>
|
||||
|
||||
{/* 특이사항 */}
|
||||
<div className="mt-4">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-sm font-semibold text-gray-700">특이사항</span>
|
||||
<button onClick={handleSaveNotes} className="text-xs text-blue-600 hover:text-blue-800">저장</button>
|
||||
</div>
|
||||
<textarea value={notes} onChange={e => setNotes(e.target.value)} rows={3}
|
||||
className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:border-blue-500" placeholder="특이사항을 입력하세요"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</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\PmisDailyAttendanceController;
|
||||
use App\Http\Controllers\Juil\PmisEquipmentController;
|
||||
use App\Http\Controllers\Juil\PmisMaterialController;
|
||||
use App\Http\Controllers\Juil\PmisWorkforceController;
|
||||
@@ -1768,6 +1769,18 @@
|
||||
Route::post('/work-volumes', [PmisWorkVolumeController::class, 'store']);
|
||||
Route::put('/work-volumes/{id}', [PmisWorkVolumeController::class, 'update']);
|
||||
Route::delete('/work-volumes/{id}', [PmisWorkVolumeController::class, 'destroy']);
|
||||
|
||||
// 출면일보 CRUD
|
||||
Route::get('/daily-attendances', [PmisDailyAttendanceController::class, 'show']);
|
||||
Route::get('/daily-attendances/month-status', [PmisDailyAttendanceController::class, 'monthStatus']);
|
||||
Route::put('/daily-attendances/{id}', [PmisDailyAttendanceController::class, 'update']);
|
||||
Route::put('/daily-attendances/{id}/reviewers', [PmisDailyAttendanceController::class, 'saveReviewers']);
|
||||
Route::post('/attendance-workers', [PmisDailyAttendanceController::class, 'workerStore']);
|
||||
Route::put('/attendance-workers/{id}', [PmisDailyAttendanceController::class, 'workerUpdate']);
|
||||
Route::delete('/attendance-workers/{id}', [PmisDailyAttendanceController::class, 'workerDestroy']);
|
||||
Route::post('/attendance-equipments', [PmisDailyAttendanceController::class, 'equipmentStore']);
|
||||
Route::put('/attendance-equipments/{id}', [PmisDailyAttendanceController::class, 'equipmentUpdate']);
|
||||
Route::delete('/attendance-equipments/{id}', [PmisDailyAttendanceController::class, 'equipmentDestroy']);
|
||||
});
|
||||
|
||||
// 공사현장 사진대지
|
||||
|
||||
Reference in New Issue
Block a user