Files
1015_IT_behavior_alignment_V2/static/js/app.js
2025-10-28 15:50:53 +08:00

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();