feat: [equipment] 설비관리 모듈 구현

- 모델 6개 (Equipment, InspectionTemplate, Inspection, InspectionDetail, Repair, Process)
- 서비스 3개 (Equipment, Inspection, Repair)
- API 컨트롤러 3개 + FormRequest 4개
- Blade 컨트롤러 + 라우트 등록
- 뷰: 대시보드, 등록대장(CRUD), 일상점검표(캘린더 그리드), 수리이력
This commit is contained in:
김보곤
2026-02-25 19:39:59 +09:00
parent 31246e3317
commit 4115bbd7db
33 changed files with 3111 additions and 0 deletions

View File

@@ -0,0 +1,141 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreEquipmentRequest;
use App\Http\Requests\UpdateEquipmentRequest;
use App\Services\EquipmentService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class EquipmentController extends Controller
{
public function __construct(
private EquipmentService $equipmentService
) {}
public function index(Request $request)
{
$equipments = $this->equipmentService->getEquipments(
$request->all(),
$request->input('per_page', 20)
);
if ($request->header('HX-Request')) {
return view('equipment.partials.table', compact('equipments'));
}
return response()->json([
'success' => true,
'data' => $equipments->items(),
'meta' => [
'current_page' => $equipments->currentPage(),
'total' => $equipments->total(),
'per_page' => $equipments->perPage(),
'last_page' => $equipments->lastPage(),
],
]);
}
public function store(StoreEquipmentRequest $request): JsonResponse
{
try {
$equipment = $this->equipmentService->createEquipment($request->validated());
return response()->json([
'success' => true,
'message' => '설비가 등록되었습니다.',
'data' => $equipment,
], 201);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
public function show(int $id): JsonResponse
{
$equipment = $this->equipmentService->getEquipmentById($id);
if (! $equipment) {
return response()->json([
'success' => false,
'message' => '설비를 찾을 수 없습니다.',
], 404);
}
return response()->json([
'success' => true,
'data' => $equipment,
]);
}
public function update(UpdateEquipmentRequest $request, int $id): JsonResponse
{
try {
$equipment = $this->equipmentService->updateEquipment($id, $request->validated());
return response()->json([
'success' => true,
'message' => '설비 정보가 수정되었습니다.',
'data' => $equipment,
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
public function destroy(int $id): JsonResponse
{
try {
$this->equipmentService->deleteEquipment($id);
return response()->json([
'success' => true,
'message' => '설비가 삭제되었습니다.',
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
public function restore(int $id): JsonResponse
{
try {
$this->equipmentService->restoreEquipment($id);
return response()->json([
'success' => true,
'message' => '설비가 복원되었습니다.',
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
public function templates(int $id): JsonResponse
{
$equipment = $this->equipmentService->getEquipmentById($id);
if (! $equipment) {
return response()->json(['success' => false, 'message' => '설비를 찾을 수 없습니다.'], 404);
}
return response()->json([
'success' => true,
'data' => $equipment->inspectionTemplates,
]);
}
}

View File

@@ -0,0 +1,161 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Services\EquipmentInspectionService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class EquipmentInspectionController extends Controller
{
public function __construct(
private EquipmentInspectionService $inspectionService
) {}
public function index(Request $request)
{
$yearMonth = $request->input('year_month', now()->format('Y-m'));
$productionLine = $request->input('production_line');
$equipmentId = $request->input('equipment_id');
$inspections = $this->inspectionService->getMonthlyInspections(
$yearMonth,
$productionLine,
$equipmentId ? (int) $equipmentId : null
);
if ($request->header('HX-Request')) {
return view('equipment.partials.inspection-grid', [
'inspections' => $inspections,
'yearMonth' => $yearMonth,
]);
}
return response()->json([
'success' => true,
'data' => $inspections,
]);
}
public function toggleDetail(Request $request): JsonResponse
{
$request->validate([
'equipment_id' => 'required|integer',
'template_item_id' => 'required|integer',
'check_date' => 'required|date',
]);
try {
$result = $this->inspectionService->toggleDetail(
$request->input('equipment_id'),
$request->input('template_item_id'),
$request->input('check_date')
);
return response()->json([
'success' => true,
'data' => $result,
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
public function updateNotes(Request $request): JsonResponse
{
$request->validate([
'equipment_id' => 'required|integer',
'year_month' => 'required|string',
'overall_judgment' => 'nullable|in:OK,NG',
'repair_note' => 'nullable|string',
'issue_note' => 'nullable|string',
'inspector_id' => 'nullable|integer',
]);
try {
$inspection = $this->inspectionService->updateInspectionNotes(
$request->input('equipment_id'),
$request->input('year_month'),
$request->only(['overall_judgment', 'repair_note', 'issue_note', 'inspector_id'])
);
return response()->json([
'success' => true,
'message' => '점검 정보가 저장되었습니다.',
'data' => $inspection,
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
public function storeTemplate(Request $request, int $equipmentId): JsonResponse
{
$request->validate([
'item_no' => 'required|integer',
'check_point' => 'required|string|max:50',
'check_item' => 'required|string|max:100',
'check_timing' => 'nullable|in:operating,stopped',
'check_frequency' => 'nullable|string|max:50',
'check_method' => 'nullable|string',
'sort_order' => 'nullable|integer',
]);
try {
$template = $this->inspectionService->saveTemplate($equipmentId, $request->all());
return response()->json([
'success' => true,
'message' => '점검항목이 추가되었습니다.',
'data' => $template,
], 201);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
public function updateTemplate(Request $request, int $templateId): JsonResponse
{
try {
$template = $this->inspectionService->updateTemplate($templateId, $request->all());
return response()->json([
'success' => true,
'message' => '점검항목이 수정되었습니다.',
'data' => $template,
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
public function deleteTemplate(int $templateId): JsonResponse
{
try {
$this->inspectionService->deleteTemplate($templateId);
return response()->json([
'success' => true,
'message' => '점검항목이 삭제되었습니다.',
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
}

View File

@@ -0,0 +1,92 @@
<?php
namespace App\Http\Controllers\Api\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\StoreEquipmentRepairRequest;
use App\Services\EquipmentRepairService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
class EquipmentRepairController extends Controller
{
public function __construct(
private EquipmentRepairService $repairService
) {}
public function index(Request $request)
{
$repairs = $this->repairService->getRepairs(
$request->all(),
$request->input('per_page', 20)
);
if ($request->header('HX-Request')) {
return view('equipment.partials.repair-table', compact('repairs'));
}
return response()->json([
'success' => true,
'data' => $repairs->items(),
'meta' => [
'current_page' => $repairs->currentPage(),
'total' => $repairs->total(),
'per_page' => $repairs->perPage(),
'last_page' => $repairs->lastPage(),
],
]);
}
public function store(StoreEquipmentRepairRequest $request): JsonResponse
{
try {
$repair = $this->repairService->createRepair($request->validated());
return response()->json([
'success' => true,
'message' => '수리이력이 등록되었습니다.',
'data' => $repair,
], 201);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
public function update(Request $request, int $id): JsonResponse
{
try {
$repair = $this->repairService->updateRepair($id, $request->all());
return response()->json([
'success' => true,
'message' => '수리이력이 수정되었습니다.',
'data' => $repair,
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
public function destroy(int $id): JsonResponse
{
try {
$this->repairService->deleteRepair($id);
return response()->json([
'success' => true,
'message' => '수리이력이 삭제되었습니다.',
]);
} catch (\Exception $e) {
return response()->json([
'success' => false,
'message' => $e->getMessage(),
], 400);
}
}
}

View File

@@ -0,0 +1,97 @@
<?php
namespace App\Http\Controllers;
use App\Services\EquipmentInspectionService;
use App\Services\EquipmentRepairService;
use App\Services\EquipmentService;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\View\View;
class EquipmentController extends Controller
{
public function __construct(
private EquipmentService $equipmentService,
private EquipmentInspectionService $inspectionService,
private EquipmentRepairService $repairService
) {}
public function dashboard(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('equipment.dashboard'));
}
$stats = $this->equipmentService->getDashboardStats();
$typeStats = $this->equipmentService->getTypeStats();
$inspectionStats = $this->inspectionService->getMonthlyStats(now()->format('Y-m'));
$recentRepairs = $this->repairService->getRecentRepairs(5);
return view('equipment.dashboard', compact('stats', 'typeStats', 'inspectionStats', 'recentRepairs'));
}
public function index(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('equipment.index'));
}
return view('equipment.index');
}
public function create(): View
{
$users = \App\Models\User::orderBy('name')->get(['id', 'name']);
return view('equipment.create', compact('users'));
}
public function show(int $id): View
{
$equipment = $this->equipmentService->getEquipmentById($id);
if (! $equipment) {
abort(404, '설비를 찾을 수 없습니다.');
}
return view('equipment.show', compact('equipment'));
}
public function edit(int $id): View
{
$users = \App\Models\User::orderBy('name')->get(['id', 'name']);
return view('equipment.edit', compact('id', 'users'));
}
public function inspections(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('equipment.inspections'));
}
$equipmentList = $this->equipmentService->getEquipmentList();
return view('equipment.inspections.index', compact('equipmentList'));
}
public function repairs(Request $request): View|Response
{
if ($request->header('HX-Request')) {
return response('', 200)->header('HX-Redirect', route('equipment.repairs'));
}
$equipmentList = $this->equipmentService->getEquipmentList();
return view('equipment.repairs.index', compact('equipmentList'));
}
public function repairCreate(): View
{
$equipmentList = $this->equipmentService->getEquipmentList();
$users = \App\Models\User::orderBy('name')->get(['id', 'name']);
return view('equipment.repairs.create', compact('equipmentList', 'users'));
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreEquipmentInspectionRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'equipment_id' => 'required|exists:equipments,id',
'template_item_id' => 'required|exists:equipment_inspection_templates,id',
'check_date' => 'required|date',
];
}
public function attributes(): array
{
return [
'equipment_id' => '설비',
'template_item_id' => '점검항목',
'check_date' => '점검일',
];
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreEquipmentRepairRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
return [
'equipment_id' => 'required|exists:equipments,id',
'repair_date' => 'required|date',
'repair_type' => 'required|in:internal,external',
'repair_hours' => 'nullable|numeric|min:0',
'description' => 'nullable|string',
'cost' => 'nullable|numeric|min:0',
'vendor' => 'nullable|string|max:100',
'repaired_by' => 'nullable|exists:users,id',
'memo' => 'nullable|string',
];
}
public function attributes(): array
{
return [
'equipment_id' => '설비',
'repair_date' => '수리일',
'repair_type' => '보전구분',
'repair_hours' => '수리시간',
'cost' => '수리비용',
];
}
public function messages(): array
{
return [
'equipment_id.required' => '설비를 선택해주세요.',
'repair_date.required' => '수리일은 필수입니다.',
'repair_type.required' => '보전구분을 선택해주세요.',
];
}
}

View File

@@ -0,0 +1,68 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class StoreEquipmentRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$tenantId = session('selected_tenant_id', 1);
return [
'equipment_code' => [
'required', 'string', 'max:20',
Rule::unique('equipments', 'equipment_code')
->where('tenant_id', $tenantId),
],
'name' => 'required|string|max:100',
'equipment_type' => 'nullable|string|max:50',
'specification' => 'nullable|string|max:255',
'manufacturer' => 'nullable|string|max:100',
'model_name' => 'nullable|string|max:100',
'serial_no' => 'nullable|string|max:100',
'location' => 'nullable|string|max:100',
'production_line' => 'nullable|string|max:50',
'purchase_date' => 'nullable|date',
'install_date' => 'nullable|date',
'purchase_price' => 'nullable|numeric|min:0',
'useful_life' => 'nullable|integer|min:0',
'status' => 'nullable|in:active,idle,disposed',
'disposed_date' => 'nullable|date',
'manager_id' => 'nullable|exists:users,id',
'photo_path' => 'nullable|string|max:500',
'memo' => 'nullable|string',
'is_active' => 'nullable|boolean',
'sort_order' => 'nullable|integer|min:0',
];
}
public function attributes(): array
{
return [
'equipment_code' => '설비코드',
'name' => '설비명',
'equipment_type' => '설비유형',
'manufacturer' => '제조사',
'purchase_date' => '구입일',
'install_date' => '설치일',
'purchase_price' => '구입가격',
];
}
public function messages(): array
{
return [
'equipment_code.required' => '설비코드는 필수입니다.',
'equipment_code.unique' => '이미 존재하는 설비코드입니다.',
'name.required' => '설비명은 필수입니다.',
];
}
}

View File

@@ -0,0 +1,56 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;
class UpdateEquipmentRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}
public function rules(): array
{
$tenantId = session('selected_tenant_id', 1);
$id = $this->route('id');
return [
'equipment_code' => [
'required', 'string', 'max:20',
Rule::unique('equipments', 'equipment_code')
->where('tenant_id', $tenantId)
->ignore($id),
],
'name' => 'required|string|max:100',
'equipment_type' => 'nullable|string|max:50',
'specification' => 'nullable|string|max:255',
'manufacturer' => 'nullable|string|max:100',
'model_name' => 'nullable|string|max:100',
'serial_no' => 'nullable|string|max:100',
'location' => 'nullable|string|max:100',
'production_line' => 'nullable|string|max:50',
'purchase_date' => 'nullable|date',
'install_date' => 'nullable|date',
'purchase_price' => 'nullable|numeric|min:0',
'useful_life' => 'nullable|integer|min:0',
'status' => 'nullable|in:active,idle,disposed',
'disposed_date' => 'nullable|date',
'manager_id' => 'nullable|exists:users,id',
'photo_path' => 'nullable|string|max:500',
'memo' => 'nullable|string',
'is_active' => 'nullable|boolean',
'sort_order' => 'nullable|integer|min:0',
];
}
public function attributes(): array
{
return [
'equipment_code' => '설비코드',
'name' => '설비명',
];
}
}

View File

@@ -0,0 +1,133 @@
<?php
namespace App\Models\Equipment;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
class Equipment extends Model
{
use BelongsToTenant, SoftDeletes;
protected $table = 'equipments';
protected $fillable = [
'tenant_id',
'equipment_code',
'name',
'equipment_type',
'specification',
'manufacturer',
'model_name',
'serial_no',
'location',
'production_line',
'purchase_date',
'install_date',
'purchase_price',
'useful_life',
'status',
'disposed_date',
'manager_id',
'photo_path',
'memo',
'is_active',
'sort_order',
'created_by',
'updated_by',
'deleted_by',
];
protected $casts = [
'purchase_date' => 'date',
'install_date' => 'date',
'disposed_date' => 'date',
'purchase_price' => 'decimal:2',
'is_active' => 'boolean',
];
public function manager(): BelongsTo
{
return $this->belongsTo(\App\Models\User::class, 'manager_id');
}
public function inspectionTemplates(): HasMany
{
return $this->hasMany(EquipmentInspectionTemplate::class, 'equipment_id')->orderBy('sort_order');
}
public function inspections(): HasMany
{
return $this->hasMany(EquipmentInspection::class, 'equipment_id');
}
public function repairs(): HasMany
{
return $this->hasMany(EquipmentRepair::class, 'equipment_id');
}
public function processes(): BelongsToMany
{
return $this->belongsToMany(\App\Models\Process::class, 'equipment_process')
->withPivot('is_primary')
->withTimestamps();
}
public function scopeActive($query)
{
return $query->where('status', 'active');
}
public function scopeByLine($query, string $line)
{
return $query->where('production_line', $line);
}
public function scopeByType($query, string $type)
{
return $query->where('equipment_type', $type);
}
public function getStatusLabelAttribute(): string
{
return match ($this->status) {
'active' => '가동',
'idle' => '유휴',
'disposed' => '폐기',
default => $this->status,
};
}
public function getStatusColorAttribute(): string
{
return match ($this->status) {
'active' => 'bg-green-100 text-green-800',
'idle' => 'bg-yellow-100 text-yellow-800',
'disposed' => 'bg-gray-100 text-gray-800',
default => 'bg-gray-100 text-gray-800',
};
}
public static function getEquipmentTypes(): array
{
return ['포밍기', '미싱기', '샤링기', 'V컷팅기', '절곡기', '프레스', '드릴', '기타'];
}
public static function getProductionLines(): array
{
return ['스라트', '스크린', '절곡', '기타'];
}
public static function getStatuses(): array
{
return [
'active' => '가동',
'idle' => '유휴',
'disposed' => '폐기',
];
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace App\Models\Equipment;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
class EquipmentInspection extends Model
{
use BelongsToTenant;
protected $fillable = [
'tenant_id',
'equipment_id',
'year_month',
'overall_judgment',
'inspector_id',
'repair_note',
'issue_note',
'created_by',
'updated_by',
];
public function equipment(): BelongsTo
{
return $this->belongsTo(Equipment::class, 'equipment_id');
}
public function inspector(): BelongsTo
{
return $this->belongsTo(\App\Models\User::class, 'inspector_id');
}
public function details(): HasMany
{
return $this->hasMany(EquipmentInspectionDetail::class, 'inspection_id');
}
public function getJudgmentLabelAttribute(): string
{
return match ($this->overall_judgment) {
'OK' => '양호',
'NG' => '이상',
default => '-',
};
}
public function getJudgmentColorAttribute(): string
{
return match ($this->overall_judgment) {
'OK' => 'bg-green-100 text-green-800',
'NG' => 'bg-red-100 text-red-800',
default => 'bg-gray-100 text-gray-800',
};
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Models\Equipment;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class EquipmentInspectionDetail extends Model
{
protected $fillable = [
'inspection_id',
'template_item_id',
'check_date',
'result',
'note',
];
protected $casts = [
'check_date' => 'date',
];
public function inspection(): BelongsTo
{
return $this->belongsTo(EquipmentInspection::class, 'inspection_id');
}
public function templateItem(): BelongsTo
{
return $this->belongsTo(EquipmentInspectionTemplate::class, 'template_item_id');
}
public function getResultSymbolAttribute(): string
{
return match ($this->result) {
'good' => '○',
'bad' => 'X',
'repaired' => '△',
default => '',
};
}
public function getResultColorAttribute(): string
{
return match ($this->result) {
'good' => 'text-green-600',
'bad' => 'text-red-600',
'repaired' => 'text-yellow-600',
default => 'text-gray-400',
};
}
public static function getNextResult(?string $current): ?string
{
return match ($current) {
null, '' => 'good',
'good' => 'bad',
'bad' => 'repaired',
'repaired' => null,
default => 'good',
};
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace App\Models\Equipment;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class EquipmentInspectionTemplate extends Model
{
use BelongsToTenant;
protected $fillable = [
'tenant_id',
'equipment_id',
'item_no',
'check_point',
'check_item',
'check_timing',
'check_frequency',
'check_method',
'sort_order',
'is_active',
];
protected $casts = [
'is_active' => 'boolean',
];
public function equipment(): BelongsTo
{
return $this->belongsTo(Equipment::class, 'equipment_id');
}
public function scopeActive($query)
{
return $query->where('is_active', true);
}
public function getTimingLabelAttribute(): string
{
return match ($this->check_timing) {
'operating' => '가동 중',
'stopped' => '정지 시',
default => $this->check_timing ?? '-',
};
}
}

View File

@@ -0,0 +1,31 @@
<?php
namespace App\Models\Equipment;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class EquipmentProcess extends Model
{
protected $table = 'equipment_process';
protected $fillable = [
'equipment_id',
'process_id',
'is_primary',
];
protected $casts = [
'is_primary' => 'boolean',
];
public function equipment(): BelongsTo
{
return $this->belongsTo(Equipment::class, 'equipment_id');
}
public function process(): BelongsTo
{
return $this->belongsTo(\App\Models\Process::class, 'process_id');
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace App\Models\Equipment;
use App\Traits\BelongsToTenant;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\SoftDeletes;
class EquipmentRepair extends Model
{
use BelongsToTenant, SoftDeletes;
protected $fillable = [
'tenant_id',
'equipment_id',
'repair_date',
'repair_type',
'repair_hours',
'description',
'cost',
'vendor',
'repaired_by',
'memo',
'created_by',
'updated_by',
];
protected $casts = [
'repair_date' => 'date',
'repair_hours' => 'decimal:1',
'cost' => 'decimal:2',
];
public function equipment(): BelongsTo
{
return $this->belongsTo(Equipment::class, 'equipment_id');
}
public function repairer(): BelongsTo
{
return $this->belongsTo(\App\Models\User::class, 'repaired_by');
}
public function getRepairTypeLabelAttribute(): string
{
return match ($this->repair_type) {
'internal' => '사내',
'external' => '외주',
default => $this->repair_type ?? '-',
};
}
public function getFormattedCostAttribute(): string
{
if (! $this->cost) {
return '-';
}
return number_format($this->cost).'원';
}
}

View File

@@ -0,0 +1,178 @@
<?php
namespace App\Services;
use App\Models\Equipment\Equipment;
use App\Models\Equipment\EquipmentInspection;
use App\Models\Equipment\EquipmentInspectionDetail;
use App\Models\Equipment\EquipmentInspectionTemplate;
use Carbon\Carbon;
class EquipmentInspectionService
{
public function getMonthlyInspections(string $yearMonth, ?string $productionLine = null, ?int $equipmentId = null): array
{
$tenantId = session('selected_tenant_id', 1);
$equipmentQuery = Equipment::where('is_active', true)->where('status', '!=', 'disposed');
if ($productionLine) {
$equipmentQuery->where('production_line', $productionLine);
}
if ($equipmentId) {
$equipmentQuery->where('id', $equipmentId);
}
$equipments = $equipmentQuery->orderBy('sort_order')->orderBy('name')->get();
$date = Carbon::createFromFormat('Y-m', $yearMonth);
$daysInMonth = $date->daysInMonth;
$result = [];
foreach ($equipments as $equipment) {
$templates = EquipmentInspectionTemplate::where('equipment_id', $equipment->id)
->where('is_active', true)
->orderBy('sort_order')
->get();
if ($templates->isEmpty()) {
continue;
}
$inspection = EquipmentInspection::where('equipment_id', $equipment->id)
->where('year_month', $yearMonth)
->first();
$details = [];
if ($inspection) {
$details = EquipmentInspectionDetail::where('inspection_id', $inspection->id)
->get()
->groupBy(function ($d) {
return $d->template_item_id.'_'.$d->check_date->format('Y-m-d');
});
}
$result[] = [
'equipment' => $equipment,
'templates' => $templates,
'inspection' => $inspection,
'details' => $details,
'days_in_month' => $daysInMonth,
];
}
return $result;
}
public function toggleDetail(int $equipmentId, int $templateItemId, string $checkDate): array
{
$tenantId = session('selected_tenant_id', 1);
$yearMonth = Carbon::parse($checkDate)->format('Y-m');
$inspection = EquipmentInspection::firstOrCreate(
[
'tenant_id' => $tenantId,
'equipment_id' => $equipmentId,
'year_month' => $yearMonth,
],
[
'created_by' => auth()->id(),
]
);
$detail = EquipmentInspectionDetail::where('inspection_id', $inspection->id)
->where('template_item_id', $templateItemId)
->where('check_date', $checkDate)
->first();
if ($detail) {
$nextResult = EquipmentInspectionDetail::getNextResult($detail->result);
if ($nextResult === null) {
$detail->delete();
return ['result' => null, 'symbol' => '', 'color' => 'text-gray-400'];
}
$detail->update(['result' => $nextResult]);
} else {
$detail = EquipmentInspectionDetail::create([
'inspection_id' => $inspection->id,
'template_item_id' => $templateItemId,
'check_date' => $checkDate,
'result' => 'good',
]);
$nextResult = 'good';
}
return [
'result' => $nextResult,
'symbol' => $detail->fresh()->result_symbol,
'color' => $detail->fresh()->result_color,
];
}
public function updateInspectionNotes(int $equipmentId, string $yearMonth, array $data): EquipmentInspection
{
$tenantId = session('selected_tenant_id', 1);
$inspection = EquipmentInspection::firstOrCreate(
[
'tenant_id' => $tenantId,
'equipment_id' => $equipmentId,
'year_month' => $yearMonth,
],
[
'created_by' => auth()->id(),
]
);
$inspection->update(array_merge($data, ['updated_by' => auth()->id()]));
return $inspection->fresh();
}
public function getMonthlyStats(string $yearMonth): array
{
$tenantId = session('selected_tenant_id', 1);
$totalEquipments = Equipment::where('is_active', true)
->where('status', '!=', 'disposed')
->count();
$inspected = EquipmentInspection::where('year_month', $yearMonth)->count();
$issueCount = EquipmentInspectionDetail::whereHas('inspection', function ($q) use ($yearMonth) {
$q->where('year_month', $yearMonth);
})->where('result', 'bad')->count();
return [
'total' => $totalEquipments,
'inspected' => $inspected,
'issue_count' => $issueCount,
];
}
public function saveTemplate(int $equipmentId, array $data): EquipmentInspectionTemplate
{
$tenantId = session('selected_tenant_id', 1);
return EquipmentInspectionTemplate::create(array_merge($data, [
'tenant_id' => $tenantId,
'equipment_id' => $equipmentId,
]));
}
public function updateTemplate(int $id, array $data): EquipmentInspectionTemplate
{
$template = EquipmentInspectionTemplate::findOrFail($id);
$template->update($data);
return $template->fresh();
}
public function deleteTemplate(int $id): bool
{
return EquipmentInspectionTemplate::findOrFail($id)->delete();
}
}

View File

@@ -0,0 +1,80 @@
<?php
namespace App\Services;
use App\Models\Equipment\EquipmentRepair;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
class EquipmentRepairService
{
public function getRepairs(array $filters = [], int $perPage = 20): LengthAwarePaginator
{
$query = EquipmentRepair::query()->with('equipment', 'repairer');
if (! empty($filters['equipment_id'])) {
$query->where('equipment_id', $filters['equipment_id']);
}
if (! empty($filters['repair_type'])) {
$query->where('repair_type', $filters['repair_type']);
}
if (! empty($filters['date_from'])) {
$query->where('repair_date', '>=', $filters['date_from']);
}
if (! empty($filters['date_to'])) {
$query->where('repair_date', '<=', $filters['date_to']);
}
if (! empty($filters['search'])) {
$search = $filters['search'];
$query->where(function ($q) use ($search) {
$q->where('description', 'like', "%{$search}%")
->orWhereHas('equipment', function ($eq) use ($search) {
$eq->where('name', 'like', "%{$search}%")
->orWhere('equipment_code', 'like', "%{$search}%");
});
});
}
return $query->orderBy('repair_date', 'desc')->paginate($perPage);
}
public function getRepairById(int $id): ?EquipmentRepair
{
return EquipmentRepair::with('equipment', 'repairer')->find($id);
}
public function createRepair(array $data): EquipmentRepair
{
$data['tenant_id'] = session('selected_tenant_id', 1);
$data['created_by'] = auth()->id();
return EquipmentRepair::create($data);
}
public function updateRepair(int $id, array $data): EquipmentRepair
{
$repair = EquipmentRepair::findOrFail($id);
$data['updated_by'] = auth()->id();
$repair->update($data);
return $repair->fresh();
}
public function deleteRepair(int $id): bool
{
$repair = EquipmentRepair::findOrFail($id);
return $repair->delete();
}
public function getRecentRepairs(int $limit = 5): \Illuminate\Database\Eloquent\Collection
{
return EquipmentRepair::with('equipment')
->orderBy('repair_date', 'desc')
->limit($limit)
->get();
}
}

View File

@@ -0,0 +1,105 @@
<?php
namespace App\Services;
use App\Models\Equipment\Equipment;
use Illuminate\Contracts\Pagination\LengthAwarePaginator;
class EquipmentService
{
public function getEquipments(array $filters = [], int $perPage = 20): LengthAwarePaginator
{
$query = Equipment::query()->with('manager');
if (! empty($filters['search'])) {
$search = $filters['search'];
$query->where(function ($q) use ($search) {
$q->where('equipment_code', 'like', "%{$search}%")
->orWhere('name', 'like', "%{$search}%");
});
}
if (! empty($filters['status'])) {
$query->where('status', $filters['status']);
}
if (! empty($filters['production_line'])) {
$query->where('production_line', $filters['production_line']);
}
if (! empty($filters['equipment_type'])) {
$query->where('equipment_type', $filters['equipment_type']);
}
$sortBy = $filters['sort_by'] ?? 'sort_order';
$sortDir = $filters['sort_direction'] ?? 'asc';
$query->orderBy($sortBy, $sortDir);
return $query->paginate($perPage);
}
public function getEquipmentById(int $id): ?Equipment
{
return Equipment::with(['manager', 'inspectionTemplates', 'repairs', 'processes'])->find($id);
}
public function createEquipment(array $data): Equipment
{
$data['tenant_id'] = session('selected_tenant_id', 1);
$data['created_by'] = auth()->id();
return Equipment::create($data);
}
public function updateEquipment(int $id, array $data): Equipment
{
$equipment = Equipment::findOrFail($id);
$data['updated_by'] = auth()->id();
$equipment->update($data);
return $equipment->fresh();
}
public function deleteEquipment(int $id): bool
{
$equipment = Equipment::findOrFail($id);
$equipment->deleted_by = auth()->id();
$equipment->save();
return $equipment->delete();
}
public function restoreEquipment(int $id): bool
{
$equipment = Equipment::onlyTrashed()->findOrFail($id);
return $equipment->restore();
}
public function getDashboardStats(): array
{
$total = Equipment::count();
$active = Equipment::where('status', 'active')->count();
$idle = Equipment::where('status', 'idle')->count();
$disposed = Equipment::where('status', 'disposed')->count();
return compact('total', 'active', 'idle', 'disposed');
}
public function getTypeStats(): array
{
return Equipment::where('status', '!=', 'disposed')
->selectRaw('equipment_type, count(*) as count')
->groupBy('equipment_type')
->pluck('count', 'equipment_type')
->toArray();
}
public function getEquipmentList(): \Illuminate\Database\Eloquent\Collection
{
return Equipment::where('is_active', true)
->orderBy('sort_order')
->orderBy('name')
->get(['id', 'equipment_code', 'name', 'equipment_type', 'production_line']);
}
}

View File

@@ -0,0 +1,198 @@
@extends('layouts.app')
@section('title', '설비 등록')
@section('content')
<div class="max-w-4xl mx-auto">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">설비 등록</h1>
<a href="{{ route('equipment.index') }}" class="text-gray-600 hover:text-gray-800">
&larr; 목록으로
</a>
</div>
<form id="equipmentForm" class="space-y-6">
@csrf
<!-- 기본정보 -->
<div class="bg-white rounded-lg shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">기본정보</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">
설비코드 <span class="text-red-500">*</span>
</label>
<input type="text" name="equipment_code" required placeholder="KD-M-001"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<p class="mt-1 text-sm text-gray-500">: KD-M-001, KD-S-002</p>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">
설비명 <span class="text-red-500">*</span>
</label>
<input type="text" name="name" required placeholder="포밍기#1"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">설비유형</label>
<select name="equipment_type"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">선택</option>
<option value="포밍기">포밍기</option>
<option value="미싱기">미싱기</option>
<option value="샤링기">샤링기</option>
<option value="V컷팅기">V컷팅기</option>
<option value="절곡기">절곡기</option>
<option value="프레스">프레스</option>
<option value="드릴">드릴</option>
<option value="기타">기타</option>
</select>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">규격</label>
<input type="text" name="specification"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
</div>
<!-- 제조사 정보 -->
<div class="bg-white rounded-lg shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">제조사 정보</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">제조사</label>
<input type="text" name="manufacturer"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">모델명</label>
<input type="text" name="model_name"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">제조번호</label>
<input type="text" name="serial_no"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
</div>
<!-- 설치 정보 -->
<div class="bg-white rounded-lg shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">설치 정보</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">위치</label>
<input type="text" name="location" placeholder="1공장-1F"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">생산라인</label>
<select name="production_line"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">선택</option>
<option value="스라트">스라트</option>
<option value="스크린">스크린</option>
<option value="절곡">절곡</option>
<option value="기타">기타</option>
</select>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">구입일</label>
<input type="date" name="purchase_date"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">설치일</label>
<input type="date" name="install_date"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">구입가격 ()</label>
<input type="number" name="purchase_price" min="0" step="1"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">내용연수 ()</label>
<input type="number" name="useful_life" min="0"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
</div>
<!-- 담당자/비고 -->
<div class="bg-white rounded-lg shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">담당자 / 비고</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">담당자</label>
<select name="manager_id"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">선택</option>
@foreach($users as $user)
<option value="{{ $user->id }}">{{ $user->name }}</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">상태</label>
<select name="status"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="active" selected>가동</option>
<option value="idle">유휴</option>
<option value="disposed">폐기</option>
</select>
</div>
</div>
<div class="mt-4">
<label class="block text-sm font-semibold text-gray-700 mb-2">비고</label>
<textarea name="memo" rows="3"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"></textarea>
</div>
</div>
<!-- 버튼 -->
<div class="flex gap-3">
<button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg transition">
등록
</button>
<a href="{{ route('equipment.index') }}"
class="bg-gray-200 hover:bg-gray-300 text-gray-800 px-6 py-2 rounded-lg transition">
취소
</a>
</div>
</form>
</div>
@endsection
@push('scripts')
<script>
document.getElementById('equipmentForm').addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
const data = Object.fromEntries(formData.entries());
fetch('/admin/equipment', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json',
},
body: JSON.stringify(data)
})
.then(response => response.json())
.then(data => {
if (data.success) {
showToast(data.message, 'success');
window.location.href = '{{ route("equipment.index") }}';
} else {
showToast(data.message || '등록에 실패했습니다.', 'error');
}
})
.catch(() => showToast('서버 오류가 발생했습니다.', 'error'));
});
</script>
@endpush

View File

@@ -0,0 +1,138 @@
@extends('layouts.app')
@section('title', '설비 현황')
@section('content')
<!-- 헤더 -->
<div class="mb-6">
<h1 class="text-2xl font-bold text-gray-800">설비 현황</h1>
<p class="text-sm text-gray-500 mt-1">{{ now()->format('Y년 m월 d일') }} 기준</p>
</div>
<!-- 상단 카드 -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div class="bg-white rounded-lg shadow-sm p-5">
<div class="text-sm text-gray-500 mb-1"> 설비</div>
<div class="text-3xl font-bold text-gray-800">{{ $stats['total'] }}</div>
<div class="text-xs text-gray-400 mt-1"></div>
</div>
<div class="bg-white rounded-lg shadow-sm p-5">
<div class="text-sm text-gray-500 mb-1">가동 </div>
<div class="text-3xl font-bold text-green-600">{{ $stats['active'] }}</div>
<div class="text-xs text-gray-400 mt-1"></div>
</div>
<div class="bg-white rounded-lg shadow-sm p-5">
<div class="text-sm text-gray-500 mb-1">유휴</div>
<div class="text-3xl font-bold text-yellow-600">{{ $stats['idle'] }}</div>
<div class="text-xs text-gray-400 mt-1"></div>
</div>
<div class="bg-white rounded-lg shadow-sm p-5">
<div class="text-sm text-gray-500 mb-1">폐기</div>
<div class="text-3xl font-bold text-gray-400">{{ $stats['disposed'] }}</div>
<div class="text-xs text-gray-400 mt-1"></div>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<!-- 이번달 점검 현황 -->
<div class="bg-white rounded-lg shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">
이번달 점검 현황
<span class="text-sm font-normal text-gray-500 ml-2">{{ now()->format('Y년 m월') }}</span>
</h2>
<div class="space-y-3">
<div class="flex justify-between items-center">
<span class="text-gray-600">점검 대상</span>
<span class="font-semibold">{{ $inspectionStats['total'] }}</span>
</div>
<div class="flex justify-between items-center">
<span class="text-gray-600">점검 완료</span>
<span class="font-semibold text-green-600">{{ $inspectionStats['inspected'] }}</span>
</div>
@if($inspectionStats['total'] > 0)
<div class="w-full bg-gray-200 rounded-full h-2">
<div class="bg-green-500 h-2 rounded-full"
style="width: {{ min(100, round($inspectionStats['inspected'] / $inspectionStats['total'] * 100)) }}%"></div>
</div>
<div class="text-sm text-gray-500 text-right">
{{ round($inspectionStats['inspected'] / $inspectionStats['total'] * 100) }}% 완료
</div>
@endif
<div class="flex justify-between items-center">
<span class="text-gray-600">이상 발견</span>
<span class="font-semibold {{ $inspectionStats['issue_count'] > 0 ? 'text-red-600' : 'text-gray-400' }}">
{{ $inspectionStats['issue_count'] }}
</span>
</div>
</div>
</div>
<!-- 설비 유형별 현황 -->
<div class="bg-white rounded-lg shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">설비 유형별 현황</h2>
@if(!empty($typeStats))
<div class="space-y-2">
@foreach($typeStats as $type => $count)
<div class="flex justify-between items-center">
<span class="text-gray-600">{{ $type ?? '미분류' }}</span>
<div class="flex items-center gap-2">
<div class="bg-gray-200 rounded-full h-2" style="width: 100px;">
@php $maxCount = max($typeStats); @endphp
<div class="bg-blue-500 h-2 rounded-full"
style="width: {{ $maxCount > 0 ? round($count / $maxCount * 100) : 0 }}%"></div>
</div>
<span class="font-semibold text-sm" style="min-width: 30px; text-align: right;">{{ $count }}</span>
</div>
</div>
@endforeach
</div>
@else
<p class="text-gray-500 text-center py-4">데이터가 없습니다.</p>
@endif
</div>
</div>
<!-- 최근 수리이력 -->
<div class="bg-white rounded-lg shadow-sm p-6">
<div class="flex justify-between items-center mb-4 pb-2 border-b">
<h2 class="text-lg font-semibold text-gray-800">최근 수리이력</h2>
<a href="{{ route('equipment.repairs') }}" class="text-blue-600 hover:text-blue-800 text-sm">
전체보기 &rarr;
</a>
</div>
@if($recentRepairs->isNotEmpty())
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700">수리일</th>
<th class="px-3 py-2 text-left text-sm font-semibold text-gray-700">설비</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700">보전구분</th>
<th class="px-3 py-2 text-left text-sm font-semibold text-gray-700">수리내용</th>
<th class="px-3 py-2 text-right text-sm font-semibold text-gray-700">비용</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@foreach($recentRepairs as $repair)
<tr class="hover:bg-gray-50">
<td class="px-3 py-2 text-sm text-center">{{ $repair->repair_date->format('m-d') }}</td>
<td class="px-3 py-2 text-sm">{{ $repair->equipment?->name ?? '-' }}</td>
<td class="px-3 py-2 text-sm text-center">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium
{{ $repair->repair_type === 'internal' ? 'bg-blue-100 text-blue-800' : 'bg-orange-100 text-orange-800' }}">
{{ $repair->repair_type_label }}
</span>
</td>
<td class="px-3 py-2 text-sm">{{ Str::limit($repair->description, 40) ?? '-' }}</td>
<td class="px-3 py-2 text-sm text-right font-mono">{{ $repair->formatted_cost }}</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<p class="text-gray-500 text-center py-8">최근 수리이력이 없습니다.</p>
@endif
</div>
@endsection

View File

@@ -0,0 +1,230 @@
@extends('layouts.app')
@section('title', '설비 수정')
@section('content')
<div class="max-w-4xl mx-auto">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">설비 수정</h1>
<a href="{{ route('equipment.index') }}" class="text-gray-600 hover:text-gray-800">
&larr; 목록으로
</a>
</div>
<!-- 로딩 상태 -->
<div id="loadingState" class="bg-white rounded-lg shadow-sm p-6">
<div class="flex justify-center items-center p-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
</div>
<!-- -->
<div id="formContainer" style="display: none;">
<form id="equipmentForm" class="space-y-6">
@csrf
<!-- 기본정보 -->
<div class="bg-white rounded-lg shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">기본정보</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">
설비코드 <span class="text-red-500">*</span>
</label>
<input type="text" name="equipment_code" id="equipment_code" required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">
설비명 <span class="text-red-500">*</span>
</label>
<input type="text" name="name" id="name" required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">설비유형</label>
<select name="equipment_type" id="equipment_type"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">선택</option>
<option value="포밍기">포밍기</option>
<option value="미싱기">미싱기</option>
<option value="샤링기">샤링기</option>
<option value="V컷팅기">V컷팅기</option>
<option value="절곡기">절곡기</option>
<option value="프레스">프레스</option>
<option value="드릴">드릴</option>
<option value="기타">기타</option>
</select>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">규격</label>
<input type="text" name="specification" id="specification"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
</div>
<!-- 제조사 정보 -->
<div class="bg-white rounded-lg shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">제조사 정보</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">제조사</label>
<input type="text" name="manufacturer" id="manufacturer"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">모델명</label>
<input type="text" name="model_name" id="model_name"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">제조번호</label>
<input type="text" name="serial_no" id="serial_no"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
</div>
<!-- 설치 정보 -->
<div class="bg-white rounded-lg shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">설치 정보</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">위치</label>
<input type="text" name="location" id="location"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">생산라인</label>
<select name="production_line" id="production_line"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">선택</option>
<option value="스라트">스라트</option>
<option value="스크린">스크린</option>
<option value="절곡">절곡</option>
<option value="기타">기타</option>
</select>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">구입일</label>
<input type="date" name="purchase_date" id="purchase_date"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">설치일</label>
<input type="date" name="install_date" id="install_date"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">구입가격 ()</label>
<input type="number" name="purchase_price" id="purchase_price" min="0" step="1"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">내용연수 ()</label>
<input type="number" name="useful_life" id="useful_life" min="0"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
</div>
<!-- 담당자/비고 -->
<div class="bg-white rounded-lg shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">담당자 / 비고</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">담당자</label>
<select name="manager_id" id="manager_id"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">선택</option>
@foreach($users as $user)
<option value="{{ $user->id }}">{{ $user->name }}</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">상태</label>
<select name="status" id="status"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="active">가동</option>
<option value="idle">유휴</option>
<option value="disposed">폐기</option>
</select>
</div>
</div>
<div class="mt-4">
<label class="block text-sm font-semibold text-gray-700 mb-2">비고</label>
<textarea name="memo" id="memo" rows="3"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"></textarea>
</div>
</div>
<!-- 버튼 -->
<div class="flex gap-3">
<button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg transition">
수정
</button>
<a href="{{ route('equipment.index') }}"
class="bg-gray-200 hover:bg-gray-300 text-gray-800 px-6 py-2 rounded-lg transition">
취소
</a>
</div>
</form>
</div>
</div>
@endsection
@push('scripts')
<script>
const equipmentId = {{ $id }};
const fields = ['equipment_code', 'name', 'equipment_type', 'specification', 'manufacturer',
'model_name', 'serial_no', 'location', 'production_line', 'purchase_date', 'install_date',
'purchase_price', 'useful_life', 'status', 'manager_id', 'memo'];
fetch(`/admin/equipment/${equipmentId}`, {
headers: { 'Accept': 'application/json', 'X-CSRF-TOKEN': '{{ csrf_token() }}' }
})
.then(r => r.json())
.then(data => {
if (data.success) {
const eq = data.data;
fields.forEach(f => {
const el = document.getElementById(f);
if (el && eq[f] != null) el.value = eq[f];
});
document.getElementById('loadingState').style.display = 'none';
document.getElementById('formContainer').style.display = 'block';
} else {
showToast('설비 정보를 불러올 수 없습니다.', 'error');
window.location.href = '{{ route("equipment.index") }}';
}
});
document.getElementById('equipmentForm').addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
const data = Object.fromEntries(formData.entries());
fetch(`/admin/equipment/${equipmentId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json',
},
body: JSON.stringify(data)
})
.then(r => r.json())
.then(data => {
if (data.success) {
showToast(data.message, 'success');
window.location.href = '{{ route("equipment.index") }}';
} else {
showToast(data.message || '수정에 실패했습니다.', 'error');
}
})
.catch(() => showToast('서버 오류가 발생했습니다.', 'error'));
});
</script>
@endpush

View File

@@ -0,0 +1,107 @@
@extends('layouts.app')
@section('title', '설비 등록대장')
@section('content')
<!-- 헤더 -->
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
<h1 class="text-2xl font-bold text-gray-800">설비 등록대장</h1>
<a href="{{ route('equipment.create') }}"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition text-center">
+ 설비 등록
</a>
</div>
<!-- 필터 -->
<x-filter-collapsible id="filterForm">
<form id="filterForm" class="flex flex-wrap gap-2 sm:gap-4">
<input type="hidden" name="per_page" id="perPageInput" value="20">
<input type="hidden" name="page" id="pageInput" value="1">
<div style="flex: 1 1 200px; max-width: 300px;">
<input type="text" name="search" placeholder="설비번호/설비명 검색..."
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div class="shrink-0" style="width: 140px;">
<select name="status" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">상태 전체</option>
<option value="active">가동</option>
<option value="idle">유휴</option>
<option value="disposed">폐기</option>
</select>
</div>
<div class="shrink-0" style="width: 140px;">
<select name="production_line" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">라인 전체</option>
<option value="스라트">스라트</option>
<option value="스크린">스크린</option>
<option value="절곡">절곡</option>
<option value="기타">기타</option>
</select>
</div>
<div class="shrink-0" style="width: 140px;">
<select name="equipment_type" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">유형 전체</option>
<option value="포밍기">포밍기</option>
<option value="미싱기">미싱기</option>
<option value="샤링기">샤링기</option>
<option value="V컷팅기">V컷팅기</option>
<option value="절곡기">절곡기</option>
<option value="프레스">프레스</option>
<option value="드릴">드릴</option>
<option value="기타">기타</option>
</select>
</div>
<button type="submit" class="bg-gray-600 hover:bg-gray-700 text-white px-6 py-2 rounded-lg transition shrink-0">
검색
</button>
</form>
</x-filter-collapsible>
<!-- 테이블 영역 -->
<div id="equipment-table"
hx-get="/admin/equipment"
hx-trigger="load, filterSubmit from:body"
hx-include="#filterForm"
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
class="bg-white rounded-lg shadow-sm">
<div class="flex justify-center items-center p-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
</div>
@endsection
@push('scripts')
<script>
document.getElementById('filterForm').addEventListener('submit', function(e) {
e.preventDefault();
document.getElementById('pageInput').value = 1;
htmx.trigger('#equipment-table', 'filterSubmit');
});
function confirmDelete(id, name) {
showDeleteConfirm(name, () => {
fetch(`/admin/equipment/${id}`, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json',
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
htmx.trigger('#equipment-table', 'filterSubmit');
showToast(data.message, 'success');
} else {
showToast(data.message, 'error');
}
});
});
}
</script>
@endpush

View File

@@ -0,0 +1,90 @@
@extends('layouts.app')
@section('title', '일상점검표')
@section('content')
<!-- 헤더 -->
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
<h1 class="text-2xl font-bold text-gray-800">일상점검표</h1>
</div>
<!-- 필터 -->
<x-filter-collapsible id="inspectionFilter" :defaultOpen="true">
<form id="inspectionFilter" class="flex flex-wrap gap-2 sm:gap-4">
<div class="shrink-0" style="width: 160px;">
<label class="block text-xs text-gray-500 mb-1">점검년월</label>
<input type="month" name="year_month" id="yearMonth" value="{{ now()->format('Y-m') }}"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div class="shrink-0" style="width: 140px;">
<label class="block text-xs text-gray-500 mb-1">생산라인</label>
<select name="production_line"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">전체</option>
<option value="스라트">스라트</option>
<option value="스크린">스크린</option>
<option value="절곡">절곡</option>
</select>
</div>
<div class="shrink-0" style="width: 200px;">
<label class="block text-xs text-gray-500 mb-1">설비</label>
<select name="equipment_id"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">전체</option>
@foreach($equipmentList as $eq)
<option value="{{ $eq->id }}">{{ $eq->equipment_code }} - {{ $eq->name }}</option>
@endforeach
</select>
</div>
<div class="self-end">
<button type="submit" class="bg-gray-600 hover:bg-gray-700 text-white px-6 py-2 rounded-lg transition">
조회
</button>
</div>
</form>
</x-filter-collapsible>
<!-- 점검 그리드 -->
<div id="inspection-grid"
hx-get="/admin/equipment/inspections"
hx-trigger="load, filterSubmit from:body"
hx-include="#inspectionFilter"
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
class="bg-white rounded-lg shadow-sm">
<div class="flex justify-center items-center p-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
</div>
@endsection
@push('scripts')
<script>
document.getElementById('inspectionFilter').addEventListener('submit', function(e) {
e.preventDefault();
htmx.trigger('#inspection-grid', 'filterSubmit');
});
function toggleCell(equipmentId, templateItemId, checkDate, cell) {
fetch('/admin/equipment/inspections/detail', {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json',
},
body: JSON.stringify({
equipment_id: equipmentId,
template_item_id: templateItemId,
check_date: checkDate,
})
})
.then(r => r.json())
.then(data => {
if (data.success) {
cell.textContent = data.data.symbol;
cell.className = 'inspection-cell cursor-pointer text-center text-lg font-bold select-none ' + data.data.color;
}
});
}
</script>
@endpush

View File

@@ -0,0 +1,97 @@
@if(empty($inspections))
<div class="p-12 text-center text-gray-500">
<p>점검 가능한 설비가 없습니다.</p>
<p class="text-sm mt-2">설비 등록대장에서 점검항목을 추가해주세요.</p>
</div>
@else
@php
$date = \Carbon\Carbon::createFromFormat('Y-m', $yearMonth);
$daysInMonth = $date->daysInMonth;
@endphp
<div class="overflow-x-auto">
<table class="min-w-full border-collapse text-xs">
<thead>
<tr class="bg-gray-100">
<th class="border border-gray-300 px-2 py-1 text-center whitespace-nowrap sticky left-0 bg-gray-100 z-10" style="min-width: 80px;">설비</th>
<th class="border border-gray-300 px-2 py-1 text-center whitespace-nowrap sticky bg-gray-100 z-10" style="left: 80px; min-width: 80px;">점검항목</th>
@for($d = 1; $d <= $daysInMonth; $d++)
@php
$dayDate = $date->copy()->day($d);
$dayOfWeek = $dayDate->dayOfWeek;
$isWeekend = in_array($dayOfWeek, [0, 6]);
@endphp
<th class="border border-gray-300 px-1 py-1 text-center {{ $isWeekend ? 'bg-red-50 text-red-600' : '' }}" style="min-width: 32px;">
{{ $d }}
</th>
@endfor
<th class="border border-gray-300 px-2 py-1 text-center whitespace-nowrap" style="min-width: 60px;">판정</th>
</tr>
</thead>
<tbody>
@foreach($inspections as $item)
@php
$equipment = $item['equipment'];
$templates = $item['templates'];
$inspection = $item['inspection'];
$details = $item['details'];
$rowCount = $templates->count();
@endphp
@foreach($templates as $idx => $tmpl)
<tr class="{{ $idx === 0 ? 'border-t-2 border-gray-400' : '' }}">
@if($idx === 0)
<td class="border border-gray-300 px-2 py-1 text-center font-medium whitespace-nowrap sticky left-0 bg-white z-10"
rowspan="{{ $rowCount }}" style="min-width: 80px;">
<div class="text-xs text-blue-600">{{ $equipment->equipment_code }}</div>
<div class="text-xs">{{ Str::limit($equipment->name, 8) }}</div>
</td>
@endif
<td class="border border-gray-300 px-2 py-1 whitespace-nowrap sticky bg-white z-10" style="left: 80px; min-width: 80px;">
<span class="text-gray-600">{{ $tmpl->check_point }}</span>
</td>
@for($d = 1; $d <= $daysInMonth; $d++)
@php
$checkDate = $date->copy()->day($d)->format('Y-m-d');
$key = $tmpl->id . '_' . $checkDate;
$detail = isset($details[$key]) ? $details[$key]->first() : null;
$symbol = $detail ? $detail->result_symbol : '';
$color = $detail ? $detail->result_color : 'text-gray-400';
$dayDate = $date->copy()->day($d);
$isWeekend = in_array($dayDate->dayOfWeek, [0, 6]);
@endphp
<td class="border border-gray-300 text-center cursor-pointer select-none {{ $isWeekend ? 'bg-red-50' : '' }}"
style="min-width: 32px; padding: 2px;"
onclick="toggleCell({{ $equipment->id }}, {{ $tmpl->id }}, '{{ $checkDate }}', this)">
<span class="inspection-cell text-lg font-bold {{ $color }}">{{ $symbol }}</span>
</td>
@endfor
@if($idx === 0)
<td class="border border-gray-300 px-2 py-1 text-center whitespace-nowrap"
rowspan="{{ $rowCount }}">
@if($inspection && $inspection->overall_judgment)
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium {{ $inspection->judgment_color }}">
{{ $inspection->judgment_label }}
</span>
@else
<span class="text-gray-400">-</span>
@endif
</td>
@endif
</tr>
@endforeach
@endforeach
</tbody>
</table>
</div>
<!-- 범례 -->
<div class="p-4 border-t flex flex-wrap gap-4 text-sm text-gray-600">
<span><span class="text-green-600 font-bold text-lg"></span> 양호</span>
<span><span class="text-red-600 font-bold text-lg">X</span> 이상</span>
<span><span class="text-yellow-600 font-bold text-lg"></span> 수리완료</span>
<span class="text-gray-400"> 클릭: 빈칸 X 빈칸</span>
</div>
@endif

View File

@@ -0,0 +1,63 @@
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
<x-table-swipe>
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700">수리일</th>
<th class="px-3 py-2 text-left text-sm font-semibold text-gray-700">설비</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700">보전구분</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700">수리시간</th>
<th class="px-3 py-2 text-left text-sm font-semibold text-gray-700">수리내용</th>
<th class="px-3 py-2 text-right text-sm font-semibold text-gray-700">비용</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700">외주업체</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700">액션</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@forelse($repairs as $repair)
<tr class="hover:bg-gray-50">
<td class="px-3 py-3 whitespace-nowrap text-sm text-center">
{{ $repair->repair_date->format('Y-m-d') }}
</td>
<td class="px-3 py-3 whitespace-nowrap text-sm">
<span class="font-mono text-blue-600 text-xs">{{ $repair->equipment?->equipment_code }}</span>
<span class="ml-1">{{ $repair->equipment?->name }}</span>
</td>
<td class="px-3 py-3 whitespace-nowrap text-sm text-center">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium
{{ $repair->repair_type === 'internal' ? 'bg-blue-100 text-blue-800' : 'bg-orange-100 text-orange-800' }}">
{{ $repair->repair_type_label }}
</span>
</td>
<td class="px-3 py-3 whitespace-nowrap text-sm text-center">
{{ $repair->repair_hours ? $repair->repair_hours . 'h' : '-' }}
</td>
<td class="px-3 py-3 text-sm">
{{ Str::limit($repair->description, 40) ?? '-' }}
</td>
<td class="px-3 py-3 whitespace-nowrap text-sm text-right font-mono">
{{ $repair->formatted_cost }}
</td>
<td class="px-3 py-3 whitespace-nowrap text-sm text-center">
{{ $repair->vendor ?? '-' }}
</td>
<td class="px-3 py-3 whitespace-nowrap text-sm text-center">
<button onclick="confirmDeleteRepair({{ $repair->id }})"
class="text-red-600 hover:text-red-900">삭제</button>
</td>
</tr>
@empty
<tr>
<td colspan="8" class="px-6 py-12 text-center text-gray-500">
수리이력이 없습니다.
</td>
</tr>
@endforelse
</tbody>
</table>
</x-table-swipe>
</div>
@if($repairs->hasPages())
@include('partials.pagination', ['paginator' => $repairs, 'target' => '#repair-table'])
@endif

View File

@@ -0,0 +1,66 @@
<div class="bg-white rounded-lg shadow-sm overflow-hidden">
<x-table-swipe>
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700">설비번호</th>
<th class="px-3 py-2 text-left text-sm font-semibold text-gray-700">설비명</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700">유형</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700">위치</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700">생산라인</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700">상태</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700">담당자</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700">구입일</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700">액션</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@forelse($equipments as $eq)
<tr class="hover:bg-gray-50 cursor-pointer" onclick="window.location='{{ route('equipment.show', $eq->id) }}'">
<td class="px-3 py-3 whitespace-nowrap text-sm text-center font-mono text-blue-600">
{{ $eq->equipment_code }}
</td>
<td class="px-3 py-3 whitespace-nowrap text-sm font-medium text-gray-900">
{{ $eq->name }}
</td>
<td class="px-3 py-3 whitespace-nowrap text-sm text-center text-gray-600">
{{ $eq->equipment_type ?? '-' }}
</td>
<td class="px-3 py-3 whitespace-nowrap text-sm text-center text-gray-600">
{{ $eq->location ?? '-' }}
</td>
<td class="px-3 py-3 whitespace-nowrap text-sm text-center text-gray-600">
{{ $eq->production_line ?? '-' }}
</td>
<td class="px-3 py-3 whitespace-nowrap text-sm text-center">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ $eq->status_color }}">
{{ $eq->status_label }}
</span>
</td>
<td class="px-3 py-3 whitespace-nowrap text-sm text-center text-gray-600">
{{ $eq->manager?->name ?? '-' }}
</td>
<td class="px-3 py-3 whitespace-nowrap text-sm text-center text-gray-600">
{{ $eq->purchase_date?->format('Y-m-d') ?? '-' }}
</td>
<td class="px-3 py-3 whitespace-nowrap text-sm text-center" onclick="event.stopPropagation()">
<a href="{{ route('equipment.edit', $eq->id) }}" class="text-blue-600 hover:text-blue-900 mr-2">수정</a>
<button onclick="confirmDelete({{ $eq->id }}, '{{ $eq->name }}')"
class="text-red-600 hover:text-red-900">삭제</button>
</td>
</tr>
@empty
<tr>
<td colspan="9" class="px-6 py-12 text-center text-gray-500">
등록된 설비가 없습니다.
</td>
</tr>
@endforelse
</tbody>
</table>
</x-table-swipe>
</div>
@if($equipments->hasPages())
@include('partials.pagination', ['paginator' => $equipments, 'target' => '#equipment-table'])
@endif

View File

@@ -0,0 +1,102 @@
<!-- 기본정보 -->
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">기본정보</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<label class="block text-sm text-gray-500 mb-1">설비코드</label>
<p class="text-gray-900 font-mono font-medium">{{ $equipment->equipment_code }}</p>
</div>
<div>
<label class="block text-sm text-gray-500 mb-1">설비명</label>
<p class="text-gray-900 font-medium">{{ $equipment->name }}</p>
</div>
<div>
<label class="block text-sm text-gray-500 mb-1">설비유형</label>
<p class="text-gray-900">{{ $equipment->equipment_type ?? '-' }}</p>
</div>
<div>
<label class="block text-sm text-gray-500 mb-1">규격</label>
<p class="text-gray-900">{{ $equipment->specification ?? '-' }}</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">제조사 정보</h2>
<div class="grid grid-cols-2 md:grid-cols-3 gap-4">
<div>
<label class="block text-sm text-gray-500 mb-1">제조사</label>
<p class="text-gray-900">{{ $equipment->manufacturer ?? '-' }}</p>
</div>
<div>
<label class="block text-sm text-gray-500 mb-1">모델명</label>
<p class="text-gray-900">{{ $equipment->model_name ?? '-' }}</p>
</div>
<div>
<label class="block text-sm text-gray-500 mb-1">제조번호</label>
<p class="text-gray-900 font-mono">{{ $equipment->serial_no ?? '-' }}</p>
</div>
</div>
</div>
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">설치 정보</h2>
<div class="grid grid-cols-2 md:grid-cols-4 gap-4">
<div>
<label class="block text-sm text-gray-500 mb-1">위치</label>
<p class="text-gray-900">{{ $equipment->location ?? '-' }}</p>
</div>
<div>
<label class="block text-sm text-gray-500 mb-1">생산라인</label>
<p class="text-gray-900">{{ $equipment->production_line ?? '-' }}</p>
</div>
<div>
<label class="block text-sm text-gray-500 mb-1">구입일</label>
<p class="text-gray-900">{{ $equipment->purchase_date?->format('Y-m-d') ?? '-' }}</p>
</div>
<div>
<label class="block text-sm text-gray-500 mb-1">설치일</label>
<p class="text-gray-900">{{ $equipment->install_date?->format('Y-m-d') ?? '-' }}</p>
</div>
<div>
<label class="block text-sm text-gray-500 mb-1">구입가격</label>
<p class="text-gray-900">{{ $equipment->purchase_price ? number_format($equipment->purchase_price) . '원' : '-' }}</p>
</div>
<div>
<label class="block text-sm text-gray-500 mb-1">내용연수</label>
<p class="text-gray-900">{{ $equipment->useful_life ? $equipment->useful_life . '년' : '-' }}</p>
</div>
<div>
<label class="block text-sm text-gray-500 mb-1">담당자</label>
<p class="text-gray-900">{{ $equipment->manager?->name ?? '-' }}</p>
</div>
<div>
<label class="block text-sm text-gray-500 mb-1">상태</label>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ $equipment->status_color }}">
{{ $equipment->status_label }}
</span>
</div>
</div>
@if($equipment->memo)
<div class="mt-4">
<label class="block text-sm text-gray-500 mb-1">비고</label>
<p class="text-gray-900 whitespace-pre-wrap">{{ $equipment->memo }}</p>
</div>
@endif
</div>
@if($equipment->processes->isNotEmpty())
<div class="bg-white rounded-lg shadow-sm p-6">
<h2 class="text-lg font-semibold text-gray-800 mb-4 pb-2 border-b">연결된 공정</h2>
<div class="flex flex-wrap gap-2">
@foreach($equipment->processes as $process)
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800">
{{ $process->process_name }}
@if($process->pivot->is_primary)
<span class="ml-1 text-xs text-blue-600">()</span>
@endif
</span>
@endforeach
</div>
</div>
@endif

View File

@@ -0,0 +1,104 @@
<!-- 점검항목 -->
<div class="bg-white rounded-lg shadow-sm p-6 mb-6">
<div class="flex justify-between items-center mb-4 pb-2 border-b">
<h2 class="text-lg font-semibold text-gray-800">점검항목 템플릿</h2>
<button onclick="document.getElementById('templateModal').classList.remove('hidden')"
class="bg-blue-600 hover:bg-blue-700 text-white px-3 py-1.5 rounded-lg text-sm transition">
+ 항목 추가
</button>
</div>
@if($equipment->inspectionTemplates->isNotEmpty())
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700">번호</th>
<th class="px-3 py-2 text-left text-sm font-semibold text-gray-700">점검개소</th>
<th class="px-3 py-2 text-left text-sm font-semibold text-gray-700">점검항목</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700">시기</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700">주기</th>
<th class="px-3 py-2 text-left text-sm font-semibold text-gray-700">점검방법</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700">액션</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@foreach($equipment->inspectionTemplates as $tmpl)
<tr>
<td class="px-3 py-2 text-sm text-center">{{ $tmpl->item_no }}</td>
<td class="px-3 py-2 text-sm">{{ $tmpl->check_point }}</td>
<td class="px-3 py-2 text-sm">{{ $tmpl->check_item }}</td>
<td class="px-3 py-2 text-sm text-center">{{ $tmpl->timing_label }}</td>
<td class="px-3 py-2 text-sm text-center">{{ $tmpl->check_frequency ?? '-' }}</td>
<td class="px-3 py-2 text-sm text-gray-600">{{ $tmpl->check_method ?? '-' }}</td>
<td class="px-3 py-2 text-sm text-center">
<button onclick="deleteTemplate({{ $tmpl->id }})" class="text-red-600 hover:text-red-900">삭제</button>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<p class="text-gray-500 text-center py-8">등록된 점검항목이 없습니다.</p>
@endif
</div>
<!-- 점검항목 추가 모달 -->
<div id="templateModal" class="fixed inset-0 z-50 hidden">
<div class="fixed inset-0 bg-black/50" onclick="document.getElementById('templateModal').classList.add('hidden')"></div>
<div class="fixed inset-0 flex items-center justify-center p-4">
<div class="bg-white rounded-xl shadow-xl w-full max-w-lg">
<div class="flex justify-between items-center p-6 border-b">
<h3 class="text-lg font-semibold">점검항목 추가</h3>
<button onclick="document.getElementById('templateModal').classList.add('hidden')" class="text-gray-400 hover:text-gray-600">&times;</button>
</div>
<form id="templateForm" class="p-6 space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">항목번호 <span class="text-red-500">*</span></label>
<input type="number" name="item_no" required min="1"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">점검개소 <span class="text-red-500">*</span></label>
<input type="text" name="check_point" required placeholder="겉모양"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">점검항목 <span class="text-red-500">*</span></label>
<input type="text" name="check_item" required placeholder="청결상태"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div class="grid grid-cols-2 gap-4">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">시기</label>
<select name="check_timing"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">선택</option>
<option value="operating">가동 </option>
<option value="stopped">정지 </option>
</select>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">주기</label>
<input type="text" name="check_frequency" placeholder="1회/일"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-1">점검방법</label>
<textarea name="check_method" rows="2" placeholder="육안 확인"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"></textarea>
</div>
<div class="flex justify-end gap-3 pt-2">
<button type="button" onclick="document.getElementById('templateModal').classList.add('hidden')"
class="bg-gray-200 hover:bg-gray-300 text-gray-800 px-4 py-2 rounded-lg">취소</button>
<button type="button" onclick="addTemplate()"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg">추가</button>
</div>
</form>
</div>
</div>
</div>

View File

@@ -0,0 +1,46 @@
<!-- 수리이력 -->
<div class="bg-white rounded-lg shadow-sm p-6">
<div class="flex justify-between items-center mb-4 pb-2 border-b">
<h2 class="text-lg font-semibold text-gray-800">수리이력</h2>
</div>
@if($equipment->repairs->isNotEmpty())
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700">수리일</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700">보전구분</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700">수리시간</th>
<th class="px-3 py-2 text-left text-sm font-semibold text-gray-700">수리내용</th>
<th class="px-3 py-2 text-right text-sm font-semibold text-gray-700">비용</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700">외주업체</th>
<th class="px-3 py-2 text-center text-sm font-semibold text-gray-700">액션</th>
</tr>
</thead>
<tbody class="divide-y divide-gray-200">
@foreach($equipment->repairs->sortByDesc('repair_date') as $repair)
<tr>
<td class="px-3 py-2 text-sm text-center">{{ $repair->repair_date->format('Y-m-d') }}</td>
<td class="px-3 py-2 text-sm text-center">
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium
{{ $repair->repair_type === 'internal' ? 'bg-blue-100 text-blue-800' : 'bg-orange-100 text-orange-800' }}">
{{ $repair->repair_type_label }}
</span>
</td>
<td class="px-3 py-2 text-sm text-center">{{ $repair->repair_hours ? $repair->repair_hours . 'h' : '-' }}</td>
<td class="px-3 py-2 text-sm">{{ Str::limit($repair->description, 50) ?? '-' }}</td>
<td class="px-3 py-2 text-sm text-right font-mono">{{ $repair->formatted_cost }}</td>
<td class="px-3 py-2 text-sm text-center">{{ $repair->vendor ?? '-' }}</td>
<td class="px-3 py-2 text-sm text-center">
<button onclick="deleteRepair({{ $repair->id }})" class="text-red-600 hover:text-red-900">삭제</button>
</td>
</tr>
@endforeach
</tbody>
</table>
</div>
@else
<p class="text-gray-500 text-center py-8">수리이력이 없습니다.</p>
@endif
</div>

View File

@@ -0,0 +1,134 @@
@extends('layouts.app')
@section('title', '수리이력 등록')
@section('content')
<div class="max-w-3xl mx-auto">
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold text-gray-800">수리이력 등록</h1>
<a href="{{ route('equipment.repairs') }}" class="text-gray-600 hover:text-gray-800">
&larr; 목록으로
</a>
</div>
<div class="bg-white rounded-lg shadow-sm p-6">
<form id="repairForm" class="space-y-4">
@csrf
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">
설비 <span class="text-red-500">*</span>
</label>
<select name="equipment_id" required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">선택</option>
@foreach($equipmentList as $eq)
<option value="{{ $eq->id }}">{{ $eq->equipment_code }} - {{ $eq->name }}</option>
@endforeach
</select>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">
수리일 <span class="text-red-500">*</span>
</label>
<input type="date" name="repair_date" required value="{{ now()->format('Y-m-d') }}"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">
보전구분 <span class="text-red-500">*</span>
</label>
<select name="repair_type" required
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">선택</option>
<option value="internal">사내</option>
<option value="external">외주</option>
</select>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">수리시간 (h)</label>
<input type="number" name="repair_hours" min="0" step="0.5"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">수리내용</label>
<textarea name="description" rows="3"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"></textarea>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">수리비용 ()</label>
<input type="number" name="cost" min="0" step="1"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">외주업체</label>
<input type="text" name="vendor"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">수리자</label>
<select name="repaired_by"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">선택</option>
@foreach($users as $user)
<option value="{{ $user->id }}">{{ $user->name }}</option>
@endforeach
</select>
</div>
</div>
<div>
<label class="block text-sm font-semibold text-gray-700 mb-2">비고</label>
<textarea name="memo" rows="2"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"></textarea>
</div>
<div class="flex gap-3 pt-2">
<button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg transition">
등록
</button>
<a href="{{ route('equipment.repairs') }}"
class="bg-gray-200 hover:bg-gray-300 text-gray-800 px-6 py-2 rounded-lg transition">
취소
</a>
</div>
</form>
</div>
</div>
@endsection
@push('scripts')
<script>
document.getElementById('repairForm').addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
const data = Object.fromEntries(formData.entries());
fetch('/admin/equipment/repairs', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json',
},
body: JSON.stringify(data)
})
.then(r => r.json())
.then(data => {
if (data.success) {
showToast(data.message, 'success');
window.location.href = '{{ route("equipment.repairs") }}';
} else {
showToast(data.message || '등록에 실패했습니다.', 'error');
}
})
.catch(() => showToast('서버 오류가 발생했습니다.', 'error'));
});
</script>
@endpush

View File

@@ -0,0 +1,100 @@
@extends('layouts.app')
@section('title', '수리이력')
@section('content')
<!-- 헤더 -->
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
<h1 class="text-2xl font-bold text-gray-800">수리이력</h1>
<a href="{{ route('equipment.repairs.create') }}"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition text-center">
+ 수리이력 등록
</a>
</div>
<!-- 필터 -->
<x-filter-collapsible id="repairFilter">
<form id="repairFilter" class="flex flex-wrap gap-2 sm:gap-4">
<input type="hidden" name="per_page" id="perPageInput" value="20">
<input type="hidden" name="page" id="pageInput" value="1">
<div style="flex: 1 1 200px; max-width: 300px;">
<input type="text" name="search" placeholder="설비명/수리내용 검색..."
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div class="shrink-0" style="width: 200px;">
<select name="equipment_id"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">설비 전체</option>
@foreach($equipmentList as $eq)
<option value="{{ $eq->id }}">{{ $eq->equipment_code }} - {{ $eq->name }}</option>
@endforeach
</select>
</div>
<div class="shrink-0" style="width: 140px;">
<select name="repair_type"
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
<option value="">구분 전체</option>
<option value="internal">사내</option>
<option value="external">외주</option>
</select>
</div>
<div class="shrink-0" style="width: 150px;">
<input type="date" name="date_from" placeholder="시작일"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<div class="shrink-0" style="width: 150px;">
<input type="date" name="date_to" placeholder="종료일"
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500">
</div>
<button type="submit" class="bg-gray-600 hover:bg-gray-700 text-white px-6 py-2 rounded-lg transition shrink-0">
검색
</button>
</form>
</x-filter-collapsible>
<!-- 테이블 -->
<div id="repair-table"
hx-get="/admin/equipment/repairs"
hx-trigger="load, filterSubmit from:body"
hx-include="#repairFilter"
hx-headers='{"X-CSRF-TOKEN": "{{ csrf_token() }}"}'
class="bg-white rounded-lg shadow-sm">
<div class="flex justify-center items-center p-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
</div>
@endsection
@push('scripts')
<script>
document.getElementById('repairFilter').addEventListener('submit', function(e) {
e.preventDefault();
document.getElementById('pageInput').value = 1;
htmx.trigger('#repair-table', 'filterSubmit');
});
function confirmDeleteRepair(id) {
showDeleteConfirm('수리이력', () => {
fetch(`/admin/equipment/repairs/${id}`, {
method: 'DELETE',
headers: {
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json',
}
})
.then(r => r.json())
.then(data => {
if (data.success) {
htmx.trigger('#repair-table', 'filterSubmit');
showToast(data.message, 'success');
}
});
});
}
</script>
@endpush

View File

@@ -0,0 +1,137 @@
@extends('layouts.app')
@section('title', $equipment->name . ' - 설비 상세')
@section('content')
<!-- 헤더 -->
<div class="flex flex-col sm:flex-row sm:justify-between sm:items-center gap-4 mb-6">
<div class="flex items-center gap-4">
<a href="{{ route('equipment.index') }}"
class="p-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7"/>
</svg>
</a>
<div>
<h1 class="text-2xl font-bold text-gray-800">{{ $equipment->name }}</h1>
<p class="text-sm text-gray-500 font-mono">{{ $equipment->equipment_code }}</p>
</div>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {{ $equipment->status_color }}">
{{ $equipment->status_label }}
</span>
</div>
<a href="{{ route('equipment.edit', $equipment->id) }}"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition text-center">
수정
</a>
</div>
<!-- 네비게이션 -->
<div class="border-b border-gray-200 mb-6">
<nav class="flex space-x-8" id="tabNav">
<button class="tab-btn border-b-2 border-blue-500 text-blue-600 px-1 py-3 text-sm font-medium" data-tab="basic">
기본정보
</button>
<button class="tab-btn border-b-2 border-transparent text-gray-500 hover:text-gray-700 px-1 py-3 text-sm font-medium" data-tab="inspection">
점검항목
</button>
<button class="tab-btn border-b-2 border-transparent text-gray-500 hover:text-gray-700 px-1 py-3 text-sm font-medium" data-tab="repair">
수리이력
</button>
</nav>
</div>
<!-- 콘텐츠 -->
<div id="tab-basic" class="tab-content">
@include('equipment.partials.tabs.basic-info', ['equipment' => $equipment])
</div>
<div id="tab-inspection" class="tab-content" style="display: none;">
@include('equipment.partials.tabs.inspection-items', ['equipment' => $equipment])
</div>
<div id="tab-repair" class="tab-content" style="display: none;">
@include('equipment.partials.tabs.repair-history', ['equipment' => $equipment])
</div>
@endsection
@push('scripts')
<script>
const equipmentId = {{ $equipment->id }};
// 탭 전환
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', function() {
document.querySelectorAll('.tab-btn').forEach(b => {
b.classList.remove('border-blue-500', 'text-blue-600');
b.classList.add('border-transparent', 'text-gray-500');
});
this.classList.remove('border-transparent', 'text-gray-500');
this.classList.add('border-blue-500', 'text-blue-600');
document.querySelectorAll('.tab-content').forEach(c => c.style.display = 'none');
document.getElementById('tab-' + this.dataset.tab).style.display = 'block';
});
});
// 점검항목 추가
function addTemplate() {
const form = document.getElementById('templateForm');
const formData = new FormData(form);
const data = Object.fromEntries(formData.entries());
fetch(`/admin/equipment/${equipmentId}/templates`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': '{{ csrf_token() }}',
'Accept': 'application/json',
},
body: JSON.stringify(data)
})
.then(r => r.json())
.then(data => {
if (data.success) {
showToast(data.message, 'success');
location.reload();
} else {
showToast(data.message, 'error');
}
});
}
// 점검항목 삭제
function deleteTemplate(id) {
showDeleteConfirm('점검항목', () => {
fetch(`/admin/equipment/templates/${id}`, {
method: 'DELETE',
headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}', 'Accept': 'application/json' }
})
.then(r => r.json())
.then(data => {
if (data.success) {
showToast(data.message, 'success');
location.reload();
}
});
});
}
// 수리이력 삭제
function deleteRepair(id) {
showDeleteConfirm('수리이력', () => {
fetch(`/admin/equipment/repairs/${id}`, {
method: 'DELETE',
headers: { 'X-CSRF-TOKEN': '{{ csrf_token() }}', 'Accept': 'application/json' }
})
.then(r => r.json())
.then(data => {
if (data.success) {
showToast(data.message, 'success');
location.reload();
}
});
});
}
</script>
@endpush

View File

@@ -957,6 +957,38 @@
| Google STT + Claude API를 사용한 회의록 생성
|
*/
/*
|--------------------------------------------------------------------------
| 설비관리 API (Equipment Management)
|--------------------------------------------------------------------------
*/
Route::middleware(['web', 'auth', 'hq.member'])->prefix('admin/equipment')->name('api.admin.equipment.')->group(function () {
// 설비 CRUD
Route::get('/', [\App\Http\Controllers\Api\Admin\EquipmentController::class, 'index'])->name('index');
Route::post('/', [\App\Http\Controllers\Api\Admin\EquipmentController::class, 'store'])->name('store');
Route::get('/{id}', [\App\Http\Controllers\Api\Admin\EquipmentController::class, 'show'])->whereNumber('id')->name('show');
Route::put('/{id}', [\App\Http\Controllers\Api\Admin\EquipmentController::class, 'update'])->whereNumber('id')->name('update');
Route::delete('/{id}', [\App\Http\Controllers\Api\Admin\EquipmentController::class, 'destroy'])->whereNumber('id')->name('destroy');
Route::post('/{id}/restore', [\App\Http\Controllers\Api\Admin\EquipmentController::class, 'restore'])->whereNumber('id')->name('restore');
// 점검 템플릿
Route::get('/{id}/templates', [\App\Http\Controllers\Api\Admin\EquipmentController::class, 'templates'])->whereNumber('id')->name('templates');
Route::post('/{id}/templates', [\App\Http\Controllers\Api\Admin\EquipmentInspectionController::class, 'storeTemplate'])->whereNumber('id')->name('templates.store');
Route::put('/templates/{templateId}', [\App\Http\Controllers\Api\Admin\EquipmentInspectionController::class, 'updateTemplate'])->whereNumber('templateId')->name('templates.update');
Route::delete('/templates/{templateId}', [\App\Http\Controllers\Api\Admin\EquipmentInspectionController::class, 'deleteTemplate'])->whereNumber('templateId')->name('templates.destroy');
// 점검 기록
Route::get('/inspections', [\App\Http\Controllers\Api\Admin\EquipmentInspectionController::class, 'index'])->name('inspections.index');
Route::patch('/inspections/detail', [\App\Http\Controllers\Api\Admin\EquipmentInspectionController::class, 'toggleDetail'])->name('inspections.toggle');
Route::patch('/inspections/notes', [\App\Http\Controllers\Api\Admin\EquipmentInspectionController::class, 'updateNotes'])->name('inspections.notes');
// 수리이력
Route::get('/repairs', [\App\Http\Controllers\Api\Admin\EquipmentRepairController::class, 'index'])->name('repairs.index');
Route::post('/repairs', [\App\Http\Controllers\Api\Admin\EquipmentRepairController::class, 'store'])->name('repairs.store');
Route::put('/repairs/{id}', [\App\Http\Controllers\Api\Admin\EquipmentRepairController::class, 'update'])->whereNumber('id')->name('repairs.update');
Route::delete('/repairs/{id}', [\App\Http\Controllers\Api\Admin\EquipmentRepairController::class, 'destroy'])->whereNumber('id')->name('repairs.destroy');
});
Route::middleware(['web', 'auth'])->prefix('meeting-logs')->name('api.admin.meeting-logs.')->group(function () {
// 목록 조회 (HTMX 지원)
Route::get('/', [MeetingLogController::class, 'index'])->name('index');

View File

@@ -1592,6 +1592,22 @@
Route::delete('/history', [\App\Http\Controllers\Video\TutorialVideoController::class, 'destroy'])->name('destroy');
});
/*
|--------------------------------------------------------------------------
| 설비관리 (Equipment Management)
|--------------------------------------------------------------------------
*/
Route::prefix('equipment')->name('equipment.')->middleware(['auth', 'hq.member', 'password.changed'])->group(function () {
Route::get('/', [\App\Http\Controllers\EquipmentController::class, 'dashboard'])->name('dashboard');
Route::get('/registry', [\App\Http\Controllers\EquipmentController::class, 'index'])->name('index');
Route::get('/registry/create', [\App\Http\Controllers\EquipmentController::class, 'create'])->name('create');
Route::get('/registry/{id}', [\App\Http\Controllers\EquipmentController::class, 'show'])->whereNumber('id')->name('show');
Route::get('/registry/{id}/edit', [\App\Http\Controllers\EquipmentController::class, 'edit'])->whereNumber('id')->name('edit');
Route::get('/inspections', [\App\Http\Controllers\EquipmentController::class, 'inspections'])->name('inspections');
Route::get('/repairs', [\App\Http\Controllers\EquipmentController::class, 'repairs'])->name('repairs');
Route::get('/repairs/create', [\App\Http\Controllers\EquipmentController::class, 'repairCreate'])->name('repairs.create');
});
/*
|--------------------------------------------------------------------------
| SAM E-Sign Public Routes (인증 불필요 - 서명자용)