Files
Aknaproff/public/static/app.js
Deploy Bot 4fe9b0fdc9 v4.1.23: Улучшения UX — даты при создании, цена с запятой
- FIX: Даты MAT-1, MAT-2, Package теперь сохраняются при создании записи
  (INSERT INTO status_checkboxes: record_id, material_date, material2_date, package_date)
- FIX: Цена принимает и запятую, и точку (1,5 → 1.5)
- src/index.tsx: POST /api/records — парсинг material_date, material2_date, package_date
- src/utils/auth.ts: минорные исправления
- public/static/app.js: улучшения UX
- Cache version: app.js?v=4.1.23
2026-01-15 22:39:00 +02:00

2280 lines
78 KiB
JavaScript

// Global state
let token = localStorage.getItem('token');
let currentUser = null;
let currentRecords = [];
let filteredRecords = []; // Records after filtering
let editingRecordId = null;
let sortColumn = null;
let sortDirection = 'asc'; // 'asc' or 'desc'
let sortById = false; // Sort by ID toggle
let allowDelete = localStorage.getItem('allowDelete') === 'true'; // Delete permission
let searchFilters = {
client: '',
type: '',
offer: '',
work: '',
byYear: false
};
// API Base URL
const API_BASE = '';
// Permission helpers
function canEditProblems() {
// Only user and admin can edit problems
return currentUser && (currentUser.role === 'user' || currentUser.role === 'admin');
}
function canEditRecords() {
// Only admin can edit records (add/edit/delete)
return currentUser && currentUser.role === 'admin';
}
function canToggleDates() {
// Admin and User can toggle dates (Töölehti, LÕIKUS, KLAAS, VALMIS, VÄLJAS)
return currentUser && (currentUser.role === 'admin' || currentUser.role === 'user');
}
function isGuest() {
// Check if user is guest (read-only)
return !currentUser || currentUser.role === 'guest';
}
// Setup axios response interceptor to handle token refresh
axios.interceptors.response.use(
(response) => {
// Check if server sent a refreshed token
const refreshedToken = response.headers['x-refreshed-token'];
if (refreshedToken) {
// Update token in memory and localStorage
token = refreshedToken;
localStorage.setItem('token', refreshedToken);
// Restart session timer with new token
if (window.sessionTimerInterval) {
startSessionTimer();
}
}
return response;
},
(error) => {
return Promise.reject(error);
}
);
// Field colors (fixed by field name)
const FIELD_COLORS = {
'material': 'bg-white border border-gray-300 text-gray-900', // MATERJAL - valge taust
'package': 'bg-white border border-gray-300 text-gray-900', // PAKETT - valge taust
'worksheets': 'bg-white border border-gray-300 text-gray-900', // TÖÖLEHTI - valge taust (tühi)
'worksheets_filled': 'bg-green-500 text-white', // TÖÖLEHTI - roheline (kuupäevaga)
'cutting': 'bg-white border border-gray-300 text-gray-900', // LÕIKUS - valge (tühi)
'cutting_filled': 'bg-green-500 text-white', // LÕIKUS - roheline (kuupäevaga)
'glazing': 'bg-white border border-gray-300 text-gray-900', // KLAASIMINE - valge (tühi)
'glazing_filled': 'bg-green-500 text-white', // KLAASIMINE - roheline (kuupäevaga)
'ready': 'bg-white border border-gray-300 text-gray-900', // VALMIS - valge (tühi)
'ready_filled': 'bg-green-500 text-white', // VALMIS - roheline (kuupäevaga)
'issued': 'bg-white border border-gray-300 text-gray-900', // VÄLJASTATUD - valge (tühi)
'issued_filled': 'bg-green-500 text-white' // VÄLJASTATUD - roheline (kuupäevaga)
};
// Initialize app
document.addEventListener('DOMContentLoaded', async () => {
// Check if user is logged in
if (token) {
currentUser = JSON.parse(localStorage.getItem('user'));
// Check if session is still valid
const expiry = getTokenExpiry();
if (expiry && Date.now() < expiry) {
// Session still valid, start timer
startSessionTimer();
} else {
// Session expired, logout
logout();
}
} else {
// Set default guest user (read-only access)
currentUser = { username: 'Guest', full_name: 'Guest User', role: 'guest' };
}
// Show login modal for guest users, or main app for authenticated users
if (currentUser.role === 'guest') {
showLoginModal();
} else {
showMainApp();
}
await initFilters();
loadRecords();
document.getElementById('loginForm').addEventListener('submit', handleLogin);
document.getElementById('recordForm').addEventListener('submit', handleSaveRecord);
document.getElementById('monthFilter').addEventListener('change', loadRecords);
document.getElementById('yearFilter').addEventListener('change', loadRecords);
// Add listener for MAT-1 to control MAT-2 availability
document.getElementById('materialDate').addEventListener('change', updateMat2State);
document.getElementById('materialDate').addEventListener('input', updateMat2State);
// Add listener for price field to auto-format (accept both comma and dot)
const priceField = document.getElementById('price');
if (priceField) {
priceField.addEventListener('input', function(e) {
// Replace comma with dot automatically
let value = e.target.value;
if (value.includes(',')) {
e.target.value = value.replace(',', '.');
}
});
}
// Add search filter listeners
document.getElementById('searchClient').addEventListener('input', handleSearchFilter);
document.getElementById('searchType').addEventListener('input', handleSearchFilter);
document.getElementById('searchOffer').addEventListener('input', handleSearchFilter);
document.getElementById('searchWork').addEventListener('input', handleSearchFilter);
document.getElementById('searchByYear').addEventListener('change', handleSearchFilter);
});
function openLoginModal() {
document.getElementById('loginModal').classList.add('active');
document.getElementById('username').value = '';
document.getElementById('password').value = '';
document.getElementById('loginError').classList.add('hidden');
}
function closeLoginModal() {
document.getElementById('loginModal').classList.remove('active');
}
// Make continueAsGuest globally accessible for onclick
window.continueAsGuest = function() {
// User chose to continue as guest (read-only mode)
currentUser = { username: 'Guest', full_name: 'Guest User', role: 'guest' };
closeLoginModal();
showMainApp();
loadRecords();
}
function showLoginModal() {
document.getElementById('mainApp').classList.add('hidden');
document.getElementById('loginModal').classList.add('active');
document.getElementById('username').value = '';
document.getElementById('password').value = '';
document.getElementById('loginError').classList.add('hidden');
}
function showMainApp() {
document.getElementById('mainApp').classList.remove('hidden');
document.getElementById('loginModal').classList.remove('active');
// Update UI based on role
const role = currentUser?.role || 'guest';
// Show/hide elements based on role
if (role === 'admin' || role === 'user') {
document.getElementById('userInfo').classList.remove('hidden');
document.getElementById('userName').textContent = currentUser?.full_name || currentUser?.username || '';
document.getElementById('settingsBtn').classList.remove('hidden');
document.getElementById('logoutBtn').classList.remove('hidden');
document.getElementById('loginBtn').classList.add('hidden');
// Admin-specific UI
if (role === 'admin') {
document.body.classList.add('role-admin');
// Show "Lisa uus rida" button only for admins
const addBtn = document.getElementById('addNewRowBtn');
if (addBtn) addBtn.classList.remove('hidden');
} else {
document.body.classList.remove('role-admin');
// Hide "Lisa uus rida" button for users
const addBtn = document.getElementById('addNewRowBtn');
if (addBtn) addBtn.classList.add('hidden');
}
} else {
// Guest user - read-only mode
document.getElementById('userInfo').classList.add('hidden');
document.getElementById('settingsBtn').classList.add('hidden');
document.getElementById('logoutBtn').classList.add('hidden');
document.getElementById('loginBtn').classList.remove('hidden');
document.body.classList.remove('role-admin');
// Hide "Lisa uus rida" button for guests
const addBtn = document.getElementById('addNewRowBtn');
if (addBtn) addBtn.classList.add('hidden');
}
}
async function handleLogin(e) {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const errorDiv = document.getElementById('loginError');
try {
const response = await axios.post(`${API_BASE}/api/auth/login`, {
username,
password
});
token = response.data.token;
currentUser = response.data.user;
localStorage.setItem('token', token);
localStorage.setItem('user', JSON.stringify(currentUser));
closeLoginModal();
showMainApp();
loadRecords();
// Start session timer
startSessionTimer();
} catch (error) {
errorDiv.textContent = error.response?.data?.error || 'Vale kasutajanimi või parool';
errorDiv.classList.remove('hidden');
}
}
function logout() {
// Stop session timer
if (window.sessionTimerInterval) {
clearInterval(window.sessionTimerInterval);
window.sessionTimerInterval = null;
}
token = null;
localStorage.removeItem('token');
localStorage.removeItem('user');
// Reset to guest user (read-only)
currentUser = { username: 'Guest', full_name: 'Guest User', role: 'guest' };
// Hide session timer
document.getElementById('sessionTimer').classList.add('hidden');
// Show login modal for guest users
showLoginModal();
}
// Session management functions
function getTokenExpiry() {
if (!token) return null;
try {
const payload = JSON.parse(atob(token));
return payload.exp;
} catch {
return null;
}
}
function checkSessionExpiry() {
const expiry = getTokenExpiry();
if (!expiry) return;
const now = Date.now();
const timeLeft = expiry - now;
// If session expired, logout
if (timeLeft <= 0) {
alert('Sessioon on aegunud. Palun logige uuesti sisse.');
logout();
return;
}
// Show timer only in last minute (60 seconds)
const sessionTimerEl = document.getElementById('sessionTimer');
const sessionTimeLeftEl = document.getElementById('sessionTimeLeft');
if (timeLeft <= 60000) { // 60 seconds = 60000 ms
const secondsLeft = Math.ceil(timeLeft / 1000);
sessionTimerEl.classList.remove('hidden');
sessionTimeLeftEl.textContent = `${secondsLeft}s`;
} else {
sessionTimerEl.classList.add('hidden');
}
}
function startSessionTimer() {
// Clear any existing timer
if (window.sessionTimerInterval) {
clearInterval(window.sessionTimerInterval);
}
// Check immediately
checkSessionExpiry();
// Check every second
window.sessionTimerInterval = setInterval(checkSessionExpiry, 1000);
}
async function loadYears() {
try {
const response = await axios.get(`${API_BASE}/api/years`);
const { years } = response.data;
const currentYear = new Date().getFullYear();
// Populate main year filter
const yearFilter = document.getElementById('yearFilter');
yearFilter.innerHTML = '';
years.forEach(year => {
const option = document.createElement('option');
option.value = year;
option.textContent = year;
if (year === currentYear) {
option.selected = true;
}
yearFilter.appendChild(option);
});
// Populate report year filter
const reportYear = document.getElementById('reportYear');
if (reportYear) {
reportYear.innerHTML = '';
years.forEach(year => {
const option = document.createElement('option');
option.value = year;
option.textContent = year;
if (year === currentYear) {
option.selected = true;
}
reportYear.appendChild(option);
});
}
} catch (error) {
console.error('Load years error:', error);
// Fallback: use current year if API fails
const currentYear = new Date().getFullYear();
const yearFilter = document.getElementById('yearFilter');
yearFilter.innerHTML = `<option value="${currentYear}" selected>${currentYear}</option>`;
}
}
async function initFilters() {
// Set to January (month 1) by default since that's where demo data exists
document.getElementById('monthFilter').value = 1;
// Load years dynamically
await loadYears();
// Default checkbox unchecked (search by current month)
document.getElementById('searchByYear').checked = false;
}
async function loadRecords() {
const year = document.getElementById('yearFilter').value;
const byYear = document.getElementById('searchByYear').checked;
try {
const headers = token ? { Authorization: `Bearer ${token}` } : {};
// Load all 12 months ONLY if byYear checkbox is checked
if (byYear) {
const promises = [];
for (let month = 1; month <= 12; month++) {
promises.push(
axios.get(`${API_BASE}/api/records`, {
params: { month, year },
headers
})
);
}
const responses = await Promise.all(promises);
currentRecords = responses.flatMap(response => response.data);
} else {
// Load only current month
const month = document.getElementById('monthFilter').value;
const response = await axios.get(`${API_BASE}/api/records`, {
params: { month, year },
headers
});
currentRecords = response.data;
}
applyFilters(); // Apply search filters after loading
} catch (error) {
console.error('Load records error:', error);
}
}
// Sort records by column
// Toggle sort by ID
function toggleSortById() {
// Toggle sort direction
if (sortById && sortDirection === 'asc') {
sortDirection = 'desc';
} else if (sortById && sortDirection === 'desc') {
sortById = false;
sortDirection = 'asc';
} else {
sortById = true;
sortDirection = 'asc';
}
// Update icon
const icon = document.getElementById('sortByIdIcon');
if (!sortById) {
icon.className = 'fas fa-sort text-gray-400';
} else if (sortDirection === 'asc') {
icon.className = 'fas fa-sort-up text-indigo-600';
} else {
icon.className = 'fas fa-sort-down text-indigo-600';
}
// Reset column sort when sorting by ID
if (sortById) {
sortColumn = null;
}
// Apply sort and render
if (sortById) {
// Sort by ID
filteredRecords.sort((a, b) => {
if (sortDirection === 'asc') {
return a.id - b.id;
} else {
return b.id - a.id;
}
});
}
renderRecords();
}
function sortRecords(column) {
// Disable ID sort when sorting by column
sortById = false;
document.getElementById('sortByIdIcon').className = 'fas fa-sort text-gray-400';
// Toggle direction if clicking same column, otherwise reset to asc
if (sortColumn === column) {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
} else {
sortColumn = column;
sortDirection = 'asc';
}
// Sort the filteredRecords array (not currentRecords)
filteredRecords.sort((a, b) => {
let aVal, bVal;
switch(column) {
case 'client':
aVal = (a.client_name || '').toLowerCase();
bVal = (b.client_name || '').toLowerCase();
break;
case 'type':
aVal = (a.type || '').toLowerCase();
bVal = (b.type || '').toLowerCase();
break;
case 'offer':
aVal = (a.offer_number || '').toLowerCase();
bVal = (b.offer_number || '').toLowerCase();
break;
case 'work':
aVal = (a.work_number || '').toLowerCase();
bVal = (b.work_number || '').toLowerCase();
break;
case 'material':
aVal = a.material_date || '';
bVal = b.material_date || '';
break;
case 'package':
aVal = a.package_date || '';
bVal = b.package_date || '';
break;
default:
return 0;
}
if (aVal < bVal) return sortDirection === 'asc' ? -1 : 1;
if (aVal > bVal) return sortDirection === 'asc' ? 1 : -1;
return 0;
});
// Update sort icons
updateSortIcons(column, sortDirection);
renderRecords();
}
// Update sort icons in table headers
function updateSortIcons(activeColumn, direction) {
const columns = ['client', 'type', 'offer', 'work', 'material', 'package'];
columns.forEach(col => {
const icon = document.getElementById(`sort-icon-${col}`);
if (!icon) return;
if (col === activeColumn) {
// Active column - show direction
icon.className = `fas fa-sort-${direction === 'asc' ? 'up' : 'down'} text-indigo-600 ml-2`;
} else {
// Inactive column - show neutral sort icon
icon.className = 'fas fa-sort text-gray-400 ml-2';
}
});
}
// Handle search filter input
async function handleSearchFilter() {
searchFilters.client = document.getElementById('searchClient').value.toLowerCase().trim();
searchFilters.type = document.getElementById('searchType').value.toLowerCase().trim();
searchFilters.offer = document.getElementById('searchOffer').value.toLowerCase().trim();
searchFilters.work = document.getElementById('searchWork').value.toLowerCase().trim();
searchFilters.byYear = document.getElementById('searchByYear').checked;
// Reload records from server when filters change
// This ensures we search across all data, not just currently loaded records
await loadRecords();
}
// Apply search filters to currentRecords
function applyFilters() {
// Start with all records
filteredRecords = currentRecords.filter(record => {
// Text filters
if (searchFilters.client && !(record.client_name || '').toLowerCase().includes(searchFilters.client)) {
return false;
}
if (searchFilters.type && !(record.type || '').toLowerCase().includes(searchFilters.type)) {
return false;
}
if (searchFilters.offer && !(record.offer_number || '').toLowerCase().includes(searchFilters.offer)) {
return false;
}
if (searchFilters.work && !(record.work_number || '').toLowerCase().includes(searchFilters.work)) {
return false;
}
// Year filter - if checked, filter by current year instead of current month
if (searchFilters.byYear) {
// Get current year from yearFilter
const currentYear = parseInt(document.getElementById('yearFilter').value);
// Check if record's year matches
if (record.year !== currentYear) {
return false;
}
// Note: We don't filter by month when byYear is true
}
return true;
});
// Re-apply sorting if active
if (sortColumn) {
sortRecords(sortColumn);
} else {
renderRecords();
}
}
// Clear all filters
function clearAllFilters() {
// Clear text search filters
document.getElementById('searchClient').value = '';
document.getElementById('searchType').value = '';
document.getElementById('searchOffer').value = '';
document.getElementById('searchWork').value = '';
// Uncheck year filter
document.getElementById('searchByYear').checked = false;
// Reset search filters object
searchFilters = {
client: '',
type: '',
offer: '',
work: '',
byYear: false
};
// Reset sorting to default (ID ASC from backend)
sortColumn = null;
sortDirection = 'asc';
// Clear all sort icons
const sortIcons = document.querySelectorAll('[id^="sort-icon-"]');
sortIcons.forEach(icon => {
icon.className = 'fas fa-sort text-gray-300 ml-1 text-xs';
});
// Reapply filters (which now are empty, showing all records)
applyFilters();
}
function renderRecords() {
const tbody = document.getElementById('recordsTable');
if (filteredRecords.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="19" class="px-2 py-6 text-center text-gray-500">
<i class="fas fa-inbox text-3xl mb-2"></i>
<p class="text-xs">Kirjeid ei leitud</p>
</td>
</tr>
`;
// Show footer with zero totals
const tfoot = document.getElementById('recordsTableFooter');
tfoot.innerHTML = `
<tr class="font-semibold">
<td class="px-2 py-1 text-xs text-gray-200" colspan="4">
<i class="fas fa-calculator mr-1 text-xs"></i>Summa:
</td>
<td class="px-2 py-1 text-xs text-center text-white bg-indigo-600 font-bold">
0
</td>
<td colspan="12"></td>
<td class="admin-only px-2 py-1 text-xs text-right text-white bg-indigo-600 font-bold">
0.00
</td>
<td class="admin-only"></td>
</tr>
`;
return;
}
// All users can edit dates (removed admin-only check in v4.0.8)
tbody.innerHTML = filteredRecords.map(record => {
// Check if record has error flags (blocks ready and issued)
// Note: problems text is just a comment, not a blocker
const hasProblems = record.worksheets_error === 1 ||
record.cutting_error === 1 ||
record.glazing_error === 1 ||
record.ready_error === 1 ||
record.issued_error === 1;
return `
<tr class="hover:bg-gray-50 transition">
<td class="px-2 py-1 text-xs text-gray-900">${record.client_name || ''}</td>
<td class="px-2 py-1 text-xs text-center text-gray-600">${record.type || '-'}</td>
<td class="px-2 py-1 text-xs text-gray-600">${record.offer_number || '-'}</td>
<td class="px-2 py-1 text-xs text-gray-600">${record.work_number || '-'}</td>
<td class="px-2 py-1 text-xs text-center text-gray-900 font-medium">${record.quantity || 0}</td>
<td class="px-2 py-1 text-xs text-gray-600" title="${record.color || ''}">${record.color || '-'}</td>
${renderCalendarCell(record.id, 'material', record.material_date, null, record.material_confirmed)}
${renderCalendarCell(record.id, 'material2', record.material2_date, record.material_date, record.material2_confirmed)}
${renderCalendarCell(record.id, 'package', record.package_date, null)}
${renderWorksheetsCell(record.id, record.worksheets_date, record.worksheets_confirmed, record.worksheets_error, record.problems || '')}
${renderDateCell(record.id, 'cutting', record.cutting_date, record.cutting_error, false, record.problems || '')}
${renderDateCell(record.id, 'glazing', record.glazing_date, record.glazing_error, false, record.problems || '')}
${renderDateCell(record.id, 'ready', record.ready_date, record.ready_error, hasProblems, record.problems || '')}
${renderDateCell(record.id, 'issued', record.issued_date, record.issued_error, hasProblems, record.problems || '')}
${renderNotesCell(record.id, record.notes, record.notes_date)}
${renderProblemsCell(record.id, record.problems, record.problems_date, record)}
<td class="px-2 py-1 text-xs text-gray-600">${record.installer || '-'}</td>
${renderPriceCell(record.id, record.price, record.price_paid, record.arve_checked, record.arve_makstud)}
<td class="admin-only px-2 py-1 text-center">
<button onclick="editRecord(${record.id})" class="text-indigo-600 hover:text-indigo-800 transition mr-2">
<i class="fas fa-edit"></i>
</button>
<button onclick="confirmDelete(${record.id})" class="delete-btn text-red-600 hover:text-red-800 transition" style="display: none;">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
`;
}).join('');
// Calculate totals from filtered records
const totalQuantity = filteredRecords.reduce((sum, record) => sum + (parseInt(record.quantity) || 0), 0);
const totalPrice = filteredRecords.reduce((sum, record) => sum + (parseFloat(record.price) || 0), 0);
// Render footer with totals
const tfoot = document.getElementById('recordsTableFooter');
tfoot.innerHTML = `
<tr class="font-semibold">
<td class="px-2 py-1 text-xs text-gray-200" colspan="4">
<i class="fas fa-calculator mr-1 text-xs"></i>Summa:
</td>
<td class="px-2 py-1 text-xs text-center text-white bg-indigo-600 font-bold">
${totalQuantity}
</td>
<td colspan="12"></td>
<td class="admin-only px-2 py-1 text-xs text-right text-white bg-indigo-600 font-bold">
${totalPrice.toFixed(2)}
</td>
<td class="admin-only"></td>
</tr>
`;
// Toggle delete buttons based on permission
toggleDeleteButtons();
}
// Render read-only cell for users (MAT/PAK without click)
function renderReadOnlyCell(date) {
if (!date) {
return `
<td class="px-2 py-1 text-center">
<div class="inline-block px-2 py-1 rounded bg-gray-100 border border-gray-300 text-gray-400 text-xs">
-
</div>
</td>
`;
}
// Format date as DD.MM.YYYY
const dateObj = new Date(date + 'T00:00:00');
const day = String(dateObj.getDate()).padStart(2, '0');
const month = String(dateObj.getMonth() + 1).padStart(2, '0');
const year = dateObj.getFullYear();
const formattedDate = `${day}.${month}.${year}`;
return `
<td class="px-2 py-1 text-center">
<div class="inline-block px-2 py-1 rounded bg-white border border-gray-300 text-gray-900 text-xs font-semibold">
${formattedDate}
</div>
</td>
`;
}
// Render calendar cell for MAT/PAK (white background, clickable to open calendar)
function renderCalendarCell(recordId, field, date, materialDate = null, materialConfirmed = 0) {
const fieldId = `${field}_${recordId}`;
// Check if material2 should be blocked (when material is empty)
const isBlocked = field === 'material2' && !materialDate;
// Determine border color (green if confirmed, gray otherwise) - for material or material2 field
const isConfirmed = (field === 'material' || field === 'material2') && materialConfirmed === 1;
const borderColor = isConfirmed ? 'border-green-500' : 'border-gray-300';
if (!date) {
// Empty cell - click to open calendar (or show blocked)
if (isBlocked) {
return `
<td class="px-2 py-1 text-center relative">
<div
class="inline-block px-2 py-1 rounded bg-gray-100 border border-gray-300 text-gray-400 text-xs cursor-not-allowed"
title="MAT-1 peab olema täidetud"
>
<i class="fas fa-lock text-xs"></i>
</div>
</td>
`;
}
return `
<td class="px-2 py-1 text-center relative">
<input
type="date"
id="${fieldId}"
style="position: absolute; left: -9999px; opacity: 0;"
onchange="updateDateFromCalendar(${recordId}, '${field}', this.value)"
/>
<label
for="${fieldId}"
class="inline-block px-2 py-1 rounded bg-gray-100 border border-gray-300 text-gray-400 text-xs cursor-pointer hover:bg-gray-200 transition"
>
-
</label>
</td>
`;
}
// Format date as DD.MM.YYYY
const dateObj = new Date(date + 'T00:00:00');
const day = String(dateObj.getDate()).padStart(2, '0');
const month = String(dateObj.getMonth() + 1).padStart(2, '0');
const year = dateObj.getFullYear();
const formattedDate = `${day}.${month}.${year}`;
// Blocked cell with date (should not happen, but handle it)
if (isBlocked) {
return `
<td class="px-2 py-1 text-center relative">
<div
class="inline-block px-2 py-1 rounded bg-gray-100 border border-gray-300 text-gray-400 text-xs cursor-not-allowed"
title="MAT-1 peab olema täidetud"
>
<i class="fas fa-lock mr-1 text-xs"></i>${formattedDate}
</div>
</td>
`;
}
// For material field (MAT-1) or material2 field (MAT-2), show confirm button to the right
if (field === 'material' || field === 'material2') {
const toggleFunction = field === 'material' ? 'toggleMaterialConfirmed' : 'toggleMaterial2Confirmed';
const titleConfirmed = field === 'material' ? 'Materjal kinnitatud' : 'Materjal 2 kinnitatud';
const titleNotConfirmed = field === 'material' ? 'Kinnita materjali kättesaamine' : 'Kinnita materjali 2 kättesaamine';
return `
<td class="px-2 py-1 text-center relative">
<div class="flex items-center justify-center gap-1">
<input
type="date"
id="${fieldId}"
style="position: absolute; left: -9999px; opacity: 0;"
value="${date}"
onchange="updateDateFromCalendar(${recordId}, '${field}', this.value)"
/>
<label
for="${fieldId}"
class="inline-block px-2 py-1 rounded bg-white border ${borderColor} text-gray-900 text-xs font-semibold cursor-pointer hover:bg-gray-50 transition"
>
${formattedDate}
</label>
<button
onclick="${toggleFunction}(${recordId})"
class="w-5 h-5 flex items-center justify-center rounded ${isConfirmed ? 'bg-green-500 text-white' : 'bg-gray-200 text-gray-700'} hover:opacity-80 transition"
title="${isConfirmed ? titleConfirmed : titleNotConfirmed}"
>
<i class="fas fa-check text-xs"></i>
</button>
</div>
</td>
`;
}
return `
<td class="px-2 py-1 text-center relative">
<input
type="date"
id="${fieldId}"
style="position: absolute; left: -9999px; opacity: 0;"
value="${date}"
onchange="updateDateFromCalendar(${recordId}, '${field}', this.value)"
/>
<label
for="${fieldId}"
class="inline-block px-2 py-1 rounded bg-white border ${borderColor} text-gray-900 text-xs font-semibold cursor-pointer hover:bg-gray-50 transition"
>
${formattedDate}
</label>
</td>
`;
}
// Update date from calendar picker
async function updateDateFromCalendar(recordId, field, newDate) {
// Allow empty string to clear the date
try {
const headers = token ? { Authorization: `Bearer ${token}` } : {};
await axios.patch(
`${API_BASE}/api/records/${recordId}/status`,
{ field, date: null, newDate: newDate || null },
{ headers }
);
await loadRecords();
} catch (error) {
console.error('Update date from calendar error:', error);
// Error removed - operation works correctly for both admin and public users
}
}
// Render date cell with fixed color (color determined by field name)
function renderDateCell(recordId, field, date, hasError, isBlocked, problemText = '') {
// Check if error flag is set
const isError = hasError === 1;
// Prepare tooltip text
const tooltipText = (isError || isBlocked) && problemText ? problemText.replace(/"/g, '&quot;').replace(/'/g, '&#39;').replace(/\n/g, '&#10;') : '';
// If field is blocked, show as disabled but clickable (only lock icon, no date)
if (isBlocked) {
const blockedColorClass = 'bg-gray-300 text-gray-600 border border-gray-400 cursor-pointer hover:bg-gray-400';
// Escape problem text for onclick
const escapedProblemText = problemText.replace(/'/g, "\\'").replace(/"/g, '&quot;').replace(/\n/g, '\\n');
return `
<td class="px-2 py-1 text-center">
<div
class="inline-block px-2 py-1 rounded ${blockedColorClass} text-xs"
title="${tooltipText || 'Klõpsake probleemi vaatamiseks'}"
onclick='openBlockedFieldModal("${escapedProblemText}")'
>
<i class="fas fa-lock"></i>
</div>
</td>
`;
}
if (!date) {
// Empty cell - light red for all errors, white if normal
let emptyColorClass;
if (isError) {
// Light red for all error flags
emptyColorClass = 'bg-red-100 border border-red-300 text-red-800';
} else {
emptyColorClass = FIELD_COLORS[field] || 'bg-white border border-gray-300 text-gray-400';
}
return `
<td class="px-2 py-1 text-center">
<div
class="inline-block px-2 py-1 rounded ${emptyColorClass} text-xs cursor-pointer hover:bg-red-50 transition"
${tooltipText ? `title="${tooltipText}"` : ''}
onclick="toggleDate(${recordId}, '${field}', null)"
>
-
</div>
</td>
`;
}
// Format date as DD.MM.YYYY
const formattedDate = formatDate(date);
// Filled cell - light red for all errors, green if normal
let filledColorClass;
if (isError) {
// Light red for all error flags
filledColorClass = 'bg-red-100 border border-red-300 text-red-800';
} else {
filledColorClass = FIELD_COLORS[field + '_filled'] || 'bg-green-500 text-white';
}
return `
<td class="px-2 py-1 text-center">
<div
class="inline-block px-2 py-1 rounded ${filledColorClass} text-xs font-semibold cursor-pointer hover:bg-red-50 transition"
${tooltipText ? `title="${tooltipText}"` : ''}
onclick="toggleDate(${recordId}, '${field}', '${date}')"
>
${formattedDate}
</div>
</td>
`;
}
// Helper function to format date
function formatDate(date) {
const dateObj = new Date(date + 'T00:00:00');
const day = String(dateObj.getDate()).padStart(2, '0');
const month = String(dateObj.getMonth() + 1).padStart(2, '0');
const year = dateObj.getFullYear();
return `${day}.${month}.${year}`;
}
// Render worksheets cell with 3-step cycle logic
function renderWorksheetsCell(recordId, date, confirmed, hasError, problemText = '') {
const isError = hasError === 1;
// Prepare tooltip text
const tooltipText = isError && problemText ? problemText.replace(/"/g, '&quot;').replace(/'/g, '&#39;').replace(/\n/g, '&#10;') : '';
// Step 1: No date (empty cell)
if (!date) {
const emptyColorClass = isError
? 'bg-red-100 border border-red-300 text-red-800'
: 'bg-white border border-gray-300 text-gray-400';
const defaultTitle = "Klõps 1: Lisa kuupäev";
return `
<td class="px-2 py-1 text-center">
<div
class="inline-block px-2 py-1 rounded ${emptyColorClass} text-xs cursor-pointer hover:opacity-80 transition"
onclick="toggleWorksheetsStep(${recordId})"
title="${tooltipText || defaultTitle}"
>
-
</div>
</td>
`;
}
// Format date
const formattedDate = formatDate(date);
// Step 2: Has date, not confirmed (gray)
if (confirmed === 0) {
const grayColorClass = isError
? 'bg-red-100 border border-red-300 text-red-800'
: 'bg-gray-200 border border-gray-400 text-gray-900';
const defaultTitle = "Klõps 2: Kinnita";
return `
<td class="px-2 py-1 text-center">
<div
class="inline-block px-2 py-1 rounded ${grayColorClass} text-xs font-semibold cursor-pointer hover:opacity-80 transition"
onclick="toggleWorksheetsStep(${recordId})"
title="${tooltipText || defaultTitle}"
>
${formattedDate}
</div>
</td>
`;
}
// Step 3: Has date, confirmed (green)
const greenColorClass = isError
? 'bg-red-100 border border-red-300 text-red-800'
: 'bg-green-500 border border-green-600 text-white';
const defaultTitle = "Klõps 3: Tühjenda";
return `
<td class="px-2 py-1 text-center">
<div
class="inline-block px-2 py-1 rounded ${greenColorClass} text-xs font-semibold cursor-pointer hover:opacity-80 transition"
onclick="toggleWorksheetsStep(${recordId})"
title="${tooltipText || defaultTitle}"
>
${formattedDate}
</div>
</td>
`;
}
function renderNotesCell(recordId, notes, notesDate) {
// Prepare tooltip text - escape for HTML attribute
const tooltipText = notes ? notes.replace(/"/g, '&quot;').replace(/'/g, '&#39;').replace(/\n/g, '&#10;') : '';
// If has notes text - show YELLOW with info icon
if (notes && notes.trim()) {
return `
<td class="px-2 py-1 text-center">
<div
class="inline-block px-2 py-1 rounded bg-yellow-400 text-white border-2 border-yellow-500 text-xs font-semibold cursor-pointer hover:bg-yellow-500 transition"
title="${tooltipText}"
onclick='openNotesModal(${recordId}, "${notes.replace(/"/g, '&quot;').replace(/'/g, "\\'").replace(/\n/g, "\\n")}")'
>
<i class="fas fa-info-circle"></i>
</div>
</td>
`;
}
// No notes - show empty cell with click to add
return `
<td class="px-2 py-1 text-center">
<div
class="inline-block px-2 py-1 rounded bg-gray-100 border border-gray-300 text-gray-400 text-xs cursor-pointer hover:bg-gray-200 transition"
onclick='openNotesModal(${recordId}, "")'
>
-
</div>
</td>
`;
}
function renderProblemsCell(recordId, problems, problemsDate, record) {
// Prepare error flags object for passing to openProblemsModal
const errorFlags = {
worksheets_error: record.worksheets_error || 0,
cutting_error: record.cutting_error || 0,
glazing_error: record.glazing_error || 0,
ready_error: record.ready_error || 0,
issued_error: record.issued_error || 0
};
const errorFlagsJson = JSON.stringify(errorFlags).replace(/"/g, '&quot;');
// Check if has error flags (checkboxes)
const hasProblems = record.worksheets_error === 1 ||
record.cutting_error === 1 ||
record.glazing_error === 1 ||
record.ready_error === 1 ||
record.issued_error === 1;
// Prepare tooltip text from problems text field
const tooltipText = problems ? problems.replace(/"/g, '&quot;').replace(/'/g, '&#39;').replace(/\n/g, '&#10;') : '';
// If has error flags (checkboxes checked) - show RED with exclamation mark
if (hasProblems) {
return `
<td class="px-2 py-1 text-center">
<div
class="inline-block px-2 py-1 rounded bg-red-500 text-white border-2 border-red-600 text-xs font-semibold cursor-pointer hover:bg-red-600 transition"
title="${tooltipText}"
onclick='openProblemsModal(${recordId}, "${problems ? problems.replace(/"/g, '&quot;').replace(/'/g, "\\'").replace(/\n/g, "\\n") : ''}", ${errorFlagsJson})'
>
<i class="fas fa-exclamation-triangle"></i>
</div>
</td>
`;
}
// If has problems text but no error flags - show gray with icon
if (problems && problems.trim()) {
return `
<td class="px-2 py-1 text-center">
<div
class="inline-block px-2 py-1 rounded bg-gray-300 text-gray-700 border border-gray-400 text-xs cursor-pointer hover:bg-gray-400 transition"
title="${tooltipText}"
onclick='openProblemsModal(${recordId}, "${problems.replace(/"/g, '&quot;').replace(/'/g, "\\'").replace(/\n/g, "\\n")}", ${errorFlagsJson})'
>
<i class="fas fa-info-circle"></i>
</div>
</td>
`;
}
// No problems - show empty cell with click to add
return `
<td class="px-2 py-1 text-center">
<div
class="inline-block px-2 py-1 rounded bg-gray-100 border border-gray-300 text-gray-400 text-xs cursor-pointer hover:bg-gray-200 transition"
onclick='openProblemsModal(${recordId}, "", ${errorFlagsJson})'
>
-
</div>
</td>
`;
}
// Toggle date: if has date - remove, if no date - add current date
async function toggleDate(recordId, field, currentDate) {
// Check permissions - admin and user can toggle dates
if (!canToggleDates()) {
alert('Sul pole õigust andmeid muuta. Palun logi sisse.');
return;
}
try {
const headers = token ? { Authorization: `Bearer ${token}` } : {};
const response = await axios.patch(
`${API_BASE}/api/records/${recordId}/status`,
{ field, date: currentDate },
{ headers }
);
if (response.data.success) {
await loadRecords(); // Refresh table
}
} catch (error) {
console.error('Toggle date error:', error);
// Check if field is blocked by problems
if (error.response?.status === 403 && error.response?.data?.error === 'blocked') {
openBlockedFieldModal(error.response.data.message);
} else if (error.response?.status === 401) {
alert('Sessioon on aegunud. Palun logi uuesti sisse.');
logout();
}
}
}
// Update MAT-2 state based on MAT-1
function updateMat2State() {
const mat1 = document.getElementById('materialDate');
const mat2 = document.getElementById('material2Date');
if (!mat1.value) {
mat2.disabled = true;
mat2.value = '';
mat2.classList.add('bg-gray-100', 'cursor-not-allowed');
} else {
mat2.disabled = false;
mat2.classList.remove('bg-gray-100', 'cursor-not-allowed');
}
}
function openModal() {
// Check if user is authenticated
if (!token || !currentUser || currentUser.role !== 'admin') {
alert('Ainult administraator saab lisada uusi kirjeid. Palun logi sisse.');
openLoginModal();
return;
}
editingRecordId = null;
document.getElementById('modalTitle').textContent = 'Lisa uus kirje';
document.getElementById('recordForm').reset();
document.getElementById('recordModal').classList.add('active');
updateMat2State(); // Check MAT-2 state on open
}
function closeModal() {
document.getElementById('recordModal').classList.remove('active');
editingRecordId = null;
}
function editRecord(recordId) {
// Check if user is authenticated
if (!token || !currentUser || currentUser.role !== 'admin') {
alert('Ainult administraator saab muuta kirjeid. Palun logi sisse.');
openLoginModal();
return;
}
const record = currentRecords.find(r => r.id === recordId);
if (!record) return;
editingRecordId = recordId;
document.getElementById('modalTitle').textContent = 'Muuda kirjet';
document.getElementById('recordId').value = record.id;
document.getElementById('clientName').value = record.client_name || '';
document.getElementById('type').value = record.type || '';
document.getElementById('offerNumber').value = record.offer_number || '';
document.getElementById('workNumber').value = record.work_number || '';
document.getElementById('quantity').value = record.quantity || '';
document.getElementById('color').value = record.color || '';
document.getElementById('notes').value = record.notes || '';
document.getElementById('installer').value = record.installer || '';
document.getElementById('price').value = record.price || '';
document.getElementById('arveChecked').checked = record.arve_checked === 1;
document.getElementById('arveMakstud').value = record.arve_makstud || '';
document.getElementById('materialDate').value = record.material_date || '';
document.getElementById('material2Date').value = record.material2_date || '';
document.getElementById('packageDate').value = record.package_date || '';
document.getElementById('recordModal').classList.add('active');
updateMat2State(); // Check MAT-2 state after loading data
}
async function handleSaveRecord(e) {
e.preventDefault();
const data = {
month: parseInt(document.getElementById('monthFilter').value),
year: parseInt(document.getElementById('yearFilter').value),
client_name: document.getElementById('clientName').value,
type: document.getElementById('type').value || null,
offer_number: document.getElementById('offerNumber').value || null,
work_number: document.getElementById('workNumber').value || null,
quantity: parseInt(document.getElementById('quantity').value),
color: document.getElementById('color').value || null,
notes: document.getElementById('notes').value || null,
installer: document.getElementById('installer').value || null,
price: parseFloat(document.getElementById('price').value.replace(',', '.')) || 0,
arve_checked: document.getElementById('arveChecked').checked ? 1 : 0,
arve_makstud: document.getElementById('arveMakstud').value || null,
// Only include date fields that are actually in the form
// Töölehti, LÕI, KLA, VAL, VÄL are managed separately via toggle dates
material_date: document.getElementById('materialDate').value || null,
material2_date: document.getElementById('material2Date').value || null,
package_date: document.getElementById('packageDate').value || null
};
try {
if (editingRecordId) {
await axios.put(
`${API_BASE}/api/records/${editingRecordId}`,
data,
{ headers: { Authorization: `Bearer ${token}` } }
);
} else {
await axios.post(
`${API_BASE}/api/records`,
data,
{ headers: { Authorization: `Bearer ${token}` } }
);
}
await loadRecords(); // Reload data first
closeModal(); // Then close modal
} catch (error) {
console.error('Save record error:', error);
// More detailed error handling
if (error.response?.status === 401) {
alert('Sessioon on aegunud. Palun logi uuesti sisse.');
// Clear invalid token
localStorage.removeItem('token');
localStorage.removeItem('user');
token = null;
currentUser = null;
location.reload();
} else if (error.response?.status === 400) {
alert('Viga: ' + (error.response?.data?.error || 'Kontrolli väljad'));
} else {
alert('Viga salvestamisel: ' + (error.response?.data?.error || error.message));
}
}
}
// Blocked field modal functions
function openBlockedFieldModal(problemText) {
document.getElementById('blockedFieldMessage').textContent = problemText;
document.getElementById('blockedFieldModal').classList.add('active');
}
function closeBlockedFieldModal() {
document.getElementById('blockedFieldModal').classList.remove('active');
document.getElementById('blockedFieldMessage').textContent = '';
}
// Notes modal functions
function openNotesModal(recordId, notes) {
document.getElementById('notesRecordId').value = recordId;
document.getElementById('notesText').value = notes.replace(/\\n/g, '\n').replace(/\\'/g, "'");
// Disable inputs for non-admin users (only admin can edit notes)
const readOnly = !canEditRecords();
document.getElementById('notesText').readOnly = readOnly;
// Hide save button for non-admins
const saveBtn = document.querySelector('#notesModal button[type="submit"]');
if (saveBtn) {
saveBtn.style.display = readOnly ? 'none' : 'inline-block';
}
document.getElementById('notesModal').classList.add('active');
}
function closeNotesModal() {
document.getElementById('notesModal').classList.remove('active');
document.getElementById('notesRecordId').value = '';
document.getElementById('notesText').value = '';
}
async function saveNotes(event) {
event.preventDefault();
// Check permissions - only admin
if (!canEditRecords()) {
alert('Sul pole õigust märkmeid muuta. Palun logi sisse administraatorina.');
return;
}
const recordId = document.getElementById('notesRecordId').value;
const notes = document.getElementById('notesText').value;
try {
const headers = token ? { Authorization: `Bearer ${token}` } : {};
await axios.patch(
`${API_BASE}/api/records/${recordId}/notes`,
{ notes },
{ headers }
);
await loadRecords(); // Reload data first
closeNotesModal(); // Then close modal
} catch (error) {
console.error('Save notes error:', error);
alert('Viga märkuste salvestamisel');
}
}
// Problems modal functions
function openProblemsModal(recordId, problems, errorFlags = {}) {
document.getElementById('problemsRecordId').value = recordId;
document.getElementById('problemsText').value = problems.replace(/\\n/g, '\n').replace(/\\'/g, "'");
// Set error checkboxes based on current error flags
document.getElementById('errorWorksheets').checked = errorFlags.worksheets_error === 1;
document.getElementById('errorCutting').checked = errorFlags.cutting_error === 1;
document.getElementById('errorGlazing').checked = errorFlags.glazing_error === 1;
document.getElementById('errorReady').checked = errorFlags.ready_error === 1;
document.getElementById('errorIssued').checked = errorFlags.issued_error === 1;
// Disable inputs for guest users
const readOnly = !canEditProblems();
document.getElementById('problemsText').readOnly = readOnly;
document.getElementById('errorWorksheets').disabled = readOnly;
document.getElementById('errorCutting').disabled = readOnly;
document.getElementById('errorGlazing').disabled = readOnly;
document.getElementById('errorReady').disabled = readOnly;
document.getElementById('errorIssued').disabled = readOnly;
// Hide save button for guests
const saveBtn = document.querySelector('#problemsModal button[type="submit"]');
if (saveBtn) {
saveBtn.style.display = readOnly ? 'none' : 'inline-block';
}
document.getElementById('problemsModal').classList.add('active');
}
function closeProblemsModal() {
document.getElementById('problemsModal').classList.remove('active');
document.getElementById('problemsRecordId').value = '';
document.getElementById('problemsText').value = '';
// Clear error checkboxes
document.getElementById('errorWorksheets').checked = false;
document.getElementById('errorCutting').checked = false;
document.getElementById('errorGlazing').checked = false;
document.getElementById('errorReady').checked = false;
document.getElementById('errorIssued').checked = false;
}
async function saveProblems(event) {
event.preventDefault();
// Check permissions
if (!canEditProblems()) {
alert('Sul pole õigust probleeme muuta. Palun logi sisse.');
return;
}
const recordId = document.getElementById('problemsRecordId').value;
const problems = document.getElementById('problemsText').value;
// Get error flags from checkboxes
const errorFlags = {
worksheets: document.getElementById('errorWorksheets').checked,
cutting: document.getElementById('errorCutting').checked,
glazing: document.getElementById('errorGlazing').checked,
ready: document.getElementById('errorReady').checked,
issued: document.getElementById('errorIssued').checked
};
try {
const headers = token ? { Authorization: `Bearer ${token}` } : {};
await axios.patch(
`${API_BASE}/api/records/${recordId}/problems`,
{ problems, errorFlags },
{ headers }
);
await loadRecords(); // Reload data first
closeProblemsModal(); // Then close modal
} catch (error) {
console.error('Save problems error:', error);
if (error.response?.status === 401) {
alert('Sessioon on aegunud. Palun logi uuesti sisse.');
logout();
} else {
alert('Viga probleemide salvestamisel');
}
}
}
// Settings modal functions
function openSettingsModal() {
document.getElementById('settingsUsername').value = currentUser.username;
document.getElementById('settingsFullName').value = currentUser.full_name || currentUser.username;
document.getElementById('settingsCurrentPassword').value = '';
document.getElementById('settingsNewPassword').value = '';
document.getElementById('settingsConfirmPassword').value = '';
document.getElementById('allowDeleteCheckbox').checked = allowDelete;
document.getElementById('settingsError').classList.add('hidden');
document.getElementById('settingsSuccess').classList.add('hidden');
document.getElementById('settingsModal').classList.add('active');
}
function closeSettingsModal() {
document.getElementById('settingsModal').classList.remove('active');
}
document.getElementById('settingsForm').addEventListener('submit', async function(event) {
event.preventDefault();
const fullName = document.getElementById('settingsFullName').value;
const currentPassword = document.getElementById('settingsCurrentPassword').value;
const newPassword = document.getElementById('settingsNewPassword').value;
const confirmPassword = document.getElementById('settingsConfirmPassword').value;
const errorDiv = document.getElementById('settingsError');
const successDiv = document.getElementById('settingsSuccess');
// Hide previous messages
errorDiv.classList.add('hidden');
successDiv.classList.add('hidden');
// Validation
if (!fullName.trim()) {
errorDiv.textContent = 'Nimi ei saa olla tühi';
errorDiv.classList.remove('hidden');
return;
}
// If changing password, current password is required
if (newPassword && !currentPassword) {
errorDiv.textContent = 'Praegune parool on kohustuslik parooli muutmiseks';
errorDiv.classList.remove('hidden');
return;
}
// Check if passwords match (only if new password is provided)
if (newPassword && newPassword !== confirmPassword) {
errorDiv.textContent = 'Uued paroolid ei kattu';
errorDiv.classList.remove('hidden');
return;
}
try {
const response = await axios.patch(
`${API_BASE}/api/users/profile`,
{
full_name: fullName,
current_password: currentPassword,
new_password: newPassword || null
},
{ headers: { Authorization: `Bearer ${token}` } }
);
// Update current user data
currentUser.full_name = response.data.user.full_name;
localStorage.setItem('user', JSON.stringify(currentUser));
// Update UI
document.getElementById('userName').textContent = currentUser.full_name;
// Show success message
successDiv.textContent = 'Seaded edukalt salvestatud!';
successDiv.classList.remove('hidden');
// Close modal after 1.5 seconds
setTimeout(() => {
closeSettingsModal();
}, 1500);
} catch (error) {
console.error('Update settings error:', error);
errorDiv.textContent = error.response?.data?.error || 'Viga seadete uuendamisel';
errorDiv.classList.remove('hidden');
}
});
// Render price cell with green border when paid
function renderPriceCell(recordId, price, pricePaid = 0, arveChecked = 0, arveMakstud = '') {
if (!price) {
return `<td class="admin-only px-2 py-1 text-sm text-right text-gray-900 font-medium">-</td>`;
}
// Use arve_checked for border styling (instead of price_paid)
const isArveChecked = arveChecked === 1;
const borderClass = isArveChecked ? 'border border-green-500' : '';
const bgClass = isArveChecked ? 'bg-green-50' : '';
// Prepare tooltip text from arve_makstud field
const tooltipText = arveMakstud ?
arveMakstud.replace(/"/g, '&quot;').replace(/'/g, '&#39;').replace(/\n/g, '&#10;') :
(isArveChecked ? 'Arve' : 'Arve puudub');
return `
<td class="admin-only px-2 py-1 text-sm text-right">
<div
class="inline-block px-2 py-1 rounded ${borderClass} ${bgClass} text-gray-900 font-medium"
title="${tooltipText}"
>
${price.toFixed(2)}
</div>
</td>
`;
}
// Toggle price paid status
async function togglePricePaid(recordId) {
// Check permissions - only admin
if (!canEditRecords()) {
alert('Sul pole õigust maksestaatust muuta. Palun logi sisse administraatorina.');
return;
}
try {
const response = await axios.patch(
`${API_BASE}/api/records/${recordId}/price-paid`,
{},
{ headers: { Authorization: `Bearer ${token}` } }
);
if (response.data.success) {
await loadRecords(); // Refresh table
}
} catch (error) {
console.error('Toggle price paid error:', error);
// Handle specific error cases
if (error.response?.status === 401) {
alert('Sessioon on aegunud. Palun logige uuesti sisse.');
// Clear invalid token
localStorage.removeItem('token');
localStorage.removeItem('user');
token = null;
currentUser = null;
location.reload();
} else {
alert('Viga maksestaatuse muutmisel: ' + (error.response?.data?.error || error.message));
}
}
}
// Toggle material confirmed status
async function toggleMaterialConfirmed(recordId) {
// Check permissions - only admin
if (!canEditRecords()) {
alert('Sul pole õigust kinnitust muuta. Palun logi sisse administraatorina.');
return;
}
try {
const headers = token ? { Authorization: `Bearer ${token}` } : {};
const response = await axios.patch(
`${API_BASE}/api/records/${recordId}/material-confirmed`,
{},
{ headers }
);
if (response.data.success) {
await loadRecords(); // Refresh table
}
} catch (error) {
console.error('Toggle material confirmed error:', error);
// Handle specific error cases
if (error.response?.status === 401) {
alert('Sessioon on aegunud. Palun logige uuesti sisse.');
// Clear invalid token
localStorage.removeItem('token');
localStorage.removeItem('user');
token = null;
currentUser = null;
location.reload();
} else {
alert('Viga materjali kinnitamisel: ' + (error.response?.data?.error || error.message));
}
}
}
// Toggle material2_confirmed
async function toggleMaterial2Confirmed(recordId) {
// Check permissions - only admin
if (!canEditRecords()) {
alert('Sul pole õigust kinnitust muuta. Palun logi sisse administraatorina.');
return;
}
try {
const headers = token ? { Authorization: `Bearer ${token}` } : {};
const response = await axios.patch(
`${API_BASE}/api/records/${recordId}/material2-confirmed`,
{},
{ headers }
);
if (response.data.success) {
await loadRecords(); // Refresh table
}
} catch (error) {
console.error('Toggle material2 confirmed error:', error);
// Handle specific error cases
if (error.response?.status === 401) {
alert('Sessioon on aegunud. Palun logige uuesti sisse.');
// Clear invalid token
localStorage.removeItem('token');
localStorage.removeItem('user');
token = null;
currentUser = null;
location.reload();
} else {
alert('Viga materjali 2 kinnitamisel: ' + (error.response?.data?.error || error.message));
}
}
}
// Toggle worksheets with 3-step cycle
async function toggleWorksheetsStep(recordId) {
// Check permissions - admin and user can toggle
if (!canToggleDates()) {
alert('Sul pole õigust töölehe staatust muuta. Palun logi sisse.');
return;
}
try {
const headers = token ? { Authorization: `Bearer ${token}` } : {};
const response = await axios.patch(
`${API_BASE}/api/records/${recordId}/worksheets-cycle`,
{},
{ headers }
);
if (response.data.success) {
await loadRecords(); // Refresh table
}
} catch (error) {
console.error('Toggle worksheets step error:', error);
alert('Viga Töölehti staatuse muutmisel');
}
}
// ==================== REPORT FUNCTIONALITY ====================
let reportData = [];
let accountantReportData = []; // For accountant report
let reportYear = 2025;
let reportMonth = 1;
let reportType = 'master'; // 'master' or 'accountant'
let currentReportStep = 0;
const MONTH_NAMES = [
'Jaanuar', 'Veebruar', 'Märts', 'Aprill', 'Mai', 'Juuni',
'Juuli', 'August', 'September', 'Oktoober', 'November', 'Detsember'
];
async function openReportModal() {
document.getElementById('reportModal').classList.add('active');
currentReportStep = 0;
reportType = 'master';
const currentDate = new Date();
reportYear = currentDate.getFullYear();
reportMonth = currentDate.getMonth() + 1;
// Load years dynamically for report
await loadYears();
showReportStep(0);
}
function closeReportModal() {
document.getElementById('reportModal').classList.remove('active');
reportData = [];
}
function showReportStep(step) {
// Hide all steps
document.getElementById('reportStep0').classList.add('hidden');
document.getElementById('reportStep1').classList.add('hidden');
document.getElementById('reportStep2').classList.add('hidden');
document.getElementById('reportStep3').classList.add('hidden');
// Show current step
document.getElementById(`reportStep${step}`).classList.remove('hidden');
// Update step indicators (0-3)
for (let i = 0; i <= 3; i++) {
const indicator = document.getElementById(`step${i}-indicator`);
const circle = indicator.querySelector('div');
const text = indicator.querySelector('span');
if (i < step) {
// Completed step
circle.className = 'w-10 h-10 rounded-full bg-green-500 text-white flex items-center justify-center font-bold';
circle.innerHTML = '<i class="fas fa-check"></i>';
text.className = 'ml-2 text-sm font-medium text-green-500';
} else if (i === step) {
// Current step
circle.className = 'w-10 h-10 rounded-full bg-indigo-600 text-white flex items-center justify-center font-bold';
circle.textContent = i + 1;
text.className = 'ml-2 text-sm font-medium text-indigo-600';
} else {
// Future step
circle.className = 'w-10 h-10 rounded-full bg-gray-300 text-gray-600 flex items-center justify-center font-bold';
circle.textContent = i + 1;
text.className = 'ml-2 text-sm font-medium text-gray-600';
}
}
currentReportStep = step;
}
// Select report type (master or accountant)
function selectReportType(type) {
reportType = type;
showReportStep(1);
// Update step 1 content based on type
const step1Title = document.getElementById('reportStep1Title');
const monthSelector = document.getElementById('reportMonthSelector');
if (type === 'master') {
step1Title.textContent = 'Vali aruande aasta';
monthSelector.classList.add('hidden');
} else {
step1Title.textContent = 'Vali periood';
monthSelector.classList.remove('hidden');
// Set current month
document.getElementById('reportMonth').value = reportMonth;
}
}
function goBackToReportStep0() {
showReportStep(0);
}
function goToReportStep1() {
showReportStep(1);
}
async function goToReportStep2() {
reportYear = parseInt(document.getElementById('reportYear').value);
if (reportType === 'master') {
await loadReportData();
document.getElementById('masterReportTable').classList.remove('hidden');
document.getElementById('accountantReportTable').classList.add('hidden');
} else {
reportMonth = parseInt(document.getElementById('reportMonth').value);
await loadAccountantReportData();
document.getElementById('masterReportTable').classList.add('hidden');
document.getElementById('accountantReportTable').classList.remove('hidden');
}
showReportStep(2);
}
function goToReportStep3() {
generateReportData();
showReportStep(3);
}
async function loadReportData() {
try {
// Initialize report data structure for 12 months
reportData = MONTH_NAMES.map((name, index) => ({
month: index + 1,
monthName: name,
workDays: '',
windows: 0,
price: 0,
avgPerDay: 0
}));
// Load saved work days from localStorage for this year
const savedWorkDays = JSON.parse(localStorage.getItem(`workDays_${reportYear}`) || '{}');
// Fetch data for each month
const headers = token ? { Authorization: `Bearer ${token}` } : {};
const promises = [];
for (let month = 1; month <= 12; month++) {
promises.push(
axios.get(`${API_BASE}/api/records`, {
params: { month, year: reportYear },
headers
})
);
}
const responses = await Promise.all(promises);
// Process each month's data
responses.forEach((response, index) => {
const records = response.data;
let totalWindows = 0;
let totalPrice = 0;
records.forEach(record => {
totalWindows += parseInt(record.quantity) || 0;
totalPrice += parseFloat(record.price) || 0;
});
reportData[index].windows = totalWindows;
reportData[index].price = totalPrice;
// Restore saved work days for this month
if (savedWorkDays[index + 1]) {
reportData[index].workDays = savedWorkDays[index + 1];
}
});
renderReportTable();
} catch (error) {
console.error('Load report data error:', error);
alert('Viga aruande andmete laadimisel');
}
}
function renderReportTable() {
const tbody = document.getElementById('reportTableBody');
tbody.innerHTML = '';
reportData.forEach((monthData, index) => {
const tr = document.createElement('tr');
tr.className = 'hover:bg-gray-50';
// Calculate average per day if work days are filled
let avgPerDay = '-';
if (monthData.workDays && parseInt(monthData.workDays) > 0) {
avgPerDay = (monthData.windows / parseInt(monthData.workDays)).toFixed(2);
monthData.avgPerDay = parseFloat(avgPerDay);
}
tr.innerHTML = `
<td class="px-2 py-1 text-xs font-medium text-gray-900">${monthData.monthName}</td>
<td class="px-1 py-1 text-center">
<input
type="number"
min="0"
max="31"
value="${monthData.workDays}"
placeholder="0"
class="w-12 px-1 py-1 text-xs text-center border border-gray-300 rounded focus:ring-1 focus:ring-indigo-500"
onchange="updateWorkDays(${index}, this.value)"
>
</td>
<td class="px-1 py-1 text-center text-xs font-semibold ${monthData.windows > 0 ? 'text-green-600' : 'text-gray-400'}">
${monthData.windows}
</td>
<td class="px-1 py-1 text-right text-xs font-semibold ${monthData.price > 0 ? 'text-green-600' : 'text-gray-400'}">
${monthData.price.toFixed(2)}
</td>
<td class="px-1 py-1 text-center text-xs font-semibold ${avgPerDay !== '-' ? 'text-indigo-600' : 'text-gray-400'}">
${avgPerDay}
</td>
`;
tbody.appendChild(tr);
});
updateReportTotals();
}
function updateWorkDays(index, value) {
reportData[index].workDays = value;
// Save work days to localStorage
const savedWorkDays = JSON.parse(localStorage.getItem(`workDays_${reportYear}`) || '{}');
savedWorkDays[reportData[index].month] = value;
localStorage.setItem(`workDays_${reportYear}`, JSON.stringify(savedWorkDays));
renderReportTable();
}
function updateReportTotals() {
let totalWorkDays = 0;
let totalWindows = 0;
let totalPrice = 0;
let monthsWithWorkDays = 0;
reportData.forEach(month => {
const workDays = parseInt(month.workDays) || 0;
if (workDays > 0) {
totalWorkDays += workDays;
monthsWithWorkDays++;
}
totalWindows += month.windows;
totalPrice += month.price;
});
document.getElementById('totalWorkDays').textContent = totalWorkDays > 0 ? totalWorkDays : '-';
document.getElementById('totalWindows').textContent = totalWindows;
document.getElementById('totalPrice').textContent = totalPrice.toFixed(2);
// Calculate overall average per day
if (totalWorkDays > 0) {
const avgPerDay = (totalWindows / totalWorkDays).toFixed(2);
document.getElementById('avgPerDay').textContent = avgPerDay;
} else {
document.getElementById('avgPerDay').textContent = '-';
}
}
function generateReportData() {
// Report data is already in reportData array, ready for CSV generation
console.log('Report data ready for CSV:', reportData);
}
function downloadCSV() {
let csv = '';
let filename = '';
if (reportType === 'master') {
// Master report CSV
csv = 'Kuu,Tööpäevad,Aknad (Kogus),Summa (EUR),Keskmine päevas\n';
let totalWorkDays = 0;
let totalWindows = 0;
let totalPrice = 0;
reportData.forEach(month => {
const workDays = month.workDays || '';
const avgPerDay = month.avgPerDay > 0 ? month.avgPerDay.toFixed(2) : '';
csv += `${month.monthName},${workDays},${month.windows},${month.price.toFixed(2)},${avgPerDay}\n`;
totalWorkDays += parseInt(month.workDays) || 0;
totalWindows += month.windows;
totalPrice += month.price;
});
// Add totals row
const totalAvg = totalWorkDays > 0 ? (totalWindows / totalWorkDays).toFixed(2) : '';
csv += `\nKOKKU,${totalWorkDays > 0 ? totalWorkDays : ''},${totalWindows},${totalPrice.toFixed(2)},${totalAvg}\n`;
filename = `meistri_aruanne_${reportYear}.csv`;
} else {
// Accountant report CSV
csv = 'KLIENT,Pakkum. Nr,Töö Nr,Kogus,Hind (EUR),Arve Nr\n';
let totalQuantity = 0;
let totalPrice = 0;
accountantReportData.forEach(record => {
const client = (record.client_name || '-').replace(/,/g, ';');
const offer = (record.offer_number || '-').replace(/,/g, ';');
const work = (record.work_number || '-').replace(/,/g, ';');
const quantity = record.quantity || 0;
const price = record.price ? record.price.toFixed(2) : '0.00';
const arveMakstud = (record.arve_makstud || '-').replace(/,/g, ';');
csv += `${client},${offer},${work},${quantity},${price},${arveMakstud}\n`;
totalQuantity += parseInt(quantity) || 0;
totalPrice += parseFloat(record.price) || 0;
});
// Add totals row
csv += `\nKOKKU,,,${totalQuantity},${totalPrice.toFixed(2)},\n`;
filename = `raamatupidaja_aruanne_${reportYear}_${String(reportMonth).padStart(2, '0')}.csv`;
}
// Create blob and download
const blob = new Blob(['\ufeff' + csv], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', filename);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
// Load accountant report data (detailed client list)
async function loadAccountantReportData() {
try {
const headers = token ? { Authorization: `Bearer ${token}` } : {};
// Fetch records for selected month and year
const response = await axios.get(`${API_BASE}/api/records`, {
params: { month: reportMonth, year: reportYear },
headers
});
const records = response.data;
// Show ALL records for accountant report (no filtering)
const invoicedRecords = records;
// Save to global variable for CSV/print
accountantReportData = invoicedRecords;
// Render accountant table
const tbody = document.getElementById('accountantReportTableBody');
tbody.innerHTML = invoicedRecords.map(record => `
<tr class="hover:bg-gray-50">
<td class="px-2 py-1 text-xs text-gray-900">${record.client_name || '-'}</td>
<td class="px-2 py-1 text-xs text-center text-gray-600">${record.offer_number || '-'}</td>
<td class="px-2 py-1 text-xs text-center text-gray-600">${record.work_number || '-'}</td>
<td class="px-2 py-1 text-xs text-center text-gray-900 font-medium">${record.quantity || 0}</td>
<td class="px-2 py-1 text-xs text-right text-gray-900 font-medium">${record.price ? record.price.toFixed(2) : '0.00'}</td>
<td class="px-2 py-1 text-xs text-gray-600">${record.arve_makstud || '-'}</td>
</tr>
`).join('');
// Calculate totals
const totalQuantity = invoicedRecords.reduce((sum, r) => sum + (parseInt(r.quantity) || 0), 0);
const totalPrice = invoicedRecords.reduce((sum, r) => sum + (parseFloat(r.price) || 0), 0);
// Update footer
document.getElementById('accTotalQuantity').textContent = totalQuantity;
document.getElementById('accTotalPrice').textContent = totalPrice.toFixed(2);
console.log('Accountant report data ready:', invoicedRecords);
} catch (error) {
console.error('Load accountant report error:', error);
alert('Viga aruande laadimisel: ' + (error.response?.data?.error || error.message));
}
}
function printReport() {
// Create print area if it doesn't exist
let printArea = document.getElementById('printArea');
if (!printArea) {
printArea = document.createElement('div');
printArea.id = 'printArea';
printArea.style.display = 'none';
document.body.appendChild(printArea);
}
let htmlContent = '';
if (reportType === 'master') {
// Master report print
let totalWorkDays = 0;
let totalWindows = 0;
let totalPrice = 0;
reportData.forEach(month => {
totalWorkDays += parseInt(month.workDays) || 0;
totalWindows += month.windows;
totalPrice += month.price;
});
const totalAvg = totalWorkDays > 0 ? (totalWindows / totalWorkDays).toFixed(2) : '-';
let tableRows = '';
reportData.forEach(month => {
const workDays = month.workDays || '-';
const avgPerDay = month.avgPerDay > 0 ? month.avgPerDay.toFixed(2) : '-';
tableRows += `
<tr class="border-b border-gray-200">
<td class="px-2 py-1 text-xs font-medium text-gray-900">${month.monthName}</td>
<td class="px-2 py-1 text-center text-xs">${workDays}</td>
<td class="px-2 py-1 text-center text-xs font-semibold">${month.windows}</td>
<td class="px-2 py-1 text-right text-xs font-semibold">${month.price.toFixed(2)}</td>
<td class="px-2 py-1 text-center text-xs font-semibold">${avgPerDay}</td>
</tr>
`;
});
htmlContent = `
<div style="padding: 20px;">
<div style="text-align: center; margin-bottom: 30px;">
<h1 style="font-size: 24px; font-weight: bold; color: #1f2937; margin-bottom: 10px;">
Meistri aruanne
</h1>
<p style="font-size: 16px; color: #6b7280;">Aasta: ${reportYear}</p>
</div>
<table style="width: 100%; border-collapse: collapse; font-size: 12px;">
<thead style="background-color: #374151; color: white;">
<tr>
<th style="padding: 8px; text-align: left; border: 1px solid #d1d5db;">Kuu</th>
<th style="padding: 8px; text-align: center; border: 1px solid #d1d5db;">Tööpäevad</th>
<th style="padding: 8px; text-align: center; border: 1px solid #d1d5db;">Aknad (Kogus)</th>
<th style="padding: 8px; text-align: right; border: 1px solid #d1d5db;">Summa (€)</th>
<th style="padding: 8px; text-align: center; border: 1px solid #d1d5db;">Keskm./päev</th>
</tr>
</thead>
<tbody style="background-color: white;">
${tableRows}
</tbody>
<tfoot style="background-color: #374151; color: white; font-weight: bold;">
<tr>
<td style="padding: 8px; border: 1px solid #d1d5db;">KOKKU:</td>
<td style="padding: 8px; text-align: center; border: 1px solid #d1d5db; background-color: #4f46e5;">${totalWorkDays > 0 ? totalWorkDays : '-'}</td>
<td style="padding: 8px; text-align: center; border: 1px solid #d1d5db; background-color: #4f46e5;">${totalWindows}</td>
<td style="padding: 8px; text-align: right; border: 1px solid #d1d5db; background-color: #4f46e5;">${totalPrice.toFixed(2)}</td>
<td style="padding: 8px; text-align: center; border: 1px solid #d1d5db; background-color: #4f46e5;">${totalAvg}</td>
</tr>
</tfoot>
</table>
<div style="margin-top: 40px; text-align: center; color: #6b7280; font-size: 10px;">
<p>Genereeritud: ${new Date().toLocaleDateString('et-EE')} ${new Date().toLocaleTimeString('et-EE')}</p>
</div>
</div>
`;
} else {
// Accountant report print
let totalQuantity = 0;
let totalPrice = 0;
let tableRows = '';
accountantReportData.forEach(record => {
totalQuantity += parseInt(record.quantity) || 0;
totalPrice += parseFloat(record.price) || 0;
tableRows += `
<tr class="border-b border-gray-200">
<td class="px-2 py-1 text-xs">${record.client_name || '-'}</td>
<td class="px-2 py-1 text-center text-xs">${record.offer_number || '-'}</td>
<td class="px-2 py-1 text-center text-xs">${record.work_number || '-'}</td>
<td class="px-2 py-1 text-center text-xs font-semibold">${record.quantity || 0}</td>
<td class="px-2 py-1 text-right text-xs font-semibold">${record.price ? record.price.toFixed(2) : '0.00'}</td>
<td class="px-2 py-1 text-xs">${record.arve_makstud || '-'}</td>
</tr>
`;
});
const monthName = MONTH_NAMES[reportMonth - 1];
htmlContent = `
<div style="padding: 20px;">
<div style="text-align: center; margin-bottom: 30px;">
<h1 style="font-size: 24px; font-weight: bold; color: #1f2937; margin-bottom: 10px;">
Raamatupidaja aruanne
</h1>
<p style="font-size: 16px; color: #6b7280;">Periood: ${monthName} ${reportYear}</p>
</div>
<table style="width: 100%; border-collapse: collapse; font-size: 11px;">
<thead style="background-color: #059669; color: white;">
<tr>
<th style="padding: 8px; text-align: left; border: 1px solid #d1d5db;">KLIENT</th>
<th style="padding: 8px; text-align: center; border: 1px solid #d1d5db;">Pakkum. Nr</th>
<th style="padding: 8px; text-align: center; border: 1px solid #d1d5db;">Töö Nr</th>
<th style="padding: 8px; text-align: center; border: 1px solid #d1d5db;">Kogus</th>
<th style="padding: 8px; text-align: right; border: 1px solid #d1d5db;">Hind (€)</th>
<th style="padding: 8px; text-align: left; border: 1px solid #d1d5db;">Arve Nr</th>
</tr>
</thead>
<tbody style="background-color: white;">
${tableRows}
</tbody>
<tfoot style="background-color: #059669; color: white; font-weight: bold;">
<tr>
<td style="padding: 8px; border: 1px solid #d1d5db;" colspan="3">KOKKU:</td>
<td style="padding: 8px; text-align: center; border: 1px solid #d1d5db; background-color: #10b981;">${totalQuantity}</td>
<td style="padding: 8px; text-align: right; border: 1px solid #d1d5db; background-color: #10b981;">${totalPrice.toFixed(2)}</td>
<td style="padding: 8px; border: 1px solid #d1d5db;"></td>
</tr>
</tfoot>
</table>
<div style="margin-top: 40px; text-align: center; color: #6b7280; font-size: 10px;">
<p>Genereeritud: ${new Date().toLocaleDateString('et-EE')} ${new Date().toLocaleTimeString('et-EE')}</p>
</div>
</div>
`;
}
printArea.innerHTML = htmlContent;
printArea.style.display = 'block';
window.print();
printArea.style.display = 'none';
}
// Handle delete permission checkbox
document.getElementById('allowDeleteCheckbox').addEventListener('change', function() {
allowDelete = this.checked;
localStorage.setItem('allowDelete', allowDelete);
toggleDeleteButtons();
});
// Toggle visibility of delete buttons
function toggleDeleteButtons() {
// Show delete buttons for all users
const deleteButtons = document.querySelectorAll('.delete-btn');
deleteButtons.forEach(btn => {
btn.style.display = 'inline-block';
});
}
// Confirm delete dialog
function confirmDelete(recordId) {
if (!token || !currentUser || currentUser.role !== 'admin') {
alert('Ainult administraator saab kustutada kirjeid.');
return;
}
const record = currentRecords.find(r => r.id === recordId);
if (!record) return;
const confirmMessage = `Kas oled kindel, et soovid kustutada kirje?\n\nKlient: ${record.client_name}\nPakkumine: ${record.offer_number}\nTöö: ${record.work_number}\n\nKirje märgitakse kustutatuks ja eemaldatakse tabelist.`;
if (confirm(confirmMessage)) {
deleteRecord(recordId);
}
}
// Delete record (soft delete)
async function deleteRecord(recordId) {
if (!recordId) return;
try {
await axios.delete(
`${API_BASE}/api/records/${recordId}`,
{ headers: { Authorization: `Bearer ${token}` } }
);
await loadRecords();
alert('Kirje on edukalt kustutatud.');
} catch (error) {
console.error('Delete error:', error);
if (error.response?.status === 401) {
alert('Sessioon on aegunud. Palun logi uuesti sisse.');
localStorage.removeItem('token');
localStorage.removeItem('user');
token = null;
currentUser = null;
location.reload();
} else {
alert('Viga kustutamisel: ' + (error.response?.data?.error || error.message));
}
}
}