Добавил функцию автоматического учета времени.

Добавил иконки.
This commit is contained in:
Fovway 2025-10-13 22:56:45 +07:00
parent d23bdc1bfa
commit f47f16f7c8
15 changed files with 1151 additions and 72 deletions

View File

@ -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" });
}
};

View File

@ -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);
}
},
};

View File

@ -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,

View File

@ -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" });
}
});

View 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
`);
},
};

View File

@ -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

View 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,
};

View File

@ -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",

View File

@ -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"
},

View File

@ -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,20 +262,172 @@ const AddTimeEntryModal = ({
</div>
<form onSubmit={handleSubmit}>
<div className="modal-body">
{/* Переключатель режима */}
<div className="mb-3">
<label htmlFor="date" className="form-label">
Дата
</label>
<input
type="date"
className="form-control"
id="date"
name="date"
value={entry.date}
onChange={handleChange}
required
/>
<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">
Дата
</label>
<input
type="date"
className="form-control"
id="date"
name="date"
value={entry.date}
onChange={handleChange}
required={!entry.isAuto}
/>
</div>
<div className="mb-3">
<label htmlFor="hours" className="form-label">
Количество часов
</label>
<input
type="number"
step="0.1"
className="form-control"
id="hours"
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">
Причина
@ -97,21 +441,6 @@ const AddTimeEntryModal = ({
required
/>
</div>
<div className="mb-3">
<label htmlFor="hours" className="form-label">
Количество часов
</label>
<input
type="number"
step="0.1"
className="form-control"
id="hours"
name="hours"
value={entry.hours}
onChange={handleChange}
required
/>
</div>
</div>
<div className="modal-footer">
<button

View File

@ -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>

View File

@ -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>
);
};

View 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;

View File

@ -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>

View File

@ -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">