Добавил функцию автоматического учета времени.
Добавил иконки.
This commit is contained in:
parent
d23bdc1bfa
commit
f47f16f7c8
@ -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" });
|
||||
}
|
||||
};
|
||||
|
||||
@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
@ -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,
|
||||
|
||||
@ -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" });
|
||||
}
|
||||
});
|
||||
|
||||
26
backend/seeders/20251013144243-init-time-entries.js
Normal file
26
backend/seeders/20251013144243-init-time-entries.js
Normal file
@ -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
|
||||
`);
|
||||
},
|
||||
};
|
||||
@ -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
|
||||
|
||||
133
backend/utils/timeCalculator.js
Normal file
133
backend/utils/timeCalculator.js
Normal file
@ -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,
|
||||
};
|
||||
10
frontend/package-lock.json
generated
10
frontend/package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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"
|
||||
},
|
||||
|
||||
@ -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 (
|
||||
<div
|
||||
className={`modal ${show ? "show d-block" : ""}`}
|
||||
@ -70,6 +262,26 @@ const AddTimeEntryModal = ({
|
||||
</div>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="modal-body">
|
||||
{/* Переключатель режима */}
|
||||
<div className="mb-3">
|
||||
<div className="form-check form-switch">
|
||||
<input
|
||||
className="form-check-input"
|
||||
type="checkbox"
|
||||
id="isAuto"
|
||||
name="isAuto"
|
||||
checked={entry.isAuto}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
<label className="form-check-label" htmlFor="isAuto">
|
||||
Автоматический расчет времени
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Ручной режим */}
|
||||
{!entry.isAuto && (
|
||||
<>
|
||||
<div className="mb-3">
|
||||
<label htmlFor="date" className="form-label">
|
||||
Дата
|
||||
@ -81,20 +293,7 @@ const AddTimeEntryModal = ({
|
||||
name="date"
|
||||
value={entry.date}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
<label htmlFor="reason" className="form-label">
|
||||
Причина
|
||||
</label>
|
||||
<textarea
|
||||
className="form-control"
|
||||
id="reason"
|
||||
name="reason"
|
||||
value={entry.reason}
|
||||
onChange={handleChange}
|
||||
required
|
||||
required={!entry.isAuto}
|
||||
/>
|
||||
</div>
|
||||
<div className="mb-3">
|
||||
@ -109,6 +308,136 @@ const AddTimeEntryModal = ({
|
||||
name="hours"
|
||||
value={entry.hours}
|
||||
onChange={handleChange}
|
||||
required={!entry.isAuto}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Автоматический режим */}
|
||||
{entry.isAuto && (
|
||||
<>
|
||||
<div className="row">
|
||||
<div className="col-md-6 mb-3">
|
||||
<label htmlFor="startDate" className="form-label">
|
||||
Дата начала
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
className={`form-control ${
|
||||
!entry.startDate ? "is-invalid" : ""
|
||||
}`}
|
||||
id="startDate"
|
||||
name="startDate"
|
||||
value={entry.startDate}
|
||||
onChange={handleChange}
|
||||
required={entry.isAuto}
|
||||
/>
|
||||
{!entry.startDate && (
|
||||
<div className="invalid-feedback">
|
||||
Обязательное поле для автоматического режима
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-md-6 mb-3">
|
||||
<label htmlFor="startTime" className="form-label">
|
||||
Время начала
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
className={`form-control ${
|
||||
!entry.startTime ? "is-invalid" : ""
|
||||
}`}
|
||||
id="startTime"
|
||||
name="startTime"
|
||||
value={entry.startTime}
|
||||
onChange={handleChange}
|
||||
required={entry.isAuto}
|
||||
/>
|
||||
{!entry.startTime && (
|
||||
<div className="invalid-feedback">
|
||||
Обязательное поле для автоматического режима
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="row">
|
||||
<div className="col-md-6 mb-3">
|
||||
<label htmlFor="endDate" className="form-label">
|
||||
Дата окончания{" "}
|
||||
<small className="text-muted">(опционально)</small>
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
className={`form-control ${
|
||||
entry.endDate &&
|
||||
entry.startDate &&
|
||||
new Date(entry.endDate) < new Date(entry.startDate)
|
||||
? "is-invalid"
|
||||
: ""
|
||||
}`}
|
||||
id="endDate"
|
||||
name="endDate"
|
||||
value={entry.endDate}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
{entry.endDate &&
|
||||
entry.startDate &&
|
||||
new Date(entry.endDate) < new Date(entry.startDate) && (
|
||||
<div className="invalid-feedback">
|
||||
Дата окончания не может быть раньше даты начала
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-md-6 mb-3">
|
||||
<label htmlFor="endTime" className="form-label">
|
||||
Время окончания{" "}
|
||||
<small className="text-muted">(опционально)</small>
|
||||
</label>
|
||||
<input
|
||||
type="time"
|
||||
className="form-control"
|
||||
id="endTime"
|
||||
name="endTime"
|
||||
value={entry.endTime}
|
||||
onChange={handleChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{previewHours > 0 && (
|
||||
<div className="alert alert-info">
|
||||
<strong>
|
||||
Предварительный расчет: {previewHours} часов
|
||||
</strong>
|
||||
<br />
|
||||
<small>
|
||||
Рабочий день: 09:00-18:00, обед: 12:00-13:00, только
|
||||
будни
|
||||
</small>
|
||||
</div>
|
||||
)}
|
||||
{(!entry.endDate || !entry.endTime) &&
|
||||
entry.startDate &&
|
||||
entry.startTime && (
|
||||
<div className="alert alert-warning">
|
||||
<strong>Открытая запись:</strong> Время окончания не
|
||||
указано. Запись будет активной до тех пор, пока вы не
|
||||
добавите время окончания.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mb-3">
|
||||
<label htmlFor="reason" className="form-label">
|
||||
Причина
|
||||
</label>
|
||||
<textarea
|
||||
className="form-control"
|
||||
id="reason"
|
||||
name="reason"
|
||||
value={entry.reason}
|
||||
onChange={handleChange}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -1,5 +1,13 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
FaHome,
|
||||
FaUserPlus,
|
||||
FaDownload,
|
||||
FaList,
|
||||
FaEye,
|
||||
FaEyeSlash,
|
||||
} from "react-icons/fa";
|
||||
import { usersAPI, timeEntriesAPI, activityLogsAPI } from "../services/api";
|
||||
import UserTimeEntriesModal from "./UserTimeEntriesModal";
|
||||
import * as XLSX from "xlsx";
|
||||
@ -191,15 +199,18 @@ const AdminPanel = () => {
|
||||
<h3>Админ панель</h3>
|
||||
<div className="mb-3 d-flex gap-2 flex-column flex-lg-row">
|
||||
<Link to="/" className="btn btn-primary">
|
||||
<FaHome className="me-1" />
|
||||
Перейти в дешборд
|
||||
</Link>
|
||||
<button
|
||||
className="btn btn-success"
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
>
|
||||
<FaUserPlus className="me-1" />
|
||||
Создать пользователя
|
||||
</button>
|
||||
<button className="btn btn-info" onClick={handleExportAll}>
|
||||
<FaDownload className="me-1" />
|
||||
Экспорт общей таблицы
|
||||
</button>
|
||||
<button
|
||||
@ -209,7 +220,17 @@ const AdminPanel = () => {
|
||||
if (!showLogs) fetchActivityLogs();
|
||||
}}
|
||||
>
|
||||
{showLogs ? "Скрыть логи" : "Показать логи"}
|
||||
{showLogs ? (
|
||||
<>
|
||||
<FaEyeSlash className="me-1" />
|
||||
Скрыть логи
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FaEye className="me-1" />
|
||||
Показать логи
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@ -2,9 +2,11 @@ import { useState, useEffect } from "react";
|
||||
import { useAuth } from "../contexts/AuthContext";
|
||||
import { timeEntriesAPI } from "../services/api";
|
||||
import { Link } from "react-router-dom";
|
||||
import { FaPlus, FaClock, FaTrash, FaQuestionCircle } from "react-icons/fa";
|
||||
import TimeEntriesTable from "./TimeEntriesTable";
|
||||
import AddTimeEntryModal from "./AddTimeEntryModal";
|
||||
import DeleteAllModal from "./DeleteAllModal";
|
||||
import HelpModal from "./HelpModal";
|
||||
|
||||
const Dashboard = () => {
|
||||
const { user, logout } = useAuth();
|
||||
@ -14,6 +16,8 @@ const Dashboard = () => {
|
||||
const [showDeleteAllModal, setShowDeleteAllModal] = useState(false);
|
||||
const [editingEntry, setEditingEntry] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [modalMode, setModalMode] = useState("manual"); // 'manual' или 'auto'
|
||||
const [showHelpModal, setShowHelpModal] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEntries();
|
||||
@ -38,6 +42,10 @@ const Dashboard = () => {
|
||||
const fetchEntries = async () => {
|
||||
try {
|
||||
const response = await timeEntriesAPI.getEntries();
|
||||
console.log("Fetched entries from API:", response.data);
|
||||
if (response.data.length > 0) {
|
||||
console.log("Sample entry:", response.data[0]);
|
||||
}
|
||||
setEntries(response.data);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch entries:", error);
|
||||
@ -66,6 +74,7 @@ const Dashboard = () => {
|
||||
};
|
||||
|
||||
const handleEdit = (entry) => {
|
||||
console.log("Editing entry from table:", entry);
|
||||
setEditingEntry(entry);
|
||||
setShowEditModal(true);
|
||||
};
|
||||
@ -111,9 +120,16 @@ const Dashboard = () => {
|
||||
)}
|
||||
{user.role === "manager" && (
|
||||
<Link to="/manager" className="btn btn-secondary">
|
||||
Менеджер панель
|
||||
Панель руководителя
|
||||
</Link>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-success"
|
||||
onClick={() => setShowHelpModal(true)}
|
||||
>
|
||||
<FaQuestionCircle className="me-1" />
|
||||
Помощь
|
||||
</button>
|
||||
<button className="btn btn-outline-danger" onClick={logout}>
|
||||
Выйти
|
||||
</button>
|
||||
@ -121,13 +137,31 @@ const Dashboard = () => {
|
||||
</div>
|
||||
|
||||
<div className="mb-4 d-flex gap-2 flex-column flex-lg-row">
|
||||
<button className="btn btn-primary" onClick={() => setShowModal(true)}>
|
||||
<button
|
||||
className="btn btn-primary"
|
||||
onClick={() => {
|
||||
setModalMode("manual");
|
||||
setShowModal(true);
|
||||
}}
|
||||
>
|
||||
<FaPlus className="me-1" />
|
||||
Добавить запись о неучтенном времени
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-success"
|
||||
onClick={() => {
|
||||
setModalMode("auto");
|
||||
setShowModal(true);
|
||||
}}
|
||||
>
|
||||
<FaClock className="me-1" />
|
||||
Добавить автоматический учет времени
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={() => setShowDeleteAllModal(true)}
|
||||
>
|
||||
<FaTrash className="me-1" />
|
||||
Удалить все записи
|
||||
</button>
|
||||
</div>
|
||||
@ -151,6 +185,7 @@ const Dashboard = () => {
|
||||
show={showModal}
|
||||
onHide={() => setShowModal(false)}
|
||||
onAdd={handleAddEntry}
|
||||
defaultAutoMode={modalMode === "auto"}
|
||||
/>
|
||||
<AddTimeEntryModal
|
||||
show={showEditModal}
|
||||
@ -164,6 +199,7 @@ const Dashboard = () => {
|
||||
onHide={() => setShowDeleteAllModal(false)}
|
||||
onConfirm={handleDeleteAll}
|
||||
/>
|
||||
<HelpModal show={showHelpModal} onHide={() => setShowHelpModal(false)} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
131
frontend/src/components/HelpModal.jsx
Normal file
131
frontend/src/components/HelpModal.jsx
Normal file
@ -0,0 +1,131 @@
|
||||
const HelpModal = ({ show, onHide }) => {
|
||||
return (
|
||||
<div
|
||||
className={`modal ${show ? "show d-block" : ""}`}
|
||||
tabIndex="-1"
|
||||
role="dialog"
|
||||
style={{
|
||||
display: show ? "block" : "none",
|
||||
zIndex: show ? "1055" : "auto",
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="modal-dialog modal-dialog-centered modal-lg"
|
||||
role="document"
|
||||
>
|
||||
<div
|
||||
className="modal-content"
|
||||
style={{ zIndex: show ? "1056" : "auto" }}
|
||||
>
|
||||
<div className="modal-header">
|
||||
<h5 className="modal-title">
|
||||
Помощь по использованию системы учета времени
|
||||
</h5>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-close"
|
||||
onClick={onHide}
|
||||
aria-label="Close"
|
||||
></button>
|
||||
</div>
|
||||
<div className="modal-body">
|
||||
<div className="help-content">
|
||||
<h5>Добро пожаловать в систему учета рабочего времени!</h5>
|
||||
<p>
|
||||
Эта система позволяет отслеживать ваше рабочее время, включая
|
||||
как ручной ввод неучтенного времени, так и автоматический расчет
|
||||
на основе рабочих дней.
|
||||
</p>
|
||||
|
||||
<h6>Основные функции:</h6>
|
||||
<ul>
|
||||
<li>
|
||||
<strong>Добавить запись о неучтенном времени:</strong>{" "}
|
||||
Позволяет вручную добавить запись о времени, которое не было
|
||||
учтено автоматически. Укажите дату, время начала и окончания,
|
||||
описание работы.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Добавить автоматический учет времени:</strong>{" "}
|
||||
Автоматически рассчитывает рабочее время на основе заданных
|
||||
рабочих дней и часов. Рабочий день считается с 9:00 до 18:00 с
|
||||
перерывом на обед с 12:00 до 13:00.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Просмотр записей:</strong> В таблице ниже отображаются
|
||||
все ваши записи о рабочем времени. Вы можете редактировать или
|
||||
удалять записи при необходимости.
|
||||
</li>
|
||||
<li>
|
||||
<strong>Общее количество часов:</strong> В нижней части
|
||||
страницы показывается суммарное количество отработанных часов.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<h6>Как пользоваться:</h6>
|
||||
<ol>
|
||||
<li>
|
||||
Для добавления неучтенного времени нажмите кнопку "Добавить
|
||||
запись о неучтенном времени" и заполните форму.
|
||||
</li>
|
||||
<li>
|
||||
Для автоматического расчета времени нажмите "Добавить
|
||||
автоматический учет времени" и укажите период работы.
|
||||
</li>
|
||||
<li>
|
||||
Просматривайте свои записи в таблице. Используйте кнопки
|
||||
редактирования и удаления для управления записями.
|
||||
</li>
|
||||
<li>
|
||||
Если вы администратор или руководитель, используйте
|
||||
соответствующие панели для управления системой.
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<h6>Полезные советы:</h6>
|
||||
<ul>
|
||||
<li>
|
||||
Регулярно добавляйте записи о неучтенном времени, чтобы точно
|
||||
отслеживать свои рабочие часы.
|
||||
</li>
|
||||
<li>
|
||||
Используйте автоматический учет для периодов, когда вы
|
||||
работали полный рабочий день.
|
||||
</li>
|
||||
<li>
|
||||
Проверяйте общее количество часов в конце месяца для
|
||||
отчетности.
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p>
|
||||
<em>
|
||||
Если у вас возникнут вопросы или проблемы, обратитесь к
|
||||
администратору системы.
|
||||
</em>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="modal-footer">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
onClick={onHide}
|
||||
>
|
||||
Закрыть
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{show && (
|
||||
<div
|
||||
className="modal-backdrop fade show"
|
||||
style={{ zIndex: "1054" }}
|
||||
onClick={onHide}
|
||||
></div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default HelpModal;
|
||||
@ -1,5 +1,6 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { Link } from "react-router-dom";
|
||||
import { FaHome, FaUserPlus, FaDownload } from "react-icons/fa";
|
||||
import { usersAPI, timeEntriesAPI } from "../services/api";
|
||||
import UserTimeEntriesModal from "./UserTimeEntriesModal";
|
||||
import * as XLSX from "xlsx";
|
||||
@ -177,18 +178,21 @@ const ManagerPanel = () => {
|
||||
|
||||
return (
|
||||
<div className="w-100 mt-5 px-0">
|
||||
<h3>Менеджер панель</h3>
|
||||
<h3>Панель руководителя</h3>
|
||||
<div className="mb-3 d-flex gap-2 flex-column flex-lg-row">
|
||||
<Link to="/" className="btn btn-primary">
|
||||
<FaHome className="me-1" />
|
||||
Перейти в дешборд
|
||||
</Link>
|
||||
<button
|
||||
className="btn btn-success"
|
||||
onClick={() => setShowCreateForm(true)}
|
||||
>
|
||||
<FaUserPlus className="me-1" />
|
||||
Создать пользователя
|
||||
</button>
|
||||
<button className="btn btn-info" onClick={handleExportAll}>
|
||||
<FaDownload className="me-1" />
|
||||
Экспорт общей таблицы
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -1,21 +1,44 @@
|
||||
const TimeEntriesTable = ({ entries, onDelete, onEdit, isManager = false }) => {
|
||||
const getStatusBadge = (entry) => {
|
||||
if (entry.status === "active") {
|
||||
return <span className="badge bg-warning text-dark">Активна</span>;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const formatDateRange = (entry) => {
|
||||
if (entry.startDate && entry.endDate) {
|
||||
const start = new Date(entry.startDate).toLocaleDateString("ru-RU");
|
||||
const end = new Date(entry.endDate).toLocaleDateString("ru-RU");
|
||||
return `${start} - ${end}`;
|
||||
} else if (entry.startDate) {
|
||||
return new Date(entry.startDate).toLocaleDateString("ru-RU");
|
||||
}
|
||||
return new Date(entry.date).toLocaleDateString("ru-RU");
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="table-responsive w-100">
|
||||
<table className="table table-striped table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Дата</th>
|
||||
<th>Дата/Период</th>
|
||||
<th>Причина</th>
|
||||
<th>Часы</th>
|
||||
<th>Статус</th>
|
||||
{!isManager && <th>Действия</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{entries.map((entry) => (
|
||||
<tr key={entry.id}>
|
||||
<td>{new Date(entry.date).toLocaleDateString("ru-RU")}</td>
|
||||
<tr
|
||||
key={entry.id}
|
||||
className={entry.status === "active" ? "table-warning" : ""}
|
||||
>
|
||||
<td>{formatDateRange(entry)}</td>
|
||||
<td>{entry.reason}</td>
|
||||
<td>{entry.hours}</td>
|
||||
<td>{getStatusBadge(entry)}</td>
|
||||
{!isManager && (
|
||||
<td>
|
||||
<div className="d-flex gap-1 flex-column flex-lg-row">
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user