1646 lines
63 KiB
JavaScript
1646 lines
63 KiB
JavaScript
/**
|
|
* 夥伴對齊系統 - 前端應用程式
|
|
* 包含認證、儀表板、評估、回饋、排名等功能
|
|
*/
|
|
|
|
class PartnerAlignmentApp {
|
|
constructor() {
|
|
this.currentUser = null;
|
|
this.accessToken = localStorage.getItem('accessToken');
|
|
this.refreshToken = localStorage.getItem('refreshToken');
|
|
this.currentSection = 'dashboard';
|
|
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
this.setupEventListeners();
|
|
this.checkAuthentication();
|
|
this.setupNavigation();
|
|
}
|
|
|
|
setupEventListeners() {
|
|
// Login/Register modals
|
|
document.getElementById('loginBtn').addEventListener('click', () => this.showLoginModal());
|
|
document.getElementById('showLoginBtn').addEventListener('click', () => this.showLoginModal());
|
|
document.getElementById('showRegisterModal').addEventListener('click', () => this.showRegisterModal());
|
|
|
|
// Forms
|
|
document.getElementById('loginForm').addEventListener('submit', (e) => this.handleLogin(e));
|
|
document.getElementById('registerForm').addEventListener('submit', (e) => this.handleRegister(e));
|
|
document.getElementById('assessmentForm').addEventListener('submit', (e) => this.handleAssessmentSubmit(e));
|
|
document.getElementById('starFeedbackForm').addEventListener('submit', (e) => this.handleStarFeedbackSubmit(e));
|
|
|
|
// Logout
|
|
document.getElementById('logoutBtn').addEventListener('click', () => this.handleLogout());
|
|
|
|
// Navigation
|
|
document.querySelectorAll('[data-section]').forEach(link => {
|
|
link.addEventListener('click', (e) => this.handleNavigation(e));
|
|
});
|
|
|
|
// Ranking filters
|
|
document.getElementById('applyFilters').addEventListener('click', () => this.loadAdvancedRankings());
|
|
|
|
// Notifications
|
|
document.getElementById('markAllReadBtn').addEventListener('click', () => this.markAllNotificationsRead());
|
|
|
|
// Admin functions
|
|
document.getElementById('refreshUsersBtn').addEventListener('click', () => this.loadAdminUsers());
|
|
|
|
// Department capabilities management
|
|
document.getElementById('deptSelect').addEventListener('change', (e) => this.handleDepartmentSelect(e));
|
|
document.getElementById('loadDeptCapabilitiesBtn').addEventListener('click', () => this.loadDepartmentCapabilities());
|
|
document.getElementById('saveDeptCapabilitiesBtn').addEventListener('click', () => this.saveDepartmentCapabilities());
|
|
|
|
// Assessment department change - reload capabilities based on department
|
|
document.getElementById('assessmentDepartment').addEventListener('change', (e) => this.handleAssessmentDepartmentChange(e));
|
|
|
|
// Capabilities CSV import
|
|
document.getElementById('importCsvBtn').addEventListener('click', () => this.importCapabilitiesCsv());
|
|
document.getElementById('downloadTemplateCsvBtn').addEventListener('click', () => this.downloadCsvTemplate());
|
|
document.getElementById('refreshCapabilitiesBtn').addEventListener('click', () => this.loadCapabilitiesList());
|
|
}
|
|
|
|
setupNavigation() {
|
|
// Set default section
|
|
this.showSection('dashboard');
|
|
}
|
|
|
|
checkAuthentication() {
|
|
if (this.accessToken) {
|
|
// 嘗試從 localStorage 恢復用戶信息
|
|
const storedUser = localStorage.getItem('currentUser');
|
|
if (storedUser) {
|
|
try {
|
|
this.currentUser = JSON.parse(storedUser);
|
|
} catch (e) {
|
|
console.error('Failed to parse stored user:', e);
|
|
}
|
|
}
|
|
|
|
if (this.currentUser) {
|
|
this.showAuthenticatedUI();
|
|
this.loadDashboard();
|
|
this.loadNotifications();
|
|
} else {
|
|
this.validateToken();
|
|
}
|
|
} else {
|
|
this.showLoginRequired();
|
|
}
|
|
}
|
|
|
|
async validateToken() {
|
|
try {
|
|
const response = await this.apiCall('/api/auth/protected', 'GET');
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
this.currentUser = data;
|
|
this.showAuthenticatedUI();
|
|
this.loadDashboard();
|
|
this.loadNotifications();
|
|
} else {
|
|
this.handleTokenExpired();
|
|
}
|
|
} catch (error) {
|
|
console.error('Token validation failed:', error);
|
|
this.handleTokenExpired();
|
|
}
|
|
}
|
|
|
|
handleTokenExpired() {
|
|
this.clearTokens();
|
|
this.showLoginRequired();
|
|
this.showToast('登入已過期,請重新登入', 'warning');
|
|
}
|
|
|
|
clearTokens() {
|
|
localStorage.removeItem('accessToken');
|
|
localStorage.removeItem('refreshToken');
|
|
this.accessToken = null;
|
|
this.refreshToken = null;
|
|
this.currentUser = null;
|
|
}
|
|
|
|
showLoginRequired() {
|
|
document.getElementById('loginRequired').style.display = 'block';
|
|
document.querySelectorAll('.section').forEach(section => {
|
|
section.style.display = 'none';
|
|
});
|
|
document.getElementById('authNav').style.display = 'block';
|
|
document.getElementById('userNav').style.display = 'none';
|
|
}
|
|
|
|
showAuthenticatedUI() {
|
|
// 隱藏登入提示,顯示用戶界面
|
|
const loginRequired = document.getElementById('loginRequired');
|
|
if (loginRequired) loginRequired.style.display = 'none';
|
|
|
|
// 隱藏登入按鈕,顯示用戶菜單
|
|
const authNav = document.getElementById('authNav');
|
|
if (authNav) authNav.style.display = 'none';
|
|
|
|
const userNav = document.getElementById('userNav');
|
|
if (userNav) userNav.style.display = 'block';
|
|
|
|
// 更新用戶顯示名稱
|
|
const userDisplayName = document.getElementById('userDisplayName');
|
|
if (userDisplayName && this.currentUser) {
|
|
userDisplayName.textContent = this.currentUser.full_name || this.currentUser.username || '用戶';
|
|
}
|
|
|
|
// 根據權限顯示/隱藏管理功能
|
|
const adminNavItem = document.getElementById('adminNavItem');
|
|
if (adminNavItem && this.currentUser) {
|
|
// 檢查用戶名或角色以決定是否顯示管理功能
|
|
const username = this.currentUser.username || '';
|
|
const hasAdminAccess = username === 'admin' || username === 'hr_manager';
|
|
adminNavItem.style.display = hasAdminAccess ? 'block' : 'none';
|
|
}
|
|
|
|
// 顯示儀表板
|
|
this.showSection('dashboard');
|
|
}
|
|
|
|
showLoginModal() {
|
|
const modal = new bootstrap.Modal(document.getElementById('loginModal'));
|
|
modal.show();
|
|
}
|
|
|
|
showRegisterModal() {
|
|
const loginModal = bootstrap.Modal.getInstance(document.getElementById('loginModal'));
|
|
if (loginModal) loginModal.hide();
|
|
|
|
const registerModal = new bootstrap.Modal(document.getElementById('registerModal'));
|
|
registerModal.show();
|
|
}
|
|
|
|
async handleLogin(e) {
|
|
e.preventDefault();
|
|
|
|
const username = document.getElementById('loginUsername').value;
|
|
const password = document.getElementById('loginPassword').value;
|
|
|
|
try {
|
|
this.showLoading(true);
|
|
|
|
const response = await fetch('/api/auth/login', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ username, password })
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
this.accessToken = data.access_token;
|
|
this.refreshToken = data.refresh_token;
|
|
this.currentUser = data.user; // 保存用戶信息
|
|
|
|
localStorage.setItem('accessToken', this.accessToken);
|
|
localStorage.setItem('refreshToken', this.refreshToken);
|
|
localStorage.setItem('currentUser', JSON.stringify(this.currentUser));
|
|
|
|
// Close modal
|
|
const modal = bootstrap.Modal.getInstance(document.getElementById('loginModal'));
|
|
if (modal) modal.hide();
|
|
|
|
// 更新 UI
|
|
this.showAuthenticatedUI();
|
|
this.loadDashboard();
|
|
this.loadNotifications();
|
|
|
|
this.showToast('登入成功!歡迎 ' + data.user.full_name, 'success');
|
|
} else {
|
|
const error = await response.json();
|
|
this.showToast(error.error || error.message || '登入失敗', 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Login error:', error);
|
|
this.showToast('登入時發生錯誤', 'error');
|
|
} finally {
|
|
this.showLoading(false);
|
|
}
|
|
}
|
|
|
|
async handleRegister(e) {
|
|
e.preventDefault();
|
|
|
|
const formData = {
|
|
username: document.getElementById('regUsername').value,
|
|
email: document.getElementById('regEmail').value,
|
|
password: document.getElementById('regPassword').value,
|
|
full_name: document.getElementById('regFullName').value,
|
|
department: document.getElementById('regDepartment').value,
|
|
position: document.getElementById('regPosition').value,
|
|
employee_id: document.getElementById('regEmployeeId').value
|
|
};
|
|
|
|
try {
|
|
this.showLoading(true);
|
|
|
|
const response = await fetch('/api/auth/register', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify(formData)
|
|
});
|
|
|
|
if (response.ok) {
|
|
const modal = bootstrap.Modal.getInstance(document.getElementById('registerModal'));
|
|
modal.hide();
|
|
this.showToast('註冊成功!請登入', 'success');
|
|
this.showLoginModal();
|
|
} else {
|
|
const error = await response.json();
|
|
this.showToast(error.message || '註冊失敗', 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Register error:', error);
|
|
this.showToast('註冊時發生錯誤', 'error');
|
|
} finally {
|
|
this.showLoading(false);
|
|
}
|
|
}
|
|
|
|
handleLogout() {
|
|
this.clearTokens();
|
|
localStorage.removeItem('currentUser'); // 清除用戶信息
|
|
this.currentUser = null;
|
|
this.showLoginRequired();
|
|
this.showToast('已登出', 'info');
|
|
}
|
|
|
|
handleNavigation(e) {
|
|
e.preventDefault();
|
|
const section = e.currentTarget.getAttribute('data-section');
|
|
this.showSection(section);
|
|
}
|
|
|
|
showSection(sectionName) {
|
|
// Hide all sections
|
|
document.querySelectorAll('.section').forEach(section => {
|
|
section.style.display = 'none';
|
|
});
|
|
|
|
// Show target section
|
|
const targetSection = document.getElementById(`${sectionName}-section`);
|
|
if (targetSection) {
|
|
targetSection.style.display = 'block';
|
|
this.currentSection = sectionName;
|
|
|
|
// Update navigation
|
|
document.querySelectorAll('[data-section]').forEach(link => {
|
|
link.classList.remove('active');
|
|
});
|
|
document.querySelector(`[data-section="${sectionName}"]`).classList.add('active');
|
|
|
|
// Load section-specific data
|
|
this.loadSectionData(sectionName);
|
|
}
|
|
}
|
|
|
|
async loadSectionData(sectionName) {
|
|
switch (sectionName) {
|
|
case 'dashboard':
|
|
await this.loadDashboard();
|
|
break;
|
|
case 'assessment':
|
|
await this.loadCapabilities();
|
|
break;
|
|
case 'star-feedback':
|
|
// STAR feedback form is static
|
|
break;
|
|
case 'rankings':
|
|
await this.loadTotalRankings();
|
|
await this.loadMonthlyRankings();
|
|
break;
|
|
case 'admin':
|
|
await this.loadAdminData();
|
|
break;
|
|
}
|
|
}
|
|
|
|
async loadDashboard() {
|
|
try {
|
|
const response = await this.apiCall('/api/dashboard/me', 'GET');
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
this.renderDashboard(data);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load dashboard:', error);
|
|
}
|
|
}
|
|
|
|
renderDashboard(data) {
|
|
const container = document.getElementById('dashboardContent');
|
|
|
|
container.innerHTML = `
|
|
<div class="col-md-3">
|
|
<div class="card bg-primary text-white">
|
|
<div class="card-body">
|
|
<div class="d-flex justify-content-between">
|
|
<div>
|
|
<h4 class="card-title">總積分</h4>
|
|
<h2>${data.points_summary.total_points || 0}</h2>
|
|
</div>
|
|
<div class="align-self-center">
|
|
<i class="bi bi-star-fill" style="font-size: 2rem;"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card bg-success text-white">
|
|
<div class="card-body">
|
|
<div class="d-flex justify-content-between">
|
|
<div>
|
|
<h4 class="card-title">本月積分</h4>
|
|
<h2>${data.points_summary.monthly_points || 0}</h2>
|
|
</div>
|
|
<div class="align-self-center">
|
|
<i class="bi bi-calendar-month" style="font-size: 2rem;"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card bg-warning text-white">
|
|
<div class="card-body">
|
|
<div class="d-flex justify-content-between">
|
|
<div>
|
|
<h4 class="card-title">部門排名</h4>
|
|
<h2>${data.points_summary.department_rank || 'N/A'}</h2>
|
|
</div>
|
|
<div class="align-self-center">
|
|
<i class="bi bi-trophy" style="font-size: 2rem;"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<div class="card bg-info text-white">
|
|
<div class="card-body">
|
|
<div class="d-flex justify-content-between">
|
|
<div>
|
|
<h4 class="card-title">總排名</h4>
|
|
<h2>${data.points_summary.total_rank || 'N/A'}</h2>
|
|
</div>
|
|
<div class="align-self-center">
|
|
<i class="bi bi-award" style="font-size: 2rem;"></i>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<div class="col-12 mt-4">
|
|
<div class="card">
|
|
<div class="card-header">
|
|
<h5 class="card-title mb-0">最近通知</h5>
|
|
</div>
|
|
<div class="card-body">
|
|
${this.renderNotifications(data.recent_notifications)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
renderNotifications(notifications) {
|
|
if (!notifications || notifications.length === 0) {
|
|
return '<p class="text-muted">暫無通知</p>';
|
|
}
|
|
|
|
return notifications.map(notification => `
|
|
<div class="alert alert-${notification.is_read ? 'light' : 'primary'} alert-dismissible fade show" role="alert">
|
|
<strong>${notification.title}</strong><br>
|
|
${notification.message}
|
|
<small class="text-muted d-block mt-1">${new Date(notification.created_at).toLocaleString()}</small>
|
|
${!notification.is_read ? `<button type="button" class="btn-close" onclick="app.markNotificationAsRead(${notification.id})"></button>` : ''}
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
async markNotificationAsRead(notificationId) {
|
|
try {
|
|
await this.apiCall(`/api/dashboard/notifications/${notificationId}/read`, 'POST');
|
|
this.loadDashboard(); // Reload dashboard to update notifications
|
|
} catch (error) {
|
|
console.error('Failed to mark notification as read:', error);
|
|
}
|
|
}
|
|
|
|
async loadCapabilities(department = null) {
|
|
try {
|
|
let url = '/api/capabilities';
|
|
|
|
// 如果指定了部門,則只載入該部門選擇的能力項目
|
|
if (department) {
|
|
url = `/api/department-capabilities/${department}`;
|
|
}
|
|
|
|
const response = await this.apiCall(url, 'GET');
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
this.renderCapabilities(data.capabilities);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load capabilities:', error);
|
|
}
|
|
}
|
|
|
|
async handleAssessmentDepartmentChange(e) {
|
|
const department = e.target.value;
|
|
const container = document.getElementById('capabilitiesContainer');
|
|
|
|
if (!department) {
|
|
container.innerHTML = `
|
|
<div class="alert alert-info">
|
|
<i class="bi bi-info-circle me-2"></i>
|
|
請先選擇部門以載入對應的能力評估項目
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
// 根據選擇的部門載入能力項目
|
|
await this.loadCapabilities(department);
|
|
}
|
|
|
|
renderCapabilities(capabilities) {
|
|
const container = document.getElementById('capabilitiesContainer');
|
|
|
|
if (!capabilities || capabilities.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="alert alert-warning">
|
|
<i class="bi bi-exclamation-triangle me-2"></i>
|
|
該部門尚未設定能力評估項目。請聯繫管理員進行設定。
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = capabilities.map(capability => `
|
|
<div class="card mb-3">
|
|
<div class="card-header">
|
|
<h6 class="card-title mb-0">${capability.name}</h6>
|
|
</div>
|
|
<div class="card-body">
|
|
<div class="row">
|
|
<div class="col-md-2">
|
|
<label class="form-label">L1 - 初級</label>
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="radio" name="capability_${capability.id}" value="1" id="cap_${capability.id}_1">
|
|
<label class="form-check-label" for="cap_${capability.id}_1">
|
|
${capability.l1_description}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label class="form-label">L2 - 中級</label>
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="radio" name="capability_${capability.id}" value="2" id="cap_${capability.id}_2">
|
|
<label class="form-check-label" for="cap_${capability.id}_2">
|
|
${capability.l2_description}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label class="form-label">L3 - 高級</label>
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="radio" name="capability_${capability.id}" value="3" id="cap_${capability.id}_3">
|
|
<label class="form-check-label" for="cap_${capability.id}_3">
|
|
${capability.l3_description}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label class="form-label">L4 - 專家</label>
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="radio" name="capability_${capability.id}" value="4" id="cap_${capability.id}_4">
|
|
<label class="form-check-label" for="cap_${capability.id}_4">
|
|
${capability.l4_description}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label class="form-label">L5 - 大師</label>
|
|
<div class="form-check">
|
|
<input class="form-check-input" type="radio" name="capability_${capability.id}" value="5" id="cap_${capability.id}_5">
|
|
<label class="form-check-label" for="cap_${capability.id}_5">
|
|
${capability.l5_description}
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
}
|
|
|
|
async handleAssessmentSubmit(e) {
|
|
e.preventDefault();
|
|
|
|
const formData = {
|
|
department: document.getElementById('assessmentDepartment').value,
|
|
position: document.getElementById('assessmentPosition').value,
|
|
employee_name: document.getElementById('assessmentEmployeeName').value,
|
|
assessment_data: this.collectAssessmentData()
|
|
};
|
|
|
|
try {
|
|
this.showLoading(true);
|
|
|
|
const response = await this.apiCall('/api/assessments', 'POST', formData);
|
|
|
|
if (response.ok) {
|
|
this.showToast('評估提交成功!', 'success');
|
|
document.getElementById('assessmentForm').reset();
|
|
} else {
|
|
const error = await response.json();
|
|
this.showToast(error.error || '提交失敗', 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('Assessment submission error:', error);
|
|
this.showToast('提交時發生錯誤', 'error');
|
|
} finally {
|
|
this.showLoading(false);
|
|
}
|
|
}
|
|
|
|
collectAssessmentData() {
|
|
const data = {};
|
|
document.querySelectorAll('[name^="capability_"]:checked').forEach(radio => {
|
|
const capabilityId = radio.name.split('_')[1];
|
|
data[capabilityId] = parseInt(radio.value);
|
|
});
|
|
return data;
|
|
}
|
|
|
|
async handleStarFeedbackSubmit(e) {
|
|
e.preventDefault();
|
|
|
|
const formData = {
|
|
evaluator_name: document.getElementById('evaluatorName').value,
|
|
evaluatee_name: document.getElementById('evaluateeName').value,
|
|
evaluatee_department: document.getElementById('evaluateeDepartment').value,
|
|
evaluatee_position: document.getElementById('evaluateePosition').value,
|
|
situation: document.getElementById('situation').value,
|
|
task: document.getElementById('task').value,
|
|
action: document.getElementById('action').value,
|
|
result: document.getElementById('result').value,
|
|
score: parseInt(document.getElementById('score').value)
|
|
};
|
|
|
|
try {
|
|
this.showLoading(true);
|
|
|
|
const response = await this.apiCall('/api/star-feedbacks', 'POST', formData);
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
this.showToast(`回饋提交成功!獲得 ${data.points_earned} 積分`, 'success');
|
|
document.getElementById('starFeedbackForm').reset();
|
|
} else {
|
|
const error = await response.json();
|
|
this.showToast(error.error || '提交失敗', 'error');
|
|
}
|
|
} catch (error) {
|
|
console.error('STAR feedback submission error:', error);
|
|
this.showToast('提交時發生錯誤', 'error');
|
|
} finally {
|
|
this.showLoading(false);
|
|
}
|
|
}
|
|
|
|
async loadTotalRankings() {
|
|
try {
|
|
const department = document.getElementById('totalRankingDepartment').value;
|
|
const params = department ? `?department=${encodeURIComponent(department)}` : '';
|
|
|
|
const response = await this.apiCall(`/api/rankings/total${params}`, 'GET');
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
this.renderTotalRankings(data.rankings);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load total rankings:', error);
|
|
}
|
|
}
|
|
|
|
renderTotalRankings(rankings) {
|
|
const container = document.getElementById('totalRankingsList');
|
|
|
|
if (!rankings || rankings.length === 0) {
|
|
container.innerHTML = '<p class="text-muted">暫無排名數據</p>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = `
|
|
<div class="table-responsive">
|
|
<table class="table table-striped">
|
|
<thead>
|
|
<tr>
|
|
<th>排名</th>
|
|
<th>姓名</th>
|
|
<th>部門</th>
|
|
<th>職位</th>
|
|
<th>總積分</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${rankings.map(ranking => `
|
|
<tr>
|
|
<td><span class="badge bg-primary">${ranking.rank}</span></td>
|
|
<td>${ranking.employee_name}</td>
|
|
<td>${ranking.department}</td>
|
|
<td>${ranking.position}</td>
|
|
<td><strong>${ranking.total_points}</strong></td>
|
|
</tr>
|
|
`).join('')}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
async loadMonthlyRankings() {
|
|
try {
|
|
const year = document.getElementById('monthlyRankingYear').value;
|
|
const month = document.getElementById('monthlyRankingMonth').value;
|
|
|
|
const response = await this.apiCall(`/api/rankings/monthly?year=${year}&month=${month}`, 'GET');
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
this.renderMonthlyRankings(data.rankings);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load monthly rankings:', error);
|
|
}
|
|
}
|
|
|
|
renderMonthlyRankings(rankings) {
|
|
const container = document.getElementById('monthlyRankingsList');
|
|
|
|
if (!rankings || rankings.length === 0) {
|
|
container.innerHTML = '<p class="text-muted">暫無月度排名數據</p>';
|
|
return;
|
|
}
|
|
|
|
container.innerHTML = `
|
|
<div class="table-responsive">
|
|
<table class="table table-striped">
|
|
<thead>
|
|
<tr>
|
|
<th>排名</th>
|
|
<th>姓名</th>
|
|
<th>部門</th>
|
|
<th>職位</th>
|
|
<th>月度積分</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${rankings.map(ranking => `
|
|
<tr>
|
|
<td><span class="badge bg-success">${ranking.ranking}</span></td>
|
|
<td>${ranking.employee_name}</td>
|
|
<td>${ranking.department}</td>
|
|
<td>${ranking.position}</td>
|
|
<td><strong>${ranking.monthly_points}</strong></td>
|
|
</tr>
|
|
`).join('')}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
async loadAdminData() {
|
|
// Load admin data based on user permissions
|
|
if (this.currentUser && this.currentUser.roles) {
|
|
const hasUserManagement = this.currentUser.roles.some(role =>
|
|
['super_admin', 'admin', 'hr_manager'].includes(role)
|
|
);
|
|
|
|
if (hasUserManagement) {
|
|
await this.loadUsersManagement();
|
|
}
|
|
}
|
|
}
|
|
|
|
async loadUsersManagement() {
|
|
try {
|
|
const response = await this.apiCall('/api/admin/users', 'GET');
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
this.renderUsersManagement(data);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load users management:', error);
|
|
}
|
|
}
|
|
|
|
renderUsersManagement(users) {
|
|
const container = document.getElementById('usersManagement');
|
|
|
|
container.innerHTML = `
|
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
|
<h5>用戶管理</h5>
|
|
<button class="btn btn-primary" onclick="app.showAddUserModal()">
|
|
<i class="bi bi-person-plus me-2"></i>新增用戶
|
|
</button>
|
|
</div>
|
|
<div class="table-responsive">
|
|
<table class="table table-striped">
|
|
<thead>
|
|
<tr>
|
|
<th>ID</th>
|
|
<th>用戶名</th>
|
|
<th>姓名</th>
|
|
<th>部門</th>
|
|
<th>職位</th>
|
|
<th>狀態</th>
|
|
<th>角色</th>
|
|
<th>操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${users.map(user => `
|
|
<tr>
|
|
<td>${user.id}</td>
|
|
<td>${user.username}</td>
|
|
<td>${user.full_name}</td>
|
|
<td>${user.department}</td>
|
|
<td>${user.position}</td>
|
|
<td>
|
|
<span class="badge bg-${user.is_active ? 'success' : 'danger'}">
|
|
${user.is_active ? '啟用' : '停用'}
|
|
</span>
|
|
</td>
|
|
<td>
|
|
${user.roles.map(role => `<span class="badge bg-secondary me-1">${role}</span>`).join('')}
|
|
</td>
|
|
<td>
|
|
<button class="btn btn-sm btn-outline-primary" onclick="app.editUser(${user.id})">
|
|
<i class="bi bi-pencil"></i>
|
|
</button>
|
|
<button class="btn btn-sm btn-outline-danger" onclick="app.deleteUser(${user.id})">
|
|
<i class="bi bi-trash"></i>
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
`).join('')}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
async apiCall(url, method = 'GET', data = null) {
|
|
const options = {
|
|
method,
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
}
|
|
};
|
|
|
|
if (this.accessToken) {
|
|
options.headers['Authorization'] = `Bearer ${this.accessToken}`;
|
|
}
|
|
|
|
if (data) {
|
|
options.body = JSON.stringify(data);
|
|
}
|
|
|
|
const response = await fetch(url, options);
|
|
|
|
// Handle token refresh if needed
|
|
if (response.status === 401 && this.refreshToken) {
|
|
const refreshed = await this.refreshAccessToken();
|
|
if (refreshed) {
|
|
// Retry the original request
|
|
options.headers['Authorization'] = `Bearer ${this.accessToken}`;
|
|
return await fetch(url, options);
|
|
}
|
|
}
|
|
|
|
return response;
|
|
}
|
|
|
|
async refreshAccessToken() {
|
|
try {
|
|
const response = await fetch('/api/auth/refresh', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ refresh_token: this.refreshToken })
|
|
});
|
|
|
|
if (response.ok) {
|
|
const data = await response.json();
|
|
this.accessToken = data.access_token;
|
|
localStorage.setItem('accessToken', this.accessToken);
|
|
return true;
|
|
}
|
|
} catch (error) {
|
|
console.error('Token refresh failed:', error);
|
|
}
|
|
|
|
this.clearTokens();
|
|
return false;
|
|
}
|
|
|
|
showLoading(show) {
|
|
document.getElementById('loadingSpinner').style.display = show ? 'block' : 'none';
|
|
}
|
|
|
|
showToast(message, type = 'info') {
|
|
const toast = document.getElementById('toast');
|
|
const toastMessage = document.getElementById('toastMessage');
|
|
|
|
toastMessage.textContent = message;
|
|
|
|
// Update toast styling based on type
|
|
const toastHeader = toast.querySelector('.toast-header');
|
|
const icon = toastHeader.querySelector('i');
|
|
|
|
icon.className = `bi me-2`;
|
|
switch (type) {
|
|
case 'success':
|
|
icon.classList.add('bi-check-circle-fill', 'text-success');
|
|
break;
|
|
case 'error':
|
|
icon.classList.add('bi-exclamation-triangle-fill', 'text-danger');
|
|
break;
|
|
case 'warning':
|
|
icon.classList.add('bi-exclamation-circle-fill', 'text-warning');
|
|
break;
|
|
default:
|
|
icon.classList.add('bi-info-circle-fill', 'text-primary');
|
|
}
|
|
|
|
const bsToast = new bootstrap.Toast(toast);
|
|
bsToast.show();
|
|
}
|
|
}
|
|
|
|
// Enhanced dashboard rendering methods
|
|
function renderRecentActivities(activities) {
|
|
const container = document.getElementById('recentActivities');
|
|
|
|
if (!activities || activities.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="text-center text-muted py-3">
|
|
<i class="bi bi-inbox" style="font-size: 2rem;"></i>
|
|
<p class="mt-2">暫無最近活動</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
const activitiesHtml = activities.map(activity => `
|
|
<div class="d-flex align-items-center mb-3">
|
|
<div class="flex-shrink-0">
|
|
<i class="bi bi-${getActivityIcon(activity.type)} text-primary"></i>
|
|
</div>
|
|
<div class="flex-grow-1 ms-3">
|
|
<h6 class="mb-1">${activity.title}</h6>
|
|
<small class="text-muted">${activity.description}</small>
|
|
<div class="small text-muted">${new Date(activity.created_at).toLocaleString()}</div>
|
|
</div>
|
|
<div class="flex-shrink-0">
|
|
<span class="badge bg-${getActivityBadgeColor(activity.type)}">+${activity.points || 0}</span>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
|
|
container.innerHTML = activitiesHtml;
|
|
}
|
|
|
|
function renderAchievements(achievements) {
|
|
const container = document.getElementById('achievements');
|
|
|
|
if (!achievements || achievements.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="text-center text-muted py-3">
|
|
<i class="bi bi-award" style="font-size: 2rem;"></i>
|
|
<p class="mt-2">暫無成就</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
const achievementsHtml = achievements.map(achievement => `
|
|
<div class="d-flex align-items-center mb-3">
|
|
<div class="flex-shrink-0">
|
|
<i class="bi bi-${achievement.icon} text-warning" style="font-size: 1.5rem;"></i>
|
|
</div>
|
|
<div class="flex-grow-1 ms-3">
|
|
<h6 class="mb-1">${achievement.name}</h6>
|
|
<small class="text-muted">${achievement.description}</small>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
|
|
container.innerHTML = achievementsHtml;
|
|
}
|
|
|
|
function renderPerformanceChart(performanceData) {
|
|
const ctx = document.getElementById('pointsChart');
|
|
if (!ctx) return;
|
|
|
|
const chartCtx = ctx.getContext('2d');
|
|
|
|
// Destroy existing chart if it exists
|
|
if (window.pointsChart) {
|
|
window.pointsChart.destroy();
|
|
}
|
|
|
|
const labels = performanceData.map(item => item.month);
|
|
const data = performanceData.map(item => item.points);
|
|
|
|
window.pointsChart = new Chart(chartCtx, {
|
|
type: 'line',
|
|
data: {
|
|
labels: labels,
|
|
datasets: [{
|
|
label: '積分',
|
|
data: data,
|
|
borderColor: 'rgb(75, 192, 192)',
|
|
backgroundColor: 'rgba(75, 192, 192, 0.2)',
|
|
tension: 0.1
|
|
}]
|
|
},
|
|
options: {
|
|
responsive: true,
|
|
plugins: {
|
|
legend: {
|
|
display: false
|
|
}
|
|
},
|
|
scales: {
|
|
y: {
|
|
beginAtZero: true
|
|
}
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function getActivityIcon(type) {
|
|
const icons = {
|
|
'assessment': 'clipboard-check',
|
|
'feedback': 'star-fill',
|
|
'achievement': 'trophy-fill',
|
|
'ranking': 'award'
|
|
};
|
|
return icons[type] || 'circle';
|
|
}
|
|
|
|
function getActivityBadgeColor(type) {
|
|
const colors = {
|
|
'assessment': 'primary',
|
|
'feedback': 'success',
|
|
'achievement': 'warning',
|
|
'ranking': 'info'
|
|
};
|
|
return colors[type] || 'secondary';
|
|
}
|
|
|
|
// Advanced ranking system methods
|
|
async function loadAdvancedRankings() {
|
|
try {
|
|
const department = document.getElementById('rankingDepartment').value;
|
|
const position = document.getElementById('rankingPosition').value;
|
|
const minPoints = document.getElementById('minPoints').value;
|
|
const maxPoints = document.getElementById('maxPoints').value;
|
|
|
|
const params = new URLSearchParams();
|
|
if (department) params.append('department', department);
|
|
if (position) params.append('position', position);
|
|
if (minPoints) params.append('min_points', minPoints);
|
|
if (maxPoints) params.append('max_points', maxPoints);
|
|
|
|
const response = await fetch(`/api/rankings/advanced?${params.toString()}`);
|
|
const data = await response.json();
|
|
|
|
renderAdvancedRankings(data);
|
|
} catch (error) {
|
|
console.error('Failed to load advanced rankings:', error);
|
|
}
|
|
}
|
|
|
|
function renderAdvancedRankings(data) {
|
|
// Update statistics
|
|
if (data.statistics) {
|
|
document.getElementById('totalCount').textContent = data.statistics.total_count;
|
|
document.getElementById('avgPoints').textContent = data.statistics.average_points;
|
|
document.getElementById('medianPoints').textContent = data.statistics.median_points;
|
|
document.getElementById('maxPoints').textContent = data.statistics.max_points;
|
|
document.getElementById('minPoints').textContent = data.statistics.min_points;
|
|
document.getElementById('stdDev').textContent = data.statistics.standard_deviation;
|
|
|
|
document.getElementById('rankingStats').style.display = 'block';
|
|
}
|
|
|
|
// Render rankings
|
|
const container = document.getElementById('rankingsList');
|
|
|
|
if (!data.rankings || data.rankings.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="text-center text-muted py-4">
|
|
<i class="bi bi-search" style="font-size: 3rem;"></i>
|
|
<p class="mt-2">沒有找到符合條件的排名數據</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
const rankingsHtml = data.rankings.map(ranking => `
|
|
<div class="d-flex align-items-center mb-3 p-3 border rounded">
|
|
<div class="flex-shrink-0 me-3">
|
|
<div class="d-flex flex-column align-items-center">
|
|
<span class="badge bg-${getRankBadgeColor(ranking.rank)} fs-6">#${ranking.rank}</span>
|
|
<small class="text-muted mt-1">${ranking.percentile}%</small>
|
|
</div>
|
|
</div>
|
|
<div class="flex-grow-1">
|
|
<div class="d-flex justify-content-between align-items-start">
|
|
<div>
|
|
<h6 class="mb-1">${ranking.employee_name}</h6>
|
|
<small class="text-muted">${ranking.department} - ${ranking.position}</small>
|
|
</div>
|
|
<div class="text-end">
|
|
<div class="d-flex align-items-center mb-1">
|
|
<span class="badge bg-${ranking.tier.color} me-2">
|
|
<i class="bi bi-${ranking.tier.icon} me-1"></i>${ranking.tier.name}
|
|
</span>
|
|
<strong>${ranking.total_points} 分</strong>
|
|
</div>
|
|
<small class="text-muted">
|
|
本月: ${ranking.monthly_points} 分
|
|
${ranking.vs_average >= 0 ? '+' : ''}${ranking.vs_average} vs 平均
|
|
</small>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
|
|
container.innerHTML = rankingsHtml;
|
|
}
|
|
|
|
function getRankBadgeColor(rank) {
|
|
if (rank === 1) return 'warning';
|
|
if (rank <= 3) return 'success';
|
|
if (rank <= 10) return 'info';
|
|
return 'secondary';
|
|
}
|
|
|
|
// Notification system methods
|
|
async function loadNotifications() {
|
|
try {
|
|
const response = await fetch('/api/notifications');
|
|
const data = await response.json();
|
|
renderNotifications(data);
|
|
} catch (error) {
|
|
console.error('Failed to load notifications:', error);
|
|
}
|
|
}
|
|
|
|
function renderNotifications(data) {
|
|
const container = document.getElementById('notificationsList');
|
|
const badge = document.getElementById('notificationBadge');
|
|
|
|
// Update badge
|
|
if (data.unread_count > 0) {
|
|
badge.textContent = data.unread_count;
|
|
badge.style.display = 'block';
|
|
} else {
|
|
badge.style.display = 'none';
|
|
}
|
|
|
|
// Render notifications
|
|
if (!data.notifications || data.notifications.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="text-center text-muted py-3">
|
|
<i class="bi bi-bell" style="font-size: 2rem;"></i>
|
|
<p class="mt-2">暫無通知</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
const notificationsHtml = data.notifications.map(notification => `
|
|
<li class="dropdown-item-text p-3 border-bottom ${notification.is_read ? '' : 'bg-light'}">
|
|
<div class="d-flex align-items-start">
|
|
<div class="flex-shrink-0 me-3">
|
|
<i class="bi bi-${notification.icon} text-${notification.color}" style="font-size: 1.2rem;"></i>
|
|
</div>
|
|
<div class="flex-grow-1">
|
|
<div class="d-flex justify-content-between align-items-start">
|
|
<div>
|
|
<h6 class="mb-1 ${notification.is_read ? 'text-muted' : ''}">${notification.title}</h6>
|
|
<p class="mb-1 small ${notification.is_read ? 'text-muted' : ''}">${notification.message}</p>
|
|
<small class="text-muted">${formatNotificationTime(notification.created_at)}</small>
|
|
</div>
|
|
${!notification.is_read ? '<span class="badge bg-primary rounded-pill">新</span>' : ''}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</li>
|
|
`).join('');
|
|
|
|
container.innerHTML = notificationsHtml;
|
|
}
|
|
|
|
async function markAllNotificationsRead() {
|
|
try {
|
|
const response = await fetch('/api/notifications/read-all', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
}
|
|
});
|
|
|
|
if (response.ok) {
|
|
// Reload notifications
|
|
loadNotifications();
|
|
showToast('所有通知已標記為已讀', 'success');
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to mark all notifications as read:', error);
|
|
}
|
|
}
|
|
|
|
function formatNotificationTime(dateString) {
|
|
const date = new Date(dateString);
|
|
const now = new Date();
|
|
const diffMs = now - date;
|
|
const diffMins = Math.floor(diffMs / 60000);
|
|
const diffHours = Math.floor(diffMs / 3600000);
|
|
const diffDays = Math.floor(diffMs / 86400000);
|
|
|
|
if (diffMins < 1) return '剛剛';
|
|
if (diffMins < 60) return `${diffMins}分鐘前`;
|
|
if (diffHours < 24) return `${diffHours}小時前`;
|
|
if (diffDays < 7) return `${diffDays}天前`;
|
|
|
|
return date.toLocaleDateString('zh-TW');
|
|
}
|
|
|
|
function showToast(message, type = 'info') {
|
|
const toast = document.getElementById('toast');
|
|
const toastMessage = document.getElementById('toastMessage');
|
|
const toastHeader = toast.querySelector('.toast-header i');
|
|
|
|
toastMessage.textContent = message;
|
|
|
|
// Update icon based on type
|
|
const icons = {
|
|
'success': 'check-circle-fill text-success',
|
|
'error': 'exclamation-triangle-fill text-danger',
|
|
'warning': 'exclamation-triangle-fill text-warning',
|
|
'info': 'info-circle-fill text-primary'
|
|
};
|
|
|
|
toastHeader.className = `bi ${icons[type] || icons.info} me-2`;
|
|
|
|
const bsToast = new bootstrap.Toast(toast);
|
|
bsToast.show();
|
|
}
|
|
|
|
// Admin management methods
|
|
async function loadAdminData() {
|
|
try {
|
|
const response = await fetch('/api/admin/statistics');
|
|
const data = await response.json();
|
|
renderAdminStatistics(data);
|
|
} catch (error) {
|
|
console.error('Failed to load admin statistics:', error);
|
|
}
|
|
}
|
|
|
|
function renderAdminStatistics(data) {
|
|
// Update overview cards
|
|
document.getElementById('adminTotalUsers').textContent = data.total_users;
|
|
document.getElementById('adminActiveUsers').textContent = data.active_users;
|
|
document.getElementById('adminTotalAssessments').textContent = data.total_assessments;
|
|
document.getElementById('adminTotalFeedbacks').textContent = data.total_feedbacks;
|
|
|
|
// Render department stats
|
|
const deptContainer = document.getElementById('departmentStats');
|
|
if (data.department_stats && data.department_stats.length > 0) {
|
|
const deptHtml = data.department_stats.map(dept => `
|
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
<span>${dept.department}</span>
|
|
<span class="badge bg-primary">${dept.count}</span>
|
|
</div>
|
|
`).join('');
|
|
deptContainer.innerHTML = deptHtml;
|
|
} else {
|
|
deptContainer.innerHTML = '<p class="text-muted">暫無部門數據</p>';
|
|
}
|
|
|
|
// Render points stats
|
|
const pointsContainer = document.getElementById('pointsStats');
|
|
if (data.points_stats) {
|
|
pointsContainer.innerHTML = `
|
|
<div class="mb-2">
|
|
<small class="text-muted">平均積分</small>
|
|
<div class="h5">${data.points_stats.average}</div>
|
|
</div>
|
|
<div class="mb-2">
|
|
<small class="text-muted">最高積分</small>
|
|
<div class="h5 text-success">${data.points_stats.maximum}</div>
|
|
</div>
|
|
<div class="mb-2">
|
|
<small class="text-muted">最低積分</small>
|
|
<div class="h5 text-warning">${data.points_stats.minimum}</div>
|
|
</div>
|
|
`;
|
|
} else {
|
|
pointsContainer.innerHTML = '<p class="text-muted">暫無積分數據</p>';
|
|
}
|
|
}
|
|
|
|
async function loadAdminUsers() {
|
|
try {
|
|
const response = await fetch('/api/admin/users');
|
|
const data = await response.json();
|
|
renderAdminUsers(data.users);
|
|
} catch (error) {
|
|
console.error('Failed to load admin users:', error);
|
|
}
|
|
}
|
|
|
|
function renderAdminUsers(users) {
|
|
const container = document.getElementById('usersManagement');
|
|
|
|
if (!users || users.length === 0) {
|
|
container.innerHTML = `
|
|
<div class="text-center text-muted py-4">
|
|
<i class="bi bi-people" style="font-size: 3rem;"></i>
|
|
<p class="mt-2">暫無用戶數據</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
const usersHtml = users.map(user => `
|
|
<div class="d-flex align-items-center mb-3 p-3 border rounded">
|
|
<div class="flex-shrink-0 me-3">
|
|
<div class="bg-primary rounded-circle d-flex align-items-center justify-content-center" style="width: 40px; height: 40px;">
|
|
<i class="bi bi-person-fill text-white"></i>
|
|
</div>
|
|
</div>
|
|
<div class="flex-grow-1">
|
|
<div class="d-flex justify-content-between align-items-start">
|
|
<div>
|
|
<h6 class="mb-1">${user.full_name}</h6>
|
|
<small class="text-muted">${user.username} | ${user.email}</small>
|
|
<div class="small text-muted">${user.department} - ${user.position}</div>
|
|
</div>
|
|
<div class="text-end">
|
|
<span class="badge bg-${user.is_active ? 'success' : 'secondary'} mb-2">
|
|
${user.is_active ? '活躍' : '停用'}
|
|
</span>
|
|
<div class="small text-muted">
|
|
員工編號: ${user.employee_id}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
|
|
container.innerHTML = usersHtml;
|
|
}
|
|
|
|
// ===================================
|
|
// Department Capabilities Management
|
|
// ===================================
|
|
|
|
PartnerAlignmentApp.prototype.handleDepartmentSelect = async function(e) {
|
|
const department = e.target.value;
|
|
const loadBtn = document.getElementById('loadDeptCapabilitiesBtn');
|
|
const saveBtn = document.getElementById('saveDeptCapabilitiesBtn');
|
|
|
|
if (!department) {
|
|
loadBtn.disabled = true;
|
|
saveBtn.disabled = true;
|
|
document.getElementById('capabilitiesCheckboxList').innerHTML = `
|
|
<div class="text-center text-muted py-4">
|
|
<i class="bi bi-diagram-3" style="font-size: 3rem;"></i>
|
|
<p class="mt-2">請先選擇部門</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
loadBtn.disabled = false;
|
|
saveBtn.disabled = false;
|
|
|
|
// 自動載入所有能力項目供選擇
|
|
await this.loadAllCapabilitiesForSelection();
|
|
|
|
// 自動載入該部門已選擇的能力項目
|
|
await this.loadDepartmentCapabilities();
|
|
}
|
|
|
|
PartnerAlignmentApp.prototype.loadAllCapabilitiesForSelection = async function() {
|
|
const container = document.getElementById('capabilitiesCheckboxList');
|
|
|
|
try {
|
|
container.innerHTML = '<div class="text-center py-3"><div class="spinner-border" role="status"></div></div>';
|
|
|
|
const response = await fetch('/api/capabilities');
|
|
const data = await response.json();
|
|
|
|
if (data.capabilities && data.capabilities.length > 0) {
|
|
container.innerHTML = `
|
|
<div class="alert alert-info">
|
|
<i class="bi bi-info-circle me-2"></i>
|
|
請勾選該部門要進行評分的能力項目
|
|
</div>
|
|
<div class="row">
|
|
${data.capabilities.map(cap => `
|
|
<div class="col-md-6 mb-3">
|
|
<div class="card h-100">
|
|
<div class="card-body">
|
|
<div class="form-check">
|
|
<input class="form-check-input capability-checkbox"
|
|
type="checkbox"
|
|
value="${cap.id}"
|
|
id="cap_${cap.id}">
|
|
<label class="form-check-label fw-bold" for="cap_${cap.id}">
|
|
${cap.name}
|
|
</label>
|
|
</div>
|
|
<div class="mt-2 small text-muted">
|
|
<div><strong>L1:</strong> ${cap.l1_description || 'N/A'}</div>
|
|
<div><strong>L2:</strong> ${cap.l2_description || 'N/A'}</div>
|
|
<div><strong>L3:</strong> ${cap.l3_description || 'N/A'}</div>
|
|
<div><strong>L4:</strong> ${cap.l4_description || 'N/A'}</div>
|
|
<div><strong>L5:</strong> ${cap.l5_description || 'N/A'}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`).join('')}
|
|
</div>
|
|
`;
|
|
} else {
|
|
container.innerHTML = `
|
|
<div class="alert alert-warning">
|
|
<i class="bi bi-exclamation-triangle me-2"></i>
|
|
目前沒有可用的能力項目。請先由系統管理員建立能力項目。
|
|
</div>
|
|
`;
|
|
}
|
|
} catch (error) {
|
|
console.error('載入能力項目失敗:', error);
|
|
this.showError('載入能力項目時發生錯誤');
|
|
container.innerHTML = `
|
|
<div class="alert alert-danger">
|
|
<i class="bi bi-x-circle me-2"></i>
|
|
載入失敗,請稍後再試
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
PartnerAlignmentApp.prototype.loadDepartmentCapabilities = async function() {
|
|
const department = document.getElementById('deptSelect').value;
|
|
|
|
if (!department) {
|
|
this.showError('請先選擇部門');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/department-capabilities/${department}`);
|
|
const data = await response.json();
|
|
|
|
// 取消所有勾選
|
|
document.querySelectorAll('.capability-checkbox').forEach(cb => {
|
|
cb.checked = false;
|
|
});
|
|
|
|
// 勾選該部門已選擇的能力項目
|
|
if (data.capabilities && data.capabilities.length > 0) {
|
|
data.capabilities.forEach(cap => {
|
|
const checkbox = document.getElementById(`cap_${cap.id}`);
|
|
if (checkbox) {
|
|
checkbox.checked = true;
|
|
}
|
|
});
|
|
|
|
this.showSuccess(`已載入 ${department} 部門的能力項目設定 (${data.capabilities.length} 項)`);
|
|
} else {
|
|
this.showInfo(`${department} 部門尚未設定能力項目`);
|
|
}
|
|
} catch (error) {
|
|
console.error('載入部門能力設定失敗:', error);
|
|
this.showError('載入部門能力設定時發生錯誤');
|
|
}
|
|
}
|
|
|
|
PartnerAlignmentApp.prototype.saveDepartmentCapabilities = async function() {
|
|
const department = document.getElementById('deptSelect').value;
|
|
|
|
if (!department) {
|
|
this.showError('請先選擇部門');
|
|
return;
|
|
}
|
|
|
|
// 收集已勾選的能力項目ID
|
|
const selectedCapabilities = [];
|
|
document.querySelectorAll('.capability-checkbox:checked').forEach(cb => {
|
|
selectedCapabilities.push(parseInt(cb.value));
|
|
});
|
|
|
|
if (selectedCapabilities.length === 0) {
|
|
this.showError('請至少選擇一個能力項目');
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const response = await fetch(`/api/department-capabilities/${department}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
capability_ids: selectedCapabilities
|
|
})
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (response.ok) {
|
|
this.showSuccess(data.message || '儲存成功');
|
|
} else {
|
|
this.showError(data.error || '儲存失敗');
|
|
}
|
|
} catch (error) {
|
|
console.error('儲存部門能力設定失敗:', error);
|
|
this.showError('儲存時發生錯誤');
|
|
}
|
|
}
|
|
|
|
// ===================================
|
|
// Capabilities CSV Import & Management
|
|
// ===================================
|
|
|
|
PartnerAlignmentApp.prototype.importCapabilitiesCsv = async function() {
|
|
const fileInput = document.getElementById('csvFileInput');
|
|
const file = fileInput.files[0];
|
|
|
|
if (!file) {
|
|
this.showError('請選擇 CSV 檔案');
|
|
return;
|
|
}
|
|
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
|
|
try {
|
|
const response = await fetch('/api/capabilities/import-csv', {
|
|
method: 'POST',
|
|
body: formData
|
|
});
|
|
|
|
const data = await response.json();
|
|
|
|
if (response.ok) {
|
|
this.showSuccess(data.message);
|
|
|
|
if (data.errors && data.errors.length > 0) {
|
|
console.warn('匯入警告:', data.errors);
|
|
alert('匯入完成,但有以下警告:\n' + data.errors.join('\n'));
|
|
}
|
|
|
|
// 清空檔案選擇
|
|
fileInput.value = '';
|
|
|
|
// 自動刷新能力項目列表
|
|
await this.loadCapabilitiesList();
|
|
} else {
|
|
this.showError(data.error || '匯入失敗');
|
|
}
|
|
} catch (error) {
|
|
console.error('CSV 匯入失敗:', error);
|
|
this.showError('匯入時發生錯誤');
|
|
}
|
|
}
|
|
|
|
PartnerAlignmentApp.prototype.downloadCsvTemplate = function() {
|
|
// 建立 CSV 範本內容
|
|
const csvContent = `name,l1_description,l2_description,l3_description,l4_description,l5_description
|
|
溝通能力,基本溝通,有效溝通,專業溝通,領導溝通,戰略溝通
|
|
技術能力,基礎技術,熟練技術,專業技術,專家技術,大師技術
|
|
領導能力,自我管理,團隊協作,團隊領導,部門領導,戰略領導`;
|
|
|
|
// 建立 Blob 並下載
|
|
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
|
const link = document.createElement('a');
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
link.setAttribute('href', url);
|
|
link.setAttribute('download', 'capabilities_template.csv');
|
|
link.style.visibility = 'hidden';
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
|
|
this.showSuccess('CSV 範本已下載');
|
|
}
|
|
|
|
PartnerAlignmentApp.prototype.loadCapabilitiesList = async function() {
|
|
const container = document.getElementById('capabilitiesList');
|
|
|
|
try {
|
|
container.innerHTML = '<div class="text-center py-3"><div class="spinner-border" role="status"></div></div>';
|
|
|
|
const response = await fetch('/api/capabilities');
|
|
const data = await response.json();
|
|
|
|
if (data.capabilities && data.capabilities.length > 0) {
|
|
container.innerHTML = `
|
|
<div class="table-responsive">
|
|
<table class="table table-striped table-hover">
|
|
<thead>
|
|
<tr>
|
|
<th>能力名稱</th>
|
|
<th>L1</th>
|
|
<th>L2</th>
|
|
<th>L3</th>
|
|
<th>L4</th>
|
|
<th>L5</th>
|
|
<th>狀態</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
${data.capabilities.map(cap => `
|
|
<tr>
|
|
<td><strong>${cap.name}</strong></td>
|
|
<td class="small">${cap.l1_description || '-'}</td>
|
|
<td class="small">${cap.l2_description || '-'}</td>
|
|
<td class="small">${cap.l3_description || '-'}</td>
|
|
<td class="small">${cap.l4_description || '-'}</td>
|
|
<td class="small">${cap.l5_description || '-'}</td>
|
|
<td><span class="badge bg-success">啟用</span></td>
|
|
</tr>
|
|
`).join('')}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div class="alert alert-success mt-3">
|
|
<i class="bi bi-check-circle me-2"></i>
|
|
共 ${data.capabilities.length} 個能力項目
|
|
</div>
|
|
`;
|
|
} else {
|
|
container.innerHTML = `
|
|
<div class="alert alert-info">
|
|
<i class="bi bi-info-circle me-2"></i>
|
|
目前沒有能力項目。請使用 CSV 匯入功能新增。
|
|
</div>
|
|
`;
|
|
}
|
|
} catch (error) {
|
|
console.error('載入能力項目列表失敗:', error);
|
|
this.showError('載入能力項目列表時發生錯誤');
|
|
container.innerHTML = `
|
|
<div class="alert alert-danger">
|
|
<i class="bi bi-x-circle me-2"></i>
|
|
載入失敗,請稍後再試
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
// Global function to fill login form with test account credentials
|
|
function fillLoginForm(username, password) {
|
|
document.getElementById('loginUsername').value = username;
|
|
document.getElementById('loginPassword').value = password;
|
|
|
|
// Show a brief notification
|
|
const toast = document.getElementById('toast');
|
|
const toastMessage = document.getElementById('toastMessage');
|
|
toastMessage.textContent = `已填入 ${username} 的登入資訊`;
|
|
const bsToast = new bootstrap.Toast(toast);
|
|
bsToast.show();
|
|
}
|
|
|
|
// Initialize the application
|
|
const app = new PartnerAlignmentApp(); |