Добавил функцию автоматического учета времени.
Добавил иконки.
This commit is contained in:
parent
d23bdc1bfa
commit
f47f16f7c8
@ -3,20 +3,28 @@ const { User } = require("../models");
|
|||||||
|
|
||||||
const authenticate = async (req, res, next) => {
|
const authenticate = async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
|
console.log("Authenticating request to:", req.path);
|
||||||
const token = req.header("Authorization")?.replace("Bearer ", "");
|
const token = req.header("Authorization")?.replace("Bearer ", "");
|
||||||
if (!token) {
|
if (!token) {
|
||||||
|
console.log("No token provided");
|
||||||
return res.status(401).json({ message: "Access denied" });
|
return res.status(401).json({ message: "Access denied" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log("Token received, verifying...");
|
||||||
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
const decoded = jwt.verify(token, process.env.JWT_SECRET);
|
||||||
|
console.log("Token decoded for user:", decoded.id);
|
||||||
|
|
||||||
const user = await User.findByPk(decoded.id);
|
const user = await User.findByPk(decoded.id);
|
||||||
if (!user) {
|
if (!user) {
|
||||||
|
console.log("User not found for token");
|
||||||
return res.status(401).json({ message: "Invalid token" });
|
return res.status(401).json({ message: "Invalid token" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log("User authenticated:", user.username);
|
||||||
req.user = user;
|
req.user = user;
|
||||||
next();
|
next();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error("Authentication error:", error.message);
|
||||||
res.status(401).json({ message: "Invalid token" });
|
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,
|
type: DataTypes.DECIMAL,
|
||||||
allowNull: false,
|
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,
|
sequelize,
|
||||||
|
|||||||
@ -1,23 +1,63 @@
|
|||||||
const express = require("express");
|
const express = require("express");
|
||||||
const { TimeEntry, User } = require("../models");
|
const { TimeEntry, User } = require("../models");
|
||||||
const { logActivity } = require("./activityLogs");
|
const { logActivity } = require("./activityLogs");
|
||||||
|
const { calculateWorkHours } = require("../utils/timeCalculator");
|
||||||
const {
|
const {
|
||||||
authenticate,
|
authenticate,
|
||||||
authorizeAdmin,
|
authorizeAdmin,
|
||||||
authorizeManager,
|
authorizeManager,
|
||||||
} = require("../middleware/auth");
|
} = require("../middleware/auth");
|
||||||
|
const { sequelize } = require("../models");
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
// Get time entries for current user
|
// Get time entries for current user
|
||||||
router.get("/", authenticate, async (req, res) => {
|
router.get("/", authenticate, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
|
console.log("Fetching entries for user:", req.user.id);
|
||||||
|
|
||||||
const entries = await TimeEntry.findAll({
|
const entries = await TimeEntry.findAll({
|
||||||
where: { userId: req.user.id },
|
where: { userId: req.user.id },
|
||||||
order: [["date", "DESC"]],
|
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);
|
res.json(entries);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error("Error fetching entries:", error);
|
||||||
res.status(500).json({ message: "Server 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) => {
|
router.get("/all", authenticate, authorizeManager, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const entries = await TimeEntry.findAll({
|
const entries = await TimeEntry.findAll({
|
||||||
|
attributes: [
|
||||||
|
"id",
|
||||||
|
"userId",
|
||||||
|
"date",
|
||||||
|
"reason",
|
||||||
|
"hours",
|
||||||
|
"createdAt",
|
||||||
|
"updatedAt",
|
||||||
|
],
|
||||||
include: [{ model: User, attributes: ["username"] }],
|
include: [{ model: User, attributes: ["username"] }],
|
||||||
order: [["date", "DESC"]],
|
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
|
// Log the export action
|
||||||
@ -80,23 +155,126 @@ router.get(
|
|||||||
// Create time entry
|
// Create time entry
|
||||||
router.post("/", authenticate, async (req, res) => {
|
router.post("/", authenticate, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { date, reason, hours } = req.body;
|
const {
|
||||||
const entry = await TimeEntry.create({
|
|
||||||
userId: req.user.id,
|
|
||||||
date,
|
date,
|
||||||
reason,
|
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);
|
res.status(201).json(entry);
|
||||||
} catch (error) {
|
} 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)
|
// Update time entry (own or admin)
|
||||||
router.put("/:id", authenticate, async (req, res) => {
|
router.put("/:id", authenticate, async (req, res) => {
|
||||||
try {
|
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);
|
const entry = await TimeEntry.findByPk(req.params.id);
|
||||||
if (!entry) {
|
if (!entry) {
|
||||||
return res.status(404).json({ message: "Entry not found" });
|
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") {
|
if (entry.userId !== req.user.id && req.user.role !== "admin") {
|
||||||
return res.status(403).json({ message: "Access denied" });
|
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({
|
await entry.update({
|
||||||
date,
|
date: date || entry.date,
|
||||||
reason,
|
reason,
|
||||||
hours: parseFloat(hours),
|
hours: calculatedHours,
|
||||||
|
startDate,
|
||||||
|
startTime,
|
||||||
|
endDate,
|
||||||
|
endTime,
|
||||||
|
status,
|
||||||
});
|
});
|
||||||
|
|
||||||
res.json(entry);
|
res.json(entry);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error("Error updating time entry:", error);
|
||||||
res.status(500).json({ message: "Server 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(cors());
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Логирование всех запросов
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
console.log(`${new Date().toISOString()} - ${req.method} ${req.path}`);
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
|
||||||
// Database connection
|
// Database connection
|
||||||
const sequelize = new Sequelize(
|
const sequelize = new Sequelize(
|
||||||
process.env.DB_NAME,
|
process.env.DB_NAME,
|
||||||
@ -26,6 +32,24 @@ const sequelize = new Sequelize(
|
|||||||
sequelize
|
sequelize
|
||||||
.authenticate()
|
.authenticate()
|
||||||
.then(() => console.log("Database connected"))
|
.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));
|
.catch((err) => console.error("Database connection failed:", err));
|
||||||
|
|
||||||
// Import models
|
// Import models
|
||||||
@ -55,27 +79,10 @@ sequelize
|
|||||||
console.log("Admin user already exists");
|
console.log("Admin user already exists");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create test time entry for admin
|
// Note: Test entry creation removed to avoid migration issues
|
||||||
return TimeEntry.findOrCreate({
|
// Test entries will be created through the UI
|
||||||
where: {
|
console.log("Skipping test entry creation during migration");
|
||||||
userId: user.id,
|
return Promise.resolve();
|
||||||
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");
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Routes
|
// 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",
|
"bootstrap": "^5.3.8",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
"react-router-dom": "^7.9.4",
|
"react-router-dom": "^7.9.4",
|
||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
@ -2867,6 +2868,15 @@
|
|||||||
"react": "^19.2.0"
|
"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": {
|
"node_modules/react-refresh": {
|
||||||
"version": "0.17.0",
|
"version": "0.17.0",
|
||||||
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
|
||||||
|
|||||||
@ -14,6 +14,7 @@
|
|||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"react": "^19.1.1",
|
"react": "^19.1.1",
|
||||||
"react-dom": "^19.1.1",
|
"react-dom": "^19.1.1",
|
||||||
|
"react-icons": "^5.5.0",
|
||||||
"react-router-dom": "^7.9.4",
|
"react-router-dom": "^7.9.4",
|
||||||
"xlsx": "^0.18.5"
|
"xlsx": "^0.18.5"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -7,39 +7,231 @@ const AddTimeEntryModal = ({
|
|||||||
isEdit = false,
|
isEdit = false,
|
||||||
existingEntry = null,
|
existingEntry = null,
|
||||||
onUpdate,
|
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(() => {
|
useEffect(() => {
|
||||||
if (isEdit && existingEntry) {
|
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({
|
setEntry({
|
||||||
date: existingEntry.date
|
date: existingEntry.date
|
||||||
? new Date(existingEntry.date).toISOString().split("T")[0]
|
? new Date(existingEntry.date).toISOString().split("T")[0]
|
||||||
: "",
|
: "",
|
||||||
reason: existingEntry.reason || "",
|
reason: existingEntry.reason || "",
|
||||||
hours: existingEntry.hours || "",
|
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) {
|
} 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) => {
|
const handleSubmit = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (isEdit && onUpdate) {
|
const submitData = { ...entry };
|
||||||
onUpdate(existingEntry.id, entry);
|
|
||||||
} else {
|
// Очищаем пустые поля
|
||||||
onAdd(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) {
|
if (!isEdit) {
|
||||||
setEntry({ date: "", reason: "", hours: "" });
|
setEntry({
|
||||||
|
date: "",
|
||||||
|
reason: "",
|
||||||
|
hours: "",
|
||||||
|
startDate: "",
|
||||||
|
startTime: "",
|
||||||
|
endDate: "",
|
||||||
|
endTime: "",
|
||||||
|
isAuto: defaultAutoMode,
|
||||||
|
});
|
||||||
|
setPreviewHours(0);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChange = (e) => {
|
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 (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`modal ${show ? "show d-block" : ""}`}
|
className={`modal ${show ? "show d-block" : ""}`}
|
||||||
@ -70,20 +262,172 @@ const AddTimeEntryModal = ({
|
|||||||
</div>
|
</div>
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
<div className="modal-body">
|
<div className="modal-body">
|
||||||
|
{/* Переключатель режима */}
|
||||||
<div className="mb-3">
|
<div className="mb-3">
|
||||||
<label htmlFor="date" className="form-label">
|
<div className="form-check form-switch">
|
||||||
Дата
|
<input
|
||||||
</label>
|
className="form-check-input"
|
||||||
<input
|
type="checkbox"
|
||||||
type="date"
|
id="isAuto"
|
||||||
className="form-control"
|
name="isAuto"
|
||||||
id="date"
|
checked={entry.isAuto}
|
||||||
name="date"
|
onChange={handleChange}
|
||||||
value={entry.date}
|
/>
|
||||||
onChange={handleChange}
|
<label className="form-check-label" htmlFor="isAuto">
|
||||||
required
|
Автоматический расчет времени
|
||||||
/>
|
</label>
|
||||||
|
</div>
|
||||||
</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">
|
<div className="mb-3">
|
||||||
<label htmlFor="reason" className="form-label">
|
<label htmlFor="reason" className="form-label">
|
||||||
Причина
|
Причина
|
||||||
@ -97,21 +441,6 @@ const AddTimeEntryModal = ({
|
|||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
||||||
<div className="modal-footer">
|
<div className="modal-footer">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@ -1,5 +1,13 @@
|
|||||||
import { useState, useEffect } from "react";
|
import { useState, useEffect } from "react";
|
||||||
import { Link } from "react-router-dom";
|
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 { usersAPI, timeEntriesAPI, activityLogsAPI } from "../services/api";
|
||||||
import UserTimeEntriesModal from "./UserTimeEntriesModal";
|
import UserTimeEntriesModal from "./UserTimeEntriesModal";
|
||||||
import * as XLSX from "xlsx";
|
import * as XLSX from "xlsx";
|
||||||
@ -191,15 +199,18 @@ const AdminPanel = () => {
|
|||||||
<h3>Админ панель</h3>
|
<h3>Админ панель</h3>
|
||||||
<div className="mb-3 d-flex gap-2 flex-column flex-lg-row">
|
<div className="mb-3 d-flex gap-2 flex-column flex-lg-row">
|
||||||
<Link to="/" className="btn btn-primary">
|
<Link to="/" className="btn btn-primary">
|
||||||
|
<FaHome className="me-1" />
|
||||||
Перейти в дешборд
|
Перейти в дешборд
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
className="btn btn-success"
|
className="btn btn-success"
|
||||||
onClick={() => setShowCreateForm(true)}
|
onClick={() => setShowCreateForm(true)}
|
||||||
>
|
>
|
||||||
|
<FaUserPlus className="me-1" />
|
||||||
Создать пользователя
|
Создать пользователя
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-info" onClick={handleExportAll}>
|
<button className="btn btn-info" onClick={handleExportAll}>
|
||||||
|
<FaDownload className="me-1" />
|
||||||
Экспорт общей таблицы
|
Экспорт общей таблицы
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@ -209,7 +220,17 @@ const AdminPanel = () => {
|
|||||||
if (!showLogs) fetchActivityLogs();
|
if (!showLogs) fetchActivityLogs();
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{showLogs ? "Скрыть логи" : "Показать логи"}
|
{showLogs ? (
|
||||||
|
<>
|
||||||
|
<FaEyeSlash className="me-1" />
|
||||||
|
Скрыть логи
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<FaEye className="me-1" />
|
||||||
|
Показать логи
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -2,9 +2,11 @@ import { useState, useEffect } from "react";
|
|||||||
import { useAuth } from "../contexts/AuthContext";
|
import { useAuth } from "../contexts/AuthContext";
|
||||||
import { timeEntriesAPI } from "../services/api";
|
import { timeEntriesAPI } from "../services/api";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import { FaPlus, FaClock, FaTrash, FaQuestionCircle } from "react-icons/fa";
|
||||||
import TimeEntriesTable from "./TimeEntriesTable";
|
import TimeEntriesTable from "./TimeEntriesTable";
|
||||||
import AddTimeEntryModal from "./AddTimeEntryModal";
|
import AddTimeEntryModal from "./AddTimeEntryModal";
|
||||||
import DeleteAllModal from "./DeleteAllModal";
|
import DeleteAllModal from "./DeleteAllModal";
|
||||||
|
import HelpModal from "./HelpModal";
|
||||||
|
|
||||||
const Dashboard = () => {
|
const Dashboard = () => {
|
||||||
const { user, logout } = useAuth();
|
const { user, logout } = useAuth();
|
||||||
@ -14,6 +16,8 @@ const Dashboard = () => {
|
|||||||
const [showDeleteAllModal, setShowDeleteAllModal] = useState(false);
|
const [showDeleteAllModal, setShowDeleteAllModal] = useState(false);
|
||||||
const [editingEntry, setEditingEntry] = useState(null);
|
const [editingEntry, setEditingEntry] = useState(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [modalMode, setModalMode] = useState("manual"); // 'manual' или 'auto'
|
||||||
|
const [showHelpModal, setShowHelpModal] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
fetchEntries();
|
fetchEntries();
|
||||||
@ -38,6 +42,10 @@ const Dashboard = () => {
|
|||||||
const fetchEntries = async () => {
|
const fetchEntries = async () => {
|
||||||
try {
|
try {
|
||||||
const response = await timeEntriesAPI.getEntries();
|
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);
|
setEntries(response.data);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to fetch entries:", error);
|
console.error("Failed to fetch entries:", error);
|
||||||
@ -66,6 +74,7 @@ const Dashboard = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleEdit = (entry) => {
|
const handleEdit = (entry) => {
|
||||||
|
console.log("Editing entry from table:", entry);
|
||||||
setEditingEntry(entry);
|
setEditingEntry(entry);
|
||||||
setShowEditModal(true);
|
setShowEditModal(true);
|
||||||
};
|
};
|
||||||
@ -111,9 +120,16 @@ const Dashboard = () => {
|
|||||||
)}
|
)}
|
||||||
{user.role === "manager" && (
|
{user.role === "manager" && (
|
||||||
<Link to="/manager" className="btn btn-secondary">
|
<Link to="/manager" className="btn btn-secondary">
|
||||||
Менеджер панель
|
Панель руководителя
|
||||||
</Link>
|
</Link>
|
||||||
)}
|
)}
|
||||||
|
<button
|
||||||
|
className="btn btn-success"
|
||||||
|
onClick={() => setShowHelpModal(true)}
|
||||||
|
>
|
||||||
|
<FaQuestionCircle className="me-1" />
|
||||||
|
Помощь
|
||||||
|
</button>
|
||||||
<button className="btn btn-outline-danger" onClick={logout}>
|
<button className="btn btn-outline-danger" onClick={logout}>
|
||||||
Выйти
|
Выйти
|
||||||
</button>
|
</button>
|
||||||
@ -121,13 +137,31 @@ const Dashboard = () => {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mb-4 d-flex gap-2 flex-column flex-lg-row">
|
<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>
|
||||||
|
<button
|
||||||
|
className="btn btn-success"
|
||||||
|
onClick={() => {
|
||||||
|
setModalMode("auto");
|
||||||
|
setShowModal(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<FaClock className="me-1" />
|
||||||
|
Добавить автоматический учет времени
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
className="btn btn-danger"
|
className="btn btn-danger"
|
||||||
onClick={() => setShowDeleteAllModal(true)}
|
onClick={() => setShowDeleteAllModal(true)}
|
||||||
>
|
>
|
||||||
|
<FaTrash className="me-1" />
|
||||||
Удалить все записи
|
Удалить все записи
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -151,6 +185,7 @@ const Dashboard = () => {
|
|||||||
show={showModal}
|
show={showModal}
|
||||||
onHide={() => setShowModal(false)}
|
onHide={() => setShowModal(false)}
|
||||||
onAdd={handleAddEntry}
|
onAdd={handleAddEntry}
|
||||||
|
defaultAutoMode={modalMode === "auto"}
|
||||||
/>
|
/>
|
||||||
<AddTimeEntryModal
|
<AddTimeEntryModal
|
||||||
show={showEditModal}
|
show={showEditModal}
|
||||||
@ -164,6 +199,7 @@ const Dashboard = () => {
|
|||||||
onHide={() => setShowDeleteAllModal(false)}
|
onHide={() => setShowDeleteAllModal(false)}
|
||||||
onConfirm={handleDeleteAll}
|
onConfirm={handleDeleteAll}
|
||||||
/>
|
/>
|
||||||
|
<HelpModal show={showHelpModal} onHide={() => setShowHelpModal(false)} />
|
||||||
</div>
|
</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 { useState, useEffect } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
import { FaHome, FaUserPlus, FaDownload } from "react-icons/fa";
|
||||||
import { usersAPI, timeEntriesAPI } from "../services/api";
|
import { usersAPI, timeEntriesAPI } from "../services/api";
|
||||||
import UserTimeEntriesModal from "./UserTimeEntriesModal";
|
import UserTimeEntriesModal from "./UserTimeEntriesModal";
|
||||||
import * as XLSX from "xlsx";
|
import * as XLSX from "xlsx";
|
||||||
@ -177,18 +178,21 @@ const ManagerPanel = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-100 mt-5 px-0">
|
<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">
|
<div className="mb-3 d-flex gap-2 flex-column flex-lg-row">
|
||||||
<Link to="/" className="btn btn-primary">
|
<Link to="/" className="btn btn-primary">
|
||||||
|
<FaHome className="me-1" />
|
||||||
Перейти в дешборд
|
Перейти в дешборд
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
className="btn btn-success"
|
className="btn btn-success"
|
||||||
onClick={() => setShowCreateForm(true)}
|
onClick={() => setShowCreateForm(true)}
|
||||||
>
|
>
|
||||||
|
<FaUserPlus className="me-1" />
|
||||||
Создать пользователя
|
Создать пользователя
|
||||||
</button>
|
</button>
|
||||||
<button className="btn btn-info" onClick={handleExportAll}>
|
<button className="btn btn-info" onClick={handleExportAll}>
|
||||||
|
<FaDownload className="me-1" />
|
||||||
Экспорт общей таблицы
|
Экспорт общей таблицы
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,21 +1,44 @@
|
|||||||
const TimeEntriesTable = ({ entries, onDelete, onEdit, isManager = false }) => {
|
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 (
|
return (
|
||||||
<div className="table-responsive w-100">
|
<div className="table-responsive w-100">
|
||||||
<table className="table table-striped table-hover">
|
<table className="table table-striped table-hover">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Дата</th>
|
<th>Дата/Период</th>
|
||||||
<th>Причина</th>
|
<th>Причина</th>
|
||||||
<th>Часы</th>
|
<th>Часы</th>
|
||||||
|
<th>Статус</th>
|
||||||
{!isManager && <th>Действия</th>}
|
{!isManager && <th>Действия</th>}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{entries.map((entry) => (
|
{entries.map((entry) => (
|
||||||
<tr key={entry.id}>
|
<tr
|
||||||
<td>{new Date(entry.date).toLocaleDateString("ru-RU")}</td>
|
key={entry.id}
|
||||||
|
className={entry.status === "active" ? "table-warning" : ""}
|
||||||
|
>
|
||||||
|
<td>{formatDateRange(entry)}</td>
|
||||||
<td>{entry.reason}</td>
|
<td>{entry.reason}</td>
|
||||||
<td>{entry.hours}</td>
|
<td>{entry.hours}</td>
|
||||||
|
<td>{getStatusBadge(entry)}</td>
|
||||||
{!isManager && (
|
{!isManager && (
|
||||||
<td>
|
<td>
|
||||||
<div className="d-flex gap-1 flex-column flex-lg-row">
|
<div className="d-flex gap-1 flex-column flex-lg-row">
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user