- 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
2280 lines
78 KiB
JavaScript
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, '"').replace(/'/g, ''').replace(/\n/g, ' ') : '';
|
|
|
|
// 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, '"').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, '"').replace(/'/g, ''').replace(/\n/g, ' ') : '';
|
|
|
|
// 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, '"').replace(/'/g, ''').replace(/\n/g, ' ') : '';
|
|
|
|
// 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, '"').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, '"');
|
|
|
|
// 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, '"').replace(/'/g, ''').replace(/\n/g, ' ') : '';
|
|
|
|
// 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, '"').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, '"').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, '"').replace(/'/g, ''').replace(/\n/g, ' ') :
|
|
(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));
|
|
}
|
|
}
|
|
}
|