jasan/laptops.php

954 lines
No EOL
59 KiB
PHP

<?php require_once 'auth_check.php'; ?>
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>노트북 자산 관리 | FKI JASAN</title>
<script src="https://cdn.tailwindcss.com"></script>
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js"></script>
<link href="https://fonts.googleapis.com/css2?family=Pretendard:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="assets/style.css">
<style>
body {
font-family: 'Pretendard', sans-serif;
}
[x-cloak] {
display: none !important;
}
.modal-bg {
background-color: rgba(15, 23, 42, 0.7);
backdrop-filter: blur(4px);
}
</style>
</head>
<body class="bg-[#f8fafc] text-slate-800" x-data="assetManagement()">
<?php include 'nav.php'; ?>
<main class="max-w-[1600px] mx-auto p-8 pt-10">
<header class="mb-10 flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h2 class="text-3xl font-extrabold text-slate-900 tracking-tight">노트북 자산 실사</h2>
<p class="text-slate-500 mt-1">사내 모빌리티 자산 상태 및 실배정 내역 관리</p>
</div>
<div class="flex items-center gap-3">
<div class="relative group">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<svg class="h-4 w-4 text-slate-400 group-focus-within:text-blue-500 transition-colors"
fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
</svg>
</div>
<input type="text" x-model="searchQuery"
class="block w-64 pl-10 pr-3 py-2.5 border border-slate-200 rounded-xl leading-5 bg-white placeholder-slate-400 focus:outline-none focus:ring-2 focus:ring-blue-500 transition-all text-sm shadow-sm"
placeholder="자산번호, 모델명, 사용자, IP 검색...">
</div>
<button @click="exportToExcel()"
class="bg-white border border-slate-200 text-slate-700 px-5 py-2.5 rounded-xl font-bold hover:bg-slate-50 transition-all flex items-center shadow-sm text-sm">
<svg class="w-4 h-4 mr-2 text-emerald-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
엑셀 내보내기
</button>
<button @click="openAddModal()"
class="bg-blue-600 text-white px-5 py-2.5 rounded-xl font-bold shadow-lg shadow-blue-200 hover:bg-blue-700 transition-all text-sm">
자산 등록
</button>
</div>
</header>
<!-- Tabs for filtering -->
<div class="mb-6 flex items-center justify-between border-b border-slate-200">
<div class="flex gap-8">
<button @click="currentTab = 'all'" class="pb-4 text-sm font-black transition-all relative"
:class="currentTab === 'all' ? 'text-blue-600' : 'text-slate-400 hover:text-slate-600'">
전체 자산
<div x-show="currentTab === 'all'" x-transition
class="absolute bottom-0 left-0 w-full h-1 bg-blue-600 rounded-t-full"></div>
</button>
<button @click="currentTab = 'stock'" class="pb-4 text-sm font-black transition-all relative"
:class="currentTab === 'stock' ? 'text-blue-600' : 'text-slate-400 hover:text-slate-600'">
재고 현황
<div x-show="currentTab === 'stock'" x-transition
class="absolute bottom-0 left-0 w-full h-1 bg-blue-600 rounded-t-full"></div>
</button>
<button @click="currentTab = 'assigned'" class="pb-4 text-sm font-black transition-all relative"
:class="currentTab === 'assigned' ? 'text-blue-600' : 'text-slate-400 hover:text-slate-600'">
지급 완료
<div x-show="currentTab === 'assigned'" x-transition
class="absolute bottom-0 left-0 w-full h-1 bg-blue-600 rounded-t-full"></div>
</button>
<button @click="currentTab = 'disposed'" class="pb-4 text-sm font-black transition-all relative"
:class="currentTab === 'disposed' ? 'text-rose-600' : 'text-slate-400 hover:text-slate-600'">
매각 자산
<div x-show="currentTab === 'disposed'" x-transition
class="absolute bottom-0 left-0 w-full h-1 bg-rose-600 rounded-t-full"></div>
</button>
</div>
<div class="pb-4 text-[10px] font-black text-slate-400 uppercase tracking-widest">
Total: <span class="text-slate-900" x-text="filteredAssets.length"></span> Items
</div>
</div>
<!-- Bulk Actions (Fixed Floating Bar) -->
<div x-show="selectedIds.length > 0" x-transition x-cloak
class="fixed bottom-10 left-10 z-[200] bg-slate-900 text-white px-6 py-4 rounded-3xl flex items-center gap-6 shadow-2xl border border-white/10 animate-in fade-in slide-in-from-left-4 duration-300">
<div class="flex items-center gap-2">
<div class="w-10 h-10 bg-blue-600 rounded-full flex items-center justify-center font-black text-sm shadow-lg shadow-blue-500/30"
x-text="selectedIds.length"></div>
<div class="flex flex-col">
<span class="text-[10px] font-black text-slate-400 uppercase tracking-tighter">Selected</span>
<span class="text-xs font-bold">자산 선택됨</span>
</div>
</div>
<div class="h-8 w-px bg-white/10 mx-2"></div>
<div class="flex items-center gap-3">
<select x-model="bulkStatus"
class="bg-slate-800 border-none rounded-xl text-xs font-bold px-4 py-2 focus:ring-2 focus:ring-blue-500 appearance-none shadow-inner min-w-[120px]">
<option value="">상태 변경...</option>
<option value="assigned">지급됨</option>
<option value="stock">재고</option>
</select>
<select x-model="bulkModelId"
class="bg-slate-800 border-none rounded-xl text-xs font-bold px-4 py-2 focus:ring-2 focus:ring-blue-500 appearance-none shadow-inner min-w-[150px]">
<option value="">모델 변경...</option>
<template x-for="model in models" :key="model.id">
<option :value="model.id" x-text="'[' + model.manufacturer + '] ' + model.model_name"></option>
</template>
</select>
<button @click="applyBulkUpdate"
class="bg-blue-600 hover:bg-blue-700 px-4 py-2 rounded-xl text-xs font-black transition-all shadow-lg shadow-blue-500/20 active:scale-95">
변경 적용
</button>
<div class="h-6 w-px bg-white/10 mx-1"></div>
<button @click="applyBulkDisposal"
class="bg-rose-600 hover:bg-rose-700 px-4 py-2 rounded-xl text-xs font-black transition-all shadow-lg shadow-rose-500/20 active:scale-95 flex items-center gap-1.5">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
매각 완료
</button>
<button @click="bulkDelete"
class="bg-slate-700 hover:bg-rose-600 px-4 py-2 rounded-xl text-xs font-black transition-all shadow-lg active:scale-95">
삭제
</button>
<button @click="selectedIds = []"
class="ml-2 w-8 h-8 flex items-center justify-center rounded-full hover:bg-white/10 text-slate-400 hover:text-white transition-all">
<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="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
</div>
<!-- Laptop List Table -->
<div class="bg-white rounded-3xl shadow-sm border border-slate-200 overflow-hidden"
x-show="!loading && filteredAssets.length > 0">
<table class="min-w-full divide-y divide-slate-100">
<thead class="bg-slate-50/50">
<tr>
<th class="px-6 py-4 text-left">
<input type="checkbox" @change="toggleSelectAll($event.target.checked)"
class="w-4 h-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500">
</th>
<th @click="sortBy('asset_tag')"
class="px-6 py-4 text-left text-xs font-bold text-slate-500 uppercase tracking-wider cursor-pointer hover:bg-slate-100 transition-colors">
자산번호 <span x-show="sortKey === 'asset_tag'" x-text="sortOrder === 'asc' ? '↑' : '↓'"></span>
</th>
<th @click="sortBy('purchase_date')"
class="px-4 py-4 text-left text-xs font-bold text-slate-500 uppercase tracking-wider cursor-pointer hover:bg-slate-100 transition-colors">
취득년 <span x-show="sortKey === 'purchase_date'"
x-text="sortOrder === 'asc' ? '↑' : '↓'"></span>
</th>
<th @click="sortBy('model_name')"
class="px-6 py-4 text-left text-xs font-bold text-slate-500 uppercase tracking-wider cursor-pointer hover:bg-slate-100 transition-colors">
기기 정보 / 사양 <span x-show="sortKey === 'model_name'"
x-text="sortOrder === 'asc' ? '↑' : '↓'"></span>
</th>
<th @click="sortBy('ip_address')"
class="px-6 py-4 text-left text-xs font-bold text-slate-500 uppercase tracking-wider cursor-pointer hover:bg-slate-100 transition-colors">
IP / 시리얼 <span x-show="sortKey === 'ip_address'"
x-text="sortOrder === 'asc' ? '↑' : '↓'"></span>
</th>
<th @click="sortBy('user_name')"
class="px-6 py-4 text-left text-xs font-bold text-slate-500 uppercase tracking-wider cursor-pointer hover:bg-slate-100 transition-colors">
배정 사용자 <span x-show="sortKey === 'user_name'"
x-text="sortOrder === 'asc' ? '↑' : '↓'"></span>
</th>
<th @click="sortBy('last_confirmed_date')"
class="px-6 py-4 text-left text-xs font-bold text-slate-500 uppercase tracking-wider cursor-pointer hover:bg-slate-100 transition-colors">
확인일 <span x-show="sortKey === 'last_confirmed_date'"
x-text="sortOrder === 'asc' ? '↑' : '↓'"></span>
</th>
<th @click="sortBy('status')"
class="px-6 py-4 text-left text-xs font-bold text-slate-500 uppercase tracking-wider cursor-pointer hover:bg-slate-100 transition-colors">
상태 <span x-show="sortKey === 'status'" x-text="sortOrder === 'asc' ? '↑' : '↓'"></span>
</th>
</tr>
</thead>
<tbody class="divide-y divide-slate-50">
<template x-for="asset in sortedAssets" :key="asset.id">
<tr class="hover:bg-slate-50/80 transition-colors group cursor-pointer" @click="editAsset(asset)">
<td class="px-6 py-4" @click.stop>
<input type="checkbox" :value="asset.id" x-model="selectedIds"
class="w-4 h-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500">
</td>
<td class="px-6 py-4 whitespace-nowrap">
<span
class="text-sm font-black text-slate-900 px-2.5 py-1 bg-slate-100 rounded-lg group-hover:bg-white transition-colors"
x-text="asset.asset_tag"></span>
</td>
<td class="px-4 py-4 whitespace-nowrap">
<span class="text-xs font-black text-blue-600 bg-blue-50 px-2 py-1 rounded"
x-text="asset.purchase_date ? asset.purchase_date.substring(0,4) : '-'"></span>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-[11px] font-black text-slate-800" x-text="asset.model_name"></div>
<div class="text-[9px] font-black text-slate-400 uppercase tracking-widest mt-0.5"
x-text="asset.manufacturer"></div>
<div class="text-[10px] text-slate-400 mt-1 space-y-0.5">
<div x-show="asset.processor" class="flex items-center"><span
class="w-8 shrink-0">CPU</span> <span x-text="asset.processor"></span></div>
<div x-show="asset.ram" class="flex items-center"><span
class="w-8 shrink-0">RAM</span>
<span x-text="asset.ram"></span>
</div>
<div x-show="asset.storage" class="flex items-center"><span
class="w-8 shrink-0">DISK</span>
<span x-text="asset.storage"></span>
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-xs font-black text-slate-900 mb-1" x-text="asset.ip_address || '-'">
</div>
<div class="text-[10px] font-mono text-slate-400" x-text="asset.serial_number || '-'">
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center" x-show="asset.user_name">
<div class="w-7 h-7 bg-indigo-50 rounded-lg flex items-center justify-center text-[10px] font-bold text-indigo-500 mr-2"
x-text="asset.user_name ? asset.user_name.charAt(0) : ''"></div>
<div class="text-sm font-bold text-slate-700" x-text="asset.user_name"></div>
</div>
<div x-show="!asset.user_name" class="text-xs text-slate-400 font-medium">배정 대기</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-xs text-slate-500"
x-text="asset.last_confirmed_date || '-'"></td>
<td class="px-6 py-4 whitespace-nowrap">
<template x-if="asset.status === 'disposed'">
<div class="flex flex-col gap-1">
<span
class="px-3 py-1 rounded-full text-[10px] font-black border uppercase tracking-tighter bg-rose-50 text-rose-600 border-rose-100 w-fit">매각됨</span>
<div class="text-[10px] font-bold text-slate-400 group-hover:text-slate-600">
매각: <span x-text="asset.disposal_recipient"></span><br>
실사용: <span x-text="asset.real_user || '-'"></span><br>
일자: <span x-text="asset.disposal_date"></span>
</div>
</div>
</template>
<template x-if="asset.status !== 'disposed'">
<div>
<span x-show="asset.user_name || asset.assigned_user_name"
:class="( (asset.user_name && asset.user_name.includes('업무용')) || (asset.assigned_user_name && asset.assigned_user_name.includes('업무용')) ) ? 'bg-amber-50 text-amber-600 border-amber-100' : 'bg-blue-50 text-blue-600 border-blue-100'"
class="px-3 py-1 rounded-full text-[10px] font-black border uppercase tracking-tighter"
x-text="( (asset.user_name && asset.user_name.includes('업무용')) || (asset.assigned_user_name && asset.assigned_user_name.includes('업무용')) ) ? '업무용' : '직원배정'"></span>
<span x-show="!asset.user_name && !asset.assigned_user_name"
class="px-3 py-1 rounded-full text-[10px] font-black border uppercase tracking-tighter bg-slate-50 text-slate-500 border-slate-100">
STOCK
</span>
</div>
</template>
</td>
</tr>
</template>
</tbody>
</table>
</div>
<div x-show="loading" class="p-20 flex justify-center">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600"></div>
</div>
<div x-show="!loading && filteredAssets.length === 0"
class="p-20 text-center bg-white rounded-3xl border border-dashed border-slate-300">
<template x-if="searchQuery">
<p class="text-slate-400 font-medium italic">검색 결과와 일치하는 자산이 없습니다.</p>
</template>
<template x-if="!searchQuery">
<p class="text-slate-400 font-medium italic">등록된 노트북 자산이 없습니다.</p>
</template>
</div>
</main>
<!-- Asset Registration Modal -->
<div x-show="showModal" x-cloak class="fixed inset-0 z-[110] flex items-center justify-center p-4">
<div x-show="showModal" @click="showModal = false" class="fixed inset-0 modal-bg transition-opacity"></div>
<div x-show="showModal"
class="bg-white rounded-3xl p-8 max-w-4xl w-full relative z-[111] shadow-2xl overflow-y-auto max-h-[90vh]">
<div class="flex justify-between items-center mb-8">
<div class="flex items-center gap-6">
<h3 class="text-2xl font-black text-slate-900" x-text="isEdit ? '자산 정보 수정' : '신규 자산 등록'"></h3>
<!-- 자산 운영 상태 (버튼형 선택기) -->
<div class="flex bg-slate-100 p-1 rounded-2xl">
<button type="button" @click="setStatus('stock')"
:class="formData.status === 'stock' ? 'bg-white text-blue-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'"
class="px-4 py-2 rounded-xl text-xs font-black transition-all flex items-center gap-2">
<div class="w-1.5 h-1.5 rounded-full"
:class="formData.status === 'stock' ? 'bg-blue-600' : 'bg-slate-300'"></div>
재고
</button>
<button type="button" @click="setStatus('assigned')"
:class="formData.status === 'assigned' ? 'bg-white text-indigo-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'"
class="px-4 py-2 rounded-xl text-xs font-black transition-all flex items-center gap-2">
<div class="w-1.5 h-1.5 rounded-full"
:class="formData.status === 'assigned' ? 'bg-indigo-600' : 'bg-slate-300'"></div>
직원배정
</button>
<button type="button" @click="setStatus('disposed')"
:class="formData.status === 'disposed' ? 'bg-white text-rose-600 shadow-sm' : 'text-slate-500 hover:text-slate-700'"
class="px-4 py-2 rounded-xl text-xs font-black transition-all flex items-center gap-2">
<div class="w-1.5 h-1.5 rounded-full"
:class="formData.status === 'disposed' ? 'bg-rose-600' : 'bg-slate-300'"></div>
매각
</button>
</div>
</div>
<button @click="showModal = false" class="text-slate-400 hover:text-slate-600">
<svg class="h-6 w-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
<form @submit.prevent="submitAsset" class="space-y-6">
<!-- 기본 정보 섹션 -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="block text-xs font-bold text-slate-500 uppercase mb-2">자산관리번호</label>
<input type="text" x-model="formData.asset_tag" required
:class="isAssetTagDuplicate ? 'border-rose-500 ring-2 ring-rose-200' : 'border-slate-200'"
class="w-full px-4 py-3 bg-slate-50 border rounded-xl focus:ring-2 focus:ring-blue-500 outline-none transition-all font-bold">
<template x-if="isAssetTagDuplicate">
<p class="text-[10px] text-rose-500 font-bold mt-1.5 ml-1 animate-pulse italic">이미 등록된
자산번호입니다.</p>
</template>
</div>
<div>
<label class="block text-xs font-bold text-slate-500 uppercase mb-2">노트북 모델 연동</label>
<select x-model="formData.model_id" required @change="applyModelDefaults"
class="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none transition-all appearance-none font-bold text-sm text-slate-700">
<option value="">모델 선택</option>
<template x-for="model in models" :key="model.id">
<option :value="model.id" x-text="'[' + model.manufacturer + '] ' + model.model_name">
</option>
</template>
</select>
</div>
<div>
<label class="block text-xs font-bold text-slate-500 uppercase mb-2">시리얼 번호</label>
<input type="text" x-model="formData.serial_number" placeholder="S/N 입력"
class="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none font-bold text-sm">
</div>
</div>
<!-- 매각 정보 (상태가 매각일 때만 노출) -->
<div x-show="formData.status === 'disposed'" x-transition
class="bg-rose-50 p-6 rounded-3xl border border-rose-100 space-y-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div class="relative" x-on:click.outside="showDisposalDropdown = false">
<label class="block text-[10px] font-black text-rose-400 uppercase mb-2 ml-1">매각 대상자 (사내
직원)</label>
<div class="relative">
<input type="text" x-model="disposalSearchQuery"
@focus="showDisposalDropdown = true; disposalSearchQuery = ''"
@input="showDisposalDropdown = true" placeholder="매각 받은 직원 검색"
class="w-full px-4 py-3 bg-white border border-rose-200 rounded-xl focus:ring-2 focus:ring-rose-500 outline-none text-sm font-bold text-slate-700">
<div class="absolute right-3 top-3.5">
<svg class="w-4 h-4 text-rose-300" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
<!-- Disposal Search Dropdown -->
<div x-show="showDisposalDropdown" x-transition
class="absolute z-[120] mt-2 w-full bg-white rounded-2xl shadow-2xl border border-slate-100 max-h-60 overflow-y-auto overflow-x-hidden no-scrollbar">
<template x-for="user in filteredDisposalUsers" :key="user.id">
<div @click="selectDisposalUser(user)"
class="px-4 py-3 hover:bg-rose-50 cursor-pointer flex items-center justify-between transition-colors group">
<div>
<div class="text-sm font-bold text-slate-700 group-hover:text-rose-600"
x-text="user.name"></div>
<div class="text-[10px] text-slate-400 font-medium"
x-text="user.dept_name || '부서 정보 없음'"></div>
</div>
<div class="text-[10px] font-black text-slate-300 group-hover:text-rose-400"
x-text="user.emp_id"></div>
</div>
</template>
</div>
</div>
<div>
<label class="block text-[10px] font-black text-rose-400 uppercase mb-2 ml-1">실제 사용자</label>
<input type="text" x-model="formData.real_user" placeholder="실제 기기 사용자 입력"
class="w-full px-4 py-3 bg-white border border-rose-200 rounded-xl focus:ring-2 focus:ring-rose-500 outline-none text-sm font-bold text-slate-700">
</div>
<div>
<label class="block text-[10px] font-black text-rose-400 uppercase mb-2 ml-1">매각 날짜</label>
<input type="date" x-model="formData.disposal_date"
class="w-full px-4 py-3 bg-white border border-rose-200 rounded-xl focus:ring-2 focus:ring-rose-500 outline-none text-sm font-bold text-slate-700">
</div>
</div>
</div>
<!-- 개별 자산 정보 섹션 -->
<div class="bg-blue-50/50 p-6 rounded-[2rem] border border-blue-100/50 space-y-4">
<div class="flex items-center gap-2 mb-2">
<div class="w-1.5 h-1.5 rounded-full bg-blue-500"></div>
<h4 class="text-xs font-black text-blue-600 uppercase tracking-widest">Individual Asset Info
</h4>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="relative" x-on:click.outside="showUserDropdown = false">
<label class="block text-[10px] font-black text-slate-400 uppercase mb-2 ml-1">
배정 사용자
<span x-show="formData.status === 'assigned' && !formData.current_user_id"
class="text-rose-500 ml-2 animate-pulse font-bold">[값이 없음]</span>
</label>
<div class="relative">
<input type="text" x-model="userSearchQuery"
@focus="showUserDropdown = true; userSearchQuery = ''"
@input="showUserDropdown = true" placeholder="사용자 검색 (이름, 사번)"
:disabled="formData.status !== 'assigned'"
:class="formData.status !== 'assigned' ? 'bg-slate-100 border-dashed cursor-not-allowed opacity-75' : 'bg-white'"
class="w-full px-4 py-3 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm font-bold text-slate-700">
<div class="absolute right-3 top-3.5 flex items-center gap-2">
<button type="button" x-show="userSearchQuery && formData.status === 'assigned'"
@click="clearUser()" class="text-slate-400 hover:text-slate-600">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<svg class="w-4 h-4 text-slate-400" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M19 9l-7 7-7-7" />
</svg>
</div>
</div>
<!-- User Search Dropdown -->
<div x-show="showUserDropdown" x-transition
class="absolute z-[120] mt-2 w-full bg-white rounded-2xl shadow-2xl border border-slate-100 max-h-60 overflow-y-auto overflow-x-hidden no-scrollbar">
<template x-for="user in filteredUsers" :key="user.id">
<div @click="selectUser(user)"
class="px-4 py-3 hover:bg-blue-50 cursor-pointer flex items-center justify-between transition-colors group">
<div>
<div class="text-sm font-bold text-slate-700 group-hover:text-blue-600"
x-text="user.name"></div>
<div class="text-[10px] text-slate-400 font-medium"
x-text="user.dept_name || '부서 정보 없음'"></div>
</div>
<div class="text-[10px] font-black text-slate-300 group-hover:text-blue-400"
x-text="user.emp_id"></div>
</div>
</template>
</div>
</div>
<div>
<label class="block text-[10px] font-black text-slate-400 uppercase mb-2 ml-1">고정 IP
주소</label>
<input type="text" x-model="formData.ip_address" placeholder="192.168.x.x"
@focus="if(!formData.ip_address) formData.ip_address = '192.168.1.'"
class="w-full px-4 py-3 bg-white border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm font-bold text-slate-700">
</div>
<div>
<label class="block text-[10px] font-black text-slate-400 uppercase mb-2 ml-1">노트북 상태
(비고)</label>
<input type="text" x-model="formData.asset_status" placeholder="예: 정상"
class="w-full px-4 py-3 bg-white border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none transition-all text-sm font-bold text-slate-700">
</div>
</div>
</div>
<hr class="border-slate-100">
<!-- 상세 사양 섹션 -->
<div class="flex items-center justify-between">
<h4 class="text-sm font-black text-slate-400 uppercase tracking-widest">Hardware Specifications</h4>
<button type="button" @click="applyModelDefaults" x-show="formData.model_id"
class="text-[10px] font-bold text-blue-600 bg-blue-50 px-2 py-1 rounded hover:bg-blue-100 transition-colors">
모델 기본값 불러오기
</button>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="block text-xs font-bold text-slate-500 uppercase mb-2">CPU</label>
<input type="text" x-model="formData.cpu"
class="w-full px-4 py-2 bg-slate-50 border border-slate-200 rounded-lg text-sm font-medium">
</div>
<div>
<label class="block text-xs font-bold text-slate-500 uppercase mb-2">NPU</label>
<input type="text" x-model="formData.npu"
class="w-full px-4 py-2 bg-slate-50 border border-slate-200 rounded-lg text-sm font-medium">
</div>
<div>
<label class="block text-xs font-bold text-slate-500 uppercase mb-2">RAM</label>
<input type="text" x-model="formData.ram"
class="w-full px-4 py-2 bg-slate-50 border border-slate-200 rounded-lg text-sm font-medium">
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="bg-slate-50 p-4 rounded-xl space-y-3">
<label class="block text-[10px] font-black text-slate-400 uppercase">Storage 0</label>
<div class="grid grid-cols-2 gap-2">
<input type="text" x-model="formData.hdd0_model" placeholder="모델명/타입"
class="w-full px-3 py-2 bg-white border border-slate-200 rounded-lg text-sm font-medium">
<input type="text" x-model="formData.hdd0_capacity" placeholder="용량"
class="w-full px-3 py-2 bg-white border border-slate-200 rounded-lg text-sm font-medium">
</div>
</div>
<div class="bg-slate-50 p-4 rounded-xl space-y-3">
<label class="block text-[10px] font-black text-slate-400 uppercase">Storage 1</label>
<div class="grid grid-cols-2 gap-2">
<input type="text" x-model="formData.hdd1_model" placeholder="모델명/타입"
class="w-full px-3 py-2 bg-white border border-slate-200 rounded-lg text-sm font-medium">
<input type="text" x-model="formData.hdd1_capacity" placeholder="용량"
class="w-full px-3 py-2 bg-white border border-slate-200 rounded-lg text-sm font-medium">
</div>
</div>
</div>
<!-- 모델 기반 공통 정보 섹션 (편집 가능하도록 개선) -->
<div class="bg-slate-50 p-6 rounded-[2rem] border border-slate-100 space-y-4">
<div class="flex items-center gap-2 mb-2">
<div class="w-1.5 h-1.5 rounded-full bg-blue-400"></div>
<h4 class="text-xs font-black text-blue-400 uppercase tracking-widest">Model Base Info (Editable)</h4>
</div>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label class="block text-[10px] font-black text-slate-400 uppercase mb-2 ml-1">취득일</label>
<input type="date" x-model="formData.purchase_date"
class="w-full px-4 py-3 bg-white border border-slate-200 rounded-xl text-sm font-bold text-slate-700 focus:ring-2 focus:ring-blue-500 outline-none transition-all">
</div>
<div>
<label class="block text-[10px] font-black text-slate-400 uppercase mb-2 ml-1">정격입출력</label>
<input type="text" x-model="formData.power_rating" placeholder="예: 20V / 3.25A"
class="w-full px-4 py-3 bg-white border border-slate-200 rounded-xl text-sm font-bold text-slate-700 focus:ring-2 focus:ring-blue-500 outline-none transition-all">
</div>
<div>
<label class="block text-[10px] font-black text-slate-400 uppercase mb-2 ml-1">옵션</label>
<input type="text" x-model="formData.options" placeholder="예: 지문인식, 백라이트"
class="w-full px-4 py-3 bg-white border border-slate-200 rounded-xl text-sm font-bold text-slate-700 focus:ring-2 focus:ring-blue-500 outline-none transition-all">
</div>
</div>
</div>
<div>
<label class="block text-xs font-bold text-slate-500 uppercase mb-2">비고 (특기사항)</label>
<textarea x-model="formData.remarks" rows="2" placeholder="자산과 관련된 특이사항을 입력하세요."
class="w-full px-4 py-3 bg-slate-50 border border-slate-200 rounded-xl focus:ring-2 focus:ring-blue-500 outline-none resize-none transition-all"></textarea>
</div>
<div class="pt-4 flex gap-4">
<button type="button" @click="showModal = false"
class="flex-1 py-4 bg-slate-100 text-slate-600 rounded-2xl font-bold hover:bg-slate-200 transition-all">취소</button>
<button type="submit"
class="flex-1 py-4 bg-blue-600 text-white rounded-2xl font-bold shadow-lg shadow-blue-200 hover:bg-blue-700 transition-all"
x-text="isEdit ? '수정 완료' : '자산 등록하기'"></button>
</div>
</form>
</div>
</div>
<script>
function assetManagement() {
return {
assets: [],
models: [],
allUsers: [],
loading: true,
showModal: false,
isEdit: false,
selectedIds: [],
bulkStatus: '',
bulkModelId: '',
searchQuery: '',
sortKey: 'asset_tag',
sortOrder: 'asc',
currentTab: 'all',
formData: {
id: '', asset_tag: '', model_id: '', current_user_id: '', status: 'stock',
serial_number: '', purchase_date: '', last_confirmed_date: '', ip_address: '',
cpu: '', npu: '', ram: '',
hdd0_model: '', hdd0_capacity: '',
hdd1_model: '', hdd1_capacity: '',
asset_status: '', assigned_user_name: '', fixed_ip: '', options: '', power_rating: '',
manufacturer: '', vendor: '', product_name: '', remarks: '',
disposal_recipient: '', disposal_date: '', real_user: '', disposal_user_id: ''
},
userSearchQuery: '',
showUserDropdown: false,
disposalSearchQuery: '',
showDisposalDropdown: false,
get filteredDisposalUsers() {
if (!this.disposalSearchQuery) return this.allUsers.slice(0, 50);
const q = this.disposalSearchQuery.toLowerCase();
return this.allUsers.filter(u =>
(u.name && u.name.toLowerCase().includes(q)) ||
(u.emp_id && u.emp_id.toLowerCase().includes(q))
);
},
selectDisposalUser(user) {
this.formData.disposal_user_id = user.id;
this.formData.disposal_recipient = user.name;
this.disposalSearchQuery = `${user.name} (${user.emp_id})`;
this.showDisposalDropdown = false;
},
setStatus(status) {
const prevStatus = this.formData.status;
this.formData.status = status;
if (status === 'stock') {
this.formData.assigned_user_name = '업무용(TEMP_44666b)';
this.formData.current_user_id = '';
this.userSearchQuery = '업무용(TEMP_44666b)';
} else if (status === 'assigned') {
this.formData.assigned_user_name = '';
this.formData.current_user_id = '';
this.userSearchQuery = '';
} else if (status === 'disposed') {
if (!this.formData.disposal_date) {
this.formData.disposal_date = new Date().toISOString().split('T')[0];
}
}
},
get filteredUsers() {
if (!this.userSearchQuery) return this.allUsers.slice(0, 50);
const q = this.userSearchQuery.toLowerCase();
return this.allUsers.filter(u =>
(u.name && u.name.toLowerCase().includes(q)) ||
(u.emp_id && u.emp_id.toLowerCase().includes(q)) ||
(u.dept_name && u.dept_name.toLowerCase().includes(q))
);
},
selectUser(user) {
this.formData.current_user_id = user.id;
this.userSearchQuery = `${user.name} (${user.emp_id})`;
this.showUserDropdown = false;
this.updateAssignedUserName();
// Do not auto-change status to assigned here because user might be in stock mode
},
clearUser() {
this.formData.current_user_id = '';
this.userSearchQuery = '';
this.updateAssignedUserName();
if (this.formData.status === 'assigned') {
this.formData.status = 'stock';
}
},
get isAssetTagDuplicate() {
const tag = (this.formData.asset_tag || '').trim();
if (!tag) return false;
return this.assets.some(a => a.asset_tag === tag && a.id !== this.formData.id);
},
init() {
this.fetchAssets();
this.fetchModels();
this.fetchUsers();
},
toggleSelectAll(checked) {
this.selectedIds = checked ? this.assets.map(a => a.id) : [];
},
applyBulkUpdate() {
if (this.selectedIds.length === 0) return;
if (!this.bulkStatus && !this.bulkModelId) {
window.showAlert('변경할 항목(상태 또는 모델)을 선택해주세요.', '선택 오류', 'error');
return;
}
fetch('api.php?action=bulk_update_laptops', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ids: this.selectedIds,
status: this.bulkStatus,
model_id: this.bulkModelId
})
}).then(res => res.json()).then(data => {
if (data.success) {
window.showAlert(`${this.selectedIds.length}개의 자산 속성이 변경되었습니다.`, '변경 성공', 'success');
this.selectedIds = [];
this.bulkStatus = '';
this.bulkModelId = '';
this.fetchAssets();
} else {
window.showAlert('자산 정보 일괄 변경에 실패했습니다.', '변경 실패', 'error');
}
});
},
applyBulkDisposal() {
if (this.selectedIds.length === 0) return;
if (!confirm(`선택한 ${this.selectedIds.length}개의 자산을 '매각 완료' 처리하시겠습니까?\n이 작업은 되돌릴 수 없으며 자산이 매각 탭으로 이동합니다.`)) return;
fetch('api.php?action=bulk_update_laptops', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ids: this.selectedIds,
status: 'disposed'
})
}).then(res => res.json()).then(data => {
if (data.success) {
window.showAlert(`${this.selectedIds.length}개의 자산이 매각 처리되었습니다.`, '매각 완료', 'success');
this.selectedIds = [];
this.fetchAssets();
this.currentTab = 'disposed'; // 매각 탭으로 보냄
} else {
window.showAlert('매각 처리에 실패했습니다.', '오류', 'error');
}
});
},
bulkDelete() {
if (this.selectedIds.length === 0) return;
if (!confirm(`선택한 ${this.selectedIds.length}개의 자산을 시스템에서 영구적으로 삭제하시겠습니까?`)) return;
fetch('api.php?action=bulk_delete_laptops', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
ids: this.selectedIds
})
}).then(res => res.json()).then(data => {
if (data.success) {
window.showAlert(`${this.selectedIds.length}개의 자산이 삭제되었습니다.`, '삭제 완료', 'success');
this.selectedIds = [];
this.fetchAssets();
} else {
window.showAlert('자산 삭제에 실패했습니다.', '오류', 'error');
}
});
},
fetchAssets() {
const scrollPos = window.scrollY;
this.loading = true;
fetch('api.php?action=get_laptops').then(res => res.json()).then(data => {
this.assets = data;
this.loading = false;
this.$nextTick(() => window.scrollTo(0, scrollPos));
});
},
get filteredAssets() {
let result = [...this.assets];
// Tab filtering
if (this.currentTab === 'stock') {
result = result.filter(a => a.status === 'stock');
} else if (this.currentTab === 'assigned') {
result = result.filter(a => a.status === 'assigned');
} else if (this.currentTab === 'disposed') {
result = result.filter(a => a.status === 'disposed');
} else {
// 'all' tab usually excludes disposed unless specifically asked,
// but user said "managed separately", so let's exclude disposed from 'all'
result = result.filter(a => a.status !== 'disposed');
}
if (this.searchQuery) {
const q = this.searchQuery.toLowerCase();
result = result.filter(a => (a.asset_tag && a.asset_tag.toLowerCase().includes(q)) ||
(a.model_name && a.model_name.toLowerCase().includes(q)) ||
(a.manufacturer && a.manufacturer.toLowerCase().includes(q)) ||
(a.user_name && a.user_name.toLowerCase().includes(q)) ||
(a.ip_address && a.ip_address.toLowerCase().includes(q)) ||
(a.serial_number && a.serial_number.toLowerCase().includes(q)) ||
(a.disposal_recipient && a.disposal_recipient.toLowerCase().includes(q))
);
}
return result;
},
get sortedAssets() {
return this.filteredAssets.sort((a, b) => {
let v1 = a[this.sortKey] || '';
let v2 = b[this.sortKey] || '';
if (this.sortOrder === 'asc') return v1 > v2 ? 1 : -1;
return v1 < v2 ? 1 : -1;
});
},
sortBy(key) {
if (this.sortKey === key) {
this.sortOrder = this.sortOrder === 'asc' ? 'desc' : 'asc';
} else {
this.sortKey = key;
this.sortOrder = 'asc';
}
},
fetchModels() {
fetch('api.php?action=get_models').then(res => res.json()).then(data => this.models = data);
},
fetchUsers() {
fetch('api.php?action=get_users').then(res => res.json()).then(data => this.allUsers = data);
},
applyModelDefaults() {
if (!this.formData.model_id) return;
const model = this.models.find(m => m.id == this.formData.model_id);
if (model) {
// 모델에 정의된 사양을 자산 폼에 기본값으로 복사
this.formData.cpu = model.cpu || this.formData.cpu;
this.formData.npu = model.npu || this.formData.npu;
this.formData.ram = model.ram || this.formData.ram;
this.formData.hdd0_model = model.hdd0_model || this.formData.hdd0_model;
this.formData.hdd0_capacity = model.hdd0_capacity || this.formData.hdd0_capacity;
this.formData.hdd1_model = model.hdd1_model || this.formData.hdd1_model;
this.formData.hdd1_capacity = model.hdd1_capacity || this.formData.hdd1_capacity;
this.formData.power_rating = model.power_rating || this.formData.power_rating;
this.formData.options = model.options || this.formData.options;
this.formData.purchase_date = model.purchase_date || this.formData.purchase_date;
this.formData.manufacturer = model.manufacturer;
this.formData.product_name = model.product_name || this.formData.product_name;
this.formData.vendor = model.vendor || this.formData.vendor;
if (!this.isEdit) {
this.formData.remarks = model.remarks || this.formData.remarks;
}
}
},
openAddModal() {
this.isEdit = false;
this.formData = {
id: '', asset_tag: '', model_id: '', current_user_id: '', status: 'stock',
serial_number: '', purchase_date: new Date().toISOString().split('T')[0], last_confirmed_date: '', ip_address: '192.168.1.',
cpu: '', npu: '', ram: '', hdd0_model: '', hdd0_capacity: '', hdd1_model: '', hdd1_capacity: '',
asset_status: '', assigned_user_name: '', fixed_ip: '', options: '', power_rating: '',
manufacturer: '', vendor: '', product_name: '', remarks: '',
disposal_recipient: '', disposal_date: '', real_user: '', disposal_user_id: ''
};
this.userSearchQuery = '';
this.disposalSearchQuery = '';
this.showModal = true;
this.setStatus('stock'); // Default to 재고
},
editAsset(asset) {
this.isEdit = true;
this.formData = { ...asset };
if (!this.formData.ip_address) this.formData.ip_address = '192.168.1.';
if (asset.current_user_id) {
const user = this.allUsers.find(u => u.id == asset.current_user_id);
this.userSearchQuery = user ? `${user.name} (${user.emp_id})` : asset.user_name;
} else {
this.userSearchQuery = asset.assigned_user_name || '';
}
if (asset.status === 'disposed' && asset.disposal_recipient) {
this.disposalSearchQuery = asset.disposal_recipient;
} else {
this.disposalSearchQuery = '';
}
this.showModal = true;
},
submitAsset() {
// 유효성 검사 (직원배정 시 사용자 필수)
if (this.formData.status === 'assigned' && !this.formData.current_user_id) {
window.showAlert('직원배정 상태인 경우 반드시 사용자를 선택해야 합니다.', '입력 오류', 'error');
return;
}
if (this.formData.status === 'disposed' && !this.formData.disposal_recipient) {
window.showAlert('매각 상태인 경우 매각 대상자를 입력해야 합니다.', '입력 오류', 'error');
return;
}
const action = this.isEdit ? 'update_laptop_asset' : 'add_laptop_asset';
fetch(`api.php?action=${action}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(this.formData)
}).then(res => res.json()).then(data => {
if (data.success) {
window.showAlert(this.isEdit ? '자산 정보가 수정되었습니다.' : '새 자산이 등록되었습니다.', '성공', 'success');
this.showModal = false;
this.fetchAssets();
} else {
window.showAlert(data.error || '처리 중 오류가 발생했습니다.', '오류', 'error');
}
}).catch(err => {
console.error(err);
window.showAlert('서버 통신 오류가 발생했습니다.', '시스템 오류', 'error');
});
},
updateAssignedUserName() {
if (!this.formData.current_user_id) {
this.formData.assigned_user_name = '';
return;
}
const user = this.allUsers.find(u => u.id == this.formData.current_user_id);
if (user) {
this.formData.assigned_user_name = user.name;
}
},
exportToExcel() {
if (this.assets.length === 0) return;
const headers = ['자산관리번호', '모델명', '제조사', '프로세서', 'RAM', 'SSD', 'IP주소', '시리얼', '사용자', '상태', '취득일'];
const rows = this.assets.map(a => [
a.asset_tag, a.model_name, a.manufacturer, a.processor || '', a.ram || '', a.storage || '',
a.ip_address || '', a.serial_number || '', a.user_name || '미배정', a.status === 'assigned' ? '지급됨' : '재고', a.purchase_date || ''
]);
let csvContent = "\uFEFF" + headers.join(",") + "\n" + rows.map(r => r.map(c => `"${c}"`).join(",")).join("\n");
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.setAttribute("href", url);
link.setAttribute("download", `FKI_Laptop_Assets_${new Date().toISOString().split('T')[0]}.csv`);
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
}
}
</script>
</body>
</html>