diff --git a/backend/middleware/auth.js b/backend/middleware/auth.js index d0812fa..c8ba0ad 100644 --- a/backend/middleware/auth.js +++ b/backend/middleware/auth.js @@ -3,20 +3,28 @@ const { User } = require("../models"); const authenticate = async (req, res, next) => { try { + console.log("Authenticating request to:", req.path); const token = req.header("Authorization")?.replace("Bearer ", ""); if (!token) { + console.log("No token provided"); return res.status(401).json({ message: "Access denied" }); } + console.log("Token received, verifying..."); const decoded = jwt.verify(token, process.env.JWT_SECRET); + console.log("Token decoded for user:", decoded.id); + const user = await User.findByPk(decoded.id); if (!user) { + console.log("User not found for token"); return res.status(401).json({ message: "Invalid token" }); } + console.log("User authenticated:", user.username); req.user = user; next(); } catch (error) { + console.error("Authentication error:", error.message); res.status(401).json({ message: "Invalid token" }); } }; diff --git a/backend/migrations/20251013142313-add-auto-time-fields-to-time-entry.js b/backend/migrations/20251013142313-add-auto-time-fields-to-time-entry.js new file mode 100644 index 0000000..7d17d1c --- /dev/null +++ b/backend/migrations/20251013142313-add-auto-time-fields-to-time-entry.js @@ -0,0 +1,111 @@ +"use strict"; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + // Добавляем колонки по одной с обработкой ошибок + try { + await queryInterface.addColumn("TimeEntries", "startDate", { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }); + console.log("Added startDate column"); + } catch (error) { + console.log( + "startDate column might already exist or error:", + error.message + ); + } + + try { + await queryInterface.addColumn("TimeEntries", "endDate", { + type: Sequelize.DATE, + allowNull: true, + defaultValue: null, + }); + console.log("Added endDate column"); + } catch (error) { + console.log( + "endDate column might already exist or error:", + error.message + ); + } + + try { + await queryInterface.addColumn("TimeEntries", "startTime", { + type: Sequelize.TIME, + allowNull: true, + defaultValue: null, + }); + console.log("Added startTime column"); + } catch (error) { + console.log( + "startTime column might already exist or error:", + error.message + ); + } + + try { + await queryInterface.addColumn("TimeEntries", "endTime", { + type: Sequelize.TIME, + allowNull: true, + defaultValue: null, + }); + console.log("Added endTime column"); + } catch (error) { + console.log( + "endTime column might already exist or error:", + error.message + ); + } + + try { + await queryInterface.addColumn("TimeEntries", "status", { + type: Sequelize.ENUM("active", "closed"), + defaultValue: "closed", + allowNull: false, + }); + console.log("Added status column"); + } catch (error) { + console.log("status column might already exist or error:", error.message); + } + }, + + async down(queryInterface, Sequelize) { + try { + await queryInterface.removeColumn("TimeEntries", "startDate"); + console.log("Removed startDate column"); + } catch (error) { + console.log("Error removing startDate:", error.message); + } + + try { + await queryInterface.removeColumn("TimeEntries", "endDate"); + console.log("Removed endDate column"); + } catch (error) { + console.log("Error removing endDate:", error.message); + } + + try { + await queryInterface.removeColumn("TimeEntries", "startTime"); + console.log("Removed startTime column"); + } catch (error) { + console.log("Error removing startTime:", error.message); + } + + try { + await queryInterface.removeColumn("TimeEntries", "endTime"); + console.log("Removed endTime column"); + } catch (error) { + console.log("Error removing endTime:", error.message); + } + + try { + await queryInterface.removeColumn("TimeEntries", "status"); + console.log("Removed status column"); + } catch (error) { + console.log("Error removing status:", error.message); + } + }, +}; diff --git a/backend/models/timeentry.js b/backend/models/timeentry.js index 9714098..fc140f6 100644 --- a/backend/models/timeentry.js +++ b/backend/models/timeentry.js @@ -29,6 +29,31 @@ module.exports = (sequelize, DataTypes) => { type: DataTypes.DECIMAL, allowNull: false, }, + startDate: { + type: DataTypes.DATE, + allowNull: true, + defaultValue: null, + }, + endDate: { + type: DataTypes.DATE, + allowNull: true, + defaultValue: null, + }, + startTime: { + type: DataTypes.TIME, + allowNull: true, + defaultValue: null, + }, + endTime: { + type: DataTypes.TIME, + allowNull: true, + defaultValue: null, + }, + status: { + type: DataTypes.ENUM("active", "closed"), + defaultValue: "closed", + allowNull: false, + }, }, { sequelize, diff --git a/backend/routes/timeEntries.js b/backend/routes/timeEntries.js index 30ee82e..3c1c3e9 100644 --- a/backend/routes/timeEntries.js +++ b/backend/routes/timeEntries.js @@ -1,23 +1,63 @@ const express = require("express"); const { TimeEntry, User } = require("../models"); const { logActivity } = require("./activityLogs"); +const { calculateWorkHours } = require("../utils/timeCalculator"); const { authenticate, authorizeAdmin, authorizeManager, } = require("../middleware/auth"); +const { sequelize } = require("../models"); const router = express.Router(); // Get time entries for current user router.get("/", authenticate, async (req, res) => { try { + console.log("Fetching entries for user:", req.user.id); + const entries = await TimeEntry.findAll({ where: { userId: req.user.id }, order: [["date", "DESC"]], + attributes: [ + "id", + "userId", + "date", + "reason", + "hours", + "startDate", + "startTime", + "endDate", + "endTime", + "status", + "createdAt", + "updatedAt", + ], + }).catch(async (error) => { + // Если ошибка с колонками, используем только базовые поля + if (error.message && error.message.includes("column")) { + console.log("Fallback to basic attributes due to column error"); + return await TimeEntry.findAll({ + where: { userId: req.user.id }, + order: [["date", "DESC"]], + attributes: [ + "id", + "userId", + "date", + "reason", + "hours", + "createdAt", + "updatedAt", + ], + }); + } + throw error; }); + + console.log("Found entries:", entries.length); res.json(entries); } catch (error) { + console.error("Error fetching entries:", error); res.status(500).json({ message: "Server error" }); } }); @@ -26,8 +66,43 @@ router.get("/", authenticate, async (req, res) => { router.get("/all", authenticate, authorizeManager, async (req, res) => { try { const entries = await TimeEntry.findAll({ + attributes: [ + "id", + "userId", + "date", + "reason", + "hours", + "createdAt", + "updatedAt", + ], include: [{ model: User, attributes: ["username"] }], order: [["date", "DESC"]], + }).catch(async (error) => { + // Если ошибка с колонками, используем только базовые поля + if (error.message && error.message.includes("column")) { + console.log( + "Fallback to basic attributes for admin view due to column error" + ); + return await TimeEntry.findAll({ + attributes: [ + "id", + "userId", + "date", + "reason", + "hours", + "startDate", + "startTime", + "endDate", + "endTime", + "status", + "createdAt", + "updatedAt", + ], + include: [{ model: User, attributes: ["username"] }], + order: [["date", "DESC"]], + }); + } + throw error; }); // Log the export action @@ -80,23 +155,126 @@ router.get( // Create time entry router.post("/", authenticate, async (req, res) => { try { - const { date, reason, hours } = req.body; - const entry = await TimeEntry.create({ - userId: req.user.id, + const { date, reason, - hours: parseFloat(hours), + hours, + startDate, + startTime, + endDate, + endTime, + isAuto = false, + } = req.body; + + console.log("Received data:", { + date, + reason, + hours, + startDate, + startTime, + endDate, + endTime, + isAuto, }); + + let calculatedHours = parseFloat(hours); + let status = "closed"; + + // Если автоматический расчет + if (isAuto && startDate && startTime) { + try { + console.log("Calculating work hours for:", { + startDate, + startTime, + endDate, + endTime, + }); + + calculatedHours = calculateWorkHours( + startDate, + startTime, + endDate, + endTime + ); + + console.log("Calculated hours:", calculatedHours); + + // Если нет даты окончания - запись активна (открытая) + if (!endDate || !endTime) { + status = "active"; + } + } catch (calcError) { + console.error("Calculation error:", calcError); + return res.status(400).json({ message: calcError.message }); + } + } + + console.log("Creating entry with data:", { + userId: req.user.id, + date: date || startDate, + reason, + hours: calculatedHours, + startDate, + startTime, + endDate, + endTime, + status, + }); + + const entryData = { + userId: req.user.id, + date: date || startDate, // Для совместимости с ручным вводом + reason, + hours: calculatedHours, + }; + + // Добавляем новые поля только если они существуют в базе данных + try { + await sequelize.query('SELECT "startDate" FROM "TimeEntries" LIMIT 1;', { + type: sequelize.QueryTypes.SELECT, + }); + entryData.startDate = startDate; + entryData.startTime = startTime; + entryData.endDate = endDate; + entryData.endTime = endTime; + entryData.status = status; + } catch (columnError) { + console.log("New columns do not exist yet, using basic fields only"); + } + + const entry = await TimeEntry.create(entryData); + + console.log("Entry created successfully:", entry.id); res.status(201).json(entry); } catch (error) { - res.status(500).json({ message: "Server error" }); + console.error("Error creating time entry:", error); + console.error("Error stack:", error.stack); + console.error("Error message:", error.message); + if (error.name === "SequelizeValidationError") { + console.error("Validation errors:", error.errors); + } + res.status(500).json({ + message: "Server error", + error: error.message, + stack: error.stack, + }); } }); // Update time entry (own or admin) router.put("/:id", authenticate, async (req, res) => { try { - const { date, reason, hours } = req.body; + const { + date, + reason, + hours, + startDate, + startTime, + endDate, + endTime, + isAuto = false, + } = req.body; + const entry = await TimeEntry.findByPk(req.params.id); if (!entry) { return res.status(404).json({ message: "Entry not found" }); @@ -104,13 +282,49 @@ router.put("/:id", authenticate, async (req, res) => { if (entry.userId !== req.user.id && req.user.role !== "admin") { return res.status(403).json({ message: "Access denied" }); } + + let calculatedHours = parseFloat(hours); + let status = entry.status; + + // Если автоматический расчет или обновление активной записи + if (isAuto || entry.status === "active") { + try { + calculatedHours = calculateWorkHours( + startDate || entry.startDate, + startTime || entry.startTime, + endDate, + endTime + ); + + // Если добавили дату окончания - закрываем запись + if ( + (endDate && endTime) || + (endDate && !endTime) || + (!endDate && endTime) + ) { + status = "closed"; + } else { + status = "active"; + } + } catch (calcError) { + return res.status(400).json({ message: calcError.message }); + } + } + await entry.update({ - date, + date: date || entry.date, reason, - hours: parseFloat(hours), + hours: calculatedHours, + startDate, + startTime, + endDate, + endTime, + status, }); + res.json(entry); } catch (error) { + console.error("Error updating time entry:", error); res.status(500).json({ message: "Server error" }); } }); diff --git a/backend/seeders/20251013144243-init-time-entries.js b/backend/seeders/20251013144243-init-time-entries.js new file mode 100644 index 0000000..7ab6181 --- /dev/null +++ b/backend/seeders/20251013144243-init-time-entries.js @@ -0,0 +1,26 @@ +"use strict"; + +/** @type {import('sequelize-cli').Migration} */ +module.exports = { + async up(queryInterface, Sequelize) { + // Обновляем существующие записи, заполняя новые поля значениями по умолчанию + await queryInterface.sequelize.query(` + UPDATE "TimeEntries" + SET "startDate" = "date", + "endDate" = "date", + "status" = 'closed' + WHERE "startDate" IS NULL OR "endDate" IS NULL OR "status" IS NULL + `); + }, + + async down(queryInterface, Sequelize) { + // Восстанавливаем предыдущие значения (null для новых полей) + await queryInterface.sequelize.query(` + UPDATE "TimeEntries" + SET "startDate" = NULL, + "endDate" = NULL, + "status" = NULL + WHERE "startDate" IS NOT NULL OR "endDate" IS NOT NULL OR "status" IS NOT NULL + `); + }, +}; diff --git a/backend/server.js b/backend/server.js index e990daa..d18be4e 100644 --- a/backend/server.js +++ b/backend/server.js @@ -10,6 +10,12 @@ const PORT = process.env.PORT || 5000; app.use(cors()); app.use(express.json()); +// Логирование всех запросов +app.use((req, res, next) => { + console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`); + next(); +}); + // Database connection const sequelize = new Sequelize( process.env.DB_NAME, @@ -26,6 +32,24 @@ const sequelize = new Sequelize( sequelize .authenticate() .then(() => console.log("Database connected")) + .then(() => { + // Проверяем структуру таблицы TimeEntries + return sequelize.query( + ` + SELECT column_name, data_type, is_nullable + FROM information_schema.columns + WHERE table_name = 'TimeEntries' + ORDER BY ordinal_position; + `, + { type: sequelize.QueryTypes.SELECT } + ); + }) + .then((columns) => { + console.log("TimeEntries table columns:"); + columns.forEach((col) => { + console.log(` ${col.column_name} (${col.data_type}) ${col.is_nullable}`); + }); + }) .catch((err) => console.error("Database connection failed:", err)); // Import models @@ -55,27 +79,10 @@ sequelize console.log("Admin user already exists"); } - // Create test time entry for admin - return TimeEntry.findOrCreate({ - where: { - userId: user.id, - date: new Date("2025-10-10"), - reason: "Test entry", - }, - defaults: { - userId: user.id, - date: new Date("2025-10-10"), - reason: "Test entry", - hours: 8.5, - }, - }); - }) - .then(([entry, created]) => { - if (created) { - console.log("Test time entry created"); - } else { - console.log("Test time entry already exists"); - } + // Note: Test entry creation removed to avoid migration issues + // Test entries will be created through the UI + console.log("Skipping test entry creation during migration"); + return Promise.resolve(); }); // Routes diff --git a/backend/utils/timeCalculator.js b/backend/utils/timeCalculator.js new file mode 100644 index 0000000..701c93a --- /dev/null +++ b/backend/utils/timeCalculator.js @@ -0,0 +1,133 @@ +// Утилиты для расчета рабочих часов + +/** + * Проверяет, является ли день рабочим (понедельник-пятница) + * @param {Date} date - Дата для проверки + * @returns {boolean} true если рабочий день + */ +function isWorkingDay(date) { + const day = date.getDay(); // 0 = воскресенье, 1 = понедельник, ..., 6 = суббота + return day >= 1 && day <= 5; // понедельник-пятница +} + +/** + * Преобразует время в формате HH:MM в минуты от начала дня + * @param {string} time - Время в формате HH:MM + * @returns {number} Минуты от начала дня + */ +function timeToMinutes(time) { + if (!time) return 0; + const [hours, minutes] = time.split(":").map(Number); + return hours * 60 + minutes; +} + +/** + * Преобразует минуты от начала дня в формат HH:MM + * @param {number} minutes - Минуты от начала дня + * @returns {string} Время в формате HH:MM + */ +function minutesToTime(minutes) { + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + return `${hours.toString().padStart(2, "0")}:${mins + .toString() + .padStart(2, "0")}`; +} + +/** + * Рассчитывает рабочие часы между двумя датами/временами + * Рабочий день: 09:00 - 18:00 + * Обед: 12:00 - 13:00 (не учитывается в расчетах) + * Только рабочие дни (понедельник-пятница) + * + * @param {string} startDate - Дата начала (YYYY-MM-DD) + * @param {string} startTime - Время начала (HH:MM) + * @param {string} endDate - Дата окончания (YYYY-MM-DD) или null для открытой записи + * @param {string} endTime - Время окончания (HH:MM) или null для открытой записи + * @returns {number} Количество рабочих часов + */ +function calculateWorkHours(startDate, startTime, endDate, endTime) { + const startDateTime = new Date(`${startDate}T${startTime || "09:00"}`); + const endDateTime = + endDate && endTime ? new Date(`${endDate}T${endTime}`) : null; + + // Если нет даты окончания - возвращаем 0 (открытая запись) + if (!endDateTime) { + return 0; + } + + // Если дата окончания раньше даты начала - ошибка + if (endDateTime < startDateTime) { + throw new Error("Дата окончания не может быть раньше даты начала"); + } + + const WORK_START = 9 * 60; // 09:00 в минутах + const WORK_END = 18 * 60; // 18:00 в минутах + const LUNCH_START = 12 * 60; // 12:00 в минутах + const LUNCH_END = 13 * 60; // 13:00 в минутах + + let totalMinutes = 0; + let currentDate = new Date(startDate); + + // Проходим по каждому дню от начала до окончания + while ( + currentDate <= (endDateTime ? new Date(endDate) : new Date(startDate)) + ) { + if (isWorkingDay(currentDate)) { + const dayStart = new Date(currentDate); + dayStart.setHours(0, 0, 0, 0); + + const dayEnd = new Date(currentDate); + dayEnd.setHours(23, 59, 59, 999); + + // Определяем время начала работы в этот день + let workStartMinutes = WORK_START; + if (currentDate.toDateString() === new Date(startDate).toDateString()) { + // Первый день - берем время начала работы + const startMinutes = + startDateTime.getHours() * 60 + startDateTime.getMinutes(); + workStartMinutes = Math.max(startMinutes, WORK_START); + } + + // Определяем время окончания работы в этот день + let workEndMinutes = WORK_END; + if ( + endDateTime && + currentDate.toDateString() === new Date(endDate).toDateString() + ) { + // Последний день - берем время окончания работы + const endMinutes = + endDateTime.getHours() * 60 + endDateTime.getMinutes(); + workEndMinutes = Math.min(endMinutes, WORK_END); + } + + // Если время окончания раньше времени начала в этот день - пропускаем + if (workEndMinutes > workStartMinutes) { + let dayMinutes = workEndMinutes - workStartMinutes; + + // Вычитаем обед, если он попадает в рабочий интервал + if (workStartMinutes < LUNCH_END && workEndMinutes > LUNCH_START) { + const lunchOverlapStart = Math.max(workStartMinutes, LUNCH_START); + const lunchOverlapEnd = Math.min(workEndMinutes, LUNCH_END); + if (lunchOverlapEnd > lunchOverlapStart) { + dayMinutes -= lunchOverlapEnd - lunchOverlapStart; + } + } + + totalMinutes += Math.max(0, dayMinutes); + } + } + + // Переходим к следующему дню + currentDate.setDate(currentDate.getDate() + 1); + } + + return totalMinutes / 60; // Возвращаем часы +} + +module.exports = { + calculateWorkHours, + isWorkingDay, + timeToMinutes, + minutesToTime, +}; diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 2600620..820f736 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "bootstrap": "^5.3.8", "react": "^19.1.1", "react-dom": "^19.1.1", + "react-icons": "^5.5.0", "react-router-dom": "^7.9.4", "xlsx": "^0.18.5" }, @@ -2867,6 +2868,15 @@ "react": "^19.2.0" } }, + "node_modules/react-icons": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz", + "integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==", + "license": "MIT", + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 88e23bf..07b021f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,7 @@ "bootstrap": "^5.3.8", "react": "^19.1.1", "react-dom": "^19.1.1", + "react-icons": "^5.5.0", "react-router-dom": "^7.9.4", "xlsx": "^0.18.5" }, diff --git a/frontend/src/components/AddTimeEntryModal.jsx b/frontend/src/components/AddTimeEntryModal.jsx index 7f9b877..d18dfac 100644 --- a/frontend/src/components/AddTimeEntryModal.jsx +++ b/frontend/src/components/AddTimeEntryModal.jsx @@ -7,39 +7,231 @@ const AddTimeEntryModal = ({ isEdit = false, existingEntry = null, onUpdate, + defaultAutoMode = false, }) => { - const [entry, setEntry] = useState({ date: "", reason: "", hours: "" }); + const [entry, setEntry] = useState({ + date: "", + reason: "", + hours: "", + startDate: "", + startTime: "", + endDate: "", + endTime: "", + isAuto: false, + }); + const [previewHours, setPreviewHours] = useState(0); useEffect(() => { if (isEdit && existingEntry) { + console.log("=== EDITING ENTRY ==="); + console.log("Existing entry data:", existingEntry); + console.log("Status:", existingEntry.status); + console.log("startDate:", existingEntry.startDate); + console.log("startTime:", existingEntry.startTime); + + const isAutoEntry = !!( + existingEntry.startDate || + existingEntry.startTime || + existingEntry.status === "active" + ); + console.log("Calculated isAutoEntry:", isAutoEntry); setEntry({ date: existingEntry.date ? new Date(existingEntry.date).toISOString().split("T")[0] : "", reason: existingEntry.reason || "", hours: existingEntry.hours || "", + startDate: existingEntry.startDate + ? new Date(existingEntry.startDate).toISOString().split("T")[0] + : "", + startTime: existingEntry.startTime || "", + endDate: existingEntry.endDate + ? new Date(existingEntry.endDate).toISOString().split("T")[0] + : "", + endTime: existingEntry.endTime || "", + isAuto: isAutoEntry, + }); + setPreviewHours(existingEntry.hours || 0); + console.log("Final entry state set:", { + isAuto: isAutoEntry, + startDate: existingEntry.startDate, + startTime: existingEntry.startTime, + status: existingEntry.status, }); } else if (!isEdit) { - setEntry({ date: "", reason: "", hours: "" }); + setEntry({ + date: "", + reason: "", + hours: "", + startDate: "", + startTime: "", + endDate: "", + endTime: "", + isAuto: defaultAutoMode, + }); + setPreviewHours(0); } - }, [isEdit, existingEntry, show]); + }, [isEdit, existingEntry, show, defaultAutoMode]); const handleSubmit = (e) => { e.preventDefault(); - if (isEdit && onUpdate) { - onUpdate(existingEntry.id, entry); - } else { - onAdd(entry); + const submitData = { ...entry }; + + // Очищаем пустые поля + Object.keys(submitData).forEach((key) => { + if (submitData[key] === "" && key !== "reason") { + submitData[key] = null; + } + }); + + // Для ручного режима используем date и hours + if (!entry.isAuto) { + submitData.startDate = null; + submitData.startTime = null; + submitData.endDate = null; + submitData.endTime = null; } + + console.log("Final submit data:", submitData); + + if (isEdit && onUpdate) { + onUpdate(existingEntry.id, submitData); + } else { + onAdd(submitData); + } + if (!isEdit) { - setEntry({ date: "", reason: "", hours: "" }); + setEntry({ + date: "", + reason: "", + hours: "", + startDate: "", + startTime: "", + endDate: "", + endTime: "", + isAuto: defaultAutoMode, + }); + setPreviewHours(0); } }; const handleChange = (e) => { - setEntry({ ...entry, [e.target.name]: e.target.value }); + const { name, value, type, checked } = e.target; + const newValue = type === "checkbox" ? checked : value; + + setEntry({ ...entry, [name]: newValue }); }; + // Функция предварительного расчета часов (соответствует бэкендовому алгоритму) + const calculatePreviewHours = (startDate, startTime, endDate, endTime) => { + if (!startDate || !startTime) return 0; + + try { + const startDateTime = new Date(`${startDate}T${startTime || "09:00"}`); + const endDateTime = + endDate && endTime ? new Date(`${endDate}T${endTime}`) : null; + + // Если нет даты окончания - возвращаем 0 (открытая запись) + if (!endDateTime) { + return 0; + } + + // Если дата окончания раньше даты начала - ошибка + if (endDateTime < startDateTime) { + throw new Error("Дата окончания не может быть раньше даты начала"); + } + + const WORK_START = 9 * 60; // 09:00 в минутах + const WORK_END = 18 * 60; // 18:00 в минутах + const LUNCH_START = 12 * 60; // 12:00 в минутах + const LUNCH_END = 13 * 60; // 13:00 в минутах + + let totalMinutes = 0; + let currentDate = new Date(startDate); + + // Проходим по каждому дню от начала до окончания + while ( + currentDate <= (endDateTime ? new Date(endDate) : new Date(startDate)) + ) { + const day = currentDate.getDay(); // 0 = воскресенье, 1 = понедельник, ..., 6 = суббота + if (day >= 1 && day <= 5) { + // понедельник-пятница + const dayStart = new Date(currentDate); + dayStart.setHours(0, 0, 0, 0); + + const dayEnd = new Date(currentDate); + dayEnd.setHours(23, 59, 59, 999); + + // Определяем время начала работы в этот день + let workStartMinutes = WORK_START; + if ( + currentDate.toDateString() === new Date(startDate).toDateString() + ) { + // Первый день - берем время начала работы + const startMinutes = + startDateTime.getHours() * 60 + startDateTime.getMinutes(); + workStartMinutes = Math.max(startMinutes, WORK_START); + } + + // Определяем время окончания работы в этот день + let workEndMinutes = WORK_END; + if ( + endDateTime && + currentDate.toDateString() === new Date(endDate).toDateString() + ) { + // Последний день - берем время окончания работы + const endMinutes = + endDateTime.getHours() * 60 + endDateTime.getMinutes(); + workEndMinutes = Math.min(endMinutes, WORK_END); + } + + // Если время окончания раньше времени начала в этот день - пропускаем + if (workEndMinutes > workStartMinutes) { + let dayMinutes = workEndMinutes - workStartMinutes; + + // Вычитаем обед, если он попадает в рабочий интервал + if (workStartMinutes < LUNCH_END && workEndMinutes > LUNCH_START) { + const lunchOverlapStart = Math.max(workStartMinutes, LUNCH_START); + const lunchOverlapEnd = Math.min(workEndMinutes, LUNCH_END); + if (lunchOverlapEnd > lunchOverlapStart) { + dayMinutes -= lunchOverlapEnd - lunchOverlapStart; + } + } + + totalMinutes += Math.max(0, dayMinutes); + } + } + + // Переходим к следующему дню + currentDate.setDate(currentDate.getDate() + 1); + } + + return Math.round((totalMinutes / 60) * 10) / 10; // Округляем до 0.1 часа + } catch (error) { + console.error("Calculation error:", error); + return 0; + } + }; + + // Обновляем предварительный расчет при изменении полей + useEffect(() => { + if (entry.isAuto) { + const hours = calculatePreviewHours( + entry.startDate, + entry.startTime, + entry.endDate, + entry.endTime + ); + setPreviewHours(hours); + } + }, [ + entry.startDate, + entry.startTime, + entry.endDate, + entry.endTime, + entry.isAuto, + ]); + return (
+ Эта система позволяет отслеживать ваше рабочее время, включая + как ручной ввод неучтенного времени, так и автоматический расчет + на основе рабочих дней. +
+ ++ + Если у вас возникнут вопросы или проблемы, обратитесь к + администратору системы. + +
+| Дата | +Дата/Период | Причина | Часы | +Статус | {!isManager &&Действия | }
|---|---|---|---|---|---|
| {new Date(entry.date).toLocaleDateString("ru-RU")} | +|||||
| {formatDateRange(entry)} | {entry.reason} | {entry.hours} | +{getStatusBadge(entry)} | {!isManager && (
|