This commit is contained in:
NAS-Admin 2026-02-09 19:52:44 +09:00
parent cfeea88124
commit 980483278a
2 changed files with 205 additions and 3 deletions

67
api.php
View file

@ -891,6 +891,73 @@ LIMIT 1")->fetchColumn();
echo json_encode(['success' => true]); echo json_encode(['success' => true]);
break; break;
case 'export_users_csv':
$data = json_decode(file_get_contents('php://input'), true);
$userIds = $data['user_ids'] ?? [];
$columns = $data['columns'] ?? []; // ['name' => '이름', 'emp_id' => '사번', ...]
if (empty($columns)) {
echo json_encode(['success' => false, 'error' => '내보낼 컬럼을 선택해주세요.']);
break;
}
// 전체 유저 정보 조회 (기본 쿼리 재사용)
$query = "WITH RECURSIVE dept_path(id, path) AS (
SELECT id, name FROM departments WHERE parent_id IS NULL
UNION ALL
SELECT d.id, dp.path || ' > ' || d.name
FROM departments d JOIN dept_path dp ON d.parent_id = dp.id
)
SELECT u.*, dp.path as dept_name,
(SELECT asset_tag FROM laptop_assets WHERE current_user_id = u.id ORDER BY id DESC LIMIT 1) as laptop_tag
FROM users u
LEFT JOIN dept_path dp ON u.department_id = dp.id";
if (!empty($userIds)) {
$placeholders = implode(',', array_fill(0, count($userIds), '?'));
$query .= " WHERE u.id IN ($placeholders)";
$stmt = $db->prepare($query);
$stmt->execute($userIds);
} else {
$stmt = $db->query($query);
}
$users = $stmt->fetchAll();
// CSV 스트림 생성
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename=직원_마스터_리스트_' . date('Ymd_His') . '.csv');
$output = fopen('php://output', 'w');
// Zero-Mojibake: BOM (Byte Order Mark) 삽입
fwrite($output, "\xEF\xBB\xBF");
// 헤더 작성
fputcsv($output, array_values($columns));
// 데이터 작성
foreach ($users as $user) {
$row = [];
foreach (array_keys($columns) as $key) {
$val = $user[$key] ?? '';
// 상태 값 한글 변환
if ($key === 'status') {
$val = match ($val) {
'active' => '재직',
'retired' => '퇴사',
'on_leave' => '휴직',
'classification' => '분류',
default => $val
};
}
$row[] = $val;
}
fputcsv($output, $row);
}
fclose($output);
exit;
case 'get_general_rentals': case 'get_general_rentals':
$query = "SELECT r.*, GROUP_CONCAT(i.item_name, ', ') as items $query = "SELECT r.*, GROUP_CONCAT(i.item_name, ', ') as items
FROM general_rentals r FROM general_rentals r

141
users.php
View file

@ -88,6 +88,14 @@
</svg> </svg>
불러오기 불러오기
</button> </button>
<button @click="openExportModal()"
class="bg-emerald-600 text-white px-5 py-2.5 rounded-xl font-bold shadow-lg shadow-emerald-200 hover:bg-emerald-700 transition-all flex items-center shrink-0">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
CSV 내보내기
</button>
</div> </div>
</div> </div>
</header> </header>
@ -152,13 +160,13 @@
<div class="h-8 w-px bg-white/10 mx-1"></div> <div class="h-8 w-px bg-white/10 mx-1"></div>
<button @click="archiveUsers" <button @click="openExportModal"
class="bg-emerald-600 hover:bg-emerald-700 px-6 py-2 rounded-xl text-xs font-black transition-all shadow-lg shadow-emerald-500/20 active:scale-95 flex items-center gap-2"> class="bg-emerald-600 hover:bg-emerald-700 px-6 py-2 rounded-xl text-xs font-black transition-all shadow-lg shadow-emerald-500/20 active:scale-95 flex items-center gap-2">
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" /> d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg> </svg>
내보내기 CSV 다운로드
</button> </button>
<button @click="bulkDelete" <button @click="bulkDelete"
class="bg-rose-600 hover:bg-rose-700 px-6 py-2 rounded-xl text-xs font-black transition-all shadow-lg shadow-rose-500/20 active:scale-95 flex items-center gap-2"> class="bg-rose-600 hover:bg-rose-700 px-6 py-2 rounded-xl text-xs font-black transition-all shadow-lg shadow-rose-500/20 active:scale-95 flex items-center gap-2">
@ -623,6 +631,72 @@
</div> </div>
</div> </div>
<!-- CSV Export Modal -->
<div x-show="showExportModal" x-cloak class="fixed inset-0 z-[130] flex items-center justify-center p-4">
<div class="fixed inset-0 modal-bg" @click="showExportModal = false"></div>
<div class="bg-white rounded-[2.5rem] p-8 max-w-lg w-full relative z-[131] shadow-2xl overflow-hidden">
<div class="flex justify-between items-start mb-6">
<div>
<h3 class="text-2xl font-black text-slate-900">CSV 내보내기 설정</h3>
<p class="text-xs font-bold text-slate-400 uppercase tracking-widest mt-1">Select Columns to Export
</p>
</div>
<button @click="showExportModal = false" class="text-slate-300 hover:text-slate-900 transition-colors">
<svg class="w-8 h-8" 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 class="mb-6">
<div class="flex items-center justify-between mb-4">
<span class="text-sm font-bold text-slate-600">내보낼 항목 선택</span>
<button @click="toggleAllColumns" class="text-xs font-bold text-blue-600 hover:underline">전체
선택/해제</button>
</div>
<div class="grid grid-cols-2 gap-3">
<template x-for="(label, key) in columnOptions" :key="key">
<label
class="flex items-center p-3 bg-slate-50 rounded-xl cursor-pointer hover:bg-blue-50 transition-colors border border-transparent"
:class="selectedColumns.includes(key) ? 'border-blue-200 bg-blue-50/50' : ''">
<input type="checkbox" :value="key" x-model="selectedColumns"
class="w-4 h-4 rounded border-slate-300 text-blue-600 focus:ring-blue-500 mr-3">
<span class="text-sm font-medium text-slate-700" x-text="label"></span>
</label>
</template>
</div>
</div>
<div class="flex flex-col gap-3">
<div class="p-4 bg-amber-50 rounded-2xl border border-amber-100 mb-2">
<div class="flex gap-3">
<svg class="w-5 h-5 text-amber-500 shrink-0" fill="none" stroke="currentColor"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<p class="text-[11px] font-bold text-amber-700 leading-normal">
<span x-show="selectedIds.length > 0"><span class="text-rose-600"
x-text="selectedIds.length"></span>명의 선택된 직원 정보를 내보냅니다.</span>
<span x-show="selectedIds.length === 0">전체 <span class="text-blue-600"
x-text="users.length"></span>명의 직원 정보를 내보냅니다.</span>
<br>UTF-8 BOM이 포함되어 엑셀에서 바로 열어도 한글이 깨지지 않습니다.
</p>
</div>
</div>
<button @click="downloadCSV" :disabled="selectedColumns.length === 0"
class="w-full py-4 bg-blue-600 text-white rounded-2xl font-black hover:bg-blue-700 transition-all shadow-lg shadow-blue-200 disabled:opacity-50 disabled:shadow-none flex items-center justify-center gap-2">
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
</svg>
CSV 다운로드 시작
</button>
</div>
</div>
</div>
<script> <script>
function userManagement() { function userManagement() {
return { return {
@ -643,8 +717,22 @@
bulkPosition: '', bulkPosition: '',
showRentalListModal: false, showRentalListModal: false,
showRestoreModal: false, showRestoreModal: false,
showExportModal: false,
userRentals: [], userRentals: [],
archivedUsers: [], archivedUsers: [],
selectedColumns: ['name', 'emp_id', 'dept_name', 'position', 'email', 'mobile', 'status', 'laptop_tag'],
columnOptions: {
'name': '이름',
'emp_id': '사번',
'dept_name': '부서',
'position': '직위',
'email': '이메일',
'mobile': '휴대폰',
'phone': '사내전화',
'accounting_type': '회계구분',
'status': '상태',
'laptop_tag': '노트북 자산번호'
},
formData: { id: '', name: '', emp_id: '', department_id: '', position: '', email: '', mobile: '', accounting_type: '일반회계', status: 'active', phone: '' }, formData: { id: '', name: '', emp_id: '', department_id: '', position: '', email: '', mobile: '', accounting_type: '일반회계', status: 'active', phone: '' },
init() { init() {
@ -917,6 +1005,53 @@
this.fetchUsers(); this.fetchUsers();
} }
}); });
},
openExportModal() {
this.showExportModal = true;
},
toggleAllColumns() {
if (this.selectedColumns.length === Object.keys(this.columnOptions).length) {
this.selectedColumns = [];
} else {
this.selectedColumns = Object.keys(this.columnOptions);
}
},
async downloadCSV() {
const columns = {};
this.selectedColumns.forEach(key => {
columns[key] = this.columnOptions[key];
});
try {
const response = await fetch('api.php?action=export_users_csv', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
user_ids: this.selectedIds,
columns: columns
})
});
if (!response.ok) throw new Error('Download failed');
const blob = await response.blob();
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `직원목록_${new Date().toISOString().slice(0, 10)}.csv`;
document.body.appendChild(a);
a.click();
window.URL.revokeObjectURL(url);
this.showExportModal = false;
window.showAlert('CSV 파일 다운로드가 시작되었습니다.', '다운로드 성공', 'success');
} catch (err) {
console.error(err);
window.showAlert('다운로드 중 오류가 발생했습니다.', '오류', 'error');
}
} }
} }
} }