Tc refactor pt1 (#589)
* Refactor ProfileSettings * Cleanup comments * Refactor TaskDetails * Refactor InboxModal * fixup! Refactor InboxModal * Fix project layout * Add visuals to project details * Refactor projectdetails * Remake project metrics * Complete project details refactor * Fix note issues and enhance view * Add filters * Fix project tasks filters * Add filters to task lists * Add filters to task lists * fixup! Add filters to task lists
This commit is contained in:
parent
a1b9095bce
commit
75a1e68730
74 changed files with 6635 additions and 4179 deletions
|
|
@ -0,0 +1,27 @@
|
|||
'use strict';
|
||||
|
||||
const {
|
||||
safeAddColumns,
|
||||
safeRemoveColumn,
|
||||
} = require('../utils/migration-utils');
|
||||
|
||||
module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
await safeAddColumns(queryInterface, 'users', [
|
||||
{
|
||||
name: 'ui_settings',
|
||||
definition: {
|
||||
type: Sequelize.JSON,
|
||||
allowNull: true,
|
||||
defaultValue: JSON.stringify({
|
||||
project: { details: { showMetrics: true } },
|
||||
}),
|
||||
},
|
||||
},
|
||||
]);
|
||||
},
|
||||
|
||||
async down(queryInterface) {
|
||||
await safeRemoveColumn(queryInterface, 'users', 'ui_settings');
|
||||
},
|
||||
};
|
||||
|
|
@ -165,6 +165,17 @@ module.exports = (sequelize) => {
|
|||
pinnedViewsOrder: [],
|
||||
},
|
||||
},
|
||||
ui_settings: {
|
||||
type: DataTypes.JSON,
|
||||
allowNull: true,
|
||||
defaultValue: {
|
||||
project: {
|
||||
details: {
|
||||
showMetrics: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
tableName: 'users',
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@ const storage = multer.diskStorage({
|
|||
const upload = multer({
|
||||
storage: storage,
|
||||
limits: {
|
||||
fileSize: 5 * 1024 * 1024, // 5MB limit
|
||||
fileSize: 10 * 1024 * 1024, // 10MB limit
|
||||
},
|
||||
fileFilter: function (req, file, cb) {
|
||||
const allowedTypes = /jpeg|jpg|png|gif|webp/;
|
||||
|
|
|
|||
|
|
@ -180,8 +180,24 @@ async function filterTasksByParams(
|
|||
],
|
||||
};
|
||||
|
||||
if (params.status === 'done') {
|
||||
whereClause.status = { [Op.in]: [Task.STATUS.DONE, 'done'] };
|
||||
if (params.status === 'done' || params.status === 'completed') {
|
||||
whereClause.status = {
|
||||
[Op.in]: [
|
||||
Task.STATUS.DONE,
|
||||
Task.STATUS.ARCHIVED,
|
||||
'done',
|
||||
'archived',
|
||||
],
|
||||
};
|
||||
} else if (params.status === 'active') {
|
||||
whereClause.status = {
|
||||
[Op.notIn]: [
|
||||
Task.STATUS.DONE,
|
||||
Task.STATUS.ARCHIVED,
|
||||
'done',
|
||||
'archived',
|
||||
],
|
||||
};
|
||||
} else if (!params.client_side_filtering) {
|
||||
whereClause.status = { [Op.notIn]: [Task.STATUS.DONE, 'done'] };
|
||||
}
|
||||
|
|
@ -205,8 +221,24 @@ async function filterTasksByParams(
|
|||
whereClause.status = Task.STATUS.WAITING;
|
||||
break;
|
||||
case 'all':
|
||||
if (params.status === 'done') {
|
||||
whereClause.status = { [Op.in]: [Task.STATUS.DONE, 'done'] };
|
||||
if (params.status === 'done' || params.status === 'completed') {
|
||||
whereClause.status = {
|
||||
[Op.in]: [
|
||||
Task.STATUS.DONE,
|
||||
Task.STATUS.ARCHIVED,
|
||||
'done',
|
||||
'archived',
|
||||
],
|
||||
};
|
||||
} else if (params.status === 'active') {
|
||||
whereClause.status = {
|
||||
[Op.notIn]: [
|
||||
Task.STATUS.DONE,
|
||||
Task.STATUS.ARCHIVED,
|
||||
'done',
|
||||
'archived',
|
||||
],
|
||||
};
|
||||
} else if (!params.client_side_filtering) {
|
||||
whereClause.status = { [Op.notIn]: [Task.STATUS.DONE, 'done'] };
|
||||
}
|
||||
|
|
@ -215,8 +247,24 @@ async function filterTasksByParams(
|
|||
if (!params.include_instances) {
|
||||
whereClause.recurring_parent_id = null;
|
||||
}
|
||||
if (params.status === 'done') {
|
||||
whereClause.status = { [Op.in]: [Task.STATUS.DONE, 'done'] };
|
||||
if (params.status === 'done' || params.status === 'completed') {
|
||||
whereClause.status = {
|
||||
[Op.in]: [
|
||||
Task.STATUS.DONE,
|
||||
Task.STATUS.ARCHIVED,
|
||||
'done',
|
||||
'archived',
|
||||
],
|
||||
};
|
||||
} else if (params.status === 'active') {
|
||||
whereClause.status = {
|
||||
[Op.notIn]: [
|
||||
Task.STATUS.DONE,
|
||||
Task.STATUS.ARCHIVED,
|
||||
'done',
|
||||
'archived',
|
||||
],
|
||||
};
|
||||
} else if (!params.client_side_filtering) {
|
||||
whereClause.status = { [Op.notIn]: [Task.STATUS.DONE, 'done'] };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -145,6 +145,14 @@ router.get('/profile', async (req, res) => {
|
|||
user.today_settings = null;
|
||||
}
|
||||
}
|
||||
if (user.ui_settings && typeof user.ui_settings === 'string') {
|
||||
try {
|
||||
user.ui_settings = JSON.parse(user.ui_settings);
|
||||
} catch (error) {
|
||||
logError('Error parsing ui_settings:', error);
|
||||
user.ui_settings = null;
|
||||
}
|
||||
}
|
||||
|
||||
res.json(user);
|
||||
} catch (error) {
|
||||
|
|
@ -177,6 +185,7 @@ router.patch('/profile', async (req, res) => {
|
|||
productivity_assistant_enabled,
|
||||
next_task_suggestion_enabled,
|
||||
pomodoro_enabled,
|
||||
ui_settings,
|
||||
currentPassword,
|
||||
newPassword,
|
||||
} = req.body;
|
||||
|
|
@ -213,6 +222,7 @@ router.patch('/profile', async (req, res) => {
|
|||
next_task_suggestion_enabled;
|
||||
if (pomodoro_enabled !== undefined)
|
||||
allowedUpdates.pomodoro_enabled = pomodoro_enabled;
|
||||
if (ui_settings !== undefined) allowedUpdates.ui_settings = ui_settings;
|
||||
|
||||
// Validate first_day_of_week if provided
|
||||
if (first_day_of_week !== undefined) {
|
||||
|
|
@ -644,6 +654,7 @@ router.put('/profile/today-settings', async (req, res) => {
|
|||
|
||||
const {
|
||||
showMetrics,
|
||||
projectShowMetrics,
|
||||
showProductivity,
|
||||
showNextTaskSuggestion,
|
||||
showSuggestions,
|
||||
|
|
@ -654,6 +665,10 @@ router.put('/profile/today-settings', async (req, res) => {
|
|||
} = req.body;
|
||||
|
||||
const todaySettings = {
|
||||
projectShowMetrics:
|
||||
projectShowMetrics !== undefined
|
||||
? projectShowMetrics
|
||||
: (user.today_settings?.projectShowMetrics ?? true),
|
||||
showMetrics:
|
||||
showMetrics !== undefined
|
||||
? showMetrics
|
||||
|
|
@ -743,4 +758,42 @@ router.put('/profile/sidebar-settings', async (req, res) => {
|
|||
}
|
||||
});
|
||||
|
||||
// Update generic UI settings (e.g., project metrics preferences)
|
||||
router.put('/profile/ui-settings', async (req, res) => {
|
||||
try {
|
||||
const user = await User.findByPk(req.authUserId);
|
||||
if (!user) {
|
||||
return res.status(404).json({ error: 'User not found.' });
|
||||
}
|
||||
|
||||
const { project } = req.body;
|
||||
|
||||
const currentSettings = (user.ui_settings &&
|
||||
typeof user.ui_settings === 'object'
|
||||
? user.ui_settings
|
||||
: {}) || { project: { details: {} } };
|
||||
|
||||
const newSettings = {
|
||||
...currentSettings,
|
||||
project: {
|
||||
...(currentSettings.project || {}),
|
||||
...(project || {}),
|
||||
details: {
|
||||
...((currentSettings.project &&
|
||||
currentSettings.project.details) ||
|
||||
{}),
|
||||
...((project && project.details) || {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await user.update({ ui_settings: newSettings });
|
||||
|
||||
res.json({ success: true, ui_settings: newSettings });
|
||||
} catch (error) {
|
||||
logError('Error updating ui settings:', error);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
|
|
|||
|
|
@ -520,12 +520,73 @@ function createMassiveTaskData(projects, getRandomDate, getPastDate) {
|
|||
},
|
||||
|
||||
// Investment Portfolio - triggers financial AI features
|
||||
// Research & Analysis Tasks
|
||||
{
|
||||
name: 'Research ESG investment options',
|
||||
project_id: projects[5].id,
|
||||
priority: 1,
|
||||
status: 2,
|
||||
completed_at: getPastDate(15),
|
||||
},
|
||||
{
|
||||
name: 'Analyze S&P 500 index fund options',
|
||||
project_id: projects[5].id,
|
||||
priority: 2,
|
||||
status: 2,
|
||||
completed_at: getPastDate(20),
|
||||
},
|
||||
{
|
||||
name: 'Research low-cost bond index funds',
|
||||
project_id: projects[5].id,
|
||||
priority: 1,
|
||||
status: 2,
|
||||
completed_at: getPastDate(18),
|
||||
},
|
||||
{
|
||||
name: 'Compare Vanguard vs Fidelity vs Schwab platforms',
|
||||
project_id: projects[5].id,
|
||||
priority: 2,
|
||||
status: 2,
|
||||
completed_at: getPastDate(25),
|
||||
},
|
||||
{
|
||||
name: 'Research international market exposure',
|
||||
project_id: projects[5].id,
|
||||
priority: 1,
|
||||
status: 1,
|
||||
},
|
||||
{
|
||||
name: 'Analyze emerging markets funds (VWO, IEMG)',
|
||||
project_id: projects[5].id,
|
||||
priority: 1,
|
||||
status: 0,
|
||||
},
|
||||
{
|
||||
name: 'Research REIT investment opportunities',
|
||||
project_id: projects[5].id,
|
||||
priority: 1,
|
||||
status: 0,
|
||||
},
|
||||
{
|
||||
name: 'Compare target-date retirement funds',
|
||||
project_id: projects[5].id,
|
||||
priority: 1,
|
||||
status: 1,
|
||||
},
|
||||
{
|
||||
name: 'Research dividend aristocrats stocks',
|
||||
project_id: projects[5].id,
|
||||
priority: 0,
|
||||
status: 0,
|
||||
},
|
||||
{
|
||||
name: 'Analyze tech sector ETF options (QQQ, VGT)',
|
||||
project_id: projects[5].id,
|
||||
priority: 1,
|
||||
status: 0,
|
||||
},
|
||||
|
||||
// Portfolio Management Tasks
|
||||
{
|
||||
name: 'Rebalance portfolio allocation',
|
||||
project_id: projects[5].id,
|
||||
|
|
@ -536,22 +597,353 @@ function createMassiveTaskData(projects, getRandomDate, getPastDate) {
|
|||
{
|
||||
name: 'Review quarterly performance',
|
||||
project_id: projects[5].id,
|
||||
priority: 1,
|
||||
priority: 2,
|
||||
status: 1,
|
||||
},
|
||||
{
|
||||
name: 'Set up automatic dividend reinvestment',
|
||||
name: 'Calculate portfolio risk metrics (Sharpe ratio)',
|
||||
project_id: projects[5].id,
|
||||
priority: 1,
|
||||
status: 0,
|
||||
},
|
||||
{
|
||||
name: 'Research international market exposure',
|
||||
name: 'Review asset allocation percentages',
|
||||
project_id: projects[5].id,
|
||||
priority: 2,
|
||||
status: 0,
|
||||
due_date: getRandomDate(7),
|
||||
},
|
||||
{
|
||||
name: 'Analyze portfolio expense ratios',
|
||||
project_id: projects[5].id,
|
||||
priority: 1,
|
||||
status: 1,
|
||||
},
|
||||
{
|
||||
name: 'Review and optimize tax-loss harvesting',
|
||||
project_id: projects[5].id,
|
||||
priority: 2,
|
||||
status: 0,
|
||||
},
|
||||
{
|
||||
name: 'Check portfolio diversification metrics',
|
||||
project_id: projects[5].id,
|
||||
priority: 1,
|
||||
status: 0,
|
||||
},
|
||||
{
|
||||
name: 'Calculate year-to-date returns',
|
||||
project_id: projects[5].id,
|
||||
priority: 1,
|
||||
status: 1,
|
||||
},
|
||||
|
||||
// Account Setup & Administration
|
||||
{
|
||||
name: 'Open Vanguard brokerage account',
|
||||
project_id: projects[5].id,
|
||||
priority: 2,
|
||||
status: 2,
|
||||
completed_at: getPastDate(30),
|
||||
},
|
||||
{
|
||||
name: 'Set up automatic dividend reinvestment',
|
||||
project_id: projects[5].id,
|
||||
priority: 2,
|
||||
status: 2,
|
||||
completed_at: getPastDate(10),
|
||||
},
|
||||
{
|
||||
name: 'Configure automatic monthly contributions',
|
||||
project_id: projects[5].id,
|
||||
priority: 2,
|
||||
status: 2,
|
||||
completed_at: getPastDate(12),
|
||||
},
|
||||
{
|
||||
name: 'Link bank account for transfers',
|
||||
project_id: projects[5].id,
|
||||
priority: 2,
|
||||
status: 2,
|
||||
completed_at: getPastDate(28),
|
||||
},
|
||||
{
|
||||
name: 'Set up 2-factor authentication',
|
||||
project_id: projects[5].id,
|
||||
priority: 2,
|
||||
status: 2,
|
||||
completed_at: getPastDate(27),
|
||||
},
|
||||
{
|
||||
name: 'Configure email alerts for large transactions',
|
||||
project_id: projects[5].id,
|
||||
priority: 1,
|
||||
status: 2,
|
||||
completed_at: getPastDate(8),
|
||||
},
|
||||
{
|
||||
name: 'Set up account beneficiaries',
|
||||
project_id: projects[5].id,
|
||||
priority: 2,
|
||||
status: 0,
|
||||
due_date: getRandomDate(14),
|
||||
},
|
||||
{
|
||||
name: 'Review account security settings',
|
||||
project_id: projects[5].id,
|
||||
priority: 1,
|
||||
status: 0,
|
||||
},
|
||||
|
||||
// Purchases & Transactions
|
||||
{
|
||||
name: 'Purchase VTSAX (Vanguard Total Stock)',
|
||||
project_id: projects[5].id,
|
||||
priority: 2,
|
||||
status: 2,
|
||||
completed_at: getPastDate(5),
|
||||
},
|
||||
{
|
||||
name: 'Purchase VBTLX (Vanguard Total Bond)',
|
||||
project_id: projects[5].id,
|
||||
priority: 2,
|
||||
status: 2,
|
||||
completed_at: getPastDate(5),
|
||||
},
|
||||
{
|
||||
name: 'Purchase VTIAX (Vanguard International)',
|
||||
project_id: projects[5].id,
|
||||
priority: 2,
|
||||
status: 2,
|
||||
completed_at: getPastDate(4),
|
||||
},
|
||||
{
|
||||
name: 'Make $1000 monthly contribution',
|
||||
project_id: projects[5].id,
|
||||
priority: 2,
|
||||
status: 0,
|
||||
due_date: getRandomDate(3),
|
||||
},
|
||||
{
|
||||
name: 'Sell underperforming position',
|
||||
project_id: projects[5].id,
|
||||
priority: 1,
|
||||
status: 0,
|
||||
},
|
||||
{
|
||||
name: 'Execute rebalancing trades',
|
||||
project_id: projects[5].id,
|
||||
priority: 2,
|
||||
status: 0,
|
||||
due_date: getRandomDate(10),
|
||||
},
|
||||
|
||||
// Tax Planning & Documentation
|
||||
{
|
||||
name: 'Download tax documents for filing',
|
||||
project_id: projects[5].id,
|
||||
priority: 2,
|
||||
status: 0,
|
||||
due_date: getRandomDate(30),
|
||||
},
|
||||
{
|
||||
name: 'Review capital gains/losses for tax year',
|
||||
project_id: projects[5].id,
|
||||
priority: 2,
|
||||
status: 0,
|
||||
},
|
||||
{
|
||||
name: 'Maximize IRA contribution for year',
|
||||
project_id: projects[5].id,
|
||||
priority: 2,
|
||||
status: 1,
|
||||
},
|
||||
{
|
||||
name: 'Research Roth IRA conversion strategy',
|
||||
project_id: projects[5].id,
|
||||
priority: 1,
|
||||
status: 0,
|
||||
},
|
||||
{
|
||||
name: 'Track cost basis for all positions',
|
||||
project_id: projects[5].id,
|
||||
priority: 1,
|
||||
status: 0,
|
||||
},
|
||||
{
|
||||
name: 'Document investment decisions for records',
|
||||
project_id: projects[5].id,
|
||||
priority: 0,
|
||||
status: 0,
|
||||
},
|
||||
|
||||
// Education & Learning
|
||||
{
|
||||
name: 'Read "The Simple Path to Wealth" book',
|
||||
project_id: projects[5].id,
|
||||
priority: 1,
|
||||
status: 2,
|
||||
completed_at: getPastDate(40),
|
||||
},
|
||||
{
|
||||
name: 'Read "A Random Walk Down Wall Street"',
|
||||
project_id: projects[5].id,
|
||||
priority: 1,
|
||||
status: 1,
|
||||
},
|
||||
{
|
||||
name: 'Complete Bogleheads investment course',
|
||||
project_id: projects[5].id,
|
||||
priority: 1,
|
||||
status: 0,
|
||||
},
|
||||
{
|
||||
name: 'Watch Warren Buffett shareholder letters',
|
||||
project_id: projects[5].id,
|
||||
priority: 0,
|
||||
status: 0,
|
||||
},
|
||||
{
|
||||
name: 'Join r/Bogleheads community discussions',
|
||||
project_id: projects[5].id,
|
||||
priority: 0,
|
||||
status: 2,
|
||||
completed_at: getPastDate(35),
|
||||
},
|
||||
{
|
||||
name: 'Subscribe to investment newsletter',
|
||||
project_id: projects[5].id,
|
||||
priority: 0,
|
||||
status: 2,
|
||||
completed_at: getPastDate(22),
|
||||
},
|
||||
{
|
||||
name: 'Learn about modern portfolio theory',
|
||||
project_id: projects[5].id,
|
||||
priority: 1,
|
||||
status: 0,
|
||||
},
|
||||
|
||||
// Monitoring & Review
|
||||
{
|
||||
name: 'Set up portfolio tracking spreadsheet',
|
||||
project_id: projects[5].id,
|
||||
priority: 1,
|
||||
status: 2,
|
||||
completed_at: getPastDate(20),
|
||||
},
|
||||
{
|
||||
name: 'Create monthly performance dashboard',
|
||||
project_id: projects[5].id,
|
||||
priority: 1,
|
||||
status: 1,
|
||||
},
|
||||
{
|
||||
name: 'Review portfolio monthly (recurring)',
|
||||
project_id: projects[5].id,
|
||||
priority: 2,
|
||||
status: 0,
|
||||
due_date: getRandomDate(5),
|
||||
},
|
||||
{
|
||||
name: 'Track expenses and fee analysis',
|
||||
project_id: projects[5].id,
|
||||
priority: 1,
|
||||
status: 0,
|
||||
},
|
||||
{
|
||||
name: 'Monitor market volatility and VIX',
|
||||
project_id: projects[5].id,
|
||||
priority: 0,
|
||||
status: 0,
|
||||
},
|
||||
{
|
||||
name: 'Review inflation-adjusted returns',
|
||||
project_id: projects[5].id,
|
||||
priority: 1,
|
||||
status: 0,
|
||||
},
|
||||
|
||||
// Strategy & Planning
|
||||
{
|
||||
name: 'Define investment time horizon',
|
||||
project_id: projects[5].id,
|
||||
priority: 2,
|
||||
status: 2,
|
||||
completed_at: getPastDate(45),
|
||||
},
|
||||
{
|
||||
name: 'Set retirement savings goals',
|
||||
project_id: projects[5].id,
|
||||
priority: 2,
|
||||
status: 2,
|
||||
completed_at: getPastDate(42),
|
||||
},
|
||||
{
|
||||
name: 'Create investment policy statement',
|
||||
project_id: projects[5].id,
|
||||
priority: 2,
|
||||
status: 1,
|
||||
},
|
||||
{
|
||||
name: 'Plan asset allocation glide path',
|
||||
project_id: projects[5].id,
|
||||
priority: 1,
|
||||
status: 0,
|
||||
},
|
||||
{
|
||||
name: 'Define risk tolerance level',
|
||||
project_id: projects[5].id,
|
||||
priority: 2,
|
||||
status: 2,
|
||||
completed_at: getPastDate(38),
|
||||
},
|
||||
{
|
||||
name: 'Create emergency fund strategy',
|
||||
project_id: projects[5].id,
|
||||
priority: 2,
|
||||
status: 2,
|
||||
completed_at: getPastDate(50),
|
||||
},
|
||||
{
|
||||
name: 'Plan for major life events (house, kids)',
|
||||
project_id: projects[5].id,
|
||||
priority: 1,
|
||||
status: 0,
|
||||
},
|
||||
|
||||
// Advanced Topics
|
||||
{
|
||||
name: 'Research options trading strategies',
|
||||
project_id: projects[5].id,
|
||||
priority: 0,
|
||||
status: 0,
|
||||
},
|
||||
{
|
||||
name: 'Explore cryptocurrency allocation (5% max)',
|
||||
project_id: projects[5].id,
|
||||
priority: 0,
|
||||
status: 0,
|
||||
},
|
||||
{
|
||||
name: 'Research factor investing (value, momentum)',
|
||||
project_id: projects[5].id,
|
||||
priority: 1,
|
||||
status: 0,
|
||||
},
|
||||
{
|
||||
name: 'Analyze sector rotation strategies',
|
||||
project_id: projects[5].id,
|
||||
priority: 0,
|
||||
status: 0,
|
||||
},
|
||||
{
|
||||
name: 'Review alternative investments (gold, commodities)',
|
||||
project_id: projects[5].id,
|
||||
priority: 1,
|
||||
status: 0,
|
||||
},
|
||||
|
||||
// Side Business - triggers entrepreneurship AI features
|
||||
{
|
||||
name: 'Create business plan document',
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Routes, Route, Navigate, Outlet } from 'react-router-dom';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import Login from './components/Login';
|
||||
import NotFound from './components/Shared/NotFound';
|
||||
import ProjectDetails from './components/Project/ProjectDetails';
|
||||
import ProjectDetails from './components/Project/ProjectDetails.tsx';
|
||||
import Projects from './components/Projects';
|
||||
import AreaDetails from './components/Area/AreaDetails';
|
||||
import Areas from './components/Areas';
|
||||
|
|
|
|||
|
|
@ -338,11 +338,7 @@ const Layout: React.FC<LayoutProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
const mainContentMarginLeft = isUpcomingView
|
||||
? 'ml-0'
|
||||
: isSidebarOpen
|
||||
? 'ml-72'
|
||||
: 'ml-0';
|
||||
const mainContentMarginLeft = isSidebarOpen ? 'ml-72' : 'ml-0';
|
||||
|
||||
const isLoading =
|
||||
isNotesLoading ||
|
||||
|
|
|
|||
|
|
@ -19,7 +19,6 @@ import { useStore } from '../../store/useStore';
|
|||
|
||||
interface InboxItemDetailProps {
|
||||
item: InboxItem;
|
||||
onProcess: (uid: string) => void;
|
||||
onDelete: (uid: string) => void;
|
||||
onUpdate?: (uid: string) => Promise<void>;
|
||||
openTaskModal: (task: Task, inboxItemUid?: string) => void;
|
||||
|
|
@ -30,7 +29,6 @@ interface InboxItemDetailProps {
|
|||
|
||||
const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
||||
item,
|
||||
onProcess, // eslint-disable-line @typescript-eslint/no-unused-vars
|
||||
onDelete,
|
||||
onUpdate,
|
||||
openTaskModal,
|
||||
|
|
@ -51,7 +49,6 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
`dropdown-${Math.random().toString(36).substr(2, 9)}`
|
||||
).current;
|
||||
|
||||
// Close dropdown when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (isDropdownOpen && buttonRef.current) {
|
||||
|
|
@ -68,7 +65,6 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
}
|
||||
};
|
||||
|
||||
// Listen for custom event to close this dropdown when another opens
|
||||
const handleCloseOtherDropdowns = (event: CustomEvent) => {
|
||||
if (event.detail.dropdownId !== dropdownId && isDropdownOpen) {
|
||||
setIsDropdownOpen(false);
|
||||
|
|
@ -92,21 +88,16 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
};
|
||||
}, [isDropdownOpen, dropdownId]);
|
||||
|
||||
// Helper function to parse hashtags from text (consecutive groups anywhere)
|
||||
const parseHashtags = (text: string): string[] => {
|
||||
const trimmedText = text.trim();
|
||||
const matches: string[] = [];
|
||||
|
||||
// Split text into words
|
||||
const words = trimmedText.split(/\s+/);
|
||||
if (words.length === 0) return matches;
|
||||
|
||||
// Find all consecutive groups of tags/projects
|
||||
let i = 0;
|
||||
while (i < words.length) {
|
||||
// Check if current word starts a tag/project group
|
||||
if (words[i].startsWith('#') || words[i].startsWith('+')) {
|
||||
// Found start of a group, collect all consecutive tags/projects
|
||||
let groupEnd = i;
|
||||
while (
|
||||
groupEnd < words.length &&
|
||||
|
|
@ -116,7 +107,6 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
groupEnd++;
|
||||
}
|
||||
|
||||
// Process all hashtags in this group
|
||||
for (let j = i; j < groupEnd; j++) {
|
||||
if (words[j].startsWith('#')) {
|
||||
const tagName = words[j].substring(1);
|
||||
|
|
@ -130,7 +120,6 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
// Skip to end of this group
|
||||
i = groupEnd;
|
||||
} else {
|
||||
i++;
|
||||
|
|
@ -140,20 +129,15 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
return matches;
|
||||
};
|
||||
|
||||
// Helper function to parse project references from text (consecutive groups anywhere)
|
||||
const parseProjectRefs = (text: string): string[] => {
|
||||
const trimmedText = text.trim();
|
||||
const matches: string[] = [];
|
||||
|
||||
// Tokenize the text handling quoted strings properly
|
||||
const tokens = tokenizeText(trimmedText);
|
||||
|
||||
// Find consecutive groups of tags/projects
|
||||
let i = 0;
|
||||
while (i < tokens.length) {
|
||||
// Check if current token starts a tag/project group
|
||||
if (tokens[i].startsWith('#') || tokens[i].startsWith('+')) {
|
||||
// Found start of a group, collect all consecutive tags/projects
|
||||
let groupEnd = i;
|
||||
while (
|
||||
groupEnd < tokens.length &&
|
||||
|
|
@ -163,12 +147,10 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
groupEnd++;
|
||||
}
|
||||
|
||||
// Process all project references in this group
|
||||
for (let j = i; j < groupEnd; j++) {
|
||||
if (tokens[j].startsWith('+')) {
|
||||
let projectName = tokens[j].substring(1);
|
||||
|
||||
// Handle quoted project names
|
||||
if (
|
||||
projectName.startsWith('"') &&
|
||||
projectName.endsWith('"')
|
||||
|
|
@ -182,7 +164,6 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
}
|
||||
}
|
||||
|
||||
// Skip to end of this group
|
||||
i = groupEnd;
|
||||
} else {
|
||||
i++;
|
||||
|
|
@ -192,7 +173,6 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
return matches;
|
||||
};
|
||||
|
||||
// Helper function to tokenize text handling quoted strings
|
||||
const tokenizeText = (text: string): string[] => {
|
||||
const tokens: string[] = [];
|
||||
let currentToken = '';
|
||||
|
|
@ -203,27 +183,22 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
const char = text[i];
|
||||
|
||||
if (char === '"' && (i === 0 || text[i - 1] === '+')) {
|
||||
// Start of a quoted string after +
|
||||
inQuotes = true;
|
||||
currentToken += char;
|
||||
} else if (char === '"' && inQuotes) {
|
||||
// End of quoted string
|
||||
inQuotes = false;
|
||||
currentToken += char;
|
||||
} else if (char === ' ' && !inQuotes) {
|
||||
// Space outside quotes - end current token
|
||||
if (currentToken) {
|
||||
tokens.push(currentToken);
|
||||
currentToken = '';
|
||||
}
|
||||
} else {
|
||||
// Regular character
|
||||
currentToken += char;
|
||||
}
|
||||
i++;
|
||||
}
|
||||
|
||||
// Add final token
|
||||
if (currentToken) {
|
||||
tokens.push(currentToken);
|
||||
}
|
||||
|
|
@ -231,7 +206,6 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
return tokens;
|
||||
};
|
||||
|
||||
// Helper function to clean text by removing tags and project references (consecutive groups anywhere)
|
||||
const cleanTextFromTagsAndProjects = (text: string): string => {
|
||||
const trimmedText = text.trim();
|
||||
const tokens = tokenizeText(trimmedText);
|
||||
|
|
@ -239,9 +213,7 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
|
||||
let i = 0;
|
||||
while (i < tokens.length) {
|
||||
// Check if current token starts a tag/project group
|
||||
if (tokens[i].startsWith('#') || tokens[i].startsWith('+')) {
|
||||
// Skip this entire consecutive group
|
||||
while (
|
||||
i < tokens.length &&
|
||||
(tokens[i].startsWith('#') || tokens[i].startsWith('+'))
|
||||
|
|
@ -249,7 +221,6 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
i++;
|
||||
}
|
||||
} else {
|
||||
// Keep regular tokens
|
||||
cleanedTokens.push(tokens[i]);
|
||||
i++;
|
||||
}
|
||||
|
|
@ -264,9 +235,7 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
|
||||
const handleConvertToTask = () => {
|
||||
try {
|
||||
// Convert hashtags to Tag objects
|
||||
const taskTags = hashtags.map((hashtagName) => {
|
||||
// Find existing tag or create a placeholder for new tag
|
||||
const existingTag = tags.find(
|
||||
(tag) =>
|
||||
tag.name.toLowerCase() === hashtagName.toLowerCase()
|
||||
|
|
@ -274,10 +243,8 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
return existingTag || { name: hashtagName };
|
||||
});
|
||||
|
||||
// Find the project to assign (use first project reference if any)
|
||||
let projectId = undefined;
|
||||
if (projectRefs.length > 0) {
|
||||
// Look for an existing project with the first project reference name
|
||||
const projectName = projectRefs[0];
|
||||
const matchingProject = projects.find(
|
||||
(project) =>
|
||||
|
|
@ -309,9 +276,7 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
|
||||
const handleConvertToProject = () => {
|
||||
try {
|
||||
// Convert hashtags to Tag objects (ignore any existing project references)
|
||||
const projectTags = hashtags.map((hashtagName) => {
|
||||
// Find existing tag or create a placeholder for new tag
|
||||
const existingTag = tags.find(
|
||||
(tag) =>
|
||||
tag.name.toLowerCase() === hashtagName.toLowerCase()
|
||||
|
|
@ -350,13 +315,8 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
if (isUrl(item.content.trim())) {
|
||||
setLoading(true);
|
||||
try {
|
||||
// Add a timeout to prevent infinite loading
|
||||
const timeoutPromise = new Promise(
|
||||
(_, reject) =>
|
||||
setTimeout(
|
||||
() => reject(new Error('Timeout')),
|
||||
10000
|
||||
) // 10 second timeout
|
||||
const timeoutPromise = new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Timeout')), 10000)
|
||||
);
|
||||
|
||||
const result = (await Promise.race([
|
||||
|
|
@ -371,8 +331,6 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
}
|
||||
} catch (titleError) {
|
||||
console.error('Error extracting URL title:', titleError);
|
||||
// Continue with default title if URL title extraction fails
|
||||
// Still mark as bookmark if it's a URL
|
||||
isBookmark = true;
|
||||
} finally {
|
||||
setLoading(false);
|
||||
|
|
@ -383,28 +341,22 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
setLoading(false);
|
||||
}
|
||||
|
||||
// Convert hashtags to Tag objects and include bookmark tag if needed
|
||||
const hashtagTags = hashtags.map((hashtagName) => {
|
||||
// Find existing tag or create a placeholder for new tag
|
||||
const existingTag = tags.find(
|
||||
(tag) => tag.name.toLowerCase() === hashtagName.toLowerCase()
|
||||
);
|
||||
return existingTag || { name: hashtagName };
|
||||
});
|
||||
|
||||
// Combine hashtag tags with bookmark tag if it's a URL
|
||||
const bookmarkTag = isBookmark ? [{ name: 'bookmark' }] : [];
|
||||
const tagObjects = [...hashtagTags, ...bookmarkTag];
|
||||
|
||||
// Use cleaned content for note title if no URL title was extracted
|
||||
const finalTitle =
|
||||
title === content ? cleanedContent || item.content : title;
|
||||
const finalContent = cleanedContent || item.content;
|
||||
|
||||
// Find the project to assign (use first project reference if any)
|
||||
let projectId = undefined;
|
||||
if (projectRefs.length > 0) {
|
||||
// Look for an existing project with the first project reference name
|
||||
const projectName = projectRefs[0];
|
||||
const matchingProject = projects.find(
|
||||
(project) =>
|
||||
|
|
@ -459,17 +411,14 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
{cleanedContent || item.content}
|
||||
</button>
|
||||
|
||||
{/* Tags and Projects display - TaskHeader style */}
|
||||
{(hashtags.length > 0 || projectRefs.length > 0) && (
|
||||
<div className="flex items-center text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{/* Projects display first */}
|
||||
{projectRefs.length > 0 && (
|
||||
<div className="flex items-center">
|
||||
<FolderIcon className="h-3 w-3 mr-1" />
|
||||
<span>
|
||||
{projectRefs.map(
|
||||
(projectRef, index) => {
|
||||
// Find matching project
|
||||
const matchingProject =
|
||||
projects.find(
|
||||
(project) =>
|
||||
|
|
@ -526,12 +475,10 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Add spacing between project and tags */}
|
||||
{projectRefs.length > 0 && hashtags.length > 0 && (
|
||||
<span className="mx-2">•</span>
|
||||
)}
|
||||
|
||||
{/* Tags display */}
|
||||
{hashtags.length > 0 && (
|
||||
<div className="flex items-center">
|
||||
<TagIcon className="h-3 w-3 mr-1" />
|
||||
|
|
@ -558,11 +505,9 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* Desktop view (md and larger) */}
|
||||
<div className="hidden md:flex items-center justify-end w-1/5 space-x-1">
|
||||
{loading && <div className="spinner" />}
|
||||
|
||||
{/* Edit Button */}
|
||||
<button
|
||||
onClick={() => {
|
||||
if (onUpdate && item.uid !== undefined) {
|
||||
|
|
@ -575,7 +520,6 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
<PencilIcon className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* Convert to Task Button */}
|
||||
<button
|
||||
onClick={handleConvertToTask}
|
||||
className={`p-2 text-blue-600 dark:text-blue-400 hover:bg-blue-100 dark:hover:bg-blue-900 rounded-full transition-opacity ${isHovered ? 'opacity-100' : 'opacity-0'}`}
|
||||
|
|
@ -584,7 +528,6 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
<ClipboardDocumentListIcon className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* Convert to Project Button */}
|
||||
<button
|
||||
onClick={handleConvertToProject}
|
||||
className={`p-2 text-green-600 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-900 rounded-full transition-opacity ${isHovered ? 'opacity-100' : 'opacity-0'}`}
|
||||
|
|
@ -593,7 +536,6 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
<FolderIcon className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* Convert to Note Button */}
|
||||
<button
|
||||
onClick={handleConvertToNote}
|
||||
className={`p-2 text-purple-600 dark:text-purple-400 hover:bg-purple-100 dark:hover:bg-purple-900 rounded-full transition-opacity ${isHovered ? 'opacity-100' : 'opacity-0'}`}
|
||||
|
|
@ -602,7 +544,6 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
<DocumentTextIcon className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
{/* Delete Button */}
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
className={`p-2 text-red-600 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900 rounded-full transition-opacity ${isHovered ? 'opacity-100' : 'opacity-0'}`}
|
||||
|
|
@ -612,7 +553,6 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Mobile 3-dot dropdown menu */}
|
||||
<div className="flex md:hidden items-center justify-end w-1/5 relative">
|
||||
{loading && <div className="spinner mr-2" />}
|
||||
<button
|
||||
|
|
@ -623,7 +563,6 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
e.stopPropagation();
|
||||
const newOpenState = !isDropdownOpen;
|
||||
|
||||
// Close other dropdowns when opening this one
|
||||
if (newOpenState) {
|
||||
document.dispatchEvent(
|
||||
new CustomEvent('closeOtherDropdowns', {
|
||||
|
|
@ -639,13 +578,11 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
<EllipsisVerticalIcon className="h-5 w-5" />
|
||||
</button>
|
||||
|
||||
{/* Dropdown Menu - Positioned Relatively */}
|
||||
{isDropdownOpen && (
|
||||
<div
|
||||
data-dropdown-id={dropdownId}
|
||||
className="absolute right-0 top-full mt-1 w-48 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 z-[9999] transform-gpu"
|
||||
style={{
|
||||
// Prevent dropdown from being cut off at the bottom of viewport
|
||||
transform:
|
||||
buttonRef.current &&
|
||||
buttonRef.current.getBoundingClientRect()
|
||||
|
|
@ -658,7 +595,6 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="py-1">
|
||||
{/* Edit Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
|
|
@ -685,7 +621,6 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
{t('common.edit', 'Edit')}
|
||||
</button>
|
||||
|
||||
{/* Convert to Task Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
|
|
@ -698,7 +633,6 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
{t('inbox.createTask', 'Create Task')}
|
||||
</button>
|
||||
|
||||
{/* Convert to Project Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
|
|
@ -711,7 +645,6 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
{t('inbox.createProject', 'Create Project')}
|
||||
</button>
|
||||
|
||||
{/* Convert to Note Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
|
|
@ -724,7 +657,6 @@ const InboxItemDetail: React.FC<InboxItemDetailProps> = ({
|
|||
{t('inbox.createNote', 'Create Note')}
|
||||
</button>
|
||||
|
||||
{/* Delete Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
|
|
|
|||
|
|
@ -32,10 +32,8 @@ const InboxItems: React.FC = () => {
|
|||
const { showSuccessToast, showErrorToast } = useToast();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
|
||||
// Track if we've done the initial load from URL
|
||||
const [hasInitialized, setHasInitialized] = useState(false);
|
||||
|
||||
// Access store data
|
||||
const { inboxItems, isLoading, pagination } = useStore(
|
||||
(state) => state.inboxStore
|
||||
);
|
||||
|
|
@ -50,30 +48,24 @@ const InboxItems: React.FC = () => {
|
|||
isLoading: tagsLoading,
|
||||
} = useStore((state) => state.tagsStore);
|
||||
|
||||
// Local projects state (keep main's approach)
|
||||
const [projects, setProjects] = useState<Project[]>([]);
|
||||
|
||||
// Modal states
|
||||
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
|
||||
const [isProjectModalOpen, setIsProjectModalOpen] = useState(false);
|
||||
const [isNoteModalOpen, setIsNoteModalOpen] = useState(false);
|
||||
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||
const [isInfoExpanded, setIsInfoExpanded] = useState(false);
|
||||
|
||||
// Data for modals
|
||||
const [taskToEdit, setTaskToEdit] = useState<Task | null>(null);
|
||||
const [projectToEdit, setProjectToEdit] = useState<Project | null>(null);
|
||||
const [noteToEdit, setNoteToEdit] = useState<Note | null>(null);
|
||||
|
||||
// Track the current inbox item UID being converted (for task/project/note conversion)
|
||||
const [currentConversionItemUid, setCurrentConversionItemUid] = useState<
|
||||
string | null
|
||||
>(null);
|
||||
|
||||
// Track the current inbox item being edited
|
||||
const [itemToEdit, setItemToEdit] = useState<string | null>(null);
|
||||
|
||||
// Create stable default task object to prevent infinite re-renders
|
||||
const defaultTask = useMemo(
|
||||
() => ({
|
||||
name: '',
|
||||
|
|
@ -85,15 +77,12 @@ const InboxItems: React.FC = () => {
|
|||
);
|
||||
|
||||
useEffect(() => {
|
||||
// Read the page size from URL parameter or use default
|
||||
const urlPageSize = searchParams.get('loaded');
|
||||
const currentLoadedCount = urlPageSize ? parseInt(urlPageSize, 10) : 20;
|
||||
|
||||
// Initial data loading - load the amount specified in URL
|
||||
loadInboxItemsToStore(true, currentLoadedCount);
|
||||
setHasInitialized(true);
|
||||
|
||||
// Load projects initially
|
||||
const loadInitialProjects = async () => {
|
||||
try {
|
||||
const projectData = await fetchProjects();
|
||||
|
|
@ -105,7 +94,6 @@ const InboxItems: React.FC = () => {
|
|||
};
|
||||
loadInitialProjects();
|
||||
|
||||
// Load areas initially
|
||||
const loadInitialAreas = async () => {
|
||||
try {
|
||||
const areasData = await fetchAreas();
|
||||
|
|
@ -117,7 +105,6 @@ const InboxItems: React.FC = () => {
|
|||
};
|
||||
loadInitialAreas();
|
||||
|
||||
// Load tags initially
|
||||
const loadInitialTags = async () => {
|
||||
if (!tagsHasLoaded && !tagsLoading) {
|
||||
try {
|
||||
|
|
@ -128,23 +115,18 @@ const InboxItems: React.FC = () => {
|
|||
}
|
||||
};
|
||||
loadInitialTags();
|
||||
// Set up an event listener for force reload
|
||||
const handleForceReload = () => {
|
||||
// Wait a short time to ensure the backend has processed the new item
|
||||
setTimeout(() => {
|
||||
const currentInboxStore = useStore.getState().inboxStore;
|
||||
const currentCount = currentInboxStore.inboxItems.length;
|
||||
loadInboxItemsToStore(false, currentCount); // Preserve current loaded count
|
||||
loadInboxItemsToStore(false, currentCount);
|
||||
}, 500);
|
||||
};
|
||||
|
||||
// Handler for the inboxItemsUpdated custom event
|
||||
const handleInboxItemsUpdated = (
|
||||
event: CustomEvent<{ count: number; firstItemContent: string }>
|
||||
) => {
|
||||
// Show toast notifications for new items
|
||||
if (event.detail.count > 0) {
|
||||
// Show notification for the first new item
|
||||
showSuccessToast(
|
||||
t(
|
||||
'inbox.newTelegramItem',
|
||||
|
|
@ -155,7 +137,6 @@ const InboxItems: React.FC = () => {
|
|||
)
|
||||
);
|
||||
|
||||
// If multiple new items, show a summary notification as well
|
||||
if (event.detail.count > 1) {
|
||||
showSuccessToast(
|
||||
t(
|
||||
|
|
@ -170,16 +151,12 @@ const InboxItems: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
// Set up polling for new inbox items (especially from Telegram)
|
||||
// This ensures real-time updates when items are added externally
|
||||
// Use a reasonable interval that balances responsiveness with performance
|
||||
const pollInterval = setInterval(() => {
|
||||
const currentInboxStore = useStore.getState().inboxStore;
|
||||
const currentCount = currentInboxStore.inboxItems.length;
|
||||
loadInboxItemsToStore(false, currentCount); // Preserve current loaded count
|
||||
}, 15000); // Check for new items every 15 seconds
|
||||
loadInboxItemsToStore(false, currentCount);
|
||||
}, 15000);
|
||||
|
||||
// Add event listeners
|
||||
window.addEventListener('forceInboxReload', handleForceReload);
|
||||
window.addEventListener(
|
||||
'inboxItemsUpdated',
|
||||
|
|
@ -194,27 +171,23 @@ const InboxItems: React.FC = () => {
|
|||
handleInboxItemsUpdated as EventListener
|
||||
);
|
||||
};
|
||||
}, [t, showSuccessToast]); // Include dependencies that are actually used
|
||||
}, [t, showSuccessToast]);
|
||||
|
||||
// Update URL when inboxItems count changes (but only after initialization)
|
||||
useEffect(() => {
|
||||
// Don't update URL until we've done the initial load from URL
|
||||
if (!hasInitialized) return;
|
||||
|
||||
const urlPageSize = searchParams.get('loaded');
|
||||
const urlLoadedCount = urlPageSize ? parseInt(urlPageSize, 10) : 0;
|
||||
|
||||
// Only update URL if the count has actually changed from what's in the URL
|
||||
if (inboxItems.length > 20 && inboxItems.length !== urlLoadedCount) {
|
||||
setSearchParams(
|
||||
{ loaded: inboxItems.length.toString() },
|
||||
{ replace: true }
|
||||
);
|
||||
} else if (inboxItems.length <= 20 && urlLoadedCount > 0) {
|
||||
// Remove the parameter if we're at the default page size
|
||||
setSearchParams({}, { replace: true });
|
||||
}
|
||||
}, [inboxItems.length, hasInitialized]); // Track hasInitialized to prevent premature URL updates
|
||||
}, [inboxItems.length, hasInitialized]);
|
||||
|
||||
const handleProcessItem = async (
|
||||
uid: string,
|
||||
|
|
@ -232,7 +205,6 @@ const InboxItems: React.FC = () => {
|
|||
};
|
||||
|
||||
const handleUpdateItem = async (uid: string): Promise<void> => {
|
||||
// When edit button is clicked, we open the InboxModal instead of doing inline editing
|
||||
setItemToEdit(uid);
|
||||
setIsEditModalOpen(true);
|
||||
};
|
||||
|
|
@ -261,20 +233,17 @@ const InboxItems: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
// Modal handlers
|
||||
const handleOpenTaskModal = async (task: Task, inboxItemUid?: string) => {
|
||||
try {
|
||||
// Load projects first before opening the modal
|
||||
try {
|
||||
const projectData = await fetchProjects();
|
||||
// Make sure we always set an array
|
||||
setProjects(Array.isArray(projectData) ? projectData : []);
|
||||
} catch (error) {
|
||||
console.error('Failed to load projects:', error);
|
||||
showErrorToast(
|
||||
t('project.loadError', 'Failed to load projects')
|
||||
);
|
||||
setProjects([]); // Ensure we have an empty array even on error
|
||||
setProjects([]);
|
||||
}
|
||||
|
||||
setTaskToEdit(task);
|
||||
|
|
@ -294,14 +263,13 @@ const InboxItems: React.FC = () => {
|
|||
inboxItemUid?: string
|
||||
) => {
|
||||
try {
|
||||
// Load areas first before opening the modal (similar to task modal)
|
||||
try {
|
||||
const areasData = await fetchAreas();
|
||||
setAreas(areasData);
|
||||
} catch (error) {
|
||||
console.error('Failed to load areas:', error);
|
||||
showErrorToast(t('area.loadError', 'Failed to load areas'));
|
||||
setAreas([]); // Ensure we have an empty array even on error
|
||||
setAreas([]);
|
||||
}
|
||||
|
||||
setProjectToEdit(project);
|
||||
|
|
@ -320,7 +288,6 @@ const InboxItems: React.FC = () => {
|
|||
note: Note | null,
|
||||
inboxItemUid?: string
|
||||
) => {
|
||||
// Set up the note data first
|
||||
if (note && note.content && isUrl(note.content.trim())) {
|
||||
if (!note.tags) {
|
||||
note.tags = [{ name: 'bookmark' }];
|
||||
|
|
@ -335,8 +302,6 @@ const InboxItems: React.FC = () => {
|
|||
setCurrentConversionItemUid(inboxItemUid);
|
||||
}
|
||||
|
||||
// Projects are already available from the store
|
||||
|
||||
setIsNoteModalOpen(true);
|
||||
};
|
||||
|
||||
|
|
@ -357,7 +322,6 @@ const InboxItems: React.FC = () => {
|
|||
);
|
||||
showSuccessToast(taskLink);
|
||||
|
||||
// Process the inbox item after successful task creation
|
||||
if (currentConversionItemUid !== null) {
|
||||
await handleProcessItem(currentConversionItemUid, false);
|
||||
setCurrentConversionItemUid(null);
|
||||
|
|
@ -375,13 +339,10 @@ const InboxItems: React.FC = () => {
|
|||
await createProject(project);
|
||||
showSuccessToast(t('project.createSuccess'));
|
||||
|
||||
// Process the inbox item after successful project creation
|
||||
if (currentConversionItemUid !== null) {
|
||||
await handleProcessItem(currentConversionItemUid, false);
|
||||
setCurrentConversionItemUid(null);
|
||||
}
|
||||
|
||||
// Don't set isProjectModalOpen here - the modal handles its own closing via handleClose()
|
||||
} catch (error) {
|
||||
console.error('Failed to create project:', error);
|
||||
showErrorToast(t('project.createError'));
|
||||
|
|
@ -390,31 +351,25 @@ const InboxItems: React.FC = () => {
|
|||
|
||||
const handleSaveNote = async (note: Note) => {
|
||||
try {
|
||||
// Check if the content appears to be a URL and add the bookmark tag
|
||||
const noteContent = note.content || '';
|
||||
const isBookmarkContent = isUrl(noteContent.trim());
|
||||
|
||||
// Ensure tags property exists
|
||||
if (!note.tags) {
|
||||
note.tags = [];
|
||||
}
|
||||
|
||||
// Add a bookmark tag if content is a URL and doesn't already have the tag
|
||||
if (
|
||||
isBookmarkContent &&
|
||||
!note.tags.some((tag) => tag.name === 'bookmark')
|
||||
) {
|
||||
// Use spread operator to create a new array with the bookmark tag added
|
||||
note.tags = [...note.tags, { name: 'bookmark' }];
|
||||
}
|
||||
|
||||
// Create the note with proper tags
|
||||
await createNote(note);
|
||||
showSuccessToast(
|
||||
t('note.createSuccess', 'Note created successfully')
|
||||
);
|
||||
|
||||
// Process the inbox item after successful note creation
|
||||
if (currentConversionItemUid !== null) {
|
||||
await handleProcessItem(currentConversionItemUid, false);
|
||||
setCurrentConversionItemUid(null);
|
||||
|
|
@ -457,7 +412,6 @@ const InboxItems: React.FC = () => {
|
|||
return (
|
||||
<div className="w-full px-2 sm:px-4 lg:px-6 pt-4 pb-8">
|
||||
<div className="w-full max-w-5xl mx-auto">
|
||||
{/* Title row with info button on the right */}
|
||||
<div className="flex items-center mb-8 justify-between">
|
||||
<div className="flex items-center">
|
||||
<h1 className="text-2xl font-light">
|
||||
|
|
@ -482,7 +436,6 @@ const InboxItems: React.FC = () => {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
{/* Info section below title row */}
|
||||
<div
|
||||
className={`transition-all duration-300 ease-in-out ${
|
||||
isInfoExpanded
|
||||
|
|
@ -491,7 +444,6 @@ const InboxItems: React.FC = () => {
|
|||
} overflow-hidden`}
|
||||
>
|
||||
<div className="bg-blue-50/50 dark:bg-blue-900/10 border border-blue-100 dark:border-blue-800/30 rounded-lg px-6 py-5 flex items-start gap-4">
|
||||
{/* Large low-opacity info icon */}
|
||||
<div className="flex-shrink-0">
|
||||
<InformationCircleIcon className="h-12 w-12 text-blue-400 opacity-20" />
|
||||
</div>
|
||||
|
|
@ -525,7 +477,6 @@ const InboxItems: React.FC = () => {
|
|||
<InboxItemDetail
|
||||
key={item.uid || item.id}
|
||||
item={item}
|
||||
onProcess={handleProcessItem}
|
||||
onDelete={handleDeleteItem}
|
||||
onUpdate={handleUpdateItem}
|
||||
openTaskModal={handleOpenTaskModal}
|
||||
|
|
@ -536,7 +487,6 @@ const InboxItems: React.FC = () => {
|
|||
))}
|
||||
</div>
|
||||
|
||||
{/* Load more button */}
|
||||
{pagination.hasMore && (
|
||||
<div className="flex justify-center pt-4">
|
||||
<button
|
||||
|
|
@ -581,7 +531,6 @@ const InboxItems: React.FC = () => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination info */}
|
||||
{inboxItems.length > 0 && (
|
||||
<div className="text-center text-sm text-gray-500 dark:text-gray-400 pt-2">
|
||||
{t(
|
||||
|
|
@ -597,8 +546,6 @@ const InboxItems: React.FC = () => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Task Modal - Always render it but control visibility with isOpen */}
|
||||
{/* Add error boundary protection for modal rendering */}
|
||||
{(() => {
|
||||
try {
|
||||
return (
|
||||
|
|
@ -610,7 +557,7 @@ const InboxItems: React.FC = () => {
|
|||
}}
|
||||
task={taskToEdit || defaultTask}
|
||||
onSave={handleSaveTask}
|
||||
onDelete={async () => {}} // No need to delete since it's a new task
|
||||
onDelete={async () => {}}
|
||||
projects={
|
||||
Array.isArray(projects) ? projects : []
|
||||
}
|
||||
|
|
@ -624,7 +571,6 @@ const InboxItems: React.FC = () => {
|
|||
}
|
||||
})()}
|
||||
|
||||
{/* Project Modal - Only render when needed to prevent infinite loops */}
|
||||
{(() => {
|
||||
return (
|
||||
isProjectModalOpen &&
|
||||
|
|
@ -655,7 +601,6 @@ const InboxItems: React.FC = () => {
|
|||
);
|
||||
})()}
|
||||
|
||||
{/* Note Modal - Always render it but control visibility with isOpen */}
|
||||
{(() => {
|
||||
try {
|
||||
return (
|
||||
|
|
@ -679,7 +624,6 @@ const InboxItems: React.FC = () => {
|
|||
}
|
||||
})()}
|
||||
|
||||
{/* Edit Inbox Item Modal */}
|
||||
{isEditModalOpen && itemToEdit !== null && (
|
||||
<InboxModal
|
||||
isOpen={isEditModalOpen}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
143
frontend/components/Inbox/InboxSelectedChips.tsx
Normal file
143
frontend/components/Inbox/InboxSelectedChips.tsx
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
import React from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { TagIcon, FolderIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import { Tag } from '../../entities/Tag';
|
||||
import { Project } from '../../entities/Project';
|
||||
|
||||
interface InboxSelectedChipsProps {
|
||||
selectedTags: string[];
|
||||
selectedProjects: string[];
|
||||
tags: Tag[];
|
||||
projects: Project[];
|
||||
onRemoveTag: (tagName: string) => void;
|
||||
onRemoveProject: (projectName: string) => void;
|
||||
}
|
||||
|
||||
const InboxSelectedChips: React.FC<InboxSelectedChipsProps> = ({
|
||||
selectedTags,
|
||||
selectedProjects,
|
||||
tags,
|
||||
projects,
|
||||
onRemoveTag,
|
||||
onRemoveProject,
|
||||
}) => {
|
||||
const renderTagChip = (tagName: string, index: number) => {
|
||||
const tag = tags.find(
|
||||
(t) => t.name.toLowerCase() === tagName.toLowerCase()
|
||||
);
|
||||
|
||||
if (tag) {
|
||||
return (
|
||||
<span
|
||||
key={`${tagName}-${index}`}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-blue-50 dark:bg-blue-900/20 rounded text-blue-600 dark:text-blue-400"
|
||||
>
|
||||
<Link
|
||||
to={`/tag/${encodeURIComponent(tag.name)}`}
|
||||
className="hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{tagName}
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => onRemoveTag(tagName)}
|
||||
className="h-3 w-3 text-blue-400 hover:text-red-500 transition-colors"
|
||||
title="Remove tag"
|
||||
>
|
||||
<XMarkIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
key={`${tagName}-${index}`}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-orange-50 dark:bg-orange-900/20 rounded text-orange-500 dark:text-orange-400"
|
||||
>
|
||||
{tagName}
|
||||
<button
|
||||
onClick={() => onRemoveTag(tagName)}
|
||||
className="h-3 w-3 text-orange-400 hover:text-red-500 transition-colors"
|
||||
title="Remove tag"
|
||||
>
|
||||
<XMarkIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const renderProjectChip = (projectName: string, index: number) => {
|
||||
const project = projects.find(
|
||||
(p) => p.name.toLowerCase() === projectName.toLowerCase()
|
||||
);
|
||||
|
||||
if (project) {
|
||||
return (
|
||||
<span
|
||||
key={`${projectName}-${index}`}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-green-50 dark:bg-green-900/20 rounded text-green-600 dark:text-green-400"
|
||||
>
|
||||
<Link
|
||||
to={`/projects?project=${encodeURIComponent(project.name)}`}
|
||||
className="hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{projectName}
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => onRemoveProject(projectName)}
|
||||
className="h-3 w-3 text-green-400 hover:text-red-500 transition-colors"
|
||||
title="Remove project"
|
||||
>
|
||||
<XMarkIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span
|
||||
key={`${projectName}-${index}`}
|
||||
className="inline-flex items-center gap-1 px-2 py-1 bg-orange-50 dark:bg-orange-900/20 rounded text-orange-500 dark:text-orange-400"
|
||||
>
|
||||
{projectName}
|
||||
<button
|
||||
onClick={() => onRemoveProject(projectName)}
|
||||
className="h-3 w-3 text-orange-400 hover:text-red-500 transition-colors"
|
||||
title="Remove project"
|
||||
>
|
||||
<XMarkIcon className="h-3 w-3" />
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{selectedTags.length > 0 && (
|
||||
<div className="flex items-center text-xs text-gray-500 dark:text-gray-400 mt-1 flex-wrap gap-1">
|
||||
<TagIcon className="h-3 w-3 mr-1" />
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedTags.map((tagName, index) =>
|
||||
renderTagChip(tagName, index)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedProjects.length > 0 && (
|
||||
<div className="flex items-center text-xs text-gray-500 dark:text-gray-400 mt-1 flex-wrap gap-1">
|
||||
<FolderIcon className="h-3 w-3 mr-1" />
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedProjects.map((projectName, index) =>
|
||||
renderProjectChip(projectName, index)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default InboxSelectedChips;
|
||||
49
frontend/components/Inbox/SuggestionsDropdown.tsx
Normal file
49
frontend/components/Inbox/SuggestionsDropdown.tsx
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
import React from 'react';
|
||||
|
||||
interface SuggestionsDropdownProps<T> {
|
||||
isVisible: boolean;
|
||||
items: T[];
|
||||
position: { left: number; top: number };
|
||||
selectedIndex: number;
|
||||
onSelect: (item: T) => void;
|
||||
renderLabel: (item: T) => React.ReactNode;
|
||||
}
|
||||
|
||||
const SuggestionsDropdown = <T,>({
|
||||
isVisible,
|
||||
items,
|
||||
position,
|
||||
selectedIndex,
|
||||
onSelect,
|
||||
renderLabel,
|
||||
}: SuggestionsDropdownProps<T>) => {
|
||||
if (!isVisible || items.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="absolute bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 rounded-md shadow-lg z-50"
|
||||
style={{
|
||||
left: `${position.left}px`,
|
||||
top: `${position.top + 4}px`,
|
||||
minWidth: '120px',
|
||||
maxWidth: '200px',
|
||||
}}
|
||||
>
|
||||
{items.map((item, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => onSelect(item)}
|
||||
className={`w-full text-left px-3 py-2 text-sm text-gray-900 dark:text-gray-100 first:rounded-t-md last:rounded-b-md ${
|
||||
selectedIndex === index
|
||||
? 'bg-blue-100 dark:bg-blue-800'
|
||||
: 'hover:bg-gray-100 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
{renderLabel(item)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SuggestionsDropdown;
|
||||
|
|
@ -524,7 +524,7 @@ const Notes: React.FC = () => {
|
|||
<div
|
||||
key={note.uid}
|
||||
onClick={() => handleSelectNote(note)}
|
||||
className={`p-5 cursor-pointer ${
|
||||
className={`relative p-5 cursor-pointer ${
|
||||
previewNote?.uid === note.uid
|
||||
? 'bg-white dark:bg-gray-900 border-b border-transparent mx-4 rounded-lg'
|
||||
: index !==
|
||||
|
|
@ -533,7 +533,10 @@ const Notes: React.FC = () => {
|
|||
: 'border-b border-transparent hover:bg-gray-50 dark:hover:bg-gray-800 mx-4'
|
||||
}`}
|
||||
>
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-gray-100 truncate">
|
||||
{previewNote?.uid === note.uid && (
|
||||
<span className="absolute inset-y-0 left-0 w-1 bg-blue-400 dark:bg-blue-500 rounded-l-md pointer-events-none" />
|
||||
)}
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100 truncate mb-1">
|
||||
{note.title ||
|
||||
t(
|
||||
'notes.untitled',
|
||||
|
|
@ -569,7 +572,7 @@ const Notes: React.FC = () => {
|
|||
{isEditing && editingNote ? (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Editor Header - matches preview structure */}
|
||||
<div className="flex items-start justify-between mb-3 flex-shrink-0 px-6 md:px-8 pt-4">
|
||||
<div className="flex items-start justify-between mb-3 flex-shrink-0 px-6 md:px-8 pt-5">
|
||||
<div className="flex-1">
|
||||
{/* Back button for mobile */}
|
||||
<button
|
||||
|
|
@ -595,7 +598,7 @@ const Notes: React.FC = () => {
|
|||
}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
placeholder="Note title..."
|
||||
className="w-full text-xl md:text-2xl font-bold bg-transparent text-gray-900 dark:text-gray-100 border-none focus:outline-none focus:ring-0 p-0 mb-1"
|
||||
className="w-full bg-transparent text-gray-900 dark:text-gray-100 border-none focus:outline-none focus:ring-0 pt-5 mb-4 block"
|
||||
style={{
|
||||
color: editingNoteColor
|
||||
? shouldUseLightText(
|
||||
|
|
@ -604,6 +607,11 @@ const Notes: React.FC = () => {
|
|||
? '#ffffff'
|
||||
: '#333333'
|
||||
: undefined,
|
||||
fontSize: '2rem',
|
||||
lineHeight: '2rem',
|
||||
fontWeight: 500,
|
||||
paddingLeft: 0,
|
||||
paddingRight: 0,
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
|
|
@ -904,7 +912,7 @@ const Notes: React.FC = () => {
|
|||
) : previewNote ? (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Preview Header */}
|
||||
<div className="flex items-start justify-between mb-3 flex-shrink-0 px-6 md:px-8 pt-6">
|
||||
<div className="flex items-start justify-between mb-3 flex-shrink-0 px-6 md:px-8 pt-5">
|
||||
<div className="flex-1">
|
||||
{/* Back button for mobile */}
|
||||
<button
|
||||
|
|
@ -919,7 +927,7 @@ const Notes: React.FC = () => {
|
|||
onClick={() =>
|
||||
handleEditNote(previewNote)
|
||||
}
|
||||
className="text-xl md:text-2xl font-bold mb-1 cursor-pointer text-gray-900 dark:text-gray-100 transition-colors"
|
||||
className="cursor-pointer text-gray-900 dark:text-gray-100 transition-colors pt-5 mb-4"
|
||||
style={{
|
||||
color: previewNoteColor
|
||||
? shouldUseLightText(
|
||||
|
|
@ -928,6 +936,9 @@ const Notes: React.FC = () => {
|
|||
? '#ffffff'
|
||||
: '#333333'
|
||||
: undefined,
|
||||
fontSize: '2rem',
|
||||
lineHeight: '2rem',
|
||||
fontWeight: 500,
|
||||
}}
|
||||
title="Click to edit"
|
||||
>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
198
frontend/components/Profile/tabs/AiTab.tsx
Normal file
198
frontend/components/Profile/tabs/AiTab.tsx
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
BoltIcon,
|
||||
ChevronRightIcon,
|
||||
ExclamationTriangleIcon,
|
||||
FaceSmileIcon,
|
||||
InformationCircleIcon,
|
||||
LightBulbIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import type { ProfileFormData } from '../types';
|
||||
|
||||
interface AiTabProps {
|
||||
isActive: boolean;
|
||||
formData: ProfileFormData;
|
||||
onToggle: (
|
||||
field: keyof Pick<
|
||||
ProfileFormData,
|
||||
| 'task_intelligence_enabled'
|
||||
| 'auto_suggest_next_actions_enabled'
|
||||
| 'productivity_assistant_enabled'
|
||||
| 'next_task_suggestion_enabled'
|
||||
>
|
||||
) => void;
|
||||
}
|
||||
|
||||
interface ToggleRowProps {
|
||||
label: string;
|
||||
icon: React.ReactNode;
|
||||
value: boolean;
|
||||
onToggle: () => void;
|
||||
}
|
||||
|
||||
const ToggleRow: React.FC<ToggleRowProps> = ({
|
||||
label,
|
||||
icon,
|
||||
value,
|
||||
onToggle,
|
||||
}) => (
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300 flex items-center">
|
||||
<span className="mr-2">{icon}</span>
|
||||
{label}
|
||||
</label>
|
||||
<div
|
||||
className={`relative inline-block w-12 h-6 transition-colors duration-200 ease-in-out rounded-full cursor-pointer ${
|
||||
value ? 'bg-blue-500' : 'bg-gray-300 dark:bg-gray-600'
|
||||
}`}
|
||||
onClick={onToggle}
|
||||
>
|
||||
<span
|
||||
className={`absolute left-0 top-0 bottom-0 m-1 w-4 h-4 transition-transform duration-200 ease-in-out transform bg-white rounded-full ${
|
||||
value ? 'translate-x-6' : 'translate-x-0'
|
||||
}`}
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const AiTab: React.FC<AiTabProps> = ({ isActive, formData, onToggle }) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isActive) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 border border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-6 flex items-center">
|
||||
<LightBulbIcon className="w-6 h-6 mr-3 text-blue-500" />
|
||||
{t(
|
||||
'profile.aiProductivityFeatures',
|
||||
'AI & Productivity Features'
|
||||
)}
|
||||
</h3>
|
||||
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-3 flex items-center">
|
||||
<BoltIcon className="w-5 h-5 mr-2 text-purple-500" />
|
||||
{t('profile.taskIntelligence', 'Task Intelligence')}
|
||||
</h4>
|
||||
|
||||
<div className="mb-4 text-sm text-gray-600 dark:text-gray-300 flex items-start">
|
||||
<InformationCircleIcon className="h-5 w-5 mr-2 flex-shrink-0 text-blue-500" />
|
||||
<p>
|
||||
{t(
|
||||
'profile.taskIntelligenceDescription',
|
||||
'Show popup alerts while typing task names that suggest improvements like "Make it more descriptive!", "Be more specific!", or "Add an action verb!". Disable this if you prefer typing in your own shorthand without suggestions.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ToggleRow
|
||||
icon={<BoltIcon className="w-5 h-5 text-purple-500" />}
|
||||
label={t(
|
||||
'profile.enableTaskIntelligence',
|
||||
'Enable Task Intelligence Assistant'
|
||||
)}
|
||||
value={Boolean(formData.task_intelligence_enabled)}
|
||||
onToggle={() => onToggle('task_intelligence_enabled')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg mt-4">
|
||||
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-3 flex items-center">
|
||||
<ChevronRightIcon className="w-5 h-5 mr-2 text-green-500" />
|
||||
{t(
|
||||
'profile.autoSuggestNextActions',
|
||||
'Auto-Suggest Next Actions'
|
||||
)}
|
||||
</h4>
|
||||
|
||||
<div className="mb-4 text-sm text-gray-600 dark:text-gray-300 flex items-start">
|
||||
<InformationCircleIcon className="h-5 w-5 mr-2 flex-shrink-0 text-blue-500" />
|
||||
<p>
|
||||
{t(
|
||||
'profile.autoSuggestNextActionsDescription',
|
||||
'When creating a project, automatically prompt for the very next physical action to take.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ToggleRow
|
||||
icon={
|
||||
<ChevronRightIcon className="w-5 h-5 text-green-500" />
|
||||
}
|
||||
label={t(
|
||||
'profile.enableAutoSuggestNextActions',
|
||||
'Enable Next Action Prompts'
|
||||
)}
|
||||
value={Boolean(formData.auto_suggest_next_actions_enabled)}
|
||||
onToggle={() =>
|
||||
onToggle('auto_suggest_next_actions_enabled')
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg mt-4">
|
||||
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-3 flex items-center">
|
||||
<ExclamationTriangleIcon className="w-5 h-5 mr-2 text-yellow-500" />
|
||||
{t(
|
||||
'profile.productivityAssistant',
|
||||
'Productivity Assistant'
|
||||
)}
|
||||
</h4>
|
||||
|
||||
<div className="mb-4 text-sm text-gray-600 dark:text-gray-300 flex items-start">
|
||||
<InformationCircleIcon className="h-5 w-5 mr-2 flex-shrink-0 text-blue-500" />
|
||||
<p>
|
||||
{t(
|
||||
'profile.productivityAssistantDescription',
|
||||
'Show productivity insights that help identify stalled projects, vague tasks, and workflow improvements on your Today page.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ToggleRow
|
||||
icon={
|
||||
<ExclamationTriangleIcon className="w-5 h-5 text-yellow-500" />
|
||||
}
|
||||
label={t(
|
||||
'profile.enableProductivityAssistant',
|
||||
'Enable Productivity Insights'
|
||||
)}
|
||||
value={Boolean(formData.productivity_assistant_enabled)}
|
||||
onToggle={() => onToggle('productivity_assistant_enabled')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg mt-4">
|
||||
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-3 flex items-center">
|
||||
<FaceSmileIcon className="w-5 h-5 mr-2 text-green-500" />
|
||||
{t('profile.nextTaskSuggestion', 'Next Task Suggestion')}
|
||||
</h4>
|
||||
|
||||
<div className="mb-4 text-sm text-gray-600 dark:text-gray-300 flex items-start">
|
||||
<InformationCircleIcon className="h-5 w-5 mr-2 flex-shrink-0 text-blue-500" />
|
||||
<p>
|
||||
{t(
|
||||
'profile.nextTaskSuggestionDescription',
|
||||
'Automatically suggest the next best task to work on when you have nothing in progress, prioritizing due today tasks, then suggested tasks, then next actions.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ToggleRow
|
||||
icon={<FaceSmileIcon className="w-5 h-5 text-green-500" />}
|
||||
label={t(
|
||||
'profile.enableNextTaskSuggestion',
|
||||
'Enable Next Task Suggestions'
|
||||
)}
|
||||
value={Boolean(formData.next_task_suggestion_enabled)}
|
||||
onToggle={() => onToggle('next_task_suggestion_enabled')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AiTab;
|
||||
340
frontend/components/Profile/tabs/ApiKeysTab.tsx
Normal file
340
frontend/components/Profile/tabs/ApiKeysTab.tsx
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ClipboardDocumentListIcon,
|
||||
KeyIcon,
|
||||
TrashIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import type { ApiKeySummary } from '../../../utils/apiKeysService';
|
||||
|
||||
interface ApiKeysTabProps {
|
||||
isActive: boolean;
|
||||
apiKeys: ApiKeySummary[];
|
||||
apiKeysLoading: boolean;
|
||||
generatedApiToken: string | null;
|
||||
newApiKeyName: string;
|
||||
newApiKeyExpiration: string;
|
||||
revokeInFlightId: number | null;
|
||||
deleteInFlightId: number | null;
|
||||
pendingDeleteId: number | null;
|
||||
onCreateApiKey: () => void;
|
||||
onCopyGeneratedToken: () => void;
|
||||
onRevokeApiKey: (apiKeyId: number) => void;
|
||||
onRequestDelete: (apiKey: ApiKeySummary) => void;
|
||||
onUpdateNewName: (value: string) => void;
|
||||
onUpdateNewExpiration: (value: string) => void;
|
||||
getApiKeyStatus: (apiKey: ApiKeySummary) => {
|
||||
label: string;
|
||||
className: string;
|
||||
};
|
||||
formatDateTime: (value: string | null) => string;
|
||||
isCreatingApiKey: boolean;
|
||||
}
|
||||
|
||||
const ApiKeysTab: React.FC<ApiKeysTabProps> = ({
|
||||
isActive,
|
||||
apiKeys,
|
||||
apiKeysLoading,
|
||||
generatedApiToken,
|
||||
newApiKeyName,
|
||||
newApiKeyExpiration,
|
||||
revokeInFlightId,
|
||||
deleteInFlightId,
|
||||
pendingDeleteId,
|
||||
onCreateApiKey,
|
||||
onCopyGeneratedToken,
|
||||
onRevokeApiKey,
|
||||
onRequestDelete,
|
||||
onUpdateNewName,
|
||||
onUpdateNewExpiration,
|
||||
getApiKeyStatus,
|
||||
formatDateTime,
|
||||
isCreatingApiKey,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isActive) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 border border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-6 flex items-center">
|
||||
<KeyIcon className="w-6 h-6 mr-3 text-indigo-500" />
|
||||
{t('profile.apiKeys.title', 'API Keys')}
|
||||
</h3>
|
||||
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300 mb-6">
|
||||
{t(
|
||||
'profile.apiKeys.description',
|
||||
'Generate personal access tokens for integrations or CLI usage. You can revoke or delete keys at any time.'
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t('profile.apiKeys.nameLabel', 'Key name')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={newApiKeyName}
|
||||
onChange={(event) =>
|
||||
onUpdateNewName(event.target.value)
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
onCreateApiKey();
|
||||
}
|
||||
}}
|
||||
className="block w-full border border-gray-300 dark:border-gray-600 rounded-md shadow-sm px-3 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder={t(
|
||||
'profile.apiKeys.namePlaceholder',
|
||||
'e.g. Personal laptop'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||
{t(
|
||||
'profile.apiKeys.expirationLabel',
|
||||
'Expires on (optional)'
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="date"
|
||||
value={newApiKeyExpiration}
|
||||
onChange={(event) =>
|
||||
onUpdateNewExpiration(event.target.value)
|
||||
}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
onCreateApiKey();
|
||||
}
|
||||
}}
|
||||
className="block w-full border border-gray-300 dark:border-gray-600 rounded-md shadow-sm px-3 py-2 bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isCreatingApiKey}
|
||||
onClick={onCreateApiKey}
|
||||
className={`w-full inline-flex justify-center items-center px-4 py-2 rounded-md text-white ${
|
||||
isCreatingApiKey
|
||||
? 'bg-gray-400 cursor-not-allowed'
|
||||
: 'bg-blue-600 hover:bg-blue-500'
|
||||
}`}
|
||||
>
|
||||
{isCreatingApiKey
|
||||
? t('common.saving', 'Saving...')
|
||||
: t(
|
||||
'profile.apiKeys.generateButton',
|
||||
'Generate key'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{generatedApiToken && (
|
||||
<div className="mt-4 p-4 bg-green-50 dark:bg-green-900/40 border border-green-200 dark:border-green-700 rounded-md">
|
||||
<p className="text-sm text-green-900 dark:text-green-100 mb-2">
|
||||
{t(
|
||||
'profile.apiKeys.copyNotice',
|
||||
'Copy this token now. It will not be shown again.'
|
||||
)}
|
||||
</p>
|
||||
<div className="flex flex-col sm:flex-row sm:items-center gap-3">
|
||||
<code className="flex-1 bg-white dark:bg-gray-900 rounded px-3 py-2 text-sm font-mono text-gray-800 dark:text-gray-100 overflow-x-auto">
|
||||
{generatedApiToken}
|
||||
</code>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onCopyGeneratedToken}
|
||||
className="inline-flex items-center justify-center px-4 py-2 rounded-md bg-green-600 text-white hover:bg-green-500"
|
||||
>
|
||||
<ClipboardDocumentListIcon className="w-5 h-5 mr-2" />
|
||||
{t('profile.apiKeys.copyButton', 'Copy key')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-6">
|
||||
{apiKeysLoading && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{t('profile.apiKeys.loading', 'Loading API keys...')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!apiKeysLoading && apiKeys.length === 0 && (
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
{t(
|
||||
'profile.apiKeys.empty',
|
||||
'No API keys yet. Generate one to begin.'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!apiKeysLoading && apiKeys.length > 0 && (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-600">
|
||||
<thead className="bg-gray-100 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
{t(
|
||||
'profile.apiKeys.table.name',
|
||||
'Name'
|
||||
)}
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
{t(
|
||||
'profile.apiKeys.table.prefix',
|
||||
'Prefix'
|
||||
)}
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
{t(
|
||||
'profile.apiKeys.table.status',
|
||||
'Status'
|
||||
)}
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
{t(
|
||||
'profile.apiKeys.table.lastUsed',
|
||||
'Last used'
|
||||
)}
|
||||
</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
{t(
|
||||
'profile.apiKeys.table.expires',
|
||||
'Expires'
|
||||
)}
|
||||
</th>
|
||||
<th className="px-4 py-2 text-right text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
|
||||
{t(
|
||||
'profile.apiKeys.table.actions',
|
||||
'Actions'
|
||||
)}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{apiKeys.map((key) => {
|
||||
const status = getApiKeyStatus(key);
|
||||
return (
|
||||
<tr key={key.id}>
|
||||
<td className="px-4 py-3">
|
||||
<div className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{key.name}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t(
|
||||
'profile.apiKeys.createdAt',
|
||||
'Created {{date}}',
|
||||
{
|
||||
date: formatDateTime(
|
||||
key.created_at
|
||||
),
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm font-mono text-gray-800 dark:text-gray-200">
|
||||
{key.token_prefix}...
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm">
|
||||
<span
|
||||
className={status.className}
|
||||
>
|
||||
{status.label}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-700 dark:text-gray-200">
|
||||
{formatDateTime(
|
||||
key.last_used_at
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-gray-700 dark:text-gray-200">
|
||||
{key.expires_at
|
||||
? formatDateTime(
|
||||
key.expires_at
|
||||
)
|
||||
: t(
|
||||
'profile.apiKeys.noExpiry',
|
||||
'None'
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right text-sm">
|
||||
<div className="flex justify-end space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onRevokeApiKey(
|
||||
key.id
|
||||
)
|
||||
}
|
||||
disabled={
|
||||
Boolean(
|
||||
key.revoked_at
|
||||
) ||
|
||||
revokeInFlightId ===
|
||||
key.id
|
||||
}
|
||||
className={`inline-flex items-center px-3 py-1.5 rounded-md border text-xs font-medium ${
|
||||
key.revoked_at
|
||||
? 'border-gray-400 text-gray-400 cursor-not-allowed'
|
||||
: 'border-yellow-600 text-yellow-700 hover:bg-yellow-50'
|
||||
}`}
|
||||
>
|
||||
{key.revoked_at
|
||||
? t(
|
||||
'profile.apiKeys.revokedLabel',
|
||||
'Revoked'
|
||||
)
|
||||
: t(
|
||||
'profile.apiKeys.revokeButton',
|
||||
'Revoke'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
onRequestDelete(key)
|
||||
}
|
||||
disabled={
|
||||
deleteInFlightId ===
|
||||
key.id ||
|
||||
Boolean(
|
||||
pendingDeleteId ===
|
||||
key.id
|
||||
)
|
||||
}
|
||||
className={`inline-flex items-center justify-center px-3 py-1.5 rounded-md border text-xs font-medium ${
|
||||
deleteInFlightId ===
|
||||
key.id
|
||||
? 'border-gray-400 text-gray-400 cursor-not-allowed'
|
||||
: 'border-red-600 text-red-700 hover:bg-red-50'
|
||||
}`}
|
||||
aria-label={t(
|
||||
'profile.apiKeys.deleteAria',
|
||||
'Delete API key'
|
||||
)}
|
||||
>
|
||||
<TrashIcon className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ApiKeysTab;
|
||||
212
frontend/components/Profile/tabs/GeneralTab.tsx
Normal file
212
frontend/components/Profile/tabs/GeneralTab.tsx
Normal file
|
|
@ -0,0 +1,212 @@
|
|||
import React, { ChangeEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
SunIcon,
|
||||
MoonIcon,
|
||||
PhotoIcon,
|
||||
UserCircleIcon,
|
||||
UserIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { getApiPath } from '../../../config/paths';
|
||||
import LanguageDropdown from '../../Shared/LanguageDropdown';
|
||||
import TimezoneDropdown from '../../Shared/TimezoneDropdown';
|
||||
import FirstDayOfWeekDropdown from '../../Shared/FirstDayOfWeekDropdown';
|
||||
import type { ProfileFormData } from '../types';
|
||||
import type {
|
||||
getRegionDisplayName,
|
||||
getTimezonesByRegion,
|
||||
} from '../../../utils/timezoneUtils';
|
||||
|
||||
interface GeneralTabProps {
|
||||
isActive: boolean;
|
||||
formData: ProfileFormData;
|
||||
onChange: (e: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => void;
|
||||
onAppearanceChange: (appearance: 'light' | 'dark') => void;
|
||||
onLanguageChange: (languageCode: string) => void;
|
||||
onTimezoneChange: (timezone: string) => void;
|
||||
onFirstDayChange: (value: number) => void;
|
||||
avatarPreview: string | null;
|
||||
onAvatarSelect: (file: File) => void;
|
||||
onAvatarRemove: () => void;
|
||||
timezonesByRegion: ReturnType<typeof getTimezonesByRegion>;
|
||||
getRegionDisplayName: typeof getRegionDisplayName;
|
||||
}
|
||||
|
||||
const GeneralTab: React.FC<GeneralTabProps> = ({
|
||||
isActive,
|
||||
formData,
|
||||
onChange,
|
||||
onAppearanceChange,
|
||||
onLanguageChange,
|
||||
onTimezoneChange,
|
||||
onFirstDayChange,
|
||||
avatarPreview,
|
||||
onAvatarSelect,
|
||||
onAvatarRemove,
|
||||
timezonesByRegion,
|
||||
getRegionDisplayName,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isActive) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 border border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-6 flex items-center">
|
||||
<UserIcon className="w-6 h-6 mr-3 text-blue-500" />
|
||||
{t('profile.accountSettings', 'Account & Preferences')}
|
||||
</h3>
|
||||
|
||||
<div className="mb-8 flex flex-col items-center">
|
||||
<div className="relative">
|
||||
{avatarPreview || formData.avatar_image ? (
|
||||
<img
|
||||
src={
|
||||
avatarPreview ||
|
||||
getApiPath(formData.avatar_image || '')
|
||||
}
|
||||
alt="Avatar"
|
||||
className="w-32 h-32 rounded-full object-cover border-4 border-blue-500"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-32 h-32 rounded-full border-4 border-gray-300 dark:border-gray-600 bg-gray-200 dark:bg-gray-700 flex items-center justify-center">
|
||||
<UserCircleIcon className="w-20 h-20 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
)}
|
||||
<label
|
||||
htmlFor="avatar-upload"
|
||||
className="absolute bottom-0 right-0 bg-blue-500 hover:bg-blue-600 text-white rounded-full p-2 cursor-pointer transition-colors"
|
||||
>
|
||||
<PhotoIcon className="w-5 h-5" />
|
||||
<input
|
||||
id="avatar-upload"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
onAvatarSelect(file);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{(formData.avatar_image || avatarPreview) && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAvatarRemove}
|
||||
className="mt-3 text-sm text-red-600 dark:text-red-400 hover:text-red-700 dark:hover:text-red-300"
|
||||
>
|
||||
{t('profile.removeAvatar', 'Remove Avatar')}
|
||||
</button>
|
||||
)}
|
||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{t(
|
||||
'profile.avatarDescription',
|
||||
'Upload a profile photo (max 5MB)'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('profile.name', 'Name')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="name"
|
||||
value={formData.name || ''}
|
||||
onChange={onChange}
|
||||
className="block w-full border border-gray-300 dark:border-gray-600 rounded-md shadow-sm px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder={t('profile.enterName', 'Enter your name')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('profile.surname', 'Surname')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="surname"
|
||||
value={formData.surname || ''}
|
||||
onChange={onChange}
|
||||
className="block w-full border border-gray-300 dark:border-gray-600 rounded-md shadow-sm px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder={t(
|
||||
'profile.enterSurname',
|
||||
'Enter your surname'
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('profile.appearance')}
|
||||
</label>
|
||||
<div className="flex rounded-md border border-gray-300 dark:border-gray-600 overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAppearanceChange('light')}
|
||||
className={`flex-1 flex items-center justify-center px-4 py-2 text-sm font-medium transition-colors ${
|
||||
formData.appearance === 'light'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<SunIcon className="h-4 w-4 mr-2" />
|
||||
{t('profile.lightMode', 'Light')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onAppearanceChange('dark')}
|
||||
className={`flex-1 flex items-center justify-center px-4 py-2 text-sm font-medium transition-colors ${
|
||||
formData.appearance === 'dark'
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-white dark:bg-gray-700 text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<MoonIcon className="h-4 w-4 mr-2" />
|
||||
{t('profile.darkMode', 'Dark')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('profile.language')}
|
||||
</label>
|
||||
<LanguageDropdown
|
||||
value={formData.language || 'en'}
|
||||
onChange={onLanguageChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('profile.timezone')}
|
||||
</label>
|
||||
<TimezoneDropdown
|
||||
value={formData.timezone || 'UTC'}
|
||||
onChange={onTimezoneChange}
|
||||
timezonesByRegion={timezonesByRegion}
|
||||
getRegionDisplayName={getRegionDisplayName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('profile.firstDayOfWeek', 'First day of week')}
|
||||
</label>
|
||||
<FirstDayOfWeekDropdown
|
||||
value={formData.first_day_of_week || 1}
|
||||
onChange={onFirstDayChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GeneralTab;
|
||||
65
frontend/components/Profile/tabs/ProductivityTab.tsx
Normal file
65
frontend/components/Profile/tabs/ProductivityTab.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ClockIcon } from '@heroicons/react/24/outline';
|
||||
|
||||
interface ProductivityTabProps {
|
||||
isActive: boolean;
|
||||
pomodoroEnabled: boolean;
|
||||
onTogglePomodoro: () => void;
|
||||
}
|
||||
|
||||
const ProductivityTab: React.FC<ProductivityTabProps> = ({
|
||||
isActive,
|
||||
pomodoroEnabled,
|
||||
onTogglePomodoro,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isActive) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 border border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-6 flex items-center">
|
||||
<ClockIcon className="w-6 h-6 mr-3 text-green-500" />
|
||||
{t('profile.productivityFeatures', 'Productivity Features')}
|
||||
</h3>
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t(
|
||||
'profile.enablePomodoro',
|
||||
'Enable Pomodoro Timer'
|
||||
)}
|
||||
</label>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t(
|
||||
'profile.pomodoroDescription',
|
||||
'Enable the Pomodoro timer in the navigation bar for focused work sessions.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
className={`relative inline-block w-12 h-6 transition-colors duration-200 ease-in-out rounded-full cursor-pointer ${
|
||||
pomodoroEnabled
|
||||
? 'bg-blue-500'
|
||||
: 'bg-gray-300 dark:bg-gray-600'
|
||||
}`}
|
||||
onClick={onTogglePomodoro}
|
||||
>
|
||||
<span
|
||||
className={`absolute left-0 top-0 bottom-0 m-1 w-4 h-4 transition-transform duration-200 ease-in-out transform bg-white rounded-full ${
|
||||
pomodoroEnabled
|
||||
? 'translate-x-6'
|
||||
: 'translate-x-0'
|
||||
}`}
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProductivityTab;
|
||||
168
frontend/components/Profile/tabs/SecurityTab.tsx
Normal file
168
frontend/components/Profile/tabs/SecurityTab.tsx
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import React, { ChangeEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
ShieldCheckIcon,
|
||||
UserIcon,
|
||||
EyeIcon,
|
||||
EyeSlashIcon,
|
||||
InformationCircleIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import type { ProfileFormData } from '../types';
|
||||
|
||||
interface SecurityTabProps {
|
||||
isActive: boolean;
|
||||
formData: ProfileFormData;
|
||||
showCurrentPassword: boolean;
|
||||
showNewPassword: boolean;
|
||||
showConfirmPassword: boolean;
|
||||
onChange: (e: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => void;
|
||||
onToggleCurrentPassword: () => void;
|
||||
onToggleNewPassword: () => void;
|
||||
onToggleConfirmPassword: () => void;
|
||||
}
|
||||
|
||||
const SecurityTab: React.FC<SecurityTabProps> = ({
|
||||
isActive,
|
||||
formData,
|
||||
showCurrentPassword,
|
||||
showNewPassword,
|
||||
showConfirmPassword,
|
||||
onChange,
|
||||
onToggleCurrentPassword,
|
||||
onToggleNewPassword,
|
||||
onToggleConfirmPassword,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isActive) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 border border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-xl font-semibold text-gray-900 dark:text-white mb-6 flex items-center">
|
||||
<ShieldCheckIcon className="w-6 h-6 mr-3 text-red-500" />
|
||||
{t('profile.security', 'Security Settings')}
|
||||
</h3>
|
||||
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-3 flex items-center">
|
||||
<UserIcon className="w-5 h-5 mr-2 text-blue-500" />
|
||||
{t('profile.changePassword', 'Change Password')}
|
||||
</h4>
|
||||
|
||||
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-800 rounded text-blue-800 dark:text-blue-200">
|
||||
<p className="text-sm">
|
||||
<InformationCircleIcon className="w-4 h-4 inline mr-1" />
|
||||
{t(
|
||||
'profile.passwordChangeOptional',
|
||||
'Leave password fields empty to update other settings without changing your password.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('profile.currentPassword', 'Current Password')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showCurrentPassword ? 'text' : 'password'}
|
||||
name="currentPassword"
|
||||
value={formData.currentPassword || ''}
|
||||
onChange={onChange}
|
||||
className="block w-full border border-gray-300 dark:border-gray-600 rounded-md shadow-sm px-3 py-2 pr-10 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder={t(
|
||||
'profile.enterCurrentPassword',
|
||||
'Enter your current password'
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={onToggleCurrentPassword}
|
||||
>
|
||||
{showCurrentPassword ? (
|
||||
<EyeSlashIcon className="h-5 w-5 text-gray-400" />
|
||||
) : (
|
||||
<EyeIcon className="h-5 w-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('profile.newPassword', 'New Password')}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showNewPassword ? 'text' : 'password'}
|
||||
name="newPassword"
|
||||
value={formData.newPassword || ''}
|
||||
onChange={onChange}
|
||||
className="block w-full border border-gray-300 dark:border-gray-600 rounded-md shadow-sm px-3 py-2 pr-10 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder={t(
|
||||
'profile.enterNewPassword',
|
||||
'Enter your new password'
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={onToggleNewPassword}
|
||||
>
|
||||
{showNewPassword ? (
|
||||
<EyeSlashIcon className="h-5 w-5 text-gray-400" />
|
||||
) : (
|
||||
<EyeIcon className="h-5 w-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t(
|
||||
'profile.confirmPassword',
|
||||
'Confirm New Password'
|
||||
)}
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showConfirmPassword ? 'text' : 'password'}
|
||||
name="confirmPassword"
|
||||
value={formData.confirmPassword || ''}
|
||||
onChange={onChange}
|
||||
className="block w-full border border-gray-300 dark:border-gray-600 rounded-md shadow-sm px-3 py-2 pr-10 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
placeholder={t(
|
||||
'profile.confirmNewPassword',
|
||||
'Confirm your new password'
|
||||
)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-y-0 right-0 pr-3 flex items-center"
|
||||
onClick={onToggleConfirmPassword}
|
||||
>
|
||||
{showConfirmPassword ? (
|
||||
<EyeSlashIcon className="h-5 w-5 text-gray-400" />
|
||||
) : (
|
||||
<EyeIcon className="h-5 w-5 text-gray-400" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t(
|
||||
'profile.passwordChangeNote',
|
||||
'Password changes will be saved when you click "Save Changes" at the bottom of the form.'
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SecurityTab;
|
||||
40
frontend/components/Profile/tabs/TabsNav.tsx
Normal file
40
frontend/components/Profile/tabs/TabsNav.tsx
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import React from 'react';
|
||||
|
||||
interface TabConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
icon: React.ReactNode;
|
||||
}
|
||||
|
||||
interface TabsNavProps {
|
||||
tabs: TabConfig[];
|
||||
activeTab: string;
|
||||
onChange: (id: string) => void;
|
||||
}
|
||||
|
||||
const TabsNav: React.FC<TabsNavProps> = ({ tabs, activeTab, onChange }) => (
|
||||
<div className="mb-8">
|
||||
<div className="border-b border-gray-200 dark:border-gray-700">
|
||||
<nav className="-mb-px flex space-x-2 sm:space-x-8 overflow-x-auto scrollbar-hide">
|
||||
{tabs.map((tab) => (
|
||||
<button
|
||||
key={tab.id}
|
||||
type="button"
|
||||
onClick={() => onChange(tab.id)}
|
||||
className={`group inline-flex items-center py-2 px-1 sm:px-2 border-b-2 font-medium text-sm whitespace-nowrap ${
|
||||
activeTab === tab.id
|
||||
? 'border-blue-500 text-blue-600 dark:text-blue-400'
|
||||
: 'border-transparent text-gray-500 hover:text-gray-700 hover:border-gray-300 dark:text-gray-400 dark:hover:text-gray-300'
|
||||
}`}
|
||||
>
|
||||
<span className="mr-1 sm:mr-2">{tab.icon}</span>
|
||||
{tab.name}
|
||||
</button>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export type { TabConfig };
|
||||
export default TabsNav;
|
||||
432
frontend/components/Profile/tabs/TelegramTab.tsx
Normal file
432
frontend/components/Profile/tabs/TelegramTab.tsx
Normal file
|
|
@ -0,0 +1,432 @@
|
|||
import React, { ChangeEvent } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
InformationCircleIcon,
|
||||
CogIcon,
|
||||
ClipboardDocumentListIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import TelegramIcon from '../../Icons/TelegramIcon';
|
||||
import type { Profile, ProfileFormData, TelegramBotInfo } from '../types';
|
||||
|
||||
interface TelegramTabProps {
|
||||
isActive: boolean;
|
||||
formData: ProfileFormData;
|
||||
profile: Profile | null;
|
||||
telegramBotInfo: TelegramBotInfo | null;
|
||||
isPolling: boolean;
|
||||
telegramSetupStatus: 'idle' | 'loading' | 'success' | 'error';
|
||||
onChange: (e: ChangeEvent<HTMLInputElement | HTMLSelectElement>) => void;
|
||||
onSetup: () => void;
|
||||
onStartPolling: () => void;
|
||||
onStopPolling: () => void;
|
||||
onToggleSummary: () => void;
|
||||
onSelectFrequency: (frequency: string) => void;
|
||||
onSendTestSummary: () => void;
|
||||
formatFrequency: (frequency: string) => string;
|
||||
}
|
||||
|
||||
const TelegramTab: React.FC<TelegramTabProps> = ({
|
||||
isActive,
|
||||
formData,
|
||||
profile,
|
||||
telegramBotInfo,
|
||||
isPolling,
|
||||
telegramSetupStatus,
|
||||
onChange,
|
||||
onSetup,
|
||||
onStartPolling,
|
||||
onStopPolling,
|
||||
onToggleSummary,
|
||||
onSelectFrequency,
|
||||
onSendTestSummary,
|
||||
formatFrequency,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!isActive) return null;
|
||||
|
||||
return (
|
||||
<div className="bg-white dark:bg-gray-800 rounded-lg p-6 border border-blue-300 dark:border-blue-700 mb-8">
|
||||
<h3 className="text-xl font-semibold text-blue-700 dark:text-blue-300 mb-6 flex items-center">
|
||||
<TelegramIcon className="w-6 h-6 mr-3 text-blue-500" />
|
||||
{t('profile.telegramIntegration', 'Telegram Integration')}
|
||||
</h3>
|
||||
|
||||
<div className="mb-8 p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-3 flex items-center">
|
||||
<CogIcon className="w-5 h-5 mr-2 text-blue-500" />
|
||||
{t('profile.botSetup', 'Bot Setup')}
|
||||
</h4>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="text-sm text-gray-600 dark:text-gray-300 flex items-start">
|
||||
<InformationCircleIcon className="h-5 w-5 mr-2 flex-shrink-0 text-blue-500" />
|
||||
<p>
|
||||
{t(
|
||||
'profile.telegramDescription',
|
||||
'Connect your tududi account to a Telegram bot to add items to your inbox via Telegram messages.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t(
|
||||
'profile.telegramBotToken',
|
||||
'Telegram Bot Token'
|
||||
)}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="telegram_bot_token"
|
||||
value={formData.telegram_bot_token || ''}
|
||||
onChange={onChange}
|
||||
placeholder="123456789:ABCDefGhIJKlmNoPQRsTUVwxyZ"
|
||||
className="mt-1 block w-full border border-gray-300 dark:border-gray-700 rounded-md shadow-sm px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{t(
|
||||
'profile.telegramTokenDescription',
|
||||
'Create a bot with @BotFather on Telegram and paste the token here.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t('profile.telegramAllowedUsers', 'Allowed Users')}
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
name="telegram_allowed_users"
|
||||
value={formData.telegram_allowed_users || ''}
|
||||
onChange={onChange}
|
||||
placeholder="@username1, 123456789, @username2"
|
||||
className="mt-1 block w-full border border-gray-300 dark:border-gray-700 rounded-md shadow-sm px-3 py-2 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
||||
/>
|
||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400 space-y-1">
|
||||
<p>
|
||||
{t(
|
||||
'profile.telegramAllowedUsersDescription',
|
||||
'Control who can send messages to your bot. Leave empty to allow all users.'
|
||||
)}
|
||||
</p>
|
||||
<div className="space-y-1">
|
||||
<p className="font-semibold text-gray-600 dark:text-gray-300">
|
||||
{t('profile.examples', 'Examples:')}
|
||||
</p>
|
||||
<ul className="list-disc list-inside ml-2 space-y-0.5">
|
||||
<li>
|
||||
<span className="font-mono bg-gray-100 dark:bg-gray-800 px-1 rounded">
|
||||
@alice, @bob
|
||||
</span>
|
||||
{' - '}
|
||||
{t(
|
||||
'profile.exampleUsernames',
|
||||
'Allow specific usernames'
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-mono bg-gray-100 dark:bg-gray-800 px-1 rounded">
|
||||
123456789, 987654321
|
||||
</span>
|
||||
{' - '}
|
||||
{t(
|
||||
'profile.exampleUserIds',
|
||||
'Allow specific user IDs'
|
||||
)}
|
||||
</li>
|
||||
<li>
|
||||
<span className="font-mono bg-gray-100 dark:bg-gray-800 px-1 rounded">
|
||||
@alice, 123456789
|
||||
</span>
|
||||
{' - '}
|
||||
{t(
|
||||
'profile.exampleMixed',
|
||||
'Mix usernames and user IDs'
|
||||
)}
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{profile?.telegram_chat_id && (
|
||||
<div className="p-2 bg-green-50 dark:bg-green-900 border border-green-200 dark:border-green-800 rounded text-green-800 dark:text-green-200">
|
||||
<p className="text-sm">
|
||||
{t(
|
||||
'profile.telegramConnected',
|
||||
'Your Telegram account is connected! Send messages to your bot to add items to your tududi inbox.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(telegramBotInfo || profile?.telegram_bot_token) && (
|
||||
<div className="p-2 bg-blue-50 dark:bg-blue-900 border border-blue-200 dark:border-blue-800 rounded text-blue-800 dark:text-blue-200">
|
||||
<p className="font-medium mb-2">
|
||||
{t(
|
||||
'profile.botConfigured',
|
||||
'Bot configured successfully!'
|
||||
)}
|
||||
</p>
|
||||
<div className="text-sm space-y-1">
|
||||
{telegramBotInfo?.first_name && (
|
||||
<p>
|
||||
<span className="font-semibold">
|
||||
Bot Name:{' '}
|
||||
</span>
|
||||
{telegramBotInfo.first_name}
|
||||
</p>
|
||||
)}
|
||||
{telegramBotInfo?.username && (
|
||||
<p>
|
||||
<span className="font-semibold">
|
||||
{t(
|
||||
'profile.botUsername',
|
||||
'Bot Username:'
|
||||
)}{' '}
|
||||
</span>
|
||||
@{telegramBotInfo.username}
|
||||
</p>
|
||||
)}
|
||||
<div className="mt-2">
|
||||
<p className="font-semibold mb-1">
|
||||
{t(
|
||||
'profile.pollingStatus',
|
||||
'Polling Status:'
|
||||
)}{' '}
|
||||
</p>
|
||||
<div className="flex items-center mb-2">
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full mr-2 ${isPolling ? 'bg-green-500' : 'bg-red-500'}`}
|
||||
></div>
|
||||
<span>
|
||||
{isPolling
|
||||
? t('profile.pollingActive')
|
||||
: t('profile.pollingInactive')}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs mb-2">
|
||||
{t(
|
||||
'profile.pollingNote',
|
||||
'Polling periodically checks for new messages from Telegram and adds them to your inbox.'
|
||||
)}
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2 mt-2">
|
||||
{isPolling ? (
|
||||
<button
|
||||
onClick={onStopPolling}
|
||||
className="px-3 py-1 bg-red-600 text-white dark:bg-red-700 rounded text-sm hover:bg-red-700 dark:hover:bg-red-800"
|
||||
>
|
||||
{t(
|
||||
'profile.stopPolling',
|
||||
'Stop Polling'
|
||||
)}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={onStartPolling}
|
||||
className="px-3 py-1 bg-blue-600 text-white dark:bg-blue-700 rounded text-sm hover:bg-blue-700 dark:hover:bg-blue-800"
|
||||
>
|
||||
{t(
|
||||
'profile.startPolling',
|
||||
'Start Polling'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{telegramBotInfo?.chat_url && (
|
||||
<a
|
||||
href={telegramBotInfo.chat_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="px-3 py-1 bg-green-600 text-white dark:bg-green-700 rounded text-sm hover:bg-green-700 dark:hover:bg-green-800"
|
||||
>
|
||||
{t(
|
||||
'profile.openTelegram',
|
||||
'Open in Telegram'
|
||||
)}
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSetup}
|
||||
disabled={
|
||||
!formData.telegram_bot_token ||
|
||||
telegramSetupStatus === 'loading'
|
||||
}
|
||||
className={`px-4 py-2 rounded-md ${
|
||||
!formData.telegram_bot_token ||
|
||||
telegramSetupStatus === 'loading'
|
||||
? 'bg-gray-300 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600'
|
||||
}`}
|
||||
>
|
||||
{telegramSetupStatus === 'loading'
|
||||
? t('profile.settingUp', 'Setting up...')
|
||||
: t('profile.setupTelegram', 'Setup Telegram')}
|
||||
</button>
|
||||
|
||||
{telegramSetupStatus === 'success' && (
|
||||
<div className="mt-2 flex items-center text-green-600 dark:text-green-400">
|
||||
<svg
|
||||
className="w-5 h-5 mr-2"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm font-medium">
|
||||
{t(
|
||||
'profile.botConfigured',
|
||||
'Bot configured successfully!'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{telegramSetupStatus === 'error' && (
|
||||
<div className="mt-2 flex items-center text-red-600 dark:text-red-400">
|
||||
<svg
|
||||
className="w-5 h-5 mr-2"
|
||||
fill="currentColor"
|
||||
viewBox="0 0 20 20"
|
||||
>
|
||||
<path
|
||||
fillRule="evenodd"
|
||||
d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z"
|
||||
clipRule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
<span className="text-sm font-medium">
|
||||
{t(
|
||||
'profile.telegramSetupFailed',
|
||||
'Setup failed. Please check your token.'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-4 bg-gray-50 dark:bg-gray-700 rounded-lg">
|
||||
<h4 className="text-lg font-medium text-gray-900 dark:text-white mb-3 flex items-center">
|
||||
<ClipboardDocumentListIcon className="w-5 h-5 mr-2 text-green-500" />
|
||||
{t(
|
||||
'profile.taskSummaryNotifications',
|
||||
'Task Summary Notifications'
|
||||
)}
|
||||
</h4>
|
||||
|
||||
<div className="mb-4 text-sm text-gray-600 dark:text-gray-300 flex items-start">
|
||||
<InformationCircleIcon className="h-5 w-5 mr-2 flex-shrink-0 text-blue-500" />
|
||||
<p>
|
||||
{t(
|
||||
'profile.taskSummaryDescription',
|
||||
'Receive regular summaries of your tasks via Telegram. This feature requires your Telegram integration to be set up.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{t(
|
||||
'profile.enableTaskSummary',
|
||||
'Enable Task Summaries'
|
||||
)}
|
||||
</label>
|
||||
<div
|
||||
className={`relative inline-block w-12 h-6 transition-colors duration-200 ease-in-out rounded-full cursor-pointer ${
|
||||
formData.task_summary_enabled
|
||||
? 'bg-blue-500'
|
||||
: 'bg-gray-300 dark:bg-gray-600'
|
||||
}`}
|
||||
onClick={onToggleSummary}
|
||||
>
|
||||
<span
|
||||
className={`absolute left-0 top-0 bottom-0 m-1 w-4 h-4 transition-transform duration-200 ease-in-out transform bg-white rounded-full ${
|
||||
formData.task_summary_enabled
|
||||
? 'translate-x-6'
|
||||
: 'translate-x-0'
|
||||
}`}
|
||||
></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-4">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('profile.summaryFrequency', 'Summary Frequency')}
|
||||
</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{['1h', '2h', '4h', '8h', '12h', 'daily', 'weekly'].map(
|
||||
(frequency) => (
|
||||
<button
|
||||
key={frequency}
|
||||
type="button"
|
||||
className={`px-3 py-1.5 text-sm rounded-full ${
|
||||
formData.task_summary_frequency ===
|
||||
frequency
|
||||
? 'bg-blue-500 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-700 text-gray-700 dark:text-gray-300'
|
||||
}`}
|
||||
onClick={() => onSelectFrequency(frequency)}
|
||||
>
|
||||
{t(
|
||||
`profile.frequency.${frequency}`,
|
||||
formatFrequency(frequency)
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
{t(
|
||||
'profile.frequencyHelp',
|
||||
'Choose how often you want to receive task summaries.'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4">
|
||||
<button
|
||||
type="button"
|
||||
disabled={
|
||||
!profile?.telegram_bot_token ||
|
||||
!profile?.telegram_chat_id
|
||||
}
|
||||
className={`px-4 py-2 rounded-md ${
|
||||
!profile?.telegram_bot_token ||
|
||||
!profile?.telegram_chat_id
|
||||
? 'bg-gray-300 dark:bg-gray-700 text-gray-500 dark:text-gray-400 cursor-not-allowed'
|
||||
: 'bg-blue-600 text-white hover:bg-blue-700 dark:bg-blue-500 dark:hover:bg-blue-600'
|
||||
}`}
|
||||
onClick={onSendTestSummary}
|
||||
>
|
||||
{t('profile.sendTestSummary', 'Send Test Summary')}
|
||||
</button>
|
||||
{(!profile?.telegram_bot_token ||
|
||||
!profile?.telegram_chat_id) && (
|
||||
<p className="mt-2 text-xs text-red-500">
|
||||
{t(
|
||||
'profile.telegramRequiredForSummaries',
|
||||
'Telegram integration must be set up to use task summaries.'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TelegramTab;
|
||||
42
frontend/components/Profile/types.ts
Normal file
42
frontend/components/Profile/types.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
export interface ProfileSettingsProps {
|
||||
currentUser: { uid: string; email: string };
|
||||
isDarkMode?: boolean;
|
||||
toggleDarkMode?: () => void;
|
||||
}
|
||||
|
||||
export interface Profile {
|
||||
uid: string;
|
||||
email: string;
|
||||
name?: string;
|
||||
surname?: string;
|
||||
appearance: 'light' | 'dark';
|
||||
language: string;
|
||||
timezone: string;
|
||||
first_day_of_week: number;
|
||||
avatar_image: string | null;
|
||||
telegram_bot_token: string | null;
|
||||
telegram_chat_id: string | null;
|
||||
telegram_allowed_users: string | null;
|
||||
task_summary_enabled: boolean;
|
||||
task_summary_frequency: string;
|
||||
task_intelligence_enabled: boolean;
|
||||
auto_suggest_next_actions_enabled: boolean;
|
||||
productivity_assistant_enabled: boolean;
|
||||
next_task_suggestion_enabled: boolean;
|
||||
pomodoro_enabled: boolean;
|
||||
}
|
||||
|
||||
export interface TelegramBotInfo {
|
||||
username: string;
|
||||
first_name?: string;
|
||||
polling_status: any;
|
||||
chat_url: string;
|
||||
}
|
||||
|
||||
export type ProfileFormData = Partial<
|
||||
Profile & {
|
||||
currentPassword: string;
|
||||
newPassword: string;
|
||||
confirmPassword: string;
|
||||
}
|
||||
>;
|
||||
175
frontend/components/Project/ProjectBanner.tsx
Normal file
175
frontend/components/Project/ProjectBanner.tsx
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
import React, { RefObject } from 'react';
|
||||
import {
|
||||
TagIcon,
|
||||
Squares2X2Icon,
|
||||
PencilSquareIcon,
|
||||
TrashIcon,
|
||||
ShareIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import BannerBadge from '../Shared/BannerBadge';
|
||||
import { Project } from '../../entities/Project';
|
||||
import { Area } from '../../entities/Area';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { TFunction } from 'i18next';
|
||||
|
||||
interface ProjectBannerProps {
|
||||
project: Project;
|
||||
areas: Area[];
|
||||
t: TFunction;
|
||||
getStateIcon: (state: string) => React.ReactNode;
|
||||
onDeleteClick: () => void;
|
||||
editButtonRef: RefObject<HTMLButtonElement>;
|
||||
}
|
||||
|
||||
const ProjectBanner: React.FC<ProjectBannerProps> = ({
|
||||
project,
|
||||
areas,
|
||||
t,
|
||||
getStateIcon,
|
||||
onDeleteClick,
|
||||
editButtonRef,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
<div className="mb-6 overflow-hidden relative group">
|
||||
{project.image_url ? (
|
||||
<img
|
||||
src={project.image_url}
|
||||
alt={project.name}
|
||||
className="w-full h-64 object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-64 bg-gradient-to-br from-blue-500 to-purple-600 dark:from-blue-600 dark:to-purple-700"></div>
|
||||
)}
|
||||
|
||||
<div className="absolute inset-0 bg-black bg-opacity-40 flex items-center justify-center">
|
||||
<div className="text-center px-4">
|
||||
<h1 className="text-4xl md:text-5xl font-bold text-white drop-shadow-lg">
|
||||
{project.name}
|
||||
</h1>
|
||||
{project.description && (
|
||||
<p className="text-lg md:text-xl text-white/90 mt-2 font-light drop-shadow-md max-w-2xl mx-auto">
|
||||
{project.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-2 left-2 right-14 flex items-center flex-wrap gap-2">
|
||||
{project.state && (
|
||||
<BannerBadge>
|
||||
{getStateIcon(project.state)}
|
||||
<span className="text-xs text-white/90 font-medium">
|
||||
{t(`projects.states.${project.state}`)}
|
||||
</span>
|
||||
</BannerBadge>
|
||||
)}
|
||||
|
||||
{project.tags && project.tags.length > 0 && (
|
||||
<BannerBadge>
|
||||
<TagIcon className="h-3 w-3 text-white/70 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-xs text-white/90 font-medium">
|
||||
{project.tags.map((tag, index) => (
|
||||
<React.Fragment
|
||||
key={tag.uid || tag.id || index}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (tag.uid) {
|
||||
const slug = tag.name
|
||||
.toLowerCase()
|
||||
.replace(
|
||||
/[^a-z0-9]+/g,
|
||||
'-'
|
||||
)
|
||||
.replace(/^-|-$/g, '');
|
||||
navigate(
|
||||
`/tag/${tag.uid}-${slug}`
|
||||
);
|
||||
} else {
|
||||
navigate(
|
||||
`/tag/${encodeURIComponent(tag.name)}`
|
||||
);
|
||||
}
|
||||
}}
|
||||
className="hover:text-blue-200 transition-colors cursor-pointer"
|
||||
>
|
||||
{tag.name}
|
||||
</button>
|
||||
{index <
|
||||
(project.tags?.length || 0) - 1 && (
|
||||
<span className="text-white/60">
|
||||
,{' '}
|
||||
</span>
|
||||
)}
|
||||
</React.Fragment>
|
||||
))}
|
||||
</span>
|
||||
</BannerBadge>
|
||||
)}
|
||||
|
||||
{(project.area || (project as any).Area) && (
|
||||
<BannerBadge>
|
||||
<Squares2X2Icon className="h-3 w-3 text-white/70 flex-shrink-0 mt-0.5" />
|
||||
<button
|
||||
onClick={() => {
|
||||
const projectArea =
|
||||
project.area || (project as any).Area;
|
||||
const area = areas.find(
|
||||
(a) => a.id === projectArea.id
|
||||
);
|
||||
const areaUid = area?.uid;
|
||||
if (!areaUid) return;
|
||||
const areaSlug = projectArea.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
navigate(
|
||||
`/projects?area=${areaUid}-${areaSlug}`
|
||||
);
|
||||
}}
|
||||
className="text-xs text-white/90 hover:text-blue-200 transition-colors cursor-pointer font-medium"
|
||||
>
|
||||
{(project.area || (project as any).Area)?.name}
|
||||
</button>
|
||||
</BannerBadge>
|
||||
)}
|
||||
|
||||
{project.is_shared && (
|
||||
<BannerBadge>
|
||||
<ShareIcon className="h-3 w-3 text-green-500 dark:text-green-400 flex-shrink-0 mt-0.5" />
|
||||
<span className="text-xs text-white/90 font-medium">
|
||||
{t('projects.shared', 'Shared')}
|
||||
</span>
|
||||
</BannerBadge>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="absolute bottom-2 right-2 flex space-x-2 opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<button
|
||||
ref={editButtonRef}
|
||||
type="button"
|
||||
className="p-2 bg-black bg-opacity-50 text-blue-400 hover:text-blue-300 hover:bg-opacity-70 rounded-full transition-all duration-200 backdrop-blur-sm"
|
||||
>
|
||||
<PencilSquareIcon className="h-5 w-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onDeleteClick();
|
||||
}}
|
||||
className="p-2 bg-black bg-opacity-50 text-red-400 hover:text-red-300 hover:bg-opacity-70 rounded-full transition-all duration-200 backdrop-blur-sm"
|
||||
>
|
||||
<TrashIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectBanner;
|
||||
File diff suppressed because it is too large
Load diff
460
frontend/components/Project/ProjectInsightsPanel.tsx
Normal file
460
frontend/components/Project/ProjectInsightsPanel.tsx
Normal file
|
|
@ -0,0 +1,460 @@
|
|||
import React from 'react';
|
||||
import { Task } from '../../entities/Task';
|
||||
import { TFunction } from 'i18next';
|
||||
|
||||
interface DueBuckets {
|
||||
overdue: Task[];
|
||||
week: Task[];
|
||||
month: Task[];
|
||||
unscheduled: Task[];
|
||||
totalDue: number;
|
||||
}
|
||||
|
||||
interface TaskStats {
|
||||
total: number;
|
||||
completed: number;
|
||||
inProgress: number;
|
||||
notStarted: number;
|
||||
overdue: number;
|
||||
dueSoon: number;
|
||||
completionRate: number;
|
||||
}
|
||||
|
||||
interface ProjectInsightsPanelProps {
|
||||
taskStats: TaskStats;
|
||||
completionGradient: string;
|
||||
dueBuckets: DueBuckets;
|
||||
dueHighlights: Task[];
|
||||
nextBestAction: Task | null;
|
||||
getDueDescriptor: (task: Task) => string;
|
||||
onStartNextAction: () => Promise<void> | void;
|
||||
t: TFunction;
|
||||
completionTrend: { label: string; count: number }[];
|
||||
upcomingDueTrend: { label: string; count: number }[];
|
||||
createdTrend: { label: string; count: number }[];
|
||||
weeklyPace: { lastWeek: number; prevWeek: number; delta: number };
|
||||
monthlyCompleted: number;
|
||||
upcomingInsights?: {
|
||||
peakLabel: string;
|
||||
peakCount: number;
|
||||
nextThreeDays: number;
|
||||
nextWeek: number;
|
||||
};
|
||||
eisenhower: {
|
||||
urgentImportant: number;
|
||||
urgentNotImportant: number;
|
||||
notUrgentImportant: number;
|
||||
notUrgentNotImportant: number;
|
||||
};
|
||||
}
|
||||
|
||||
const ProjectInsightsPanel: React.FC<ProjectInsightsPanelProps> = ({
|
||||
taskStats,
|
||||
completionGradient,
|
||||
nextBestAction,
|
||||
getDueDescriptor,
|
||||
onStartNextAction,
|
||||
t,
|
||||
upcomingDueTrend,
|
||||
weeklyPace,
|
||||
monthlyCompleted,
|
||||
upcomingInsights,
|
||||
eisenhower,
|
||||
}) => {
|
||||
const maxUpcoming = Math.max(...upcomingDueTrend.map((d) => d.count), 1);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl shadow-sm p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{t('projects.progress', 'Progress')}
|
||||
</p>
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100">
|
||||
{t('projects.taskMomentum', 'Task momentum')}
|
||||
</h3>
|
||||
</div>
|
||||
<span className="text-xs font-medium text-gray-600 dark:text-gray-300">
|
||||
{taskStats.total} {t('tasks.tasks', 'tasks')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-center gap-4">
|
||||
<div className="relative w-28 h-28">
|
||||
<div
|
||||
className="w-full h-full rounded-full shadow-inner"
|
||||
style={{
|
||||
background: completionGradient,
|
||||
}}
|
||||
></div>
|
||||
<div className="absolute inset-3 rounded-full bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 flex flex-col items-center justify-center text-center">
|
||||
<span className="text-lg font-bold text-gray-900 dark:text-gray-100">
|
||||
{taskStats.completionRate}%
|
||||
</span>
|
||||
<span className="text-[11px] text-gray-500 dark:text-gray-400">
|
||||
{t('common.done', 'done')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 space-y-3">
|
||||
<div className="flex items-center justify-between text-sm text-gray-600 dark:text-gray-300">
|
||||
<span>
|
||||
{t('projects.activeTasks', 'Active tasks')}
|
||||
</span>
|
||||
<span className="font-semibold text-gray-900 dark:text-gray-100">
|
||||
{Math.max(
|
||||
taskStats.total - taskStats.completed,
|
||||
0
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className="w-2 h-2 rounded-full bg-red-500"></span>
|
||||
<span>
|
||||
{taskStats.overdue}{' '}
|
||||
{t('tasks.overdue', 'overdue')},{' '}
|
||||
{taskStats.dueSoon}{' '}
|
||||
{t('tasks.dueSoon', 'due soon')}
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>{t('tasks.progress', 'Progress')}</span>
|
||||
<span className="font-semibold text-gray-700 dark:text-gray-200">
|
||||
{taskStats.completionRate}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-1 h-2 rounded-full bg-gray-200 dark:bg-gray-800 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 dark:bg-blue-400 transition-all duration-300 ease-in-out"
|
||||
style={{
|
||||
width: `${taskStats.total > 0 ? taskStats.completionRate : 0}%`,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl shadow-sm p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100">
|
||||
{t('projects.dueSchedule', 'Due schedule')}
|
||||
</h3>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('projects.next14Days', 'Next 14 days')}
|
||||
</span>
|
||||
</div>
|
||||
{upcomingDueTrend.some((d) => d.count > 0) ? (
|
||||
<>
|
||||
<div className="mt-3 flex flex-wrap gap-1">
|
||||
{upcomingDueTrend.map((d, idx) => {
|
||||
const intensity =
|
||||
maxUpcoming > 0
|
||||
? Math.max(
|
||||
(d.count / maxUpcoming) * 0.8,
|
||||
0.12
|
||||
)
|
||||
: 0;
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex flex-col items-center"
|
||||
style={{
|
||||
width: 'calc(100% / 7 - 4px)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="w-full h-10 rounded-md border border-amber-200 dark:border-amber-800 transition-all duration-300"
|
||||
style={{
|
||||
backgroundColor: `rgba(251, 191, 36, ${intensity})`,
|
||||
}}
|
||||
></div>
|
||||
<span className="mt-1 text-[10px] text-gray-500 dark:text-gray-400">
|
||||
{d.label}
|
||||
</span>
|
||||
<span className="text-[10px] text-gray-600 dark:text-gray-300">
|
||||
{d.count}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{upcomingInsights && (
|
||||
<div className="mt-4 flex items-center gap-2 text-xs text-gray-600 dark:text-gray-300 flex-wrap">
|
||||
<span className="px-2 py-1 rounded-full bg-amber-50 dark:bg-amber-900/30 text-amber-700 dark:text-amber-200">
|
||||
{t('projects.peakDay', 'Peak')}:{' '}
|
||||
{upcomingInsights.peakCount > 0
|
||||
? `${upcomingInsights.peakLabel} · ${upcomingInsights.peakCount}`
|
||||
: t('projects.none', 'None')}
|
||||
</span>
|
||||
<span className="px-2 py-1 rounded-full bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-200">
|
||||
{t('projects.next3days', 'Next 3 days')}:{' '}
|
||||
{upcomingInsights.nextThreeDays}
|
||||
</span>
|
||||
<span className="px-2 py-1 rounded-full bg-green-50 dark:bg-green-900/30 text-green-700 dark:text-green-200">
|
||||
{t('projects.nextWeek', 'Next 7 days')}:{' '}
|
||||
{upcomingInsights.nextWeek}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<p className="mt-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
{t(
|
||||
'projects.noUpcomingDue',
|
||||
'No due dates in the next 14 days.'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl shadow-sm p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100">
|
||||
{t('projects.recentCompletion', 'Recent completion')}
|
||||
</h3>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('projects.last7And30', 'Last 7 & 30 days')}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{t('projects.weeklyPace', 'Weekly pace')}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{weeklyPace.lastWeek}
|
||||
</p>
|
||||
<p className="text-[11px] text-gray-500 dark:text-gray-400">
|
||||
{t(
|
||||
'projects.prevWeekCompleted',
|
||||
'{{count}} prior week',
|
||||
{
|
||||
count: weeklyPace.prevWeek,
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 flex-shrink-0">
|
||||
<div
|
||||
className={`px-2 py-1 rounded-full text-[11px] font-semibold ${
|
||||
weeklyPace.delta >= 0
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-200'
|
||||
: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-200'
|
||||
}`}
|
||||
>
|
||||
{weeklyPace.delta >= 0 ? '+' : ''}
|
||||
{weeklyPace.delta}{' '}
|
||||
{t('projects.vsPrevWeek', 'vs prev week')}
|
||||
</div>
|
||||
<div className="w-32 h-1.5 rounded-full bg-gray-200 dark:bg-gray-800 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-blue-500 dark:bg-blue-400 transition-all duration-300"
|
||||
style={{
|
||||
width: `${Math.min(
|
||||
(weeklyPace.lastWeek /
|
||||
Math.max(
|
||||
Math.max(
|
||||
weeklyPace.lastWeek,
|
||||
weeklyPace.prevWeek
|
||||
),
|
||||
1
|
||||
)) *
|
||||
100,
|
||||
100
|
||||
)}%`,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{t(
|
||||
'projects.monthlyCompletion',
|
||||
'30-day completions'
|
||||
)}
|
||||
</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
{monthlyCompleted}
|
||||
</p>
|
||||
<p className="text-[11px] text-gray-500 dark:text-gray-400">
|
||||
{t('projects.last30Days', 'Last 30 days')}
|
||||
</p>
|
||||
</div>
|
||||
<div className="w-32 h-1.5 rounded-full bg-gray-200 dark:bg-gray-800 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-indigo-500 dark:bg-indigo-400 transition-all duration-300"
|
||||
style={{
|
||||
width: `${Math.min(monthlyCompleted * 3, 100)}%`,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl shadow-sm p-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100">
|
||||
{t('projects.eisenhower', 'Eisenhower matrix')}
|
||||
</h3>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t('projects.priorityVsUrgency', 'Priority vs urgency')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 text-xs text-gray-600 dark:text-gray-300">
|
||||
{[
|
||||
{
|
||||
label: t('projects.urgentImportant', 'Do now'),
|
||||
value: eisenhower.urgentImportant,
|
||||
accent: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-200',
|
||||
},
|
||||
{
|
||||
label: t('projects.urgentNotImportant', 'Delegate'),
|
||||
value: eisenhower.urgentNotImportant,
|
||||
accent: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-200',
|
||||
},
|
||||
{
|
||||
label: t('projects.notUrgentImportant', 'Schedule'),
|
||||
value: eisenhower.notUrgentImportant,
|
||||
accent: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-200',
|
||||
},
|
||||
{
|
||||
label: t(
|
||||
'projects.notUrgentNotImportant',
|
||||
'Drop/avoid'
|
||||
),
|
||||
value: eisenhower.notUrgentNotImportant,
|
||||
accent: 'bg-gray-100 text-gray-700 dark:bg-gray-800/60 dark:text-gray-200',
|
||||
},
|
||||
].map((item, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className={`rounded-lg p-3 border border-gray-200 dark:border-gray-800 ${item.accent}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold">
|
||||
{item.value}
|
||||
</span>
|
||||
<span className="text-[11px] uppercase tracking-wide">
|
||||
{item.label}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 h-1.5 rounded-full bg-white/30 dark:bg-gray-700 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-white/80 dark:bg-white"
|
||||
style={{
|
||||
width: `${Math.min(item.value * 15, 100)}%`,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl shadow-sm p-5">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||
{t('projects.nextUp', 'Next best action')}
|
||||
</p>
|
||||
<h3 className="text-base font-semibold text-gray-900 dark:text-gray-100">
|
||||
{t('projects.focusTask', 'Most impactful task')}
|
||||
</h3>
|
||||
</div>
|
||||
{nextBestAction && (
|
||||
<span className="px-2 py-1 text-[11px] rounded-full bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-200">
|
||||
{getDueDescriptor(nextBestAction)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{nextBestAction ? (
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-1 w-2 h-2 rounded-full bg-blue-500" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{nextBestAction.name}
|
||||
</p>
|
||||
{nextBestAction.note && (
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 line-clamp-2">
|
||||
{nextBestAction.note}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-gray-500 dark:text-gray-400 flex-wrap">
|
||||
{nextBestAction.priority && (
|
||||
<span className="px-2 py-1 rounded-full bg-gray-100 dark:bg-gray-800">
|
||||
{t('tasks.priority', 'Priority')}:{' '}
|
||||
{String(nextBestAction.priority)}
|
||||
</span>
|
||||
)}
|
||||
{nextBestAction.today && (
|
||||
<span className="px-2 py-1 rounded-full bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-200">
|
||||
{t('tasks.todayPlan', 'Today plan')}
|
||||
</span>
|
||||
)}
|
||||
{(nextBestAction.status === 'in_progress' ||
|
||||
nextBestAction.status === 1) && (
|
||||
<span className="px-2 py-1 rounded-full bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-200">
|
||||
{t('task.status.inProgress', 'In progress')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStartNextAction}
|
||||
disabled={
|
||||
(nextBestAction.status === 'in_progress' ||
|
||||
nextBestAction.status === 1) &&
|
||||
nextBestAction.today
|
||||
}
|
||||
className={`inline-flex items-center justify-center px-3 py-1.5 text-sm font-medium text-white rounded-md transition-colors ${
|
||||
(nextBestAction.status === 'in_progress' ||
|
||||
nextBestAction.status === 1) &&
|
||||
nextBestAction.today
|
||||
? 'bg-gray-400 dark:bg-gray-700 cursor-not-allowed'
|
||||
: 'bg-blue-600 hover:bg-blue-700'
|
||||
}`}
|
||||
>
|
||||
{(nextBestAction.status === 'in_progress' ||
|
||||
nextBestAction.status === 1) &&
|
||||
nextBestAction.today
|
||||
? t('tasks.inProgress', 'In progress')
|
||||
: t('tasks.startNow', 'Start now')}
|
||||
</button>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{t(
|
||||
'projects.focusHint',
|
||||
'Shifts this task to in progress and today'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
{t(
|
||||
'projects.noNextAction',
|
||||
'All clear—no outstanding tasks.'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectInsightsPanel;
|
||||
|
|
@ -276,16 +276,31 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
|
||||
const handleImageSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setImageFile(file);
|
||||
if (!file) return;
|
||||
|
||||
// Create preview
|
||||
const reader = new FileReader();
|
||||
reader.onload = (e) => {
|
||||
setImagePreview(e.target?.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
// Simple client-side guard (10MB max)
|
||||
const maxSizeBytes = 10 * 1024 * 1024;
|
||||
if (file.size > maxSizeBytes) {
|
||||
setError(
|
||||
t(
|
||||
'errors.projectImageTooLarge',
|
||||
'Image is too large. Please choose a file under 10MB.'
|
||||
)
|
||||
);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setImageFile(file);
|
||||
|
||||
// Create preview
|
||||
const reader = new FileReader();
|
||||
reader.onload = (ev) => {
|
||||
setImagePreview(ev.target?.result as string);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
};
|
||||
|
||||
const handleImageUpload = async (): Promise<string | null> => {
|
||||
|
|
@ -303,13 +318,30 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to upload image');
|
||||
let serverMessage = 'Failed to upload image';
|
||||
try {
|
||||
const errData = await response.json();
|
||||
if (errData?.error) serverMessage = errData.error;
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
throw new Error(serverMessage);
|
||||
}
|
||||
|
||||
const result = await response.json();
|
||||
return result.imageUrl;
|
||||
if (result?.imageUrl) {
|
||||
return result.imageUrl;
|
||||
}
|
||||
|
||||
throw new Error('Image URL missing from upload response');
|
||||
} catch (error) {
|
||||
console.error('Error uploading image:', error);
|
||||
setError(
|
||||
t(
|
||||
'errors.projectImageUpload',
|
||||
'Failed to upload image. Please try a smaller file or a different format.'
|
||||
)
|
||||
);
|
||||
return null;
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
|
|
@ -355,6 +387,9 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
const uploadedImageUrl = await handleImageUpload();
|
||||
if (uploadedImageUrl) {
|
||||
imageUrl = uploadedImageUrl;
|
||||
} else {
|
||||
setIsSaving(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -741,7 +776,7 @@ const ProjectModal: React.FC<ProjectModalProps> = ({
|
|||
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
{t(
|
||||
'project.uploadImageHint',
|
||||
'Upload an image for your project (max 5MB)'
|
||||
'Upload an image for your project (max 10MB)'
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
|
|
|||
63
frontend/components/Project/ProjectNotesSection.tsx
Normal file
63
frontend/components/Project/ProjectNotesSection.tsx
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
import React from 'react';
|
||||
import { Note } from '../../entities/Note';
|
||||
import NoteCard from '../Shared/NoteCard';
|
||||
import { TFunction } from 'i18next';
|
||||
import { PlusCircleIcon } from '@heroicons/react/24/outline';
|
||||
import { Project } from '../../entities/Project';
|
||||
|
||||
interface ProjectNotesSectionProps {
|
||||
project: Project;
|
||||
notes: Note[];
|
||||
t: TFunction;
|
||||
onCreateNote: () => void;
|
||||
onEditNote: (note: Note) => Promise<void>;
|
||||
onDeleteNote: (note: Note) => void;
|
||||
}
|
||||
|
||||
const ProjectNotesSection: React.FC<ProjectNotesSectionProps> = ({
|
||||
project,
|
||||
notes,
|
||||
t,
|
||||
onCreateNote,
|
||||
onEditNote,
|
||||
onDeleteNote,
|
||||
}) => {
|
||||
return (
|
||||
<div className="transition-all duration-300 ease-in-out">
|
||||
<div className="mb-4">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex items-center gap-2 px-3 py-1.5 text-sm rounded-md bg-gray-200 text-gray-800 hover:bg-gray-300 dark:bg-gray-700 dark:text-gray-200 dark:hover:bg-gray-600 transition-colors"
|
||||
onClick={() => {
|
||||
if (!project?.id || !project.name) return;
|
||||
onCreateNote();
|
||||
}}
|
||||
>
|
||||
<PlusCircleIcon className="h-5 w-5" />
|
||||
{t('noteCreation', 'Create New Note')}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{notes.length > 0 ? (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{notes.map((note) => (
|
||||
<NoteCard
|
||||
key={note.uid}
|
||||
note={note}
|
||||
onEdit={onEditNote}
|
||||
onDelete={onDeleteNote}
|
||||
showActions={true}
|
||||
showProject={false}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-gray-500 dark:text-gray-400">
|
||||
<p>{t('project.noNotes', 'No notes for this project.')}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectNotesSection;
|
||||
97
frontend/components/Project/ProjectTasksSection.tsx
Normal file
97
frontend/components/Project/ProjectTasksSection.tsx
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import React from 'react';
|
||||
import { Project } from '../../entities/Project';
|
||||
import { Task } from '../../entities/Task';
|
||||
import AutoSuggestNextActionBox from './AutoSuggestNextActionBox';
|
||||
import NewTask from '../Task/NewTask';
|
||||
import TaskList from '../Task/TaskList';
|
||||
import { TFunction } from 'i18next';
|
||||
|
||||
interface ProjectTasksSectionProps {
|
||||
project: Project | null;
|
||||
displayTasks: Task[];
|
||||
showAutoSuggestForm: boolean;
|
||||
onAddNextAction: (projectId: number, description: string) => void;
|
||||
onDismissNextAction: () => void;
|
||||
onTaskCreate: (taskName: string) => Promise<void>;
|
||||
onTaskUpdate: (task: Task) => Promise<void>;
|
||||
onTaskCompletionToggle: (task: Task) => void;
|
||||
onTaskDelete: (taskId: number) => void;
|
||||
onToggleToday: (taskId: number, task?: Task) => Promise<void>;
|
||||
allProjects: Project[];
|
||||
showCompleted: boolean;
|
||||
taskSearchQuery: string;
|
||||
t: TFunction;
|
||||
}
|
||||
|
||||
const ProjectTasksSection: React.FC<ProjectTasksSectionProps> = ({
|
||||
project,
|
||||
displayTasks,
|
||||
showAutoSuggestForm,
|
||||
onAddNextAction,
|
||||
onDismissNextAction,
|
||||
onTaskCreate,
|
||||
onTaskUpdate,
|
||||
onTaskCompletionToggle,
|
||||
onTaskDelete,
|
||||
onToggleToday,
|
||||
allProjects,
|
||||
showCompleted,
|
||||
taskSearchQuery,
|
||||
t,
|
||||
}) => {
|
||||
return (
|
||||
<div className="xl:col-span-2 flex flex-col gap-2">
|
||||
{showAutoSuggestForm && (
|
||||
<div className="transition-all duration-300 ease-in-out opacity-100 transform translate-y-0">
|
||||
<AutoSuggestNextActionBox
|
||||
onAddAction={(actionDescription) => {
|
||||
if (project?.id) {
|
||||
onAddNextAction(project.id, actionDescription);
|
||||
}
|
||||
}}
|
||||
onDismiss={onDismissNextAction}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="transition-all duration-300 ease-in-out overflow-visible opacity-100 transform translate-y-0">
|
||||
<NewTask onTaskCreate={onTaskCreate} />
|
||||
</div>
|
||||
|
||||
<div className="transition-all duration-300 ease-in-out overflow-visible">
|
||||
{displayTasks.length > 0 ? (
|
||||
<div className="transition-all duration-300 ease-in-out opacity-100 transform translate-y-0 overflow-visible">
|
||||
<TaskList
|
||||
tasks={displayTasks}
|
||||
onTaskUpdate={onTaskUpdate}
|
||||
onTaskCompletionToggle={onTaskCompletionToggle}
|
||||
onTaskDelete={onTaskDelete}
|
||||
projects={allProjects}
|
||||
hideProjectName={true}
|
||||
onToggleToday={onToggleToday}
|
||||
showCompletedTasks={showCompleted}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="transition-all duration-300 ease-in-out opacity-100 transform translate-y-0">
|
||||
<p className="text-gray-500 dark:text-gray-400">
|
||||
{taskSearchQuery.trim()
|
||||
? t(
|
||||
'tasks.noTasksAvailable',
|
||||
'No tasks available.'
|
||||
)
|
||||
: showCompleted
|
||||
? t(
|
||||
'project.noCompletedTasks',
|
||||
'No completed tasks.'
|
||||
)
|
||||
: t('project.noTasks', 'No tasks.')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ProjectTasksSection;
|
||||
527
frontend/components/Project/useProjectMetrics.ts
Normal file
527
frontend/components/Project/useProjectMetrics.ts
Normal file
|
|
@ -0,0 +1,527 @@
|
|||
import { useMemo, useCallback } from 'react';
|
||||
import { Task } from '../../entities/Task';
|
||||
import { TFunction } from 'i18next';
|
||||
|
||||
export const useProjectMetrics = (
|
||||
tasks: Task[],
|
||||
handleTaskUpdate: (task: Task) => Promise<void>,
|
||||
t: TFunction
|
||||
) => {
|
||||
const taskStats = useMemo(() => {
|
||||
const stats = {
|
||||
total: tasks.length,
|
||||
completed: 0,
|
||||
inProgress: 0,
|
||||
notStarted: 0,
|
||||
overdue: 0,
|
||||
dueSoon: 0,
|
||||
};
|
||||
|
||||
const today = new Date();
|
||||
const startOfToday = new Date(
|
||||
today.getFullYear(),
|
||||
today.getMonth(),
|
||||
today.getDate()
|
||||
);
|
||||
const soonBoundary = new Date(startOfToday);
|
||||
soonBoundary.setDate(startOfToday.getDate() + 7);
|
||||
|
||||
const isCompleted = (status: Task['status']) =>
|
||||
status === 'done' ||
|
||||
status === 'archived' ||
|
||||
status === 2 ||
|
||||
status === 3;
|
||||
|
||||
const isInProgress = (status: Task['status']) =>
|
||||
status === 'in_progress' || status === 1;
|
||||
|
||||
const isNotStarted = (status: Task['status']) =>
|
||||
status === 'not_started' || status === 0;
|
||||
|
||||
tasks.forEach((task) => {
|
||||
const status = task.status;
|
||||
|
||||
if (isCompleted(status)) {
|
||||
stats.completed += 1;
|
||||
} else if (isInProgress(status)) {
|
||||
stats.inProgress += 1;
|
||||
} else if (isNotStarted(status)) {
|
||||
stats.notStarted += 1;
|
||||
} else {
|
||||
stats.notStarted += 1;
|
||||
}
|
||||
|
||||
if (!isCompleted(status) && task.due_date) {
|
||||
const dueDate = new Date(task.due_date);
|
||||
if (!Number.isNaN(dueDate.getTime())) {
|
||||
if (dueDate < startOfToday) {
|
||||
stats.overdue += 1;
|
||||
} else if (dueDate <= soonBoundary) {
|
||||
stats.dueSoon += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const completionRate =
|
||||
stats.total > 0
|
||||
? Math.round((stats.completed / stats.total) * 100)
|
||||
: 0;
|
||||
|
||||
return {
|
||||
...stats,
|
||||
completionRate,
|
||||
};
|
||||
}, [tasks]);
|
||||
|
||||
const completionGradient = useMemo(() => {
|
||||
if (taskStats.total === 0) {
|
||||
return 'conic-gradient(#e5e7eb 0% 100%)';
|
||||
}
|
||||
|
||||
const segments = [
|
||||
{ value: taskStats.completed, color: '#22c55e' },
|
||||
{ value: taskStats.inProgress, color: '#3b82f6' },
|
||||
{ value: taskStats.notStarted, color: '#9ca3af' },
|
||||
];
|
||||
|
||||
let current = 0;
|
||||
const gradientStops: string[] = [];
|
||||
|
||||
segments.forEach((segment) => {
|
||||
if (segment.value === 0) return;
|
||||
const start = current;
|
||||
const percentage = (segment.value / taskStats.total) * 100;
|
||||
const end = start + percentage;
|
||||
gradientStops.push(
|
||||
`${segment.color} ${start}% ${Math.min(end, 100)}%`
|
||||
);
|
||||
current += percentage;
|
||||
});
|
||||
|
||||
return gradientStops.length
|
||||
? `conic-gradient(${gradientStops.join(', ')})`
|
||||
: 'conic-gradient(#e5e7eb 0% 100%)';
|
||||
}, [taskStats]);
|
||||
|
||||
const dueBuckets = useMemo(() => {
|
||||
const buckets = {
|
||||
overdue: [] as Task[],
|
||||
week: [] as Task[],
|
||||
month: [] as Task[],
|
||||
unscheduled: [] as Task[],
|
||||
};
|
||||
|
||||
const now = new Date();
|
||||
const startOfToday = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate()
|
||||
);
|
||||
const weekBoundary = new Date(startOfToday);
|
||||
weekBoundary.setDate(startOfToday.getDate() + 7);
|
||||
const monthBoundary = new Date(startOfToday);
|
||||
monthBoundary.setDate(startOfToday.getDate() + 30);
|
||||
|
||||
const isCompleted = (status: Task['status']) =>
|
||||
status === 'done' ||
|
||||
status === 'archived' ||
|
||||
status === 2 ||
|
||||
status === 3;
|
||||
|
||||
tasks.forEach((task) => {
|
||||
if (isCompleted(task.status)) return;
|
||||
|
||||
if (!task.due_date) {
|
||||
buckets.unscheduled.push(task);
|
||||
return;
|
||||
}
|
||||
|
||||
const due = new Date(task.due_date);
|
||||
if (Number.isNaN(due.getTime())) {
|
||||
buckets.unscheduled.push(task);
|
||||
return;
|
||||
}
|
||||
|
||||
if (due < startOfToday) {
|
||||
buckets.overdue.push(task);
|
||||
} else if (due <= weekBoundary) {
|
||||
buckets.week.push(task);
|
||||
} else if (due <= monthBoundary) {
|
||||
buckets.month.push(task);
|
||||
} else {
|
||||
buckets.unscheduled.push(task);
|
||||
}
|
||||
});
|
||||
|
||||
const totalDue =
|
||||
buckets.overdue.length + buckets.week.length + buckets.month.length;
|
||||
|
||||
return { ...buckets, totalDue };
|
||||
}, [tasks]);
|
||||
|
||||
const completionTrend = useMemo(() => {
|
||||
const days = 14;
|
||||
const today = new Date();
|
||||
const labels: { dateKey: string; label: string }[] = [];
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const d = new Date(today);
|
||||
d.setDate(today.getDate() - i);
|
||||
const key = d.toISOString().split('T')[0];
|
||||
labels.push({
|
||||
dateKey: key,
|
||||
label: `${d.getMonth() + 1}/${d.getDate()}`,
|
||||
});
|
||||
}
|
||||
|
||||
const counts: Record<string, number> = {};
|
||||
labels.forEach((l) => (counts[l.dateKey] = 0));
|
||||
|
||||
tasks.forEach((task) => {
|
||||
if (!task.completed_at) return;
|
||||
const key = new Date(task.completed_at).toISOString().split('T')[0];
|
||||
if (counts[key] !== undefined) {
|
||||
counts[key] += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return labels.map((l) => ({
|
||||
label: l.label,
|
||||
dateKey: l.dateKey,
|
||||
count: counts[l.dateKey] || 0,
|
||||
}));
|
||||
}, [tasks]);
|
||||
|
||||
const createdTrend = useMemo(() => {
|
||||
const days = 14;
|
||||
const today = new Date();
|
||||
const labels: { dateKey: string; label: string }[] = [];
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const d = new Date(today);
|
||||
d.setDate(today.getDate() - i);
|
||||
const key = d.toISOString().split('T')[0];
|
||||
labels.push({
|
||||
dateKey: key,
|
||||
label: `${d.getMonth() + 1}/${d.getDate()}`,
|
||||
});
|
||||
}
|
||||
|
||||
const counts: Record<string, number> = {};
|
||||
labels.forEach((l) => (counts[l.dateKey] = 0));
|
||||
|
||||
tasks.forEach((task) => {
|
||||
if (!task.created_at) return;
|
||||
const key = new Date(task.created_at).toISOString().split('T')[0];
|
||||
if (counts[key] !== undefined) {
|
||||
counts[key] += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return labels.map((l) => ({
|
||||
label: l.label,
|
||||
dateKey: l.dateKey,
|
||||
count: counts[l.dateKey] || 0,
|
||||
}));
|
||||
}, [tasks]);
|
||||
|
||||
const upcomingDueTrend = useMemo(() => {
|
||||
const days = 14;
|
||||
const today = new Date();
|
||||
const labels: { dateKey: string; label: string }[] = [];
|
||||
for (let i = 0; i < days; i++) {
|
||||
const d = new Date(today);
|
||||
d.setDate(today.getDate() + i);
|
||||
const key = d.toISOString().split('T')[0];
|
||||
labels.push({
|
||||
dateKey: key,
|
||||
label: `${d.getMonth() + 1}/${d.getDate()}`,
|
||||
});
|
||||
}
|
||||
|
||||
const counts: Record<string, number> = {};
|
||||
labels.forEach((l) => (counts[l.dateKey] = 0));
|
||||
|
||||
const isCompleted = (status: Task['status']) =>
|
||||
status === 'done' ||
|
||||
status === 'archived' ||
|
||||
status === 2 ||
|
||||
status === 3;
|
||||
|
||||
tasks.forEach((task) => {
|
||||
if (!task.due_date || isCompleted(task.status)) return;
|
||||
const key = new Date(task.due_date).toISOString().split('T')[0];
|
||||
if (counts[key] !== undefined) {
|
||||
counts[key] += 1;
|
||||
}
|
||||
});
|
||||
|
||||
return labels.map((l) => ({
|
||||
label: l.label,
|
||||
dateKey: l.dateKey,
|
||||
count: counts[l.dateKey] || 0,
|
||||
}));
|
||||
}, [tasks]);
|
||||
|
||||
const upcomingInsights = useMemo(() => {
|
||||
const peak = upcomingDueTrend.reduce(
|
||||
(acc, cur) => (cur.count > acc.count ? cur : acc),
|
||||
{ label: '', count: 0 }
|
||||
);
|
||||
const nextThreeDays = upcomingDueTrend
|
||||
.slice(0, 3)
|
||||
.reduce((sum, d) => sum + d.count, 0);
|
||||
const nextWeek = upcomingDueTrend
|
||||
.slice(0, 7)
|
||||
.reduce((sum, d) => sum + d.count, 0);
|
||||
|
||||
return {
|
||||
peakLabel: peak.label,
|
||||
peakCount: peak.count,
|
||||
nextThreeDays,
|
||||
nextWeek,
|
||||
};
|
||||
}, [upcomingDueTrend]);
|
||||
|
||||
const eisenhower = useMemo(() => {
|
||||
const buckets = {
|
||||
urgentImportant: 0,
|
||||
urgentNotImportant: 0,
|
||||
notUrgentImportant: 0,
|
||||
notUrgentNotImportant: 0,
|
||||
};
|
||||
|
||||
const today = new Date();
|
||||
const startOfToday = new Date(
|
||||
today.getFullYear(),
|
||||
today.getMonth(),
|
||||
today.getDate()
|
||||
);
|
||||
const threeDays = new Date(startOfToday);
|
||||
threeDays.setDate(startOfToday.getDate() + 3);
|
||||
|
||||
const isCompleted = (status: Task['status']) =>
|
||||
status === 'done' ||
|
||||
status === 'archived' ||
|
||||
status === 2 ||
|
||||
status === 3;
|
||||
|
||||
tasks.forEach((task) => {
|
||||
if (isCompleted(task.status)) return;
|
||||
|
||||
const isUrgent = (() => {
|
||||
if (!task.due_date) return false;
|
||||
const due = new Date(task.due_date);
|
||||
if (Number.isNaN(due.getTime())) return false;
|
||||
return due <= threeDays;
|
||||
})();
|
||||
|
||||
const isImportant =
|
||||
task.priority === 'high' ||
|
||||
task.priority === 2 ||
|
||||
task.priority === 'medium' ||
|
||||
task.priority === 1;
|
||||
|
||||
if (isUrgent && isImportant) buckets.urgentImportant += 1;
|
||||
else if (isUrgent && !isImportant) buckets.urgentNotImportant += 1;
|
||||
else if (!isUrgent && isImportant) buckets.notUrgentImportant += 1;
|
||||
else buckets.notUrgentNotImportant += 1;
|
||||
});
|
||||
|
||||
return buckets;
|
||||
}, [tasks]);
|
||||
|
||||
const dueHighlights = useMemo(() => {
|
||||
const combined = [
|
||||
...dueBuckets.overdue,
|
||||
...dueBuckets.week,
|
||||
...dueBuckets.month,
|
||||
];
|
||||
|
||||
return combined
|
||||
.sort((a, b) => {
|
||||
const aDate = a.due_date ? new Date(a.due_date).getTime() : 0;
|
||||
const bDate = b.due_date ? new Date(b.due_date).getTime() : 0;
|
||||
return aDate - bDate;
|
||||
})
|
||||
.slice(0, 3);
|
||||
}, [dueBuckets]);
|
||||
|
||||
const nextBestAction = useMemo(() => {
|
||||
const isCompleted = (status: Task['status']) =>
|
||||
status === 'done' ||
|
||||
status === 'archived' ||
|
||||
status === 2 ||
|
||||
status === 3;
|
||||
|
||||
const candidates = tasks.filter(
|
||||
(task) =>
|
||||
!isCompleted(task.status) &&
|
||||
task.status !== 'in_progress' &&
|
||||
task.status !== 1
|
||||
);
|
||||
if (candidates.length === 0) return null;
|
||||
|
||||
const startOfToday = new Date();
|
||||
startOfToday.setHours(0, 0, 0, 0);
|
||||
|
||||
const getPriorityScore = (priority: Task['priority']) => {
|
||||
if (priority === 'high' || priority === 2) return -8;
|
||||
if (priority === 'medium' || priority === 1) return -4;
|
||||
return 0;
|
||||
};
|
||||
|
||||
const scored = candidates
|
||||
.map((task) => {
|
||||
let score = 0;
|
||||
|
||||
if (task.status === 'in_progress' || task.status === 1) {
|
||||
score -= 30;
|
||||
}
|
||||
|
||||
if (task.due_date) {
|
||||
const due = new Date(task.due_date);
|
||||
const diffDays = Math.floor(
|
||||
(due.getTime() - startOfToday.getTime()) /
|
||||
(1000 * 60 * 60 * 24)
|
||||
);
|
||||
if (diffDays < 0) score -= 25;
|
||||
else if (diffDays === 0) score -= 20;
|
||||
else if (diffDays <= 2) score -= 15;
|
||||
else if (diffDays <= 7) score -= 10;
|
||||
}
|
||||
|
||||
score += getPriorityScore(task.priority);
|
||||
|
||||
if (task.today) {
|
||||
score -= 6;
|
||||
}
|
||||
|
||||
const createdAt = task.created_at
|
||||
? new Date(task.created_at).getTime()
|
||||
: 0;
|
||||
|
||||
return {
|
||||
task,
|
||||
score,
|
||||
createdAt,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (a.score !== b.score) return a.score - b.score;
|
||||
if (a.createdAt !== b.createdAt)
|
||||
return a.createdAt - b.createdAt;
|
||||
return (a.task.id || 0) - (b.task.id || 0);
|
||||
});
|
||||
|
||||
return scored[0]?.task ?? null;
|
||||
}, [tasks]);
|
||||
|
||||
const getDueDescriptor = useCallback(
|
||||
(task: Task) => {
|
||||
if (!task.due_date) return t('tasks.noDue', 'No due date');
|
||||
|
||||
const now = new Date();
|
||||
const startOfToday = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate()
|
||||
);
|
||||
const due = new Date(task.due_date);
|
||||
if (Number.isNaN(due.getTime()))
|
||||
return t('tasks.noDue', 'No due date');
|
||||
|
||||
const diffDays = Math.floor(
|
||||
(due.getTime() - startOfToday.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
if (diffDays < 0) {
|
||||
return t('tasks.overdueBy', {
|
||||
defaultValue: 'Overdue by {{days}}d',
|
||||
days: Math.abs(diffDays),
|
||||
} as any);
|
||||
}
|
||||
if (diffDays === 0) return t('dateIndicators.today', 'Today');
|
||||
if (diffDays === 1) return t('dateIndicators.tomorrow', 'Tomorrow');
|
||||
if (diffDays <= 7)
|
||||
return t('tasks.dueInDays', {
|
||||
defaultValue: 'Due in {{days}}d',
|
||||
days: diffDays,
|
||||
} as any);
|
||||
|
||||
return t('tasks.dueInDays', {
|
||||
defaultValue: 'Due in {{days}}d',
|
||||
days: diffDays,
|
||||
} as any);
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
const handleStartNextAction = useCallback(async () => {
|
||||
if (!nextBestAction?.id) return;
|
||||
|
||||
const isAlreadyInProgress =
|
||||
nextBestAction.status === 'in_progress' ||
|
||||
nextBestAction.status === 1;
|
||||
const isAlreadyToday = !!nextBestAction.today;
|
||||
|
||||
if (isAlreadyInProgress && isAlreadyToday) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await handleTaskUpdate({
|
||||
...nextBestAction,
|
||||
status: 'in_progress',
|
||||
today: true,
|
||||
});
|
||||
} catch {
|
||||
// Silent fail
|
||||
}
|
||||
}, [handleTaskUpdate, nextBestAction]);
|
||||
|
||||
const weeklyPace = useMemo(() => {
|
||||
const lastWeek = completionTrend
|
||||
.slice(-7)
|
||||
.reduce((sum, d) => sum + d.count, 0);
|
||||
const prevWeek = completionTrend
|
||||
.slice(0, -7)
|
||||
.reduce((sum, d) => sum + d.count, 0);
|
||||
const delta = lastWeek - prevWeek;
|
||||
return { lastWeek, prevWeek, delta };
|
||||
}, [completionTrend]);
|
||||
|
||||
const monthlyCompleted = useMemo(() => {
|
||||
const today = new Date();
|
||||
const startWindow = new Date();
|
||||
startWindow.setDate(today.getDate() - 30);
|
||||
let count = 0;
|
||||
tasks.forEach((task) => {
|
||||
if (!task.completed_at) return;
|
||||
const completedDate = new Date(task.completed_at);
|
||||
if (
|
||||
!Number.isNaN(completedDate.getTime()) &&
|
||||
completedDate >= startWindow
|
||||
) {
|
||||
count += 1;
|
||||
}
|
||||
});
|
||||
return count;
|
||||
}, [tasks]);
|
||||
|
||||
return {
|
||||
taskStats,
|
||||
completionGradient,
|
||||
dueBuckets,
|
||||
dueHighlights,
|
||||
nextBestAction,
|
||||
getDueDescriptor,
|
||||
handleStartNextAction,
|
||||
completionTrend,
|
||||
upcomingDueTrend,
|
||||
createdTrend,
|
||||
upcomingInsights,
|
||||
eisenhower,
|
||||
weeklyPace,
|
||||
monthlyCompleted,
|
||||
};
|
||||
};
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { ReactNode, useEffect, useRef, useState } from 'react';
|
||||
import { FunnelIcon, CheckIcon } from '@heroicons/react/24/outline';
|
||||
import { ListBulletIcon, CheckIcon } from '@heroicons/react/24/outline';
|
||||
import { SortOption } from './SortFilterButton';
|
||||
|
||||
interface IconSortDropdownProps {
|
||||
|
|
@ -13,6 +13,7 @@ interface IconSortDropdownProps {
|
|||
dropdownLabel?: string;
|
||||
align?: 'left' | 'right';
|
||||
extraContent?: ReactNode;
|
||||
footerContent?: ReactNode;
|
||||
}
|
||||
|
||||
const IconSortDropdown: React.FC<IconSortDropdownProps> = ({
|
||||
|
|
@ -26,6 +27,7 @@ const IconSortDropdown: React.FC<IconSortDropdownProps> = ({
|
|||
dropdownLabel = 'Sort by',
|
||||
align = 'right',
|
||||
extraContent,
|
||||
footerContent,
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -58,14 +60,14 @@ const IconSortDropdown: React.FC<IconSortDropdownProps> = ({
|
|||
aria-label={ariaLabel}
|
||||
title={title}
|
||||
>
|
||||
<FunnelIcon className="h-5 w-5" />
|
||||
<ListBulletIcon className="h-5 w-5" />
|
||||
</button>
|
||||
{isOpen && (
|
||||
<div
|
||||
className={`absolute ${align === 'left' ? 'left-0' : 'right-0'} mt-1 w-48 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 z-50`}
|
||||
className={`absolute ${align === 'left' ? 'left-0' : 'right-0'} mt-1 w-56 bg-white dark:bg-gray-800 rounded-md shadow-lg border border-gray-200 dark:border-gray-700 z-50`}
|
||||
>
|
||||
{dropdownLabel && (
|
||||
<div className="px-3 py-2 text-xs font-semibold text-gray-500 dark:text-gray-400 border-b border-gray-200 dark:border-gray-700">
|
||||
<div className="px-3 py-2 text-xs font-bold text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700">
|
||||
{dropdownLabel}
|
||||
</div>
|
||||
)}
|
||||
|
|
@ -93,10 +95,15 @@ const IconSortDropdown: React.FC<IconSortDropdownProps> = ({
|
|||
))}
|
||||
</div>
|
||||
{extraContent && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 px-3 py-2">
|
||||
<div className="border-t border-gray-200 dark:border-gray-700">
|
||||
{extraContent}
|
||||
</div>
|
||||
)}
|
||||
{footerContent && (
|
||||
<div className="border-t border-gray-200 dark:border-gray-700">
|
||||
{footerContent}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -60,7 +60,7 @@ const NoteCard: React.FC<NoteCardProps> = ({
|
|||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '')}`}
|
||||
className="bg-gray-50 dark:bg-gray-900 rounded-lg shadow-md relative flex flex-col hover:opacity-80 transition-opacity duration-300 ease-in-out cursor-pointer"
|
||||
className="bg-gray-50 dark:bg-gray-900 rounded-lg shadow-md relative flex flex-col hover:opacity-80 transition-opacity duration-300 ease-in-out cursor-pointer border-l-4 border-l-blue-400 dark:border-l-blue-500"
|
||||
style={{
|
||||
minHeight: '280px',
|
||||
maxHeight: '280px',
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ const SidebarNav: React.FC<SidebarNavProps> = ({
|
|||
query: 'type=today',
|
||||
},
|
||||
{
|
||||
path: '/upcoming',
|
||||
path: '/upcoming?status=active',
|
||||
title: t('sidebar.upcoming', 'Upcoming'),
|
||||
icon: <ClockIcon className="h-5 w-5" />,
|
||||
},
|
||||
|
|
@ -57,13 +57,21 @@ const SidebarNav: React.FC<SidebarNavProps> = ({
|
|||
|
||||
const isActive = (path: string, query?: string) => {
|
||||
// Handle special case for paths without query parameters
|
||||
if (path === '/inbox' || path === '/today' || path === '/upcoming') {
|
||||
if (path === '/inbox' || path === '/today') {
|
||||
const isPathMatch = location.pathname === path;
|
||||
return isPathMatch
|
||||
? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white'
|
||||
: 'text-gray-700 dark:text-gray-300';
|
||||
}
|
||||
|
||||
// Handle upcoming with query parameters
|
||||
if (path.startsWith('/upcoming')) {
|
||||
const isPathMatch = location.pathname === '/upcoming';
|
||||
return isPathMatch
|
||||
? 'bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-white'
|
||||
: 'text-gray-700 dark:text-gray-300';
|
||||
}
|
||||
|
||||
// Regular case for /tasks with query params
|
||||
const isPathMatch = location.pathname === '/tasks';
|
||||
const isQueryMatch = query
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import { GroupedTasks } from '../../utils/tasksService';
|
|||
interface GroupedTaskListProps {
|
||||
tasks: Task[];
|
||||
groupedTasks?: GroupedTasks | null;
|
||||
groupBy?: 'none' | 'project';
|
||||
onTaskUpdate: (task: Task) => Promise<void>;
|
||||
onTaskCompletionToggle?: (task: Task) => void;
|
||||
onTaskCreate?: (task: Task) => void;
|
||||
|
|
@ -32,6 +33,7 @@ interface TaskGroup {
|
|||
const GroupedTaskList: React.FC<GroupedTaskListProps> = ({
|
||||
tasks,
|
||||
groupedTasks,
|
||||
groupBy = 'none',
|
||||
onTaskUpdate,
|
||||
onTaskCompletionToggle,
|
||||
onTaskDelete,
|
||||
|
|
@ -178,6 +180,53 @@ const GroupedTaskList: React.FC<GroupedTaskListProps> = ({
|
|||
return filtered;
|
||||
}, [groupedTasks, showCompletedTasks, shouldUseDayGrouping, searchQuery]);
|
||||
|
||||
// Group tasks by project when requested (only applies to standalone view)
|
||||
const groupedByProject = useMemo(() => {
|
||||
if (groupBy !== 'project') return null;
|
||||
|
||||
// Apply completion filter
|
||||
const filtered = showCompletedTasks
|
||||
? tasks.filter((task) => {
|
||||
const isCompleted =
|
||||
task.status === 'done' ||
|
||||
task.status === 'archived' ||
|
||||
task.status === 2 ||
|
||||
task.status === 3;
|
||||
return isCompleted;
|
||||
})
|
||||
: tasks.filter((task) => {
|
||||
const isCompleted =
|
||||
task.status === 'done' ||
|
||||
task.status === 'archived' ||
|
||||
task.status === 2 ||
|
||||
task.status === 3;
|
||||
return !isCompleted;
|
||||
});
|
||||
|
||||
// Apply search
|
||||
const filteredBySearch = searchQuery.trim()
|
||||
? filtered.filter((task) =>
|
||||
(task.name || '')
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.toLowerCase())
|
||||
)
|
||||
: filtered;
|
||||
|
||||
const byProject = new Map<string | number, Task[]>();
|
||||
filteredBySearch.forEach((task) => {
|
||||
const key = task.project_id || 'no_project';
|
||||
const arr = byProject.get(key) || [];
|
||||
arr.push(task);
|
||||
byProject.set(key, arr);
|
||||
});
|
||||
return Array.from(byProject.entries()).map(
|
||||
([projectId, projectTasks]) => ({
|
||||
projectId,
|
||||
tasks: projectTasks,
|
||||
})
|
||||
);
|
||||
}, [groupBy, tasks, showCompletedTasks, searchQuery]);
|
||||
|
||||
const toggleRecurringGroup = (templateId: number) => {
|
||||
setExpandedRecurringGroups((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
|
|
@ -312,22 +361,69 @@ const GroupedTaskList: React.FC<GroupedTaskListProps> = ({
|
|||
return (
|
||||
<div className="task-list-container space-y-1.5">
|
||||
{/* Standalone tasks */}
|
||||
{standaloneTask.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="task-item-wrapper transition-all duration-200 ease-in-out"
|
||||
>
|
||||
<TaskItem
|
||||
task={task}
|
||||
onTaskUpdate={onTaskUpdate}
|
||||
onTaskCompletionToggle={onTaskCompletionToggle}
|
||||
onTaskDelete={onTaskDelete}
|
||||
projects={projects}
|
||||
hideProjectName={hideProjectName}
|
||||
onToggleToday={onToggleToday}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
{groupBy === 'project' && groupedByProject
|
||||
? groupedByProject.map(
|
||||
({ projectId, tasks: projectTasks }, index) => {
|
||||
const projectName =
|
||||
projects.find((p) => p.id === projectId)?.name ||
|
||||
(projectId === 'no_project'
|
||||
? t('tasks.noProject', 'No project')
|
||||
: t(
|
||||
'tasks.unknownProject',
|
||||
'Unknown project'
|
||||
));
|
||||
return (
|
||||
<div
|
||||
key={String(projectId)}
|
||||
className={`space-y-1.5 pb-4 mb-2 border-b border-gray-200/50 dark:border-gray-800/60 last:border-b-0 ${index > 0 ? 'pt-4' : ''}`}
|
||||
>
|
||||
<div className="flex items-center justify-between px-1 text-base font-semibold text-gray-900 dark:text-gray-100">
|
||||
<span className="truncate">
|
||||
{projectName}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{projectTasks.length}{' '}
|
||||
{t('tasks.tasks', 'tasks')}
|
||||
</span>
|
||||
</div>
|
||||
{projectTasks.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="task-item-wrapper transition-all duration-200 ease-in-out"
|
||||
>
|
||||
<TaskItem
|
||||
task={task}
|
||||
onTaskUpdate={onTaskUpdate}
|
||||
onTaskCompletionToggle={
|
||||
onTaskCompletionToggle
|
||||
}
|
||||
onTaskDelete={onTaskDelete}
|
||||
projects={projects}
|
||||
hideProjectName={hideProjectName}
|
||||
onToggleToday={onToggleToday}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)
|
||||
: standaloneTask.map((task) => (
|
||||
<div
|
||||
key={task.id}
|
||||
className="task-item-wrapper transition-all duration-200 ease-in-out"
|
||||
>
|
||||
<TaskItem
|
||||
task={task}
|
||||
onTaskUpdate={onTaskUpdate}
|
||||
onTaskCompletionToggle={onTaskCompletionToggle}
|
||||
onTaskDelete={onTaskDelete}
|
||||
projects={projects}
|
||||
hideProjectName={hideProjectName}
|
||||
onToggleToday={onToggleToday}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Grouped recurring tasks */}
|
||||
{recurringGroups.map((group) => {
|
||||
|
|
|
|||
|
|
@ -82,7 +82,7 @@ const NewTask: React.FC<NewTaskProps> = ({ onTaskCreate }) => {
|
|||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between py-3 px-4 border-b border-gray-200 dark:border-gray-800 rounded-lg shadow-sm bg-white dark:bg-gray-900">
|
||||
<div className="flex items-center justify-between py-3.5 px-5 border-b border-gray-200 dark:border-gray-800 rounded-lg shadow-sm bg-white dark:bg-gray-900">
|
||||
<span className="text-xl text-gray-500 dark:text-gray-400 mr-2">
|
||||
<PlusCircleIcon className="h-5 w-5" />
|
||||
</span>
|
||||
|
|
@ -91,7 +91,7 @@ const NewTask: React.FC<NewTaskProps> = ({ onTaskCreate }) => {
|
|||
value={taskName}
|
||||
onChange={handleInputChange}
|
||||
onKeyDown={handleKeyDown}
|
||||
className="font-medium text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-600 bg-transparent dark:bg-transparent focus:outline-none focus:ring-0 w-full appearance-none"
|
||||
className="font-semibold text-base text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-600 bg-transparent dark:bg-transparent focus:outline-none focus:ring-0 w-full appearance-none"
|
||||
placeholder={t(
|
||||
'tasks.addNewTask',
|
||||
'Προσθήκη Νέας Εργασίας'
|
||||
|
|
|
|||
|
|
@ -1,16 +1,9 @@
|
|||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
CalendarIcon,
|
||||
ExclamationTriangleIcon,
|
||||
ListBulletIcon,
|
||||
ClockIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline';
|
||||
import ConfirmDialog from '../Shared/ConfirmDialog';
|
||||
import TaskModal from './TaskModal';
|
||||
import RecurrenceDisplay from './RecurrenceDisplay';
|
||||
import TaskSubtasksSection from './TaskForm/TaskSubtasksSection';
|
||||
import { Task } from '../../entities/Task';
|
||||
import { Project } from '../../entities/Project';
|
||||
import {
|
||||
|
|
@ -24,19 +17,18 @@ import {
|
|||
import { createProject } from '../../utils/projectsService';
|
||||
import { useStore } from '../../store/useStore';
|
||||
import { useToast } from '../Shared/ToastContext';
|
||||
import TaskPriorityIcon from './TaskPriorityIcon';
|
||||
import LoadingScreen from '../Shared/LoadingScreen';
|
||||
import TaskTimeline from './TaskTimeline';
|
||||
import TaskDueDateSection from './TaskForm/TaskDueDateSection';
|
||||
import TaskRecurrenceSection from './TaskForm/TaskRecurrenceSection';
|
||||
import {
|
||||
TaskDetailsHeader,
|
||||
TaskSummaryAlerts,
|
||||
TaskContentSection,
|
||||
TaskRecurringInstanceInfo,
|
||||
TaskProjectSection,
|
||||
TaskTagsSection,
|
||||
TaskPrioritySection,
|
||||
TaskContentCard,
|
||||
TaskProjectCard,
|
||||
TaskTagsCard,
|
||||
TaskPriorityCard,
|
||||
TaskSubtasksCard,
|
||||
TaskRecurrenceCard,
|
||||
TaskDueDateCard,
|
||||
} from './TaskDetails/';
|
||||
|
||||
const TaskDetails: React.FC = () => {
|
||||
|
|
@ -276,11 +268,6 @@ const TaskDetails: React.FC = () => {
|
|||
});
|
||||
};
|
||||
|
||||
const handleRecurrenceCardClick = () => {
|
||||
if (task.recurring_parent_id) return;
|
||||
handleStartRecurrenceEdit();
|
||||
};
|
||||
|
||||
const handleStartDueDateEdit = () => {
|
||||
setEditedDueDate(task?.due_date || '');
|
||||
setIsEditingDueDate(true);
|
||||
|
|
@ -337,66 +324,6 @@ const TaskDetails: React.FC = () => {
|
|||
setEditedDueDate(task?.due_date || '');
|
||||
};
|
||||
|
||||
const formatDateWithDayName = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const isToday = dateString === today;
|
||||
|
||||
const dayName = date.toLocaleDateString(i18n.language, {
|
||||
weekday: 'long',
|
||||
});
|
||||
const formattedDate = date.toLocaleDateString(i18n.language, {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
});
|
||||
|
||||
return {
|
||||
dayName,
|
||||
formattedDate,
|
||||
fullText: `${dayName}, ${formattedDate}`,
|
||||
isToday,
|
||||
};
|
||||
};
|
||||
|
||||
const getDueDateDisplay = (dueDate: string) => {
|
||||
const date = new Date(dueDate);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
|
||||
const formattedDate = date.toLocaleDateString(i18n.language, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const target = new Date(date);
|
||||
target.setHours(0, 0, 0, 0);
|
||||
|
||||
const diffDays = Math.round(
|
||||
(target.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
let relativeText = '';
|
||||
if (diffDays === 0) {
|
||||
relativeText = t('dateIndicators.today', 'today');
|
||||
} else if (diffDays === 1) {
|
||||
relativeText = t('dateIndicators.tomorrow', 'tomorrow');
|
||||
} else if (diffDays === -1) {
|
||||
relativeText = t('dateIndicators.yesterday', 'yesterday');
|
||||
} else if (diffDays > 0) {
|
||||
relativeText = t('task.inDays', 'in {{count}} days', {
|
||||
count: diffDays,
|
||||
});
|
||||
} else {
|
||||
relativeText = t('task.daysAgo', '{{count}} days ago', {
|
||||
count: Math.abs(diffDays),
|
||||
});
|
||||
}
|
||||
|
||||
return { formattedDate, relativeText };
|
||||
};
|
||||
|
||||
const getStatusLabel = () => {
|
||||
switch (task.status) {
|
||||
case 'not_started':
|
||||
|
|
@ -435,6 +362,54 @@ const TaskDetails: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const getDueDateDisplay = (dueDate: string) => {
|
||||
const date = new Date(dueDate);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
|
||||
const formattedDate = date.toLocaleDateString(i18n.language, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const target = new Date(date);
|
||||
target.setHours(0, 0, 0, 0);
|
||||
|
||||
const diffDays = Math.round(
|
||||
(target.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
if (diffDays === 0) {
|
||||
return {
|
||||
formattedDate,
|
||||
relativeText: t('dateIndicators.today', 'today'),
|
||||
};
|
||||
}
|
||||
if (diffDays === 1) {
|
||||
return {
|
||||
formattedDate,
|
||||
relativeText: t('dateIndicators.tomorrow', 'tomorrow'),
|
||||
};
|
||||
}
|
||||
if (diffDays === -1) {
|
||||
return {
|
||||
formattedDate,
|
||||
relativeText: t('dateIndicators.yesterday', 'yesterday'),
|
||||
};
|
||||
}
|
||||
|
||||
const relativeText =
|
||||
diffDays > 0
|
||||
? t('task.inDays', 'in {{count}} days', { count: diffDays })
|
||||
: t('task.daysAgo', '{{count}} days ago', {
|
||||
count: Math.abs(diffDays),
|
||||
});
|
||||
|
||||
return { formattedDate, relativeText };
|
||||
};
|
||||
|
||||
const getTaskPlainSummary = () => {
|
||||
const statusText = getStatusLabel();
|
||||
const priorityText = getPriorityLabel();
|
||||
|
|
@ -636,6 +611,27 @@ const TaskDetails: React.FC = () => {
|
|||
setEditedSubtasks([]);
|
||||
};
|
||||
|
||||
const handleToggleSubtaskCompletion = async (subtask: Task) => {
|
||||
if (!subtask.id) return;
|
||||
try {
|
||||
await toggleTaskCompletion(subtask.id, subtask);
|
||||
if (uid) {
|
||||
const updatedTask = await fetchTaskByUid(uid);
|
||||
const existingIndex = tasksStore.tasks.findIndex(
|
||||
(t: Task) => t.uid === uid
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
const updatedTasks = [...tasksStore.tasks];
|
||||
updatedTasks[existingIndex] = updatedTask;
|
||||
tasksStore.setTasks(updatedTasks);
|
||||
}
|
||||
}
|
||||
setTimelineRefreshKey((prev) => prev + 1);
|
||||
} catch (error) {
|
||||
console.error('Error toggling subtask completion:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleProjectSelection = async (project: Project) => {
|
||||
if (!task?.id) return;
|
||||
|
||||
|
|
@ -1080,500 +1076,45 @@ const TaskDetails: React.FC = () => {
|
|||
{/* Left Column - Main Content */}
|
||||
<div className="lg:col-span-3 space-y-8">
|
||||
{/* Notes Section - Always Visible */}
|
||||
<TaskContentSection
|
||||
<TaskContentCard
|
||||
content={task.note || ''}
|
||||
onUpdate={handleContentUpdate}
|
||||
/>
|
||||
|
||||
{/* Subtasks Section - Always Visible */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('task.subtasks', 'Subtasks')}
|
||||
</h4>
|
||||
{isEditingSubtasks ? (
|
||||
<div className="rounded-lg shadow-sm bg-white dark:bg-gray-900 border-2 border-blue-500 dark:border-blue-400 p-6">
|
||||
<TaskSubtasksSection
|
||||
parentTaskId={task.id!}
|
||||
subtasks={editedSubtasks}
|
||||
onSubtasksChange={setEditedSubtasks}
|
||||
/>
|
||||
<div className="flex items-center justify-end mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={handleSaveSubtasks}
|
||||
className="px-4 py-2 text-sm bg-green-600 dark:bg-green-500 text-white rounded hover:bg-green-700 dark:hover:bg-green-600 transition-colors"
|
||||
>
|
||||
{t('common.save', 'Save')}
|
||||
</button>
|
||||
<button
|
||||
onClick={
|
||||
handleCancelSubtasksEdit
|
||||
}
|
||||
className="px-4 py-2 text-sm bg-gray-200 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded hover:bg-gray-300 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
{t(
|
||||
'common.cancel',
|
||||
'Cancel'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : subtasks.length > 0 ? (
|
||||
<div className="space-y-0.5">
|
||||
{subtasks.map((subtask: Task) => (
|
||||
<div
|
||||
key={subtask.id}
|
||||
className={`rounded-lg shadow-sm bg-white dark:bg-gray-900 border-2 transition-all duration-200 ${
|
||||
subtask.status ===
|
||||
'in_progress' ||
|
||||
subtask.status === 1
|
||||
? 'border-green-400/60 dark:border-green-500/60'
|
||||
: 'border-gray-50 dark:border-gray-800'
|
||||
}`}
|
||||
>
|
||||
<div className="px-3 py-3 flex items-center space-x-3">
|
||||
<TaskPriorityIcon
|
||||
priority={
|
||||
subtask.priority
|
||||
}
|
||||
status={subtask.status}
|
||||
onToggleCompletion={async () => {
|
||||
console.log(
|
||||
'Toggling subtask:',
|
||||
subtask.id
|
||||
);
|
||||
if (subtask.id) {
|
||||
try {
|
||||
// Pass the current subtask to avoid fetching it
|
||||
await toggleTaskCompletion(
|
||||
subtask.id,
|
||||
subtask
|
||||
);
|
||||
// Refresh task data which includes updated subtasks
|
||||
if (uid) {
|
||||
const updatedTask =
|
||||
await fetchTaskByUid(
|
||||
uid
|
||||
);
|
||||
const existingIndex =
|
||||
tasksStore.tasks.findIndex(
|
||||
(
|
||||
t: Task
|
||||
) =>
|
||||
t.uid ===
|
||||
uid
|
||||
);
|
||||
if (
|
||||
existingIndex >=
|
||||
0
|
||||
) {
|
||||
const updatedTasks =
|
||||
[
|
||||
...tasksStore.tasks,
|
||||
];
|
||||
updatedTasks[
|
||||
existingIndex
|
||||
] =
|
||||
updatedTask;
|
||||
tasksStore.setTasks(
|
||||
updatedTasks
|
||||
);
|
||||
}
|
||||
}
|
||||
<TaskSubtasksCard
|
||||
task={task}
|
||||
subtasks={subtasks}
|
||||
isEditing={isEditingSubtasks}
|
||||
editedSubtasks={editedSubtasks}
|
||||
onSubtasksChange={setEditedSubtasks}
|
||||
onStartEdit={handleStartSubtasksEdit}
|
||||
onSave={handleSaveSubtasks}
|
||||
onCancel={handleCancelSubtasksEdit}
|
||||
onToggleSubtaskCompletion={
|
||||
handleToggleSubtaskCompletion
|
||||
}
|
||||
/>
|
||||
|
||||
// Refresh timeline to show subtask completion activity
|
||||
setTimelineRefreshKey(
|
||||
(
|
||||
prev
|
||||
) =>
|
||||
prev +
|
||||
1
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'Error toggling subtask completion:',
|
||||
error
|
||||
);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<span
|
||||
onClick={
|
||||
handleStartSubtasksEdit
|
||||
}
|
||||
className={`text-base flex-1 truncate cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 transition-colors ${
|
||||
subtask.status ===
|
||||
'done' ||
|
||||
subtask.status ===
|
||||
2 ||
|
||||
subtask.status ===
|
||||
'archived' ||
|
||||
subtask.status === 3
|
||||
? 'text-gray-500 dark:text-gray-400'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
}`}
|
||||
title={t(
|
||||
'task.clickToEditSubtasks',
|
||||
'Click to edit subtasks'
|
||||
)}
|
||||
>
|
||||
{subtask.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={handleStartSubtasksEdit}
|
||||
className="rounded-lg shadow-sm bg-white dark:bg-gray-900 border-2 border-gray-50 dark:border-gray-800 hover:border-gray-200 dark:hover:border-gray-700 p-6 cursor-pointer transition-colors"
|
||||
title={t(
|
||||
'task.clickToEditSubtasks',
|
||||
'Click to add or edit subtasks'
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<ListBulletIcon className="h-12 w-12 mb-3 opacity-50" />
|
||||
<span className="text-sm text-center">
|
||||
{t(
|
||||
'task.noSubtasksClickToAdd',
|
||||
'No subtasks yet, click to add'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t(
|
||||
'task.recurringSetup',
|
||||
'Recurring Setup'
|
||||
)}
|
||||
</h4>
|
||||
<div
|
||||
className={`rounded-lg shadow-sm bg-white dark:bg-gray-900 border-2 border-gray-50 dark:border-gray-800 hover:border-gray-200 dark:hover:border-gray-700 p-6 space-y-4 ${
|
||||
!task.recurring_parent_id &&
|
||||
!isEditingRecurrence
|
||||
? 'cursor-pointer'
|
||||
: ''
|
||||
}`}
|
||||
onClick={
|
||||
!task.recurring_parent_id &&
|
||||
!isEditingRecurrence
|
||||
? handleRecurrenceCardClick
|
||||
: undefined
|
||||
}
|
||||
role={
|
||||
!task.recurring_parent_id &&
|
||||
!isEditingRecurrence
|
||||
? 'button'
|
||||
: undefined
|
||||
}
|
||||
tabIndex={
|
||||
!task.recurring_parent_id &&
|
||||
!isEditingRecurrence
|
||||
? 0
|
||||
: -1
|
||||
}
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
!isEditingRecurrence &&
|
||||
!task.recurring_parent_id &&
|
||||
e.key === 'Enter'
|
||||
) {
|
||||
e.preventDefault();
|
||||
handleRecurrenceCardClick();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TaskRecurringInstanceInfo
|
||||
task={task}
|
||||
parentTask={parentTask}
|
||||
loadingParent={loadingParent}
|
||||
/>
|
||||
|
||||
{isEditingRecurrence &&
|
||||
!task.recurring_parent_id ? (
|
||||
<div className="space-y-4">
|
||||
<TaskRecurrenceSection
|
||||
recurrenceType={
|
||||
recurrenceForm.recurrence_type
|
||||
}
|
||||
recurrenceInterval={
|
||||
recurrenceForm.recurrence_interval
|
||||
}
|
||||
recurrenceEndDate={
|
||||
recurrenceForm.recurrence_end_date ||
|
||||
undefined
|
||||
}
|
||||
recurrenceWeekday={
|
||||
recurrenceForm.recurrence_weekday ||
|
||||
undefined
|
||||
}
|
||||
recurrenceWeekdays={
|
||||
recurrenceForm.recurrence_weekdays ||
|
||||
[]
|
||||
}
|
||||
recurrenceMonthDay={
|
||||
recurrenceForm.recurrence_month_day ||
|
||||
undefined
|
||||
}
|
||||
recurrenceWeekOfMonth={
|
||||
recurrenceForm.recurrence_week_of_month ||
|
||||
undefined
|
||||
}
|
||||
completionBased={
|
||||
recurrenceForm.completion_based
|
||||
}
|
||||
onChange={
|
||||
handleRecurrenceChange
|
||||
}
|
||||
/>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<button
|
||||
onClick={
|
||||
handleSaveRecurrence
|
||||
}
|
||||
className="px-4 py-2 text-sm bg-green-600 dark:bg-green-500 text-white rounded hover:bg-green-700 dark:hover:bg-green-600 transition-colors"
|
||||
>
|
||||
{t('common.save', 'Save')}
|
||||
</button>
|
||||
<button
|
||||
onClick={
|
||||
handleCancelRecurrenceEdit
|
||||
}
|
||||
className="px-4 py-2 text-sm bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
{t(
|
||||
'common.cancel',
|
||||
'Cancel'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{(task.recurrence_type &&
|
||||
task.recurrence_type !==
|
||||
'none') ||
|
||||
(parentTask?.recurrence_type &&
|
||||
parentTask.recurrence_type !==
|
||||
'none') ? (
|
||||
<div className="mb-4">
|
||||
<RecurrenceDisplay
|
||||
recurrenceType={
|
||||
task.recurring_parent_id &&
|
||||
parentTask?.recurrence_type
|
||||
? parentTask.recurrence_type
|
||||
: task.recurrence_type
|
||||
}
|
||||
recurrenceInterval={
|
||||
task.recurring_parent_id &&
|
||||
parentTask?.recurrence_interval
|
||||
? parentTask.recurrence_interval
|
||||
: task.recurrence_interval
|
||||
}
|
||||
recurrenceWeekdays={
|
||||
task.recurring_parent_id &&
|
||||
parentTask?.recurrence_weekdays
|
||||
? parentTask.recurrence_weekdays
|
||||
: task.recurrence_weekdays
|
||||
}
|
||||
recurrenceEndDate={
|
||||
task.recurring_parent_id &&
|
||||
parentTask?.recurrence_end_date
|
||||
? parentTask.recurrence_end_date
|
||||
: task.recurrence_end_date
|
||||
}
|
||||
recurrenceMonthDay={
|
||||
task.recurring_parent_id &&
|
||||
parentTask?.recurrence_month_day
|
||||
? parentTask.recurrence_month_day
|
||||
: task.recurrence_month_day
|
||||
}
|
||||
recurrenceWeekOfMonth={
|
||||
task.recurring_parent_id &&
|
||||
parentTask?.recurrence_week_of_month
|
||||
? parentTask.recurrence_week_of_month
|
||||
: task.recurrence_week_of_month
|
||||
}
|
||||
recurrenceWeekday={
|
||||
task.recurring_parent_id &&
|
||||
parentTask?.recurrence_weekday
|
||||
? parentTask.recurrence_weekday
|
||||
: task.recurrence_weekday
|
||||
}
|
||||
completionBased={
|
||||
task.recurring_parent_id &&
|
||||
parentTask?.completion_based
|
||||
? parentTask.completion_based
|
||||
: task.completion_based
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 text-center">
|
||||
{t(
|
||||
'task.notRecurring',
|
||||
'This task is not recurring yet.'
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{((task.recurrence_type &&
|
||||
task.recurrence_type !==
|
||||
'none') ||
|
||||
(task.recurring_parent_id &&
|
||||
parentTask?.recurrence_type &&
|
||||
parentTask.recurrence_type !==
|
||||
'none')) && (
|
||||
<div>
|
||||
<div className="flex items-center mb-3">
|
||||
<ClockIcon className="h-4 w-4 mr-2 text-gray-500 dark:text-gray-400" />
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{task.recurring_parent_id
|
||||
? t(
|
||||
'task.nextOccurrencesAfterThis',
|
||||
'Next Occurrences After This'
|
||||
)
|
||||
: t(
|
||||
'task.nextOccurrences',
|
||||
'Next Occurrences'
|
||||
)}
|
||||
{!loadingIterations &&
|
||||
nextIterations.length >
|
||||
0 &&
|
||||
nextIterations.some(
|
||||
(iter) =>
|
||||
formatDateWithDayName(
|
||||
iter.date
|
||||
)
|
||||
.isToday
|
||||
) && (
|
||||
<span className="ml-2 text-xs text-blue-600 dark:text-blue-400">
|
||||
(
|
||||
{t(
|
||||
'task.includingToday',
|
||||
'including today'
|
||||
)}
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loadingIterations ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"></div>
|
||||
<span className="ml-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
{t(
|
||||
'common.loading',
|
||||
'Loading...'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
) : nextIterations.length >
|
||||
0 ? (
|
||||
<div className="space-y-2">
|
||||
{nextIterations.map(
|
||||
(
|
||||
iteration,
|
||||
index
|
||||
) => {
|
||||
const dateInfo =
|
||||
formatDateWithDayName(
|
||||
iteration.date
|
||||
);
|
||||
return (
|
||||
<div
|
||||
key={
|
||||
index
|
||||
}
|
||||
className={`flex items-center py-2 px-3 rounded transition-colors ${
|
||||
dateInfo.isToday
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 border-2 border-blue-200 dark:border-blue-800'
|
||||
: 'bg-gray-50 dark:bg-gray-800 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={`w-7 h-7 rounded-full flex items-center justify-center mr-3 ${
|
||||
dateInfo.isToday
|
||||
? 'bg-blue-600 dark:bg-blue-500'
|
||||
: 'bg-blue-100 dark:bg-blue-900'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`text-xs font-medium ${
|
||||
dateInfo.isToday
|
||||
? 'text-white'
|
||||
: 'text-blue-600 dark:text-blue-400'
|
||||
}`}
|
||||
>
|
||||
{index +
|
||||
1}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<div
|
||||
className={`text-sm font-medium ${
|
||||
dateInfo.isToday
|
||||
? 'text-blue-900 dark:text-blue-100'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
}`}
|
||||
>
|
||||
{
|
||||
dateInfo.dayName
|
||||
}
|
||||
{dateInfo.isToday && (
|
||||
<span className="ml-2 text-xs px-2 py-0.5 bg-blue-600 dark:bg-blue-500 text-white rounded-full font-semibold">
|
||||
{t(
|
||||
'dateIndicators.today',
|
||||
'TODAY'
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`text-xs ${
|
||||
dateInfo.isToday
|
||||
? 'text-blue-700 dark:text-blue-300'
|
||||
: 'text-gray-500 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{
|
||||
dateInfo.formattedDate
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-500 dark:text-gray-400 py-2">
|
||||
{t(
|
||||
'task.noMoreIterations',
|
||||
'No more iterations scheduled'
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<TaskRecurrenceCard
|
||||
task={task}
|
||||
parentTask={parentTask}
|
||||
loadingParent={loadingParent}
|
||||
isEditing={isEditingRecurrence}
|
||||
recurrenceForm={recurrenceForm}
|
||||
onStartEdit={handleStartRecurrenceEdit}
|
||||
onChange={handleRecurrenceChange}
|
||||
onSave={handleSaveRecurrence}
|
||||
onCancel={handleCancelRecurrenceEdit}
|
||||
loadingIterations={loadingIterations}
|
||||
nextIterations={nextIterations}
|
||||
canEdit={!task.recurring_parent_id}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right Column - Metadata and Recent Activity */}
|
||||
<div className="space-y-6">
|
||||
{/* Project Section */}
|
||||
<TaskProjectSection
|
||||
<TaskProjectCard
|
||||
task={task}
|
||||
projects={projectsStore.projects}
|
||||
onProjectSelect={handleProjectSelection}
|
||||
|
|
@ -1585,7 +1126,7 @@ const TaskDetails: React.FC = () => {
|
|||
/>
|
||||
|
||||
{/* Tags Section */}
|
||||
<TaskTagsSection
|
||||
<TaskTagsCard
|
||||
task={task}
|
||||
availableTags={tagsStore.tags}
|
||||
hasLoadedTags={tagsStore.hasLoaded}
|
||||
|
|
@ -1595,160 +1136,20 @@ const TaskDetails: React.FC = () => {
|
|||
/>
|
||||
|
||||
{/* Priority Section */}
|
||||
<TaskPrioritySection
|
||||
<TaskPriorityCard
|
||||
task={task}
|
||||
onUpdate={handlePriorityUpdate}
|
||||
/>
|
||||
|
||||
{/* Due Date Section */}
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('task.dueDate', 'Due Date')}
|
||||
</h4>
|
||||
<div
|
||||
className={`rounded-lg shadow-sm bg-white dark:bg-gray-900 border-2 border-gray-50 dark:border-gray-800 hover:border-gray-200 dark:hover:border-gray-700 p-4 transition-colors ${
|
||||
task.due_date &&
|
||||
(() => {
|
||||
const dueDate = new Date(
|
||||
task.due_date
|
||||
);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
dueDate.setHours(0, 0, 0, 0);
|
||||
const isCompleted =
|
||||
task.status === 'done' ||
|
||||
task.status === 2 ||
|
||||
task.status === 'archived' ||
|
||||
task.status === 3 ||
|
||||
task.completed_at;
|
||||
return (
|
||||
dueDate < today && !isCompleted
|
||||
);
|
||||
})()
|
||||
? 'border-red-500 dark:border-red-400'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{isEditingDueDate ? (
|
||||
<div className="space-y-3">
|
||||
<TaskDueDateSection
|
||||
value={editedDueDate}
|
||||
onChange={setEditedDueDate}
|
||||
placeholder={t(
|
||||
'forms.task.dueDatePlaceholder',
|
||||
'Select due date'
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<button
|
||||
onClick={handleSaveDueDate}
|
||||
className="px-4 py-2 text-sm bg-green-600 dark:bg-green-500 text-white rounded hover:bg-green-700 dark:hover:bg-green-600 transition-colors"
|
||||
>
|
||||
{t('common.save', 'Save')}
|
||||
</button>
|
||||
<button
|
||||
onClick={
|
||||
handleCancelDueDateEdit
|
||||
}
|
||||
className="px-4 py-2 text-sm bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
{t(
|
||||
'common.cancel',
|
||||
'Cancel'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleStartDueDateEdit}
|
||||
className="flex w-full items-center justify-between text-left"
|
||||
>
|
||||
{task.due_date ? (
|
||||
(() => {
|
||||
const display =
|
||||
getDueDateDisplay(
|
||||
task.due_date
|
||||
);
|
||||
if (!display) return null;
|
||||
// Check if due date is in the past and task is not completed
|
||||
const dueDate = new Date(
|
||||
task.due_date
|
||||
);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
dueDate.setHours(
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
);
|
||||
const isCompleted =
|
||||
task.status ===
|
||||
'done' ||
|
||||
task.status === 2 ||
|
||||
task.status ===
|
||||
'archived' ||
|
||||
task.status === 3 ||
|
||||
task.completed_at;
|
||||
const overdue =
|
||||
dueDate < today &&
|
||||
!isCompleted;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-between w-full ${
|
||||
overdue
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-2 flex-1 min-w-0">
|
||||
<CalendarIcon
|
||||
className={`h-4 w-4 flex-shrink-0 ${
|
||||
overdue
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: 'text-gray-500 dark:text-gray-400'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{
|
||||
display.formattedDate
|
||||
}
|
||||
</span>
|
||||
<span
|
||||
className={`text-sm italic ${
|
||||
overdue
|
||||
? 'text-red-500 dark:text-red-400 font-medium'
|
||||
: 'text-gray-500 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
(
|
||||
{
|
||||
display.relativeText
|
||||
}
|
||||
)
|
||||
</span>
|
||||
</div>
|
||||
{overdue && (
|
||||
<ExclamationTriangleIcon className="h-5 w-5 text-red-600 dark:text-red-400 flex-shrink-0 ml-2" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||
{t(
|
||||
'task.noDueDate',
|
||||
'No due date'
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<TaskDueDateCard
|
||||
task={task}
|
||||
isEditing={isEditingDueDate}
|
||||
editedDueDate={editedDueDate}
|
||||
onChangeDate={setEditedDueDate}
|
||||
onStartEdit={handleStartDueDateEdit}
|
||||
onSave={handleSaveDueDate}
|
||||
onCancel={handleCancelDueDateEdit}
|
||||
/>
|
||||
|
||||
{/* Recent Activity Section */}
|
||||
<div>
|
||||
|
|
|
|||
|
|
@ -7,12 +7,12 @@ import {
|
|||
} from '@heroicons/react/24/outline';
|
||||
import MarkdownRenderer from '../../Shared/MarkdownRenderer';
|
||||
|
||||
interface TaskContentSectionProps {
|
||||
interface TaskContentCardProps {
|
||||
content: string;
|
||||
onUpdate: (newContent: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const TaskContentSection: React.FC<TaskContentSectionProps> = ({
|
||||
const TaskContentCard: React.FC<TaskContentCardProps> = ({
|
||||
content,
|
||||
onUpdate,
|
||||
}) => {
|
||||
|
|
@ -179,4 +179,4 @@ const TaskContentSection: React.FC<TaskContentSectionProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
export default TaskContentSection;
|
||||
export default TaskContentCard;
|
||||
191
frontend/components/Task/TaskDetails/TaskDueDateCard.tsx
Normal file
191
frontend/components/Task/TaskDetails/TaskDueDateCard.tsx
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
CalendarIcon,
|
||||
ExclamationTriangleIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import TaskDueDateSection from '../TaskForm/TaskDueDateSection';
|
||||
import { Task } from '../../../entities/Task';
|
||||
|
||||
interface TaskDueDateCardProps {
|
||||
task: Task;
|
||||
isEditing: boolean;
|
||||
editedDueDate: string;
|
||||
onChangeDate: (value: string) => void;
|
||||
onStartEdit: () => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const TaskDueDateCard: React.FC<TaskDueDateCardProps> = ({
|
||||
task,
|
||||
isEditing,
|
||||
editedDueDate,
|
||||
onChangeDate,
|
||||
onStartEdit,
|
||||
onSave,
|
||||
onCancel,
|
||||
}) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const getDueDateDisplay = (dueDate: string) => {
|
||||
const date = new Date(dueDate);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
|
||||
const formattedDate = date.toLocaleDateString(i18n.language, {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
});
|
||||
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
const target = new Date(date);
|
||||
target.setHours(0, 0, 0, 0);
|
||||
|
||||
const diffDays = Math.round(
|
||||
(target.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
let relativeText = '';
|
||||
if (diffDays === 0) {
|
||||
relativeText = t('dateIndicators.today', 'today');
|
||||
} else if (diffDays === 1) {
|
||||
relativeText = t('dateIndicators.tomorrow', 'tomorrow');
|
||||
} else if (diffDays === -1) {
|
||||
relativeText = t('dateIndicators.yesterday', 'yesterday');
|
||||
} else if (diffDays > 0) {
|
||||
relativeText = t('task.inDays', 'in {{count}} days', {
|
||||
count: diffDays,
|
||||
});
|
||||
} else {
|
||||
relativeText = t('task.daysAgo', '{{count}} days ago', {
|
||||
count: Math.abs(diffDays),
|
||||
});
|
||||
}
|
||||
|
||||
return { formattedDate, relativeText };
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('task.dueDate', 'Due Date')}
|
||||
</h4>
|
||||
<div
|
||||
className={`rounded-lg shadow-sm bg-white dark:bg-gray-900 border-2 border-gray-50 dark:border-gray-800 hover:border-gray-200 dark:hover:border-gray-700 p-4 transition-colors ${
|
||||
task.due_date &&
|
||||
(() => {
|
||||
const dueDate = new Date(task.due_date);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
dueDate.setHours(0, 0, 0, 0);
|
||||
const isCompleted =
|
||||
task.status === 'done' ||
|
||||
task.status === 2 ||
|
||||
task.status === 'archived' ||
|
||||
task.status === 3 ||
|
||||
task.completed_at;
|
||||
return dueDate < today && !isCompleted;
|
||||
})()
|
||||
? 'border-red-500 dark:border-red-400'
|
||||
: ''
|
||||
}`}
|
||||
>
|
||||
{isEditing ? (
|
||||
<div className="space-y-3">
|
||||
<TaskDueDateSection
|
||||
value={editedDueDate}
|
||||
onChange={onChangeDate}
|
||||
placeholder={t(
|
||||
'forms.task.dueDatePlaceholder',
|
||||
'Select due date'
|
||||
)}
|
||||
/>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<button
|
||||
onClick={onSave}
|
||||
className="px-4 py-2 text-sm bg-green-600 dark:bg-green-500 text-white rounded hover:bg-green-700 dark:hover:bg-green-600 transition-colors"
|
||||
>
|
||||
{t('common.save', 'Save')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-sm bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onStartEdit}
|
||||
className="flex w-full items-center justify-between text-left"
|
||||
>
|
||||
{task.due_date ? (
|
||||
(() => {
|
||||
const display = getDueDateDisplay(
|
||||
task.due_date
|
||||
);
|
||||
if (!display) return null;
|
||||
const dueDate = new Date(task.due_date);
|
||||
const today = new Date();
|
||||
today.setHours(0, 0, 0, 0);
|
||||
dueDate.setHours(0, 0, 0, 0);
|
||||
const isCompleted =
|
||||
task.status === 'done' ||
|
||||
task.status === 2 ||
|
||||
task.status === 'archived' ||
|
||||
task.status === 3 ||
|
||||
task.completed_at;
|
||||
const overdue = dueDate < today && !isCompleted;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center justify-between w-full ${
|
||||
overdue
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center space-x-2 flex-1 min-w-0">
|
||||
<CalendarIcon
|
||||
className={`h-4 w-4 flex-shrink-0 ${
|
||||
overdue
|
||||
? 'text-red-600 dark:text-red-400'
|
||||
: 'text-gray-500 dark:text-gray-400'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm font-medium">
|
||||
{display.formattedDate}
|
||||
</span>
|
||||
<span
|
||||
className={`text-sm italic ${
|
||||
overdue
|
||||
? 'text-red-500 dark:text-red-400 font-medium'
|
||||
: 'text-gray-500 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
({display.relativeText})
|
||||
</span>
|
||||
</div>
|
||||
{overdue && (
|
||||
<ExclamationTriangleIcon className="h-5 w-5 text-red-600 dark:text-red-400 flex-shrink-0 ml-2" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()
|
||||
) : (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||
{t('task.noDueDate', 'No due date')}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskDueDateCard;
|
||||
|
|
@ -2,12 +2,12 @@ import React from 'react';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import { Task, PriorityType } from '../../../entities/Task';
|
||||
|
||||
interface TaskPrioritySectionProps {
|
||||
interface TaskPriorityCardProps {
|
||||
task: Task;
|
||||
onUpdate: (priority: PriorityType) => Promise<void>;
|
||||
}
|
||||
|
||||
const TaskPrioritySection: React.FC<TaskPrioritySectionProps> = ({
|
||||
const TaskPriorityCard: React.FC<TaskPriorityCardProps> = ({
|
||||
task,
|
||||
onUpdate,
|
||||
}) => {
|
||||
|
|
@ -72,4 +72,4 @@ const TaskPrioritySection: React.FC<TaskPrioritySectionProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
export default TaskPrioritySection;
|
||||
export default TaskPriorityCard;
|
||||
|
|
@ -1,12 +1,12 @@
|
|||
import React, { useRef, useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { ArrowRightIcon } from '@heroicons/react/24/outline';
|
||||
import { ArrowRightIcon, XMarkIcon } from '@heroicons/react/24/outline';
|
||||
import ProjectDropdown from '../../Shared/ProjectDropdown';
|
||||
import { Project } from '../../../entities/Project';
|
||||
import { Task } from '../../../entities/Task';
|
||||
|
||||
interface TaskProjectSectionProps {
|
||||
interface TaskProjectCardProps {
|
||||
task: Task;
|
||||
projects: Project[];
|
||||
onProjectSelect: (project: Project) => Promise<void>;
|
||||
|
|
@ -15,7 +15,7 @@ interface TaskProjectSectionProps {
|
|||
getProjectLink: (project: Project) => string;
|
||||
}
|
||||
|
||||
const TaskProjectSection: React.FC<TaskProjectSectionProps> = ({
|
||||
const TaskProjectCard: React.FC<TaskProjectCardProps> = ({
|
||||
task,
|
||||
projects,
|
||||
onProjectSelect,
|
||||
|
|
@ -105,53 +105,70 @@ const TaskProjectSection: React.FC<TaskProjectSectionProps> = ({
|
|||
onClearProject={handleClearProject}
|
||||
/>
|
||||
) : task.Project ? (
|
||||
<div
|
||||
onClick={() => setProjectDropdownOpen(true)}
|
||||
className="bg-gray-50 dark:bg-gray-900 rounded-lg shadow-sm relative cursor-pointer hover:opacity-90 transition-opacity"
|
||||
>
|
||||
<div
|
||||
className="flex items-center justify-center overflow-hidden rounded-t-lg relative"
|
||||
style={{ height: '100px' }}
|
||||
<div className="bg-gray-50 dark:bg-gray-900 rounded-lg shadow-sm relative overflow-hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setProjectDropdownOpen(true)}
|
||||
className="group w-full text-left hover:opacity-90 transition-opacity"
|
||||
>
|
||||
{task.Project.image_url ? (
|
||||
<img
|
||||
src={task.Project.image_url}
|
||||
alt={task.Project.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gradient-to-br from-blue-500 to-purple-600 dark:from-blue-600 dark:to-purple-700"></div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<div className="flex items-center text-md font-semibold text-gray-900 dark:text-gray-100">
|
||||
<span className="truncate">
|
||||
{task.Project.name}
|
||||
</span>
|
||||
<Link
|
||||
to={getProjectLink(task.Project)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="p-1.5 rounded-full text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 flex-shrink-0 ml-auto"
|
||||
title={t(
|
||||
'project.viewProject',
|
||||
'Go to project'
|
||||
)}
|
||||
>
|
||||
<ArrowRightIcon className="h-4 w-4" />
|
||||
<span className="sr-only">
|
||||
{t(
|
||||
<div
|
||||
className="flex items-center justify-center overflow-hidden relative"
|
||||
style={{ height: '100px' }}
|
||||
>
|
||||
{task.Project.image_url ? (
|
||||
<img
|
||||
src={task.Project.image_url}
|
||||
alt={task.Project.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full bg-gradient-to-br from-blue-500 to-purple-600 dark:from-blue-600 dark:to-purple-700"></div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3">
|
||||
<div className="flex items-center text-md font-semibold text-gray-900 dark:text-gray-100">
|
||||
<span className="truncate">
|
||||
{task.Project.name}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClearProject();
|
||||
}}
|
||||
className="ml-auto inline-flex items-center justify-center w-6 h-6 rounded-full text-gray-500 dark:text-gray-400 bg-transparent hover:bg-gray-200 dark:hover:bg-gray-700 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
title={t(
|
||||
'task.clearProject',
|
||||
'Remove project'
|
||||
)}
|
||||
>
|
||||
<XMarkIcon className="h-4 w-4" />
|
||||
</button>
|
||||
<Link
|
||||
to={getProjectLink(task.Project)}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="p-1.5 rounded-full text-blue-600 dark:text-blue-400 hover:text-blue-700 dark:hover:text-blue-300 flex-shrink-0 ml-2"
|
||||
title={t(
|
||||
'project.viewProject',
|
||||
'Go to project'
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
>
|
||||
<ArrowRightIcon className="h-4 w-4" />
|
||||
<span className="sr-only">
|
||||
{t(
|
||||
'project.viewProject',
|
||||
'Go to project'
|
||||
)}
|
||||
</span>
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={() => setProjectDropdownOpen(true)}
|
||||
className="rounded-lg shadow-sm bg-white dark:bg-gray-900 border-2 border-dashed border-gray-300 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-600 p-6 cursor-pointer transition-colors flex items-center justify-center"
|
||||
className="rounded-lg shadow-sm bg-white dark:bg-gray-900 hover:border-gray-400 dark:hover:border-gray-600 p-6 cursor-pointer transition-colors flex items-center justify-center"
|
||||
>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 italic">
|
||||
{t(
|
||||
|
|
@ -166,4 +183,4 @@ const TaskProjectSection: React.FC<TaskProjectSectionProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
export default TaskProjectSection;
|
||||
export default TaskProjectCard;
|
||||
302
frontend/components/Task/TaskDetails/TaskRecurrenceCard.tsx
Normal file
302
frontend/components/Task/TaskDetails/TaskRecurrenceCard.tsx
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ClockIcon } from '@heroicons/react/24/outline';
|
||||
import RecurrenceDisplay from '../RecurrenceDisplay';
|
||||
import TaskRecurrenceSection from '../TaskForm/TaskRecurrenceSection';
|
||||
import TaskRecurringInstanceInfo from './TaskRecurringInstanceInfo';
|
||||
import { Task } from '../../../entities/Task';
|
||||
import { TaskIteration } from '../../../utils/tasksService';
|
||||
|
||||
interface TaskRecurrenceCardProps {
|
||||
task: Task;
|
||||
parentTask: Task | null;
|
||||
loadingParent: boolean;
|
||||
isEditing: boolean;
|
||||
recurrenceForm: {
|
||||
recurrence_type: string;
|
||||
recurrence_interval: number;
|
||||
recurrence_end_date: string | null;
|
||||
recurrence_weekday: number | null;
|
||||
recurrence_weekdays: number[] | null;
|
||||
recurrence_month_day: number | null;
|
||||
recurrence_week_of_month: number | null;
|
||||
completion_based: boolean;
|
||||
};
|
||||
onStartEdit: () => void;
|
||||
onChange: (field: string, value: any) => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
loadingIterations: boolean;
|
||||
nextIterations: TaskIteration[];
|
||||
canEdit: boolean;
|
||||
}
|
||||
|
||||
const TaskRecurrenceCard: React.FC<TaskRecurrenceCardProps> = ({
|
||||
task,
|
||||
parentTask,
|
||||
loadingParent,
|
||||
isEditing,
|
||||
recurrenceForm,
|
||||
onStartEdit,
|
||||
onChange,
|
||||
onSave,
|
||||
onCancel,
|
||||
loadingIterations,
|
||||
nextIterations,
|
||||
canEdit,
|
||||
}) => {
|
||||
const { t, i18n } = useTranslation();
|
||||
|
||||
const formatDateWithDayName = (dateString: string) => {
|
||||
const date = new Date(dateString);
|
||||
const today = new Date().toISOString().split('T')[0];
|
||||
const isToday = dateString === today;
|
||||
|
||||
const dayName = date.toLocaleDateString(i18n.language, {
|
||||
weekday: 'long',
|
||||
});
|
||||
const formattedDate = date.toLocaleDateString(i18n.language, {
|
||||
day: 'numeric',
|
||||
month: 'long',
|
||||
});
|
||||
|
||||
return {
|
||||
dayName,
|
||||
formattedDate,
|
||||
fullText: `${dayName}, ${formattedDate}`,
|
||||
isToday,
|
||||
};
|
||||
};
|
||||
|
||||
const renderNextIterations = () => {
|
||||
if (loadingIterations) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-blue-500"></div>
|
||||
<span className="ml-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
{t('common.loading', 'Loading...')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (nextIterations.length === 0) {
|
||||
return (
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||
{t(
|
||||
'task.noUpcomingOccurrences',
|
||||
'No upcoming occurrences.'
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{nextIterations.map((iteration, index) => {
|
||||
const dateInfo = formatDateWithDayName(iteration.date);
|
||||
return (
|
||||
<div
|
||||
key={index}
|
||||
className={`flex items-center py-2 px-3 rounded transition-colors ${
|
||||
dateInfo.isToday
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 border-2 border-blue-200 dark:border-blue-800'
|
||||
: 'bg-gray-50 dark:bg-gray-800 border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-medium text-gray-800 dark:text-gray-100">
|
||||
{dateInfo.formattedDate}
|
||||
</div>
|
||||
<div className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{dateInfo.dayName}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('task.recurringSetup', 'Recurring Setup')}
|
||||
</h4>
|
||||
<div
|
||||
className={`rounded-lg shadow-sm bg-white dark:bg-gray-900 border-2 border-gray-50 dark:border-gray-800 hover:border-gray-200 dark:hover:border-gray-700 p-6 space-y-4 ${
|
||||
canEdit && !isEditing ? 'cursor-pointer' : ''
|
||||
}`}
|
||||
onClick={canEdit && !isEditing ? onStartEdit : undefined}
|
||||
role={canEdit && !isEditing ? 'button' : undefined}
|
||||
tabIndex={canEdit && !isEditing ? 0 : -1}
|
||||
onKeyDown={(e) => {
|
||||
if (canEdit && !isEditing && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
onStartEdit();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<TaskRecurringInstanceInfo
|
||||
task={task}
|
||||
parentTask={parentTask}
|
||||
loadingParent={loadingParent}
|
||||
/>
|
||||
|
||||
{isEditing && canEdit ? (
|
||||
<div className="space-y-4">
|
||||
<TaskRecurrenceSection
|
||||
recurrenceType={recurrenceForm.recurrence_type}
|
||||
recurrenceInterval={
|
||||
recurrenceForm.recurrence_interval
|
||||
}
|
||||
recurrenceEndDate={
|
||||
recurrenceForm.recurrence_end_date || undefined
|
||||
}
|
||||
recurrenceWeekday={
|
||||
recurrenceForm.recurrence_weekday || undefined
|
||||
}
|
||||
recurrenceWeekdays={
|
||||
recurrenceForm.recurrence_weekdays || []
|
||||
}
|
||||
recurrenceMonthDay={
|
||||
recurrenceForm.recurrence_month_day || undefined
|
||||
}
|
||||
recurrenceWeekOfMonth={
|
||||
recurrenceForm.recurrence_week_of_month ||
|
||||
undefined
|
||||
}
|
||||
completionBased={recurrenceForm.completion_based}
|
||||
onChange={onChange}
|
||||
/>
|
||||
<div className="flex justify-end space-x-2">
|
||||
<button
|
||||
onClick={onSave}
|
||||
className="px-4 py-2 text-sm bg-green-600 dark:bg-green-500 text-white rounded hover:bg-green-700 dark:hover:bg-green-600 transition-colors"
|
||||
>
|
||||
{t('common.save', 'Save')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-sm bg-gray-200 dark:bg-gray-700 text-gray-900 dark:text-gray-100 rounded hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors"
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{(task.recurrence_type &&
|
||||
task.recurrence_type !== 'none') ||
|
||||
(parentTask?.recurrence_type &&
|
||||
parentTask.recurrence_type !== 'none') ? (
|
||||
<div className="mb-4">
|
||||
<RecurrenceDisplay
|
||||
recurrenceType={
|
||||
task.recurring_parent_id &&
|
||||
parentTask?.recurrence_type
|
||||
? parentTask.recurrence_type
|
||||
: task.recurrence_type
|
||||
}
|
||||
recurrenceInterval={
|
||||
task.recurring_parent_id &&
|
||||
parentTask?.recurrence_interval
|
||||
? parentTask.recurrence_interval
|
||||
: task.recurrence_interval
|
||||
}
|
||||
recurrenceWeekdays={
|
||||
task.recurring_parent_id &&
|
||||
parentTask?.recurrence_weekdays
|
||||
? parentTask.recurrence_weekdays
|
||||
: task.recurrence_weekdays
|
||||
}
|
||||
recurrenceEndDate={
|
||||
task.recurring_parent_id &&
|
||||
parentTask?.recurrence_end_date
|
||||
? parentTask.recurrence_end_date
|
||||
: task.recurrence_end_date
|
||||
}
|
||||
recurrenceMonthDay={
|
||||
task.recurring_parent_id &&
|
||||
parentTask?.recurrence_month_day
|
||||
? parentTask.recurrence_month_day
|
||||
: task.recurrence_month_day
|
||||
}
|
||||
recurrenceWeekOfMonth={
|
||||
task.recurring_parent_id &&
|
||||
parentTask?.recurrence_week_of_month
|
||||
? parentTask.recurrence_week_of_month
|
||||
: task.recurrence_week_of_month
|
||||
}
|
||||
recurrenceWeekday={
|
||||
task.recurring_parent_id &&
|
||||
parentTask?.recurrence_weekday
|
||||
? parentTask.recurrence_weekday
|
||||
: task.recurrence_weekday
|
||||
}
|
||||
completionBased={
|
||||
task.recurring_parent_id &&
|
||||
parentTask?.completion_based
|
||||
? parentTask.completion_based
|
||||
: task.completion_based
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-sm text-gray-600 dark:text-gray-400 text-center">
|
||||
{t(
|
||||
'task.notRecurring',
|
||||
'This task is not recurring yet.'
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{((task.recurrence_type &&
|
||||
task.recurrence_type !== 'none') ||
|
||||
(task.recurring_parent_id &&
|
||||
parentTask?.recurrence_type &&
|
||||
parentTask.recurrence_type !== 'none')) && (
|
||||
<div>
|
||||
<div className="flex items-center mb-3">
|
||||
<ClockIcon className="h-4 w-4 mr-2 text-gray-500 dark:text-gray-400" />
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{task.recurring_parent_id
|
||||
? t(
|
||||
'task.nextOccurrencesAfterThis',
|
||||
'Next Occurrences After This'
|
||||
)
|
||||
: t(
|
||||
'task.nextOccurrences',
|
||||
'Next Occurrences'
|
||||
)}
|
||||
{!loadingIterations &&
|
||||
nextIterations.length > 0 &&
|
||||
nextIterations.some(
|
||||
(iter) =>
|
||||
formatDateWithDayName(
|
||||
iter.date
|
||||
).isToday
|
||||
) && (
|
||||
<span className="ml-2 text-xs text-blue-600 dark:text-blue-400">
|
||||
(
|
||||
{t(
|
||||
'task.includingToday',
|
||||
'including today'
|
||||
)}
|
||||
)
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{renderNextIterations()}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskRecurrenceCard;
|
||||
127
frontend/components/Task/TaskDetails/TaskSubtasksCard.tsx
Normal file
127
frontend/components/Task/TaskDetails/TaskSubtasksCard.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ListBulletIcon } from '@heroicons/react/24/outline';
|
||||
import TaskSubtasksSection from '../TaskForm/TaskSubtasksSection';
|
||||
import TaskPriorityIcon from '../TaskPriorityIcon';
|
||||
import { Task } from '../../../entities/Task';
|
||||
|
||||
interface TaskSubtasksCardProps {
|
||||
task: Task;
|
||||
subtasks: Task[];
|
||||
isEditing: boolean;
|
||||
editedSubtasks: Task[];
|
||||
onSubtasksChange: (subtasks: Task[]) => void;
|
||||
onStartEdit: () => void;
|
||||
onSave: () => void;
|
||||
onCancel: () => void;
|
||||
onToggleSubtaskCompletion: (subtask: Task) => Promise<void>;
|
||||
}
|
||||
|
||||
const TaskSubtasksCard: React.FC<TaskSubtasksCardProps> = ({
|
||||
task,
|
||||
subtasks,
|
||||
isEditing,
|
||||
editedSubtasks,
|
||||
onSubtasksChange,
|
||||
onStartEdit,
|
||||
onSave,
|
||||
onCancel,
|
||||
onToggleSubtaskCompletion,
|
||||
}) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h4 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-2">
|
||||
{t('task.subtasks', 'Subtasks')}
|
||||
</h4>
|
||||
{isEditing ? (
|
||||
<div className="rounded-lg shadow-sm bg-white dark:bg-gray-900 border-2 border-blue-500 dark:border-blue-400 p-6">
|
||||
<TaskSubtasksSection
|
||||
parentTaskId={task.id!}
|
||||
subtasks={editedSubtasks}
|
||||
onSubtasksChange={onSubtasksChange}
|
||||
/>
|
||||
<div className="flex items-center justify-end mt-4 pt-4 border-t border-gray-200 dark:border-gray-700">
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
onClick={onSave}
|
||||
className="px-4 py-2 text-sm bg-green-600 dark:bg-green-500 text-white rounded hover:bg-green-700 dark:hover:bg-green-600 transition-colors"
|
||||
>
|
||||
{t('common.save', 'Save')}
|
||||
</button>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="px-4 py-2 text-sm bg-gray-200 dark:bg-gray-800 text-gray-900 dark:text-gray-100 rounded hover:bg-gray-300 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : subtasks.length > 0 ? (
|
||||
<div className="space-y-0.5">
|
||||
{subtasks.map((subtask: Task) => (
|
||||
<div
|
||||
key={subtask.id}
|
||||
className={`rounded-lg shadow-sm bg-white dark:bg-gray-900 border-2 transition-all duration-200 ${
|
||||
subtask.status === 'in_progress' ||
|
||||
subtask.status === 1
|
||||
? 'border-green-400/60 dark:border-green-500/60'
|
||||
: 'border-gray-50 dark:border-gray-800'
|
||||
}`}
|
||||
>
|
||||
<div className="px-3 py-3 flex items-center space-x-3">
|
||||
<TaskPriorityIcon
|
||||
priority={subtask.priority}
|
||||
status={subtask.status}
|
||||
onToggleCompletion={() =>
|
||||
onToggleSubtaskCompletion(subtask)
|
||||
}
|
||||
/>
|
||||
<span
|
||||
onClick={onStartEdit}
|
||||
className={`text-base flex-1 truncate cursor-pointer hover:text-blue-600 dark:hover:text-blue-400 transition-colors ${
|
||||
subtask.status === 'done' ||
|
||||
subtask.status === 2 ||
|
||||
subtask.status === 'archived' ||
|
||||
subtask.status === 3
|
||||
? 'text-gray-500 dark:text-gray-400'
|
||||
: 'text-gray-900 dark:text-gray-100'
|
||||
}`}
|
||||
title={t(
|
||||
'task.clickToEditSubtasks',
|
||||
'Click to edit subtasks'
|
||||
)}
|
||||
>
|
||||
{subtask.name}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
onClick={onStartEdit}
|
||||
className="rounded-lg shadow-sm bg-white dark:bg-gray-900 border-2 border-gray-50 dark:border-gray-800 hover:border-gray-200 dark:hover:border-gray-700 p-6 cursor-pointer transition-colors"
|
||||
title={t(
|
||||
'task.clickToEditSubtasks',
|
||||
'Click to add or edit subtasks'
|
||||
)}
|
||||
>
|
||||
<div className="flex flex-col items-center justify-center py-8 text-gray-500 dark:text-gray-400">
|
||||
<ListBulletIcon className="h-12 w-12 mb-3 opacity-50" />
|
||||
<span className="text-sm text-center">
|
||||
{t(
|
||||
'task.noSubtasksClickToAdd',
|
||||
'No subtasks yet, click to add'
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaskSubtasksCard;
|
||||
|
|
@ -5,7 +5,7 @@ import TagInput from '../../Tag/TagInput';
|
|||
import { Task } from '../../../entities/Task';
|
||||
import { Tag } from '../../../entities/Tag';
|
||||
|
||||
interface TaskTagsSectionProps {
|
||||
interface TaskTagsCardProps {
|
||||
task: Task;
|
||||
availableTags: Tag[];
|
||||
hasLoadedTags: boolean;
|
||||
|
|
@ -14,7 +14,7 @@ interface TaskTagsSectionProps {
|
|||
onLoadTags: () => void;
|
||||
}
|
||||
|
||||
const TaskTagsSection: React.FC<TaskTagsSectionProps> = ({
|
||||
const TaskTagsCard: React.FC<TaskTagsCardProps> = ({
|
||||
task,
|
||||
availableTags,
|
||||
hasLoadedTags,
|
||||
|
|
@ -131,4 +131,4 @@ const TaskTagsSection: React.FC<TaskTagsSectionProps> = ({
|
|||
);
|
||||
};
|
||||
|
||||
export default TaskTagsSection;
|
||||
export default TaskTagsCard;
|
||||
|
|
@ -1,7 +1,10 @@
|
|||
export { default as TaskDetailsHeader } from './TaskDetailsHeader';
|
||||
export { default as TaskSummaryAlerts } from './TaskSummaryAlerts';
|
||||
export { default as TaskContentSection } from './TaskContentSection';
|
||||
export { default as TaskProjectSection } from './TaskProjectSection';
|
||||
export { default as TaskTagsSection } from './TaskTagsSection';
|
||||
export { default as TaskPrioritySection } from './TaskPrioritySection';
|
||||
export { default as TaskContentCard } from './TaskContentCard';
|
||||
export { default as TaskProjectCard } from './TaskProjectCard';
|
||||
export { default as TaskTagsCard } from './TaskTagsCard';
|
||||
export { default as TaskPriorityCard } from './TaskPriorityCard';
|
||||
export { default as TaskRecurringInstanceInfo } from './TaskRecurringInstanceInfo';
|
||||
export { default as TaskSubtasksCard } from './TaskSubtasksCard';
|
||||
export { default as TaskRecurrenceCard } from './TaskRecurrenceCard';
|
||||
export { default as TaskDueDateCard } from './TaskDueDateCard';
|
||||
|
|
|
|||
|
|
@ -66,7 +66,8 @@ const SubtasksDisplay: React.FC<SubtasksDisplayProps> = ({
|
|||
try {
|
||||
const updatedSubtask =
|
||||
await toggleTaskCompletion(
|
||||
subtask.id
|
||||
subtask.id,
|
||||
subtask
|
||||
);
|
||||
|
||||
// Check if parent-child logic was executed
|
||||
|
|
@ -344,7 +345,7 @@ const TaskItem: React.FC<TaskItemProps> = ({
|
|||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
}
|
||||
|
||||
const response = await toggleTaskCompletion(task.id);
|
||||
const response = await toggleTaskCompletion(task.id, task);
|
||||
|
||||
// Handle the updated task
|
||||
if (onTaskCompletionToggle) {
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ import {
|
|||
QueueListIcon,
|
||||
InformationCircleIcon,
|
||||
MagnifyingGlassIcon,
|
||||
CheckIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { getApiPath } from '../config/paths';
|
||||
|
||||
|
|
@ -57,50 +58,81 @@ const Tasks: React.FC = () => {
|
|||
const [isSearchExpanded, setIsSearchExpanded] = useState(false); // Collapsed by default
|
||||
const [showCompleted, setShowCompleted] = useState(false); // Show completed tasks toggle
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||
const [groupBy, setGroupBy] = useState<'none' | 'project'>('none');
|
||||
|
||||
// Pagination state
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
const [totalCount, setTotalCount] = useState(0);
|
||||
const limit = 20;
|
||||
const DEFAULT_LIMIT = 20;
|
||||
const [limit, setLimit] = useState(DEFAULT_LIMIT);
|
||||
|
||||
const dropdownRef = useRef<HTMLDivElement>(null);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const query = new URLSearchParams(location.search);
|
||||
const isUpcomingView = query.get('type') === 'upcoming';
|
||||
const isUpcomingView =
|
||||
query.get('type') === 'upcoming' || location.pathname === '/upcoming';
|
||||
const status = query.get('status');
|
||||
const tag = query.get('tag');
|
||||
|
||||
// Sync showCompleted state with status URL parameter (skip for upcoming view)
|
||||
useEffect(() => {
|
||||
if (isUpcomingView) return; // Don't apply status filtering in upcoming view
|
||||
|
||||
if (status === 'completed') {
|
||||
setShowCompleted(true);
|
||||
} else if (status === 'active') {
|
||||
setShowCompleted(false);
|
||||
} else if (status === null) {
|
||||
// When status is null, we show "All" (both completed and active)
|
||||
setShowCompleted(true);
|
||||
}
|
||||
}, [status, isUpcomingView]);
|
||||
|
||||
// Filter tasks based on completion status and search query
|
||||
const displayTasks = useMemo(() => {
|
||||
let filteredTasks: Task[];
|
||||
let filteredTasks: Task[] = tasks;
|
||||
|
||||
// Filter by completion status (applies to all views)
|
||||
filteredTasks = showCompleted
|
||||
? tasks // Show everything when completed tasks are toggled on
|
||||
: tasks.filter(
|
||||
// Otherwise hide completed/archived items
|
||||
(task: Task) =>
|
||||
task.status !== 'done' &&
|
||||
task.status !== 'archived' &&
|
||||
task.status !== 2 &&
|
||||
task.status !== 3
|
||||
);
|
||||
// Status-based filtering
|
||||
if (status === 'completed') {
|
||||
// Show only completed tasks
|
||||
filteredTasks = filteredTasks.filter((task: Task) => {
|
||||
const isCompleted =
|
||||
task.status === 'done' ||
|
||||
task.status === 'archived' ||
|
||||
task.status === 2 ||
|
||||
task.status === 3;
|
||||
return isCompleted;
|
||||
});
|
||||
} else if (status === 'active') {
|
||||
// Show only active (not completed) tasks
|
||||
filteredTasks = filteredTasks.filter((task: Task) => {
|
||||
const isCompleted =
|
||||
task.status === 'done' ||
|
||||
task.status === 'archived' ||
|
||||
task.status === 2 ||
|
||||
task.status === 3;
|
||||
return !isCompleted;
|
||||
});
|
||||
}
|
||||
// When status is null, show all tasks (no filtering)
|
||||
|
||||
// Then filter by search query if provided (skip for upcoming view)
|
||||
if (taskSearchQuery.trim() && !isUpcomingView) {
|
||||
const query = taskSearchQuery.toLowerCase();
|
||||
const queryLower = taskSearchQuery.toLowerCase();
|
||||
filteredTasks = filteredTasks.filter(
|
||||
(task: Task) =>
|
||||
task.name.toLowerCase().includes(query) ||
|
||||
task.original_name?.toLowerCase().includes(query) ||
|
||||
task.note?.toLowerCase().includes(query)
|
||||
task.name.toLowerCase().includes(queryLower) ||
|
||||
task.original_name?.toLowerCase().includes(queryLower) ||
|
||||
task.note?.toLowerCase().includes(queryLower)
|
||||
);
|
||||
}
|
||||
|
||||
return filteredTasks;
|
||||
}, [tasks, showCompleted, taskSearchQuery, isUpcomingView]);
|
||||
}, [tasks, showCompleted, status, taskSearchQuery, isUpcomingView]);
|
||||
|
||||
// Handle the /upcoming route by setting type=upcoming in query params
|
||||
if (location.pathname === '/upcoming' && !query.get('type')) {
|
||||
|
|
@ -113,13 +145,14 @@ const Tasks: React.FC = () => {
|
|||
stateTitle ||
|
||||
getTitleAndIcon(query, projects, t, location.pathname).title;
|
||||
|
||||
const tag = query.get('tag');
|
||||
const status = query.get('status');
|
||||
|
||||
useEffect(() => {
|
||||
const savedOrderBy =
|
||||
localStorage.getItem('order_by') || 'created_at:desc';
|
||||
setOrderBy(savedOrderBy);
|
||||
const savedGroupBy =
|
||||
(localStorage.getItem('tasks_group_by') as 'none' | 'project') ||
|
||||
'none';
|
||||
setGroupBy(savedGroupBy);
|
||||
|
||||
const params = new URLSearchParams(location.search);
|
||||
if (!params.get('order_by')) {
|
||||
|
|
@ -159,7 +192,15 @@ const Tasks: React.FC = () => {
|
|||
};
|
||||
}, [dropdownOpen]);
|
||||
|
||||
const fetchData = async (resetPagination = true) => {
|
||||
const fetchData = async (
|
||||
resetPagination = true,
|
||||
options?: {
|
||||
limitOverride?: number;
|
||||
forceOffset?: number;
|
||||
disableHasMore?: boolean;
|
||||
disablePagination?: boolean;
|
||||
}
|
||||
) => {
|
||||
setLoading(resetPagination);
|
||||
setError(null);
|
||||
try {
|
||||
|
|
@ -181,10 +222,18 @@ const Tasks: React.FC = () => {
|
|||
allTasksUrl.set('isMobile', isMobile.toString());
|
||||
}
|
||||
|
||||
// Add pagination parameters
|
||||
const currentOffset = resetPagination ? 0 : offset;
|
||||
allTasksUrl.set('limit', limit.toString());
|
||||
allTasksUrl.set('offset', currentOffset.toString());
|
||||
// Add pagination parameters (skip when explicitly disabled or for upcoming view)
|
||||
if (!options?.disablePagination && type !== 'upcoming') {
|
||||
const currentOffset =
|
||||
options?.forceOffset !== undefined
|
||||
? options.forceOffset
|
||||
: resetPagination
|
||||
? 0
|
||||
: offset;
|
||||
const limitToUse = options?.limitOverride ?? limit;
|
||||
allTasksUrl.set('limit', limitToUse.toString());
|
||||
allTasksUrl.set('offset', currentOffset.toString());
|
||||
}
|
||||
|
||||
const searchParams = allTasksUrl.toString();
|
||||
|
||||
|
|
@ -200,7 +249,10 @@ const Tasks: React.FC = () => {
|
|||
if (resetPagination) {
|
||||
setTasks(tasksData.tasks || []);
|
||||
setGroupedTasks(tasksData.groupedTasks || null);
|
||||
setOffset(limit);
|
||||
if (!options?.disablePagination) {
|
||||
const limitToUse = options?.limitOverride ?? limit;
|
||||
setOffset(limitToUse);
|
||||
}
|
||||
} else {
|
||||
setTasks((prev) => [...prev, ...(tasksData.tasks || [])]);
|
||||
// For grouped tasks, merge them
|
||||
|
|
@ -213,12 +265,23 @@ const Tasks: React.FC = () => {
|
|||
};
|
||||
});
|
||||
}
|
||||
setOffset((prev) => prev + limit);
|
||||
if (!options?.disablePagination) {
|
||||
const limitToUse = options?.limitOverride ?? limit;
|
||||
setOffset((prev) => prev + limitToUse);
|
||||
}
|
||||
}
|
||||
|
||||
setHasMore(tasksData.pagination?.hasMore || false);
|
||||
setHasMore(
|
||||
options?.disableHasMore ||
|
||||
options?.disablePagination ||
|
||||
type === 'upcoming'
|
||||
? false
|
||||
: tasksData.pagination?.hasMore || false
|
||||
);
|
||||
if (tasksData.pagination) {
|
||||
setTotalCount(tasksData.pagination.total);
|
||||
} else if (options?.disablePagination || type === 'upcoming') {
|
||||
setTotalCount(tasksData.tasks?.length || 0);
|
||||
}
|
||||
} else {
|
||||
throw new Error('Failed to fetch tasks.');
|
||||
|
|
@ -233,15 +296,45 @@ const Tasks: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const loadMore = async () => {
|
||||
if (!hasMore || isLoadingMore) return;
|
||||
const loadMore = async (all: boolean) => {
|
||||
if (isLoadingMore) return;
|
||||
if (!hasMore && !all) return;
|
||||
setIsLoadingMore(true);
|
||||
await fetchData(false);
|
||||
const shouldDisablePagination =
|
||||
!isUpcomingView && groupBy === 'project';
|
||||
if (all || shouldDisablePagination) {
|
||||
const newLimit = totalCount > 0 ? totalCount : 10000;
|
||||
await fetchData(true, {
|
||||
limitOverride: newLimit,
|
||||
forceOffset: 0,
|
||||
disableHasMore: true,
|
||||
disablePagination: true,
|
||||
});
|
||||
setLimit(DEFAULT_LIMIT);
|
||||
setHasMore(false);
|
||||
} else {
|
||||
await fetchData(false);
|
||||
}
|
||||
if (all) {
|
||||
setHasMore(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData(true);
|
||||
}, [location, isSidebarOpen, isMobile]);
|
||||
// Disable pagination for: upcoming view OR when grouping by project
|
||||
const shouldDisablePagination = isUpcomingView || groupBy === 'project';
|
||||
fetchData(
|
||||
true,
|
||||
shouldDisablePagination
|
||||
? {
|
||||
disablePagination: true,
|
||||
disableHasMore: true,
|
||||
limitOverride: 10000,
|
||||
forceOffset: 0,
|
||||
}
|
||||
: undefined
|
||||
);
|
||||
}, [location, isSidebarOpen, isMobile, groupBy, isUpcomingView]);
|
||||
|
||||
// Handle window resize for mobile detection
|
||||
useEffect(() => {
|
||||
|
|
@ -469,7 +562,7 @@ const Tasks: React.FC = () => {
|
|||
|
||||
return (
|
||||
<div
|
||||
className={`w-full pt-4 pb-8 ${isUpcomingView ? '' : 'px-2 sm:px-4 lg:px-6'}`}
|
||||
className={`w-full pt-4 pb-8 ${isUpcomingView ? 'pl-2 sm:pl-4' : 'px-2 sm:px-4 lg:px-6'}`}
|
||||
>
|
||||
<div
|
||||
className={`w-full ${isUpcomingView ? '' : 'max-w-5xl mx-auto'}`}
|
||||
|
|
@ -560,57 +653,244 @@ const Tasks: React.FC = () => {
|
|||
ariaLabel={t('tasks.sortTasks', 'Sort tasks')}
|
||||
title={t('tasks.sortTasks', 'Sort tasks')}
|
||||
dropdownLabel={t('tasks.sortBy', 'Sort by')}
|
||||
extraContent={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCompleted((v) => !v)}
|
||||
className="w-full flex items-center justify-between text-sm text-gray-700 dark:text-gray-300"
|
||||
aria-pressed={showCompleted}
|
||||
aria-label={
|
||||
showCompleted
|
||||
? t(
|
||||
'tasks.hideCompleted',
|
||||
'Hide completed tasks'
|
||||
)
|
||||
: t(
|
||||
'tasks.showCompleted',
|
||||
'Show completed tasks'
|
||||
)
|
||||
}
|
||||
title={
|
||||
showCompleted
|
||||
? t(
|
||||
'tasks.hideCompleted',
|
||||
'Hide completed tasks'
|
||||
)
|
||||
: t(
|
||||
'tasks.showCompleted',
|
||||
'Show completed tasks'
|
||||
)
|
||||
}
|
||||
>
|
||||
<span>
|
||||
{t(
|
||||
'tasks.showCompleted',
|
||||
'Show completed'
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
||||
showCompleted
|
||||
? 'bg-blue-600'
|
||||
: 'bg-gray-200 dark:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
showCompleted
|
||||
? 'translate-x-4'
|
||||
: 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
align="right"
|
||||
footerContent={
|
||||
!isUpcomingView && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="px-3 py-2 text-xs font-bold text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700">
|
||||
{t('tasks.groupBy', 'Group by')}
|
||||
</div>
|
||||
<div className="py-1">
|
||||
{['none', 'project'].map(
|
||||
(val) => (
|
||||
<button
|
||||
key={val}
|
||||
onClick={() => {
|
||||
setGroupBy(
|
||||
val as
|
||||
| 'none'
|
||||
| 'project'
|
||||
);
|
||||
localStorage.setItem(
|
||||
'tasks_group_by',
|
||||
val
|
||||
);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 text-sm transition-colors flex items-center justify-between ${
|
||||
groupBy === val
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span>
|
||||
{val ===
|
||||
'project'
|
||||
? t(
|
||||
'tasks.groupByProject',
|
||||
'Project'
|
||||
)
|
||||
: t(
|
||||
'tasks.grouping.none',
|
||||
'None'
|
||||
)}
|
||||
</span>
|
||||
{groupBy ===
|
||||
val && (
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="px-3 py-2 text-xs font-bold text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-900/50 border-t border-b border-gray-200 dark:border-gray-700">
|
||||
{t('tasks.show', 'Show')}
|
||||
</div>
|
||||
<div className="py-1 space-y-1">
|
||||
{[
|
||||
{
|
||||
key: 'active',
|
||||
label: t(
|
||||
'tasks.open',
|
||||
'Open'
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'all',
|
||||
label: t(
|
||||
'tasks.all',
|
||||
'All'
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'completed',
|
||||
label: t(
|
||||
'tasks.completed',
|
||||
'Completed'
|
||||
),
|
||||
},
|
||||
].map((opt) => {
|
||||
const isActive =
|
||||
(opt.key === 'all' &&
|
||||
status === null) ||
|
||||
(opt.key ===
|
||||
'completed' &&
|
||||
status ===
|
||||
'completed') ||
|
||||
(opt.key === 'active' &&
|
||||
status ===
|
||||
'active');
|
||||
return (
|
||||
<button
|
||||
key={opt.key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (
|
||||
opt.key ===
|
||||
'completed'
|
||||
) {
|
||||
const params =
|
||||
new URLSearchParams(
|
||||
location.search
|
||||
);
|
||||
params.set(
|
||||
'status',
|
||||
'completed'
|
||||
);
|
||||
navigate(
|
||||
{
|
||||
pathname:
|
||||
location.pathname,
|
||||
search: `?${params.toString()}`,
|
||||
},
|
||||
{
|
||||
replace: true,
|
||||
}
|
||||
);
|
||||
} else if (
|
||||
opt.key ===
|
||||
'all'
|
||||
) {
|
||||
const params =
|
||||
new URLSearchParams(
|
||||
location.search
|
||||
);
|
||||
params.delete(
|
||||
'status'
|
||||
);
|
||||
navigate(
|
||||
{
|
||||
pathname:
|
||||
location.pathname,
|
||||
search: `?${params.toString()}`,
|
||||
},
|
||||
{
|
||||
replace: true,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// active (not completed)
|
||||
const params =
|
||||
new URLSearchParams(
|
||||
location.search
|
||||
);
|
||||
params.set(
|
||||
'status',
|
||||
'active'
|
||||
);
|
||||
navigate(
|
||||
{
|
||||
pathname:
|
||||
location.pathname,
|
||||
search: `?${params.toString()}`,
|
||||
},
|
||||
{
|
||||
replace: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 text-sm transition-colors flex items-center justify-between ${
|
||||
isActive
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span>
|
||||
{opt.label}
|
||||
</span>
|
||||
{isActive && (
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="px-3 py-2 text-xs font-bold text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-900/50 border-t border-b border-gray-200 dark:border-gray-700">
|
||||
{t(
|
||||
'tasks.direction',
|
||||
'Direction'
|
||||
)}
|
||||
</div>
|
||||
<div className="py-1">
|
||||
{[
|
||||
{
|
||||
key: 'asc',
|
||||
label: t(
|
||||
'tasks.ascending',
|
||||
'Ascending'
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'desc',
|
||||
label: t(
|
||||
'tasks.descending',
|
||||
'Descending'
|
||||
),
|
||||
},
|
||||
].map((dir) => {
|
||||
const currentDirection =
|
||||
orderBy.split(':')[1] ||
|
||||
'asc';
|
||||
const isActive =
|
||||
currentDirection ===
|
||||
dir.key;
|
||||
return (
|
||||
<button
|
||||
key={dir.key}
|
||||
onClick={() => {
|
||||
const [field] =
|
||||
orderBy.split(
|
||||
':'
|
||||
);
|
||||
const newOrderBy = `${field}:${dir.key}`;
|
||||
handleSortChange(
|
||||
newOrderBy
|
||||
);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 text-sm transition-colors flex items-center justify-between ${
|
||||
isActive
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span>
|
||||
{dir.label}
|
||||
</span>
|
||||
{isActive && (
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -670,7 +950,7 @@ const Tasks: React.FC = () => {
|
|||
<>
|
||||
{/* New Task Form */}
|
||||
{isNewTaskAllowed() && (
|
||||
<div className="mb-1.5">
|
||||
<div className="mb-6">
|
||||
<NewTask
|
||||
onTaskCreate={async (taskName: string) =>
|
||||
await handleTaskCreate({
|
||||
|
|
@ -690,6 +970,7 @@ const Tasks: React.FC = () => {
|
|||
<GroupedTaskList
|
||||
tasks={displayTasks}
|
||||
groupedTasks={groupedTasks}
|
||||
groupBy="none"
|
||||
onTaskCreate={handleTaskCreate}
|
||||
onTaskUpdate={handleTaskUpdate}
|
||||
onTaskCompletionToggle={
|
||||
|
|
@ -702,6 +983,23 @@ const Tasks: React.FC = () => {
|
|||
showCompletedTasks={showCompleted}
|
||||
searchQuery={taskSearchQuery}
|
||||
/>
|
||||
) : groupBy === 'project' ? (
|
||||
<GroupedTaskList
|
||||
tasks={displayTasks}
|
||||
groupedTasks={null}
|
||||
groupBy="project"
|
||||
onTaskCreate={handleTaskCreate}
|
||||
onTaskUpdate={handleTaskUpdate}
|
||||
onTaskCompletionToggle={
|
||||
handleTaskCompletionToggle
|
||||
}
|
||||
onTaskDelete={handleTaskDelete}
|
||||
projects={projects}
|
||||
hideProjectName={false}
|
||||
onToggleToday={handleToggleToday}
|
||||
showCompletedTasks={showCompleted}
|
||||
searchQuery={taskSearchQuery}
|
||||
/>
|
||||
) : (
|
||||
<TaskList
|
||||
tasks={displayTasks}
|
||||
|
|
@ -716,13 +1014,13 @@ const Tasks: React.FC = () => {
|
|||
showCompletedTasks={showCompleted}
|
||||
/>
|
||||
)}
|
||||
{/* Load more button */}
|
||||
{hasMore && (
|
||||
<div className="flex justify-center pt-4">
|
||||
{/* Load more button - hide in upcoming view */}
|
||||
{!isUpcomingView && hasMore && (
|
||||
<div className="flex justify-center pt-4 gap-3">
|
||||
<button
|
||||
onClick={loadMore}
|
||||
onClick={() => loadMore(false)}
|
||||
disabled={isLoadingMore}
|
||||
className="inline-flex items-center px-6 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
className="inline-flex items-center px-5 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{isLoadingMore ? (
|
||||
<>
|
||||
|
|
@ -761,11 +1059,18 @@ const Tasks: React.FC = () => {
|
|||
</>
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => loadMore(true)}
|
||||
disabled={isLoadingMore}
|
||||
className="inline-flex items-center px-5 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm hover:bg-gray-50 dark:hover:bg-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{t('common.showAll', 'Show All')}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pagination info */}
|
||||
{tasks.length > 0 && (
|
||||
{/* Pagination info - hide in upcoming view */}
|
||||
{!isUpcomingView && tasks.length > 0 && (
|
||||
<div className="text-center text-sm text-gray-500 dark:text-gray-400 pt-2 pb-4">
|
||||
{t(
|
||||
'tasks.showingItems',
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
InformationCircleIcon,
|
||||
PencilSquareIcon,
|
||||
MagnifyingGlassIcon,
|
||||
CheckIcon,
|
||||
} from '@heroicons/react/24/outline';
|
||||
import { StarIcon as StarIconSolid } from '@heroicons/react/24/solid';
|
||||
import { Task } from '../entities/Task';
|
||||
|
|
@ -52,7 +53,9 @@ const ViewDetail: React.FC = () => {
|
|||
// Search, filter, and sort state
|
||||
const [taskSearchQuery, setTaskSearchQuery] = useState<string>('');
|
||||
const [isSearchExpanded, setIsSearchExpanded] = useState(false);
|
||||
const [showCompleted, setShowCompleted] = useState(false);
|
||||
const [taskStatusFilter, setTaskStatusFilter] = useState<
|
||||
'all' | 'active' | 'completed'
|
||||
>('active');
|
||||
const [orderBy, setOrderBy] = useState<string>('created_at:desc');
|
||||
|
||||
// Pagination state
|
||||
|
|
@ -85,7 +88,7 @@ const ViewDetail: React.FC = () => {
|
|||
let filteredTasks: Task[];
|
||||
|
||||
// Filter by completion status
|
||||
if (showCompleted) {
|
||||
if (taskStatusFilter === 'completed') {
|
||||
filteredTasks = tasks.filter(
|
||||
(task: Task) =>
|
||||
task.status === 'done' ||
|
||||
|
|
@ -93,7 +96,7 @@ const ViewDetail: React.FC = () => {
|
|||
task.status === 2 ||
|
||||
task.status === 3
|
||||
);
|
||||
} else {
|
||||
} else if (taskStatusFilter === 'active') {
|
||||
filteredTasks = tasks.filter(
|
||||
(task: Task) =>
|
||||
task.status !== 'done' &&
|
||||
|
|
@ -101,6 +104,9 @@ const ViewDetail: React.FC = () => {
|
|||
task.status !== 2 &&
|
||||
task.status !== 3
|
||||
);
|
||||
} else {
|
||||
// taskStatusFilter === 'all'
|
||||
filteredTasks = tasks;
|
||||
}
|
||||
|
||||
// Filter by search query
|
||||
|
|
@ -165,7 +171,7 @@ const ViewDetail: React.FC = () => {
|
|||
});
|
||||
|
||||
return sortedTasks;
|
||||
}, [tasks, showCompleted, taskSearchQuery, orderBy, t]);
|
||||
}, [tasks, taskStatusFilter, taskSearchQuery, orderBy, t]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchViewAndResults();
|
||||
|
|
@ -533,57 +539,121 @@ const ViewDetail: React.FC = () => {
|
|||
ariaLabel={t('views.sortTasks', 'Sort tasks')}
|
||||
title={t('views.sortTasks', 'Sort tasks')}
|
||||
dropdownLabel={t('tasks.sortBy', 'Sort by')}
|
||||
extraContent={
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowCompleted((v) => !v)}
|
||||
className="w-full flex items-center justify-between text-sm text-gray-700 dark:text-gray-300"
|
||||
aria-pressed={showCompleted}
|
||||
aria-label={
|
||||
showCompleted
|
||||
? t(
|
||||
'views.hideCompleted',
|
||||
'Hide completed tasks'
|
||||
)
|
||||
: t(
|
||||
'views.showCompleted',
|
||||
'Show completed tasks'
|
||||
)
|
||||
}
|
||||
title={
|
||||
showCompleted
|
||||
? t(
|
||||
'views.hideCompleted',
|
||||
'Hide completed tasks'
|
||||
)
|
||||
: t(
|
||||
'views.showCompleted',
|
||||
'Show completed tasks'
|
||||
)
|
||||
}
|
||||
>
|
||||
<span>
|
||||
{t(
|
||||
'common.showCompleted',
|
||||
'Show completed'
|
||||
)}
|
||||
</span>
|
||||
<span
|
||||
className={`relative inline-flex h-5 w-9 items-center rounded-full transition-colors ${
|
||||
showCompleted
|
||||
? 'bg-blue-600'
|
||||
: 'bg-gray-200 dark:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
||||
showCompleted
|
||||
? 'translate-x-4'
|
||||
: 'translate-x-0.5'
|
||||
}`}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
footerContent={
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<div className="px-3 py-2 text-xs font-bold text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-900/50 border-t border-b border-gray-200 dark:border-gray-700">
|
||||
{t('tasks.show', 'Show')}
|
||||
</div>
|
||||
<div className="py-1 space-y-1">
|
||||
{[
|
||||
{
|
||||
key: 'active',
|
||||
label: t(
|
||||
'tasks.open',
|
||||
'Open'
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'all',
|
||||
label: t(
|
||||
'tasks.all',
|
||||
'All'
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'completed',
|
||||
label: t(
|
||||
'tasks.completed',
|
||||
'Completed'
|
||||
),
|
||||
},
|
||||
].map((opt) => {
|
||||
const isActive =
|
||||
taskStatusFilter ===
|
||||
opt.key;
|
||||
return (
|
||||
<button
|
||||
key={opt.key}
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setTaskStatusFilter(
|
||||
opt.key as
|
||||
| 'all'
|
||||
| 'active'
|
||||
| 'completed'
|
||||
)
|
||||
}
|
||||
className={`w-full text-left px-3 py-2 text-sm transition-colors flex items-center justify-between ${
|
||||
isActive
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span>{opt.label}</span>
|
||||
{isActive && (
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="px-3 py-2 text-xs font-bold text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-900/50 border-t border-b border-gray-200 dark:border-gray-700">
|
||||
{t('tasks.direction', 'Direction')}
|
||||
</div>
|
||||
<div className="py-1">
|
||||
{[
|
||||
{
|
||||
key: 'asc',
|
||||
label: t(
|
||||
'tasks.ascending',
|
||||
'Ascending'
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'desc',
|
||||
label: t(
|
||||
'tasks.descending',
|
||||
'Descending'
|
||||
),
|
||||
},
|
||||
].map((dir) => {
|
||||
const currentDirection =
|
||||
orderBy.split(':')[1] ||
|
||||
'asc';
|
||||
const isActive =
|
||||
currentDirection ===
|
||||
dir.key;
|
||||
return (
|
||||
<button
|
||||
key={dir.key}
|
||||
onClick={() => {
|
||||
const [field] =
|
||||
orderBy.split(
|
||||
':'
|
||||
);
|
||||
setOrderBy(
|
||||
`${field}:${dir.key}`
|
||||
);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 text-sm transition-colors flex items-center justify-between ${
|
||||
isActive
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span>{dir.label}</span>
|
||||
{isActive && (
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<div className="relative" ref={criteriaDropdownRef}>
|
||||
|
|
@ -781,7 +851,7 @@ const ViewDetail: React.FC = () => {
|
|||
projects={projects}
|
||||
hideProjectName={false}
|
||||
onToggleToday={handleToggleToday}
|
||||
showCompletedTasks={showCompleted}
|
||||
showCompletedTasks={taskStatusFilter !== 'active'}
|
||||
/>
|
||||
{/* Load more button */}
|
||||
{hasMore && (
|
||||
|
|
|
|||
|
|
@ -77,7 +77,7 @@ export const createTask = async (taskData: Task): Promise<Task> => {
|
|||
|
||||
export const updateTask = async (
|
||||
taskId: number,
|
||||
taskData: Task
|
||||
taskData: Partial<Task>
|
||||
): Promise<Task> => {
|
||||
const response = await fetch(getApiPath(`task/${taskId}`), {
|
||||
method: 'PATCH',
|
||||
|
|
@ -94,18 +94,12 @@ export const toggleTaskCompletion = async (
|
|||
taskId: number,
|
||||
currentTask?: Task
|
||||
): Promise<Task> => {
|
||||
if (!currentTask) {
|
||||
currentTask = await fetchTaskById(taskId);
|
||||
}
|
||||
const task = currentTask ?? (await fetchTaskById(taskId));
|
||||
|
||||
const newStatus =
|
||||
currentTask.status === 2 || currentTask.status === 'done'
|
||||
? currentTask.note
|
||||
? 1
|
||||
: 0
|
||||
: 2;
|
||||
task.status === 2 || task.status === 'done' ? (task.note ? 1 : 0) : 2;
|
||||
|
||||
return await updateTask(taskId, { ...currentTask, status: newStatus });
|
||||
return await updateTask(taskId, { status: newStatus });
|
||||
};
|
||||
|
||||
export const deleteTask = async (taskId: number): Promise<void> => {
|
||||
|
|
|
|||
|
|
@ -122,7 +122,25 @@
|
|||
"showSubtasks": "عرض المهام الفرعية",
|
||||
"hideSubtasks": "إخفاء المهام الفرعية",
|
||||
"edit": "تعديل المهمة",
|
||||
"delete": "حذف المهمة"
|
||||
"delete": "حذف المهمة",
|
||||
"sortTasks": "ترتيب المهام",
|
||||
"sortBy": "ترتيب حسب",
|
||||
"direction": "الاتجاه",
|
||||
"ascending": "تصاعدي",
|
||||
"descending": "تنازلي",
|
||||
"groupBy": "تجميع حسب",
|
||||
"groupByProject": "المشروع",
|
||||
"grouping": {
|
||||
"none": "بدون"
|
||||
},
|
||||
"show": "عرض",
|
||||
"all": "الكل",
|
||||
"completedOnly": "المكتملة فقط",
|
||||
"notCompleted": "غير مكتملة",
|
||||
"noProject": "بدون مشروع",
|
||||
"unknownProject": "مشروع غير معروف",
|
||||
"tasks": "مهام",
|
||||
"showingItems": "عرض {{current}} من {{total}} عنصر"
|
||||
},
|
||||
"timeline": {
|
||||
"activityTimeline": "جدول الأنشطة",
|
||||
|
|
@ -508,7 +526,7 @@
|
|||
"project": {
|
||||
"name": "اسم المشروع",
|
||||
"projectImage": "صورة المشروع",
|
||||
"uploadImageHint": "قم بتحميل صورة لمشروعك (حد أقصى 5 ميجابايت)",
|
||||
"uploadImageHint": "قم بتحميل صورة لمشروعك (حد أقصى 10 ميجابايت)",
|
||||
"browseImage": "تصفح الصورة",
|
||||
"noNotes": "لا توجد ملاحظات لهذا المشروع.",
|
||||
"deleteProject": "حذف المشروع",
|
||||
|
|
@ -765,7 +783,9 @@
|
|||
"in_progress_desc": "يتم العمل النشط",
|
||||
"blocked_desc": "مؤقتًا متوقف أو عالق",
|
||||
"completed_desc": "تم الانتهاء منها"
|
||||
}
|
||||
},
|
||||
"showMetrics": "عرض المقاييس",
|
||||
"hideMetrics": "إخفاء المقاييس"
|
||||
},
|
||||
"projectItem": {
|
||||
"edit": "تعديل",
|
||||
|
|
@ -1138,4 +1158,4 @@
|
|||
"subtasks": {
|
||||
"placeholder": "أضف مهمة فرعية..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -122,7 +122,25 @@
|
|||
"showSubtasks": "Покажи подзадачи",
|
||||
"hideSubtasks": "Скрий подзадачи",
|
||||
"edit": "Редактиране на задача",
|
||||
"delete": "Изтриване на задача"
|
||||
"delete": "Изтриване на задача",
|
||||
"sortTasks": "Сортиране на задачи",
|
||||
"sortBy": "Сортиране по",
|
||||
"direction": "Посока",
|
||||
"ascending": "Възходящ",
|
||||
"descending": "Низходящ",
|
||||
"groupBy": "Групиране по",
|
||||
"groupByProject": "Проект",
|
||||
"grouping": {
|
||||
"none": "Без"
|
||||
},
|
||||
"show": "Покажи",
|
||||
"all": "Всички",
|
||||
"completedOnly": "Само завършени",
|
||||
"notCompleted": "Незавършени",
|
||||
"noProject": "Без проект",
|
||||
"unknownProject": "Неизвестен проект",
|
||||
"tasks": "задачи",
|
||||
"showingItems": "Показване на {{current}} от {{total}} елемента"
|
||||
},
|
||||
"timeline": {
|
||||
"activityTimeline": "Хронология на активността",
|
||||
|
|
@ -508,7 +526,7 @@
|
|||
"project": {
|
||||
"name": "Име на проекта",
|
||||
"projectImage": "Снимка на проекта",
|
||||
"uploadImageHint": "Качете изображение за вашия проект (макс 5MB)",
|
||||
"uploadImageHint": "Качете изображение за вашия проект (макс 10MB)",
|
||||
"browseImage": "Преглед на изображение",
|
||||
"noNotes": "Няма бележки за този проект.",
|
||||
"deleteProject": "Изтрий проекта",
|
||||
|
|
@ -765,7 +783,9 @@
|
|||
"in_progress_desc": "Активна работа в ход",
|
||||
"blocked_desc": "Временно спряно или блокирано",
|
||||
"completed_desc": "Завършено и готово"
|
||||
}
|
||||
},
|
||||
"showMetrics": "Покажи метрики",
|
||||
"hideMetrics": "Скрий метрики"
|
||||
},
|
||||
"projectItem": {
|
||||
"edit": "Редактиране",
|
||||
|
|
@ -1138,4 +1158,4 @@
|
|||
"subtasks": {
|
||||
"placeholder": "Добавете подзадача..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -122,7 +122,25 @@
|
|||
"showSubtasks": "Vis underopgaver",
|
||||
"hideSubtasks": "Skjul underopgaver",
|
||||
"edit": "Rediger opgave",
|
||||
"delete": "Slet opgave"
|
||||
"delete": "Slet opgave",
|
||||
"sortTasks": "Sortér opgaver",
|
||||
"sortBy": "Sortér efter",
|
||||
"direction": "Retning",
|
||||
"ascending": "Stigende",
|
||||
"descending": "Faldende",
|
||||
"groupBy": "Gruppér efter",
|
||||
"groupByProject": "Projekt",
|
||||
"grouping": {
|
||||
"none": "Ingen"
|
||||
},
|
||||
"show": "Vis",
|
||||
"all": "Alle",
|
||||
"completedOnly": "Kun afsluttede",
|
||||
"notCompleted": "Ikke afsluttet",
|
||||
"noProject": "Intet projekt",
|
||||
"unknownProject": "Ukendt projekt",
|
||||
"tasks": "opgaver",
|
||||
"showingItems": "Viser {{current}} af {{total}} elementer"
|
||||
},
|
||||
"timeline": {
|
||||
"activityTimeline": "Aktivitets Tidslinje",
|
||||
|
|
@ -508,7 +526,7 @@
|
|||
"project": {
|
||||
"name": "Projekt Navn",
|
||||
"projectImage": "Projektbillede",
|
||||
"uploadImageHint": "Upload et billede til dit projekt (maks 5MB)",
|
||||
"uploadImageHint": "Upload et billede til dit projekt (maks 10MB)",
|
||||
"browseImage": "Gennemse Billede",
|
||||
"noNotes": "Ingen noter til dette projekt.",
|
||||
"deleteProject": "Slet Projekt",
|
||||
|
|
@ -765,7 +783,9 @@
|
|||
"in_progress_desc": "Aktivt arbejde i gang",
|
||||
"blocked_desc": "Midlertidigt pauseret eller fastlåst",
|
||||
"completed_desc": "Afsluttet og færdig"
|
||||
}
|
||||
},
|
||||
"showMetrics": "Vis målinger",
|
||||
"hideMetrics": "Skjul målinger"
|
||||
},
|
||||
"projectItem": {
|
||||
"edit": "Rediger",
|
||||
|
|
@ -1138,4 +1158,4 @@
|
|||
"subtasks": {
|
||||
"placeholder": "Tilføj en underopgave..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -122,7 +122,25 @@
|
|||
"showSubtasks": "Unteraufgaben anzeigen",
|
||||
"hideSubtasks": "Unteraufgaben ausblenden",
|
||||
"edit": "Aufgabe bearbeiten",
|
||||
"delete": "Aufgabe löschen"
|
||||
"delete": "Aufgabe löschen",
|
||||
"sortTasks": "Aufgaben sortieren",
|
||||
"sortBy": "Sortieren nach",
|
||||
"direction": "Richtung",
|
||||
"ascending": "Aufsteigend",
|
||||
"descending": "Absteigend",
|
||||
"groupBy": "Gruppieren nach",
|
||||
"groupByProject": "Projekt",
|
||||
"grouping": {
|
||||
"none": "Keine"
|
||||
},
|
||||
"show": "Anzeigen",
|
||||
"all": "Alle",
|
||||
"completedOnly": "Nur abgeschlossen",
|
||||
"notCompleted": "Nicht abgeschlossen",
|
||||
"noProject": "Kein Projekt",
|
||||
"unknownProject": "Unbekanntes Projekt",
|
||||
"tasks": "Aufgaben",
|
||||
"showingItems": "{{current}} von {{total}} Elementen angezeigt"
|
||||
},
|
||||
"timeline": {
|
||||
"activityTimeline": "Aktivitätsverlauf",
|
||||
|
|
@ -384,7 +402,7 @@
|
|||
},
|
||||
"project": {
|
||||
"projectImage": "Projektbild",
|
||||
"uploadImageHint": "Laden Sie ein Bild für Ihr Projekt hoch (max. 5MB)",
|
||||
"uploadImageHint": "Laden Sie ein Bild für Ihr Projekt hoch (max. 10MB)",
|
||||
"browseImage": "Bild durchsuchen",
|
||||
"name": "Projektname",
|
||||
"noNotes": "Keine Notizen für dieses Projekt.",
|
||||
|
|
@ -855,7 +873,9 @@
|
|||
"in_progress_desc": "Aktive Arbeit im Gange",
|
||||
"blocked_desc": "Vorübergehend pausiert oder festgefahren",
|
||||
"completed_desc": "Fertiggestellt und abgeschlossen"
|
||||
}
|
||||
},
|
||||
"showMetrics": "Metriken anzeigen",
|
||||
"hideMetrics": "Metriken ausblenden"
|
||||
},
|
||||
"projectItem": {
|
||||
"edit": "Bearbeiten",
|
||||
|
|
@ -1147,4 +1167,4 @@
|
|||
"subtasks": {
|
||||
"placeholder": "Fügen Sie eine Unteraufgabe hinzu..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -332,7 +332,25 @@
|
|||
"showSubtasks": "Εμφάνιση υποκαθηκόντων",
|
||||
"hideSubtasks": "Απόκρυψη υποκαθηκόντων",
|
||||
"edit": "Επεξεργασία καθήκοντος",
|
||||
"delete": "Διαγραφή καθήκοντος"
|
||||
"delete": "Διαγραφή καθήκοντος",
|
||||
"sortTasks": "Ταξινόμηση εργασιών",
|
||||
"sortBy": "Ταξινόμηση κατά",
|
||||
"direction": "Κατεύθυνση",
|
||||
"ascending": "Αύξουσα",
|
||||
"descending": "Φθίνουσα",
|
||||
"groupBy": "Ομαδοποίηση κατά",
|
||||
"groupByProject": "Έργο",
|
||||
"grouping": {
|
||||
"none": "Καμία"
|
||||
},
|
||||
"show": "Εμφάνιση",
|
||||
"all": "Όλα",
|
||||
"completedOnly": "Μόνο ολοκληρωμένα",
|
||||
"notCompleted": "Μη ολοκληρωμένα",
|
||||
"noProject": "Χωρίς έργο",
|
||||
"unknownProject": "Άγνωστο έργο",
|
||||
"tasks": "εργασίες",
|
||||
"showingItems": "Εμφάνιση {{current}} από {{total}} στοιχεία"
|
||||
},
|
||||
"timeline": {
|
||||
"activityTimeline": "Χρονοδιάγραμμα Δραστηριότητας",
|
||||
|
|
@ -403,7 +421,9 @@
|
|||
"in_progress_desc": "Ενεργή εργασία σε εξέλιξη",
|
||||
"blocked_desc": "Προσωρινά παγωμένο ή κολλημένο",
|
||||
"completed_desc": "Ολοκληρώθηκε και τελείωσε"
|
||||
}
|
||||
},
|
||||
"showMetrics": "Εμφάνιση μετρήσεων",
|
||||
"hideMetrics": "Απόκρυψη μετρήσεων"
|
||||
},
|
||||
"notes": {
|
||||
"loading": "Φόρτωση σημειώσεων...",
|
||||
|
|
@ -845,7 +865,7 @@
|
|||
"project": {
|
||||
"name": "Όνομα Έργου",
|
||||
"projectImage": "Εικόνα Έργου",
|
||||
"uploadImageHint": "Μεταφορτώστε μια εικόνα για το έργο σας (μέγ. 5MB)",
|
||||
"uploadImageHint": "Μεταφορτώστε μια εικόνα για το έργο σας (μέγ. 10MB)",
|
||||
"browseImage": "Περιήγηση Εικόνας",
|
||||
"noNotes": "Δεν υπάρχουν σημειώσεις για αυτό το έργο.",
|
||||
"deleteProject": "Διαγραφή Έργου",
|
||||
|
|
@ -1142,4 +1162,4 @@
|
|||
"subtasks": {
|
||||
"placeholder": "Προσθέστε μια υποεργασία..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -122,7 +122,25 @@
|
|||
"showSubtasks": "Show subtasks",
|
||||
"hideSubtasks": "Hide subtasks",
|
||||
"edit": "Edit task",
|
||||
"delete": "Delete task"
|
||||
"delete": "Delete task",
|
||||
"sortTasks": "Sort tasks",
|
||||
"sortBy": "Sort by",
|
||||
"direction": "Direction",
|
||||
"ascending": "Ascending",
|
||||
"descending": "Descending",
|
||||
"groupBy": "Group by",
|
||||
"groupByProject": "Project",
|
||||
"grouping": {
|
||||
"none": "None"
|
||||
},
|
||||
"show": "Show",
|
||||
"all": "All",
|
||||
"completedOnly": "Completed only",
|
||||
"notCompleted": "Not completed",
|
||||
"noProject": "No project",
|
||||
"unknownProject": "Unknown project",
|
||||
"tasks": "tasks",
|
||||
"showingItems": "Showing {{current}} of {{total}} items"
|
||||
},
|
||||
"timeline": {
|
||||
"activityTimeline": "Activity Timeline",
|
||||
|
|
@ -508,7 +526,7 @@
|
|||
"project": {
|
||||
"name": "Project Name",
|
||||
"projectImage": "Project Image",
|
||||
"uploadImageHint": "Upload an image for your project (max 5MB)",
|
||||
"uploadImageHint": "Upload an image for your project (max 10MB)",
|
||||
"browseImage": "Browse Image",
|
||||
"noNotes": "No notes for this project.",
|
||||
"deleteProject": "Delete Project",
|
||||
|
|
@ -768,7 +786,9 @@
|
|||
"in_progress_desc": "Active work happening",
|
||||
"blocked_desc": "Temporarily paused or stuck",
|
||||
"completed_desc": "Finished and done"
|
||||
}
|
||||
},
|
||||
"showMetrics": "Show metrics",
|
||||
"hideMetrics": "Hide metrics"
|
||||
},
|
||||
"projectItem": {
|
||||
"edit": "Edit",
|
||||
|
|
@ -874,7 +894,7 @@
|
|||
},
|
||||
"calendar": {
|
||||
"month": "Month",
|
||||
"week": "Week",
|
||||
"week": "Week",
|
||||
"day": "Day",
|
||||
"today": "Today",
|
||||
"addEvent": "Add Event",
|
||||
|
|
@ -899,7 +919,7 @@
|
|||
"close": "Close",
|
||||
"title": "Title",
|
||||
"status": "Status",
|
||||
"dueDate": "Due Date",
|
||||
"dueDate": "Due Date",
|
||||
"priority": "Priority",
|
||||
"project": "Project",
|
||||
"area": "Area",
|
||||
|
|
@ -1030,8 +1050,7 @@
|
|||
"viewOnGitHub": "View on GitHub",
|
||||
"license": "Licensed for personal use",
|
||||
"builtBy": "Built by"
|
||||
}
|
||||
,
|
||||
},
|
||||
"admin": {
|
||||
"manageUsers": "Manage users",
|
||||
"userManagement": "User Management",
|
||||
|
|
|
|||
|
|
@ -332,7 +332,25 @@
|
|||
"showSubtasks": "Mostrar subtareas",
|
||||
"hideSubtasks": "Ocultar subtareas",
|
||||
"edit": "Editar tarea",
|
||||
"delete": "Eliminar tarea"
|
||||
"delete": "Eliminar tarea",
|
||||
"sortTasks": "Ordenar tareas",
|
||||
"sortBy": "Ordenar por",
|
||||
"direction": "Dirección",
|
||||
"ascending": "Ascendente",
|
||||
"descending": "Descendente",
|
||||
"groupBy": "Agrupar por",
|
||||
"groupByProject": "Proyecto",
|
||||
"grouping": {
|
||||
"none": "Ninguno"
|
||||
},
|
||||
"show": "Mostrar",
|
||||
"all": "Todos",
|
||||
"completedOnly": "Solo completadas",
|
||||
"notCompleted": "No completadas",
|
||||
"noProject": "Sin proyecto",
|
||||
"unknownProject": "Proyecto desconocido",
|
||||
"tasks": "tareas",
|
||||
"showingItems": "Mostrando {{current}} de {{total}} elementos"
|
||||
},
|
||||
"timeline": {
|
||||
"activityTimeline": "Línea de Tiempo de Actividad",
|
||||
|
|
@ -403,7 +421,9 @@
|
|||
"in_progress_desc": "Trabajo activo en curso",
|
||||
"blocked_desc": "Pausado temporalmente o atascado",
|
||||
"completed_desc": "Terminado y completado"
|
||||
}
|
||||
},
|
||||
"showMetrics": "Mostrar métricas",
|
||||
"hideMetrics": "Ocultar métricas"
|
||||
},
|
||||
"projectItem": {
|
||||
"edit": "Editar",
|
||||
|
|
@ -580,7 +600,7 @@
|
|||
"project": {
|
||||
"name": "Nombre del Proyecto",
|
||||
"projectImage": "Imagen del Proyecto",
|
||||
"uploadImageHint": "Sube una imagen para tu proyecto (máx. 5MB)",
|
||||
"uploadImageHint": "Sube una imagen para tu proyecto (máx. 10MB)",
|
||||
"browseImage": "Examinar Imagen",
|
||||
"noNotes": "No hay notas para este proyecto.",
|
||||
"deleteProject": "Eliminar Proyecto",
|
||||
|
|
@ -1139,4 +1159,4 @@
|
|||
"subtasks": {
|
||||
"placeholder": "Agregar una subtarea..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -122,7 +122,25 @@
|
|||
"showSubtasks": "Näytä alitehtävät",
|
||||
"hideSubtasks": "Piilota alitehtävät",
|
||||
"edit": "Muokkaa tehtävää",
|
||||
"delete": "Poista tehtävä"
|
||||
"delete": "Poista tehtävä",
|
||||
"sortTasks": "Lajittele tehtävät",
|
||||
"sortBy": "Lajittele",
|
||||
"direction": "Suunta",
|
||||
"ascending": "Nouseva",
|
||||
"descending": "Laskeva",
|
||||
"groupBy": "Ryhmittele",
|
||||
"groupByProject": "Projekti",
|
||||
"grouping": {
|
||||
"none": "Ei mitään"
|
||||
},
|
||||
"show": "Näytä",
|
||||
"all": "Kaikki",
|
||||
"completedOnly": "Vain valmiit",
|
||||
"notCompleted": "Keskeneräiset",
|
||||
"noProject": "Ei projektia",
|
||||
"unknownProject": "Tuntematon projekti",
|
||||
"tasks": "tehtävät",
|
||||
"showingItems": "Näytetään {{current}} / {{total}} kohdetta"
|
||||
},
|
||||
"timeline": {
|
||||
"activityTimeline": "Toiminta-aikajana",
|
||||
|
|
@ -508,7 +526,7 @@
|
|||
"project": {
|
||||
"name": "Projektin nimi",
|
||||
"projectImage": "Projektikuva",
|
||||
"uploadImageHint": "Lataa kuva projektiisi (max 5MB)",
|
||||
"uploadImageHint": "Lataa kuva projektiisi (max 10MB)",
|
||||
"browseImage": "Selaa kuvaa",
|
||||
"noNotes": "Ei muistiinpanoja tälle projektille.",
|
||||
"deleteProject": "Poista projekti",
|
||||
|
|
@ -765,7 +783,9 @@
|
|||
"in_progress_desc": "Aktiivista työtä käynnissä",
|
||||
"blocked_desc": "Tilapäisesti keskeytetty tai jumissa",
|
||||
"completed_desc": "Valmistunut ja tehty"
|
||||
}
|
||||
},
|
||||
"showMetrics": "Näytä mittarit",
|
||||
"hideMetrics": "Piilota mittarit"
|
||||
},
|
||||
"projectItem": {
|
||||
"edit": "Muokkaa",
|
||||
|
|
@ -1138,4 +1158,4 @@
|
|||
"subtasks": {
|
||||
"placeholder": "Lisää alitehtävä..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -122,7 +122,25 @@
|
|||
"showSubtasks": "Afficher les sous-tâches",
|
||||
"hideSubtasks": "Masquer les sous-tâches",
|
||||
"edit": "Modifier la tâche",
|
||||
"delete": "Supprimer la tâche"
|
||||
"delete": "Supprimer la tâche",
|
||||
"sortTasks": "Trier les tâches",
|
||||
"sortBy": "Trier par",
|
||||
"direction": "Direction",
|
||||
"ascending": "Croissant",
|
||||
"descending": "Décroissant",
|
||||
"groupBy": "Grouper par",
|
||||
"groupByProject": "Projet",
|
||||
"grouping": {
|
||||
"none": "Aucun"
|
||||
},
|
||||
"show": "Afficher",
|
||||
"all": "Tous",
|
||||
"completedOnly": "Terminées uniquement",
|
||||
"notCompleted": "Non terminées",
|
||||
"noProject": "Aucun projet",
|
||||
"unknownProject": "Projet inconnu",
|
||||
"tasks": "tâches",
|
||||
"showingItems": "Affichage de {{current}} sur {{total}} éléments"
|
||||
},
|
||||
"timeline": {
|
||||
"activityTimeline": "Chronologie d'Activité",
|
||||
|
|
@ -508,7 +526,7 @@
|
|||
"project": {
|
||||
"name": "Nom du projet",
|
||||
"projectImage": "Image du projet",
|
||||
"uploadImageHint": "Téléchargez une image pour votre projet (max 5 Mo)",
|
||||
"uploadImageHint": "Téléchargez une image pour votre projet (max 10 Mo)",
|
||||
"browseImage": "Parcourir l'image",
|
||||
"noNotes": "Aucune note pour ce projet.",
|
||||
"deleteProject": "Supprimer le projet",
|
||||
|
|
@ -765,7 +783,9 @@
|
|||
"in_progress_desc": "Travail actif en cours",
|
||||
"blocked_desc": "Temporairement mis en pause ou bloqué",
|
||||
"completed_desc": "Fini et terminé"
|
||||
}
|
||||
},
|
||||
"showMetrics": "Afficher les métriques",
|
||||
"hideMetrics": "Masquer les métriques"
|
||||
},
|
||||
"projectItem": {
|
||||
"edit": "Modifier",
|
||||
|
|
@ -1138,4 +1158,4 @@
|
|||
"subtasks": {
|
||||
"placeholder": "Ajouter une sous-tâche..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -122,7 +122,25 @@
|
|||
"showSubtasks": "Tampilkan subtugas",
|
||||
"hideSubtasks": "Sembunyikan subtugas",
|
||||
"edit": "Edit tugas",
|
||||
"delete": "Hapus tugas"
|
||||
"delete": "Hapus tugas",
|
||||
"sortTasks": "Urutkan tugas",
|
||||
"sortBy": "Urutkan berdasarkan",
|
||||
"direction": "Arah",
|
||||
"ascending": "Menaik",
|
||||
"descending": "Menurun",
|
||||
"groupBy": "Kelompokkan berdasarkan",
|
||||
"groupByProject": "Proyek",
|
||||
"grouping": {
|
||||
"none": "Tidak ada"
|
||||
},
|
||||
"show": "Tampilkan",
|
||||
"all": "Semua",
|
||||
"completedOnly": "Hanya selesai",
|
||||
"notCompleted": "Belum selesai",
|
||||
"noProject": "Tanpa proyek",
|
||||
"unknownProject": "Proyek tidak dikenal",
|
||||
"tasks": "tugas",
|
||||
"showingItems": "Menampilkan {{current}} dari {{total}} item"
|
||||
},
|
||||
"timeline": {
|
||||
"activityTimeline": "Garis Waktu Aktivitas",
|
||||
|
|
@ -508,7 +526,7 @@
|
|||
"project": {
|
||||
"name": "Nama Proyek",
|
||||
"projectImage": "Gambar Proyek",
|
||||
"uploadImageHint": "Unggah gambar untuk proyek Anda (maks 5MB)",
|
||||
"uploadImageHint": "Unggah gambar untuk proyek Anda (maks 10MB)",
|
||||
"browseImage": "Telusuri Gambar",
|
||||
"noNotes": "Tidak ada catatan untuk proyek ini.",
|
||||
"deleteProject": "Hapus Proyek",
|
||||
|
|
@ -765,7 +783,9 @@
|
|||
"in_progress_desc": "Pekerjaan aktif sedang berlangsung",
|
||||
"blocked_desc": "Sementara terhenti atau terjebak",
|
||||
"completed_desc": "Selesai dan selesai"
|
||||
}
|
||||
},
|
||||
"showMetrics": "Tampilkan metrik",
|
||||
"hideMetrics": "Sembunyikan metrik"
|
||||
},
|
||||
"projectItem": {
|
||||
"edit": "Sunting",
|
||||
|
|
@ -1138,4 +1158,4 @@
|
|||
"subtasks": {
|
||||
"placeholder": "Tambahkan subtugas..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -122,7 +122,25 @@
|
|||
"showSubtasks": "Mostra sottocompiti",
|
||||
"hideSubtasks": "Nascondi sottocompiti",
|
||||
"edit": "Modifica attività",
|
||||
"delete": "Elimina attività"
|
||||
"delete": "Elimina attività",
|
||||
"sortTasks": "Ordina attività",
|
||||
"sortBy": "Ordina per",
|
||||
"direction": "Direzione",
|
||||
"ascending": "Crescente",
|
||||
"descending": "Decrescente",
|
||||
"groupBy": "Raggruppa per",
|
||||
"groupByProject": "Progetto",
|
||||
"grouping": {
|
||||
"none": "Nessuno"
|
||||
},
|
||||
"show": "Mostra",
|
||||
"all": "Tutti",
|
||||
"completedOnly": "Solo completate",
|
||||
"notCompleted": "Non completate",
|
||||
"noProject": "Nessun progetto",
|
||||
"unknownProject": "Progetto sconosciuto",
|
||||
"tasks": "attività",
|
||||
"showingItems": "Visualizzazione di {{current}} su {{total}} elementi"
|
||||
},
|
||||
"timeline": {
|
||||
"activityTimeline": "Timeline delle Attività",
|
||||
|
|
@ -508,7 +526,7 @@
|
|||
"project": {
|
||||
"name": "Nome Progetto",
|
||||
"projectImage": "Immagine Progetto",
|
||||
"uploadImageHint": "Carica un'immagine per il tuo progetto (max 5MB)",
|
||||
"uploadImageHint": "Carica un'immagine per il tuo progetto (max 10MB)",
|
||||
"browseImage": "Sfoglia Immagine",
|
||||
"noNotes": "Nessuna nota per questo progetto.",
|
||||
"deleteProject": "Elimina Progetto",
|
||||
|
|
@ -765,7 +783,9 @@
|
|||
"in_progress_desc": "Lavoro attivo in corso",
|
||||
"blocked_desc": "Pausa temporanea o bloccato",
|
||||
"completed_desc": "Finito e completato"
|
||||
}
|
||||
},
|
||||
"showMetrics": "Mostra metriche",
|
||||
"hideMetrics": "Nascondi metriche"
|
||||
},
|
||||
"projectItem": {
|
||||
"edit": "Modifica",
|
||||
|
|
@ -1138,4 +1158,4 @@
|
|||
"subtasks": {
|
||||
"placeholder": "Aggiungi un sottocompito..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -122,7 +122,25 @@
|
|||
"showSubtasks": "サブタスクを表示",
|
||||
"hideSubtasks": "サブタスクを非表示",
|
||||
"edit": "タスクを編集",
|
||||
"delete": "タスクを削除"
|
||||
"delete": "タスクを削除",
|
||||
"sortTasks": "タスクを並べ替え",
|
||||
"sortBy": "並べ替え",
|
||||
"direction": "方向",
|
||||
"ascending": "昇順",
|
||||
"descending": "降順",
|
||||
"groupBy": "グループ化",
|
||||
"groupByProject": "プロジェクト",
|
||||
"grouping": {
|
||||
"none": "なし"
|
||||
},
|
||||
"show": "表示",
|
||||
"all": "すべて",
|
||||
"completedOnly": "完了のみ",
|
||||
"notCompleted": "未完了",
|
||||
"noProject": "プロジェクトなし",
|
||||
"unknownProject": "不明なプロジェクト",
|
||||
"tasks": "タスク",
|
||||
"showingItems": "{{total}}件中{{current}}件を表示"
|
||||
},
|
||||
"timeline": {
|
||||
"activityTimeline": "アクティビティタイムライン",
|
||||
|
|
@ -563,7 +581,9 @@
|
|||
"in_progress_desc": "アクティブな作業が行われている",
|
||||
"blocked_desc": "一時的に停止または行き詰まっている",
|
||||
"completed_desc": "完了し、終了した"
|
||||
}
|
||||
},
|
||||
"showMetrics": "メトリクスを表示",
|
||||
"hideMetrics": "メトリクスを非表示"
|
||||
},
|
||||
"projectItem": {
|
||||
"edit": "編集",
|
||||
|
|
@ -684,7 +704,7 @@
|
|||
},
|
||||
"project": {
|
||||
"projectImage": "プロジェクト画像",
|
||||
"uploadImageHint": "プロジェクト用の画像をアップロード(最大5MB)",
|
||||
"uploadImageHint": "プロジェクト用の画像をアップロード(最大10MB)",
|
||||
"browseImage": "画像を参照",
|
||||
"name": "プロジェクト名",
|
||||
"noNotes": "このプロジェクトにはノートがありません。",
|
||||
|
|
@ -1138,4 +1158,4 @@
|
|||
"subtasks": {
|
||||
"placeholder": "サブタスクを追加..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -122,7 +122,25 @@
|
|||
"showSubtasks": "하위 작업 표시",
|
||||
"hideSubtasks": "하위 작업 숨기기",
|
||||
"edit": "작업 편집",
|
||||
"delete": "작업 삭제"
|
||||
"delete": "작업 삭제",
|
||||
"sortTasks": "작업 정렬",
|
||||
"sortBy": "정렬 기준",
|
||||
"direction": "방향",
|
||||
"ascending": "오름차순",
|
||||
"descending": "내림차순",
|
||||
"groupBy": "그룹화 기준",
|
||||
"groupByProject": "프로젝트",
|
||||
"grouping": {
|
||||
"none": "없음"
|
||||
},
|
||||
"show": "표시",
|
||||
"all": "모두",
|
||||
"completedOnly": "완료된 항목만",
|
||||
"notCompleted": "미완료",
|
||||
"noProject": "프로젝트 없음",
|
||||
"unknownProject": "알 수 없는 프로젝트",
|
||||
"tasks": "작업",
|
||||
"showingItems": "{{total}}개 중 {{current}}개 표시"
|
||||
},
|
||||
"timeline": {
|
||||
"activityTimeline": "활동 타임라인",
|
||||
|
|
@ -508,7 +526,7 @@
|
|||
"project": {
|
||||
"name": "프로젝트 이름",
|
||||
"projectImage": "프로젝트 이미지",
|
||||
"uploadImageHint": "프로젝트를 위한 이미지를 업로드하세요 (최대 5MB)",
|
||||
"uploadImageHint": "프로젝트를 위한 이미지를 업로드하세요 (최대 10MB)",
|
||||
"browseImage": "이미지 찾아보기",
|
||||
"noNotes": "이 프로젝트에 대한 메모가 없습니다.",
|
||||
"deleteProject": "프로젝트 삭제",
|
||||
|
|
@ -765,7 +783,9 @@
|
|||
"in_progress_desc": "활동적인 작업 진행 중",
|
||||
"blocked_desc": "일시적으로 중단되거나 막힘",
|
||||
"completed_desc": "완료되고 끝남"
|
||||
}
|
||||
},
|
||||
"showMetrics": "메트릭 표시",
|
||||
"hideMetrics": "메트릭 숨기기"
|
||||
},
|
||||
"projectItem": {
|
||||
"edit": "편집",
|
||||
|
|
@ -1138,4 +1158,4 @@
|
|||
"subtasks": {
|
||||
"placeholder": "하위 작업 추가..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -122,7 +122,25 @@
|
|||
"showSubtasks": "Toon subtaken",
|
||||
"hideSubtasks": "Verberg subtaken",
|
||||
"edit": "Bewerk taak",
|
||||
"delete": "Verwijder taak"
|
||||
"delete": "Verwijder taak",
|
||||
"sortTasks": "Taken sorteren",
|
||||
"sortBy": "Sorteren op",
|
||||
"direction": "Richting",
|
||||
"ascending": "Oplopend",
|
||||
"descending": "Aflopend",
|
||||
"groupBy": "Groeperen op",
|
||||
"groupByProject": "Project",
|
||||
"grouping": {
|
||||
"none": "Geen"
|
||||
},
|
||||
"show": "Tonen",
|
||||
"all": "Alle",
|
||||
"completedOnly": "Alleen voltooid",
|
||||
"notCompleted": "Niet voltooid",
|
||||
"noProject": "Geen project",
|
||||
"unknownProject": "Onbekend project",
|
||||
"tasks": "taken",
|
||||
"showingItems": "{{current}} van {{total}} items weergegeven"
|
||||
},
|
||||
"timeline": {
|
||||
"activityTimeline": "Activiteit Tijdlijn",
|
||||
|
|
@ -508,7 +526,7 @@
|
|||
"project": {
|
||||
"name": "Projectnaam",
|
||||
"projectImage": "Projectafbeelding",
|
||||
"uploadImageHint": "Upload een afbeelding voor je project (max 5MB)",
|
||||
"uploadImageHint": "Upload een afbeelding voor je project (max 10MB)",
|
||||
"browseImage": "Afbeelding Bladeren",
|
||||
"noNotes": "Geen notities voor dit project.",
|
||||
"deleteProject": "Project Verwijderen",
|
||||
|
|
@ -765,7 +783,9 @@
|
|||
"in_progress_desc": "Actief werk aan de gang",
|
||||
"blocked_desc": "Tijdelijk gepauzeerd of vastgelopen",
|
||||
"completed_desc": "Afgerond en gedaan"
|
||||
}
|
||||
},
|
||||
"showMetrics": "Toon statistieken",
|
||||
"hideMetrics": "Verberg statistieken"
|
||||
},
|
||||
"projectItem": {
|
||||
"edit": "Bewerken",
|
||||
|
|
@ -1138,4 +1158,4 @@
|
|||
"subtasks": {
|
||||
"placeholder": "Voeg een subtaak toe..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -122,7 +122,25 @@
|
|||
"showSubtasks": "Vis undertasker",
|
||||
"hideSubtasks": "Skjul undertasker",
|
||||
"edit": "Rediger oppgave",
|
||||
"delete": "Slett oppgave"
|
||||
"delete": "Slett oppgave",
|
||||
"sortTasks": "Sorter oppgaver",
|
||||
"sortBy": "Sorter etter",
|
||||
"direction": "Retning",
|
||||
"ascending": "Stigende",
|
||||
"descending": "Fallende",
|
||||
"groupBy": "Grupper etter",
|
||||
"groupByProject": "Prosjekt",
|
||||
"grouping": {
|
||||
"none": "Ingen"
|
||||
},
|
||||
"show": "Vis",
|
||||
"all": "Alle",
|
||||
"completedOnly": "Kun fullførte",
|
||||
"notCompleted": "Ikke fullført",
|
||||
"noProject": "Ingen prosjekt",
|
||||
"unknownProject": "Ukjent prosjekt",
|
||||
"tasks": "oppgaver",
|
||||
"showingItems": "Viser {{current}} av {{total}} elementer"
|
||||
},
|
||||
"timeline": {
|
||||
"activityTimeline": "Aktivitetslinje",
|
||||
|
|
@ -508,7 +526,7 @@
|
|||
"project": {
|
||||
"name": "Prosjektnavn",
|
||||
"projectImage": "Prosjektbilde",
|
||||
"uploadImageHint": "Last opp et bilde for prosjektet ditt (maks 5MB)",
|
||||
"uploadImageHint": "Last opp et bilde for prosjektet ditt (maks 10MB)",
|
||||
"browseImage": "Bla gjennom bilde",
|
||||
"noNotes": "Ingen notater for dette prosjektet.",
|
||||
"deleteProject": "Slett prosjekt",
|
||||
|
|
@ -765,7 +783,9 @@
|
|||
"in_progress_desc": "Aktivt arbeid pågår",
|
||||
"blocked_desc": "Midlertidig pauset eller fastlåst",
|
||||
"completed_desc": "Ferdig og gjort"
|
||||
}
|
||||
},
|
||||
"showMetrics": "Vis målinger",
|
||||
"hideMetrics": "Skjul målinger"
|
||||
},
|
||||
"projectItem": {
|
||||
"edit": "Rediger",
|
||||
|
|
@ -1138,4 +1158,4 @@
|
|||
"subtasks": {
|
||||
"placeholder": "Legg til en underoppgave..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -122,7 +122,25 @@
|
|||
"showSubtasks": "Pokaż podzadania",
|
||||
"hideSubtasks": "Ukryj podzadania",
|
||||
"edit": "Edytuj zadanie",
|
||||
"delete": "Usuń zadanie"
|
||||
"delete": "Usuń zadanie",
|
||||
"sortTasks": "Sortuj zadania",
|
||||
"sortBy": "Sortuj według",
|
||||
"direction": "Kierunek",
|
||||
"ascending": "Rosnąco",
|
||||
"descending": "Malejąco",
|
||||
"groupBy": "Grupuj według",
|
||||
"groupByProject": "Projekt",
|
||||
"grouping": {
|
||||
"none": "Brak"
|
||||
},
|
||||
"show": "Pokaż",
|
||||
"all": "Wszystkie",
|
||||
"completedOnly": "Tylko ukończone",
|
||||
"notCompleted": "Nieukończone",
|
||||
"noProject": "Brak projektu",
|
||||
"unknownProject": "Nieznany projekt",
|
||||
"tasks": "zadania",
|
||||
"showingItems": "Wyświetlanie {{current}} z {{total}} elementów"
|
||||
},
|
||||
"timeline": {
|
||||
"activityTimeline": "Oś czasu aktywności",
|
||||
|
|
@ -508,7 +526,7 @@
|
|||
"project": {
|
||||
"name": "Nazwa projektu",
|
||||
"projectImage": "Obraz projektu",
|
||||
"uploadImageHint": "Prześlij obraz dla swojego projektu (maks. 5MB)",
|
||||
"uploadImageHint": "Prześlij obraz dla swojego projektu (maks. 10MB)",
|
||||
"browseImage": "Przeglądaj obraz",
|
||||
"noNotes": "Brak notatek dla tego projektu.",
|
||||
"deleteProject": "Usuń projekt",
|
||||
|
|
@ -765,7 +783,9 @@
|
|||
"in_progress_desc": "Aktywna praca w toku",
|
||||
"blocked_desc": "Tymczasowo wstrzymane lub utknęło",
|
||||
"completed_desc": "Zakończone i gotowe"
|
||||
}
|
||||
},
|
||||
"showMetrics": "Pokaż metryki",
|
||||
"hideMetrics": "Ukryj metryki"
|
||||
},
|
||||
"projectItem": {
|
||||
"edit": "Edytuj",
|
||||
|
|
@ -1138,4 +1158,4 @@
|
|||
"subtasks": {
|
||||
"placeholder": "Dodaj podzadanie..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -122,7 +122,25 @@
|
|||
"showSubtasks": "Mostrar subtarefas",
|
||||
"hideSubtasks": "Ocultar subtarefas",
|
||||
"edit": "Editar tarefa",
|
||||
"delete": "Excluir tarefa"
|
||||
"delete": "Excluir tarefa",
|
||||
"sortTasks": "Ordenar tarefas",
|
||||
"sortBy": "Ordenar por",
|
||||
"direction": "Direção",
|
||||
"ascending": "Ascendente",
|
||||
"descending": "Descendente",
|
||||
"groupBy": "Agrupar por",
|
||||
"groupByProject": "Projeto",
|
||||
"grouping": {
|
||||
"none": "Nenhum"
|
||||
},
|
||||
"show": "Mostrar",
|
||||
"all": "Todos",
|
||||
"completedOnly": "Somente concluídas",
|
||||
"notCompleted": "Não concluídas",
|
||||
"noProject": "Sem projeto",
|
||||
"unknownProject": "Projeto desconhecido",
|
||||
"tasks": "tarefas",
|
||||
"showingItems": "Mostrando {{current}} de {{total}} itens"
|
||||
},
|
||||
"timeline": {
|
||||
"activityTimeline": "Linha do Tempo de Atividades",
|
||||
|
|
@ -508,7 +526,7 @@
|
|||
"project": {
|
||||
"name": "Nome do Projeto",
|
||||
"projectImage": "Imagem do Projeto",
|
||||
"uploadImageHint": "Carregue uma imagem para o seu projeto (máx 5MB)",
|
||||
"uploadImageHint": "Carregue uma imagem para o seu projeto (máx 10MB)",
|
||||
"browseImage": "Procurar Imagem",
|
||||
"noNotes": "Nenhuma nota para este projeto.",
|
||||
"deleteProject": "Excluir Projeto",
|
||||
|
|
@ -765,7 +783,9 @@
|
|||
"in_progress_desc": "Trabalho ativo em andamento",
|
||||
"blocked_desc": "Pausado temporariamente ou preso",
|
||||
"completed_desc": "Finalizado e concluído"
|
||||
}
|
||||
},
|
||||
"showMetrics": "Mostrar métricas",
|
||||
"hideMetrics": "Ocultar métricas"
|
||||
},
|
||||
"projectItem": {
|
||||
"edit": "Editar",
|
||||
|
|
@ -1138,4 +1158,4 @@
|
|||
"subtasks": {
|
||||
"placeholder": "Adicionar uma subtarefa..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -122,7 +122,25 @@
|
|||
"showSubtasks": "Arată subtask-uri",
|
||||
"hideSubtasks": "Ascunde subtask-uri",
|
||||
"edit": "Editează task-ul",
|
||||
"delete": "Șterge task-ul"
|
||||
"delete": "Șterge task-ul",
|
||||
"sortTasks": "Sortează sarcini",
|
||||
"sortBy": "Sortează după",
|
||||
"direction": "Direcție",
|
||||
"ascending": "Crescător",
|
||||
"descending": "Descrescător",
|
||||
"groupBy": "Grupează după",
|
||||
"groupByProject": "Proiect",
|
||||
"grouping": {
|
||||
"none": "Niciunul"
|
||||
},
|
||||
"show": "Afișează",
|
||||
"all": "Toate",
|
||||
"completedOnly": "Doar finalizate",
|
||||
"notCompleted": "Nefinalizate",
|
||||
"noProject": "Fără proiect",
|
||||
"unknownProject": "Proiect necunoscut",
|
||||
"tasks": "sarcini",
|
||||
"showingItems": "Se afișează {{current}} din {{total}} elemente"
|
||||
},
|
||||
"timeline": {
|
||||
"activityTimeline": "Cronologia activităților",
|
||||
|
|
@ -508,7 +526,7 @@
|
|||
"project": {
|
||||
"name": "Numele Proiectului",
|
||||
"projectImage": "Imaginea Proiectului",
|
||||
"uploadImageHint": "Încărcați o imagine pentru proiectul dvs. (max 5MB)",
|
||||
"uploadImageHint": "Încărcați o imagine pentru proiectul dvs. (max 10MB)",
|
||||
"browseImage": "Răsfoiți Imaginea",
|
||||
"noNotes": "Nu există note pentru acest proiect.",
|
||||
"deleteProject": "Șterge Proiectul",
|
||||
|
|
@ -765,7 +783,9 @@
|
|||
"in_progress_desc": "Lucru activ în desfășurare",
|
||||
"blocked_desc": "Pauză temporară sau blocat",
|
||||
"completed_desc": "Finalizat și terminat"
|
||||
}
|
||||
},
|
||||
"showMetrics": "Arată metrici",
|
||||
"hideMetrics": "Ascunde metrici"
|
||||
},
|
||||
"projectItem": {
|
||||
"edit": "Editează",
|
||||
|
|
@ -1138,4 +1158,4 @@
|
|||
"subtasks": {
|
||||
"placeholder": "Adaugă o subtask..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -122,7 +122,25 @@
|
|||
"showSubtasks": "Показать подзадачи",
|
||||
"hideSubtasks": "Скрыть подзадачи",
|
||||
"edit": "Редактировать задачу",
|
||||
"delete": "Удалить задачу"
|
||||
"delete": "Удалить задачу",
|
||||
"sortTasks": "Сортировка задач",
|
||||
"sortBy": "Сортировать по",
|
||||
"direction": "Направление",
|
||||
"ascending": "По возрастанию",
|
||||
"descending": "По убыванию",
|
||||
"groupBy": "Группировать по",
|
||||
"groupByProject": "Проект",
|
||||
"grouping": {
|
||||
"none": "Нет"
|
||||
},
|
||||
"show": "Показать",
|
||||
"all": "Все",
|
||||
"completedOnly": "Только завершенные",
|
||||
"notCompleted": "Незавершенные",
|
||||
"noProject": "Без проекта",
|
||||
"unknownProject": "Неизвестный проект",
|
||||
"tasks": "задачи",
|
||||
"showingItems": "Показано {{current}} из {{total}} элементов"
|
||||
},
|
||||
"timeline": {
|
||||
"activityTimeline": "Хронология активности",
|
||||
|
|
@ -508,7 +526,7 @@
|
|||
"project": {
|
||||
"name": "Название проекта",
|
||||
"projectImage": "Изображение проекта",
|
||||
"uploadImageHint": "Загрузите изображение для вашего проекта (макс 5МБ)",
|
||||
"uploadImageHint": "Загрузите изображение для вашего проекта (макс 10МБ)",
|
||||
"browseImage": "Выбрать изображение",
|
||||
"noNotes": "Нет заметок для этого проекта.",
|
||||
"deleteProject": "Удалить проект",
|
||||
|
|
@ -765,7 +783,9 @@
|
|||
"in_progress_desc": "Активная работа идет",
|
||||
"blocked_desc": "Временно приостановлено или застряло",
|
||||
"completed_desc": "Завершено и выполнено"
|
||||
}
|
||||
},
|
||||
"showMetrics": "Показать метрики",
|
||||
"hideMetrics": "Скрыть метрики"
|
||||
},
|
||||
"projectItem": {
|
||||
"edit": "Редактировать",
|
||||
|
|
@ -1138,4 +1158,4 @@
|
|||
"subtasks": {
|
||||
"placeholder": "Добавить подзадачу..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -122,7 +122,25 @@
|
|||
"showSubtasks": "Prikaži podnaloge",
|
||||
"hideSubtasks": "Skrij podnaloge",
|
||||
"edit": "Uredi nalogo",
|
||||
"delete": "Izbriši nalogo"
|
||||
"delete": "Izbriši nalogo",
|
||||
"sortTasks": "Razvrsti naloge",
|
||||
"sortBy": "Razvrsti po",
|
||||
"direction": "Smer",
|
||||
"ascending": "Naraščajoče",
|
||||
"descending": "Padajoče",
|
||||
"groupBy": "Združi po",
|
||||
"groupByProject": "Projekt",
|
||||
"grouping": {
|
||||
"none": "Brez"
|
||||
},
|
||||
"show": "Prikaži",
|
||||
"all": "Vse",
|
||||
"completedOnly": "Samo dokončane",
|
||||
"notCompleted": "Nedokončane",
|
||||
"noProject": "Brez projekta",
|
||||
"unknownProject": "Neznan projekt",
|
||||
"tasks": "naloge",
|
||||
"showingItems": "Prikazovanje {{current}} od {{total}} elementov"
|
||||
},
|
||||
"timeline": {
|
||||
"activityTimeline": "Časovnica aktivnosti",
|
||||
|
|
@ -508,7 +526,7 @@
|
|||
"project": {
|
||||
"name": "Ime projekta",
|
||||
"projectImage": "Slika projekta",
|
||||
"uploadImageHint": "Naložite sliko za vaš projekt (max 5MB)",
|
||||
"uploadImageHint": "Naložite sliko za vaš projekt (max 10MB)",
|
||||
"browseImage": "Prebrskaj sliko",
|
||||
"noNotes": "Ni opomb za ta projekt.",
|
||||
"deleteProject": "Izbriši projekt",
|
||||
|
|
@ -765,7 +783,9 @@
|
|||
"in_progress_desc": "Aktivno delo poteka",
|
||||
"blocked_desc": "Začasno ustavljeno ali zastojev",
|
||||
"completed_desc": "Dokončano in opravljeno"
|
||||
}
|
||||
},
|
||||
"showMetrics": "Prikaži metrike",
|
||||
"hideMetrics": "Skrij metrike"
|
||||
},
|
||||
"projectItem": {
|
||||
"edit": "Uredi",
|
||||
|
|
@ -1138,4 +1158,4 @@
|
|||
"subtasks": {
|
||||
"placeholder": "Dodaj podnalogo..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -122,7 +122,25 @@
|
|||
"showSubtasks": "Visa deluppgifter",
|
||||
"hideSubtasks": "Dölj deluppgifter",
|
||||
"edit": "Ändra uppgift",
|
||||
"delete": "Ta bort uppgift"
|
||||
"delete": "Ta bort uppgift",
|
||||
"sortTasks": "Sortera uppgifter",
|
||||
"sortBy": "Sortera efter",
|
||||
"direction": "Riktning",
|
||||
"ascending": "Stigande",
|
||||
"descending": "Fallande",
|
||||
"groupBy": "Gruppera efter",
|
||||
"groupByProject": "Projekt",
|
||||
"grouping": {
|
||||
"none": "Ingen"
|
||||
},
|
||||
"show": "Visa",
|
||||
"all": "Alla",
|
||||
"completedOnly": "Endast slutförda",
|
||||
"notCompleted": "Ej slutförda",
|
||||
"noProject": "Inget projekt",
|
||||
"unknownProject": "Okänt projekt",
|
||||
"tasks": "uppgifter",
|
||||
"showingItems": "Visar {{current}} av {{total}} objekt"
|
||||
},
|
||||
"timeline": {
|
||||
"activityTimeline": "Aktivitetslinje",
|
||||
|
|
@ -508,7 +526,7 @@
|
|||
"project": {
|
||||
"name": "Projektnamn",
|
||||
"projectImage": "Projektbild",
|
||||
"uploadImageHint": "Ladda upp en bild för ditt projekt (max 5 MB)",
|
||||
"uploadImageHint": "Ladda upp en bild för ditt projekt (max 10 MB)",
|
||||
"browseImage": "Bläddra efter bild",
|
||||
"noNotes": "Inga anteckningar för detta projekt.",
|
||||
"deleteProject": "Ta bort projekt",
|
||||
|
|
@ -765,7 +783,9 @@
|
|||
"in_progress_desc": "Aktivt arbete pågår",
|
||||
"blocked_desc": "Tillfälligt pausad eller fast",
|
||||
"completed_desc": "Avslutad och klar"
|
||||
}
|
||||
},
|
||||
"showMetrics": "Visa mätvärden",
|
||||
"hideMetrics": "Dölj mätvärden"
|
||||
},
|
||||
"projectItem": {
|
||||
"edit": "Ändra",
|
||||
|
|
@ -1138,4 +1158,4 @@
|
|||
"subtasks": {
|
||||
"placeholder": "Lägg till en deluppgift..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -122,7 +122,25 @@
|
|||
"showSubtasks": "Alt görevleri göster",
|
||||
"hideSubtasks": "Alt görevleri gizle",
|
||||
"edit": "Görevi düzenle",
|
||||
"delete": "Görevi sil"
|
||||
"delete": "Görevi sil",
|
||||
"sortTasks": "Görevleri sırala",
|
||||
"sortBy": "Sıralama ölçütü",
|
||||
"direction": "Yön",
|
||||
"ascending": "Artan",
|
||||
"descending": "Azalan",
|
||||
"groupBy": "Gruplandırma ölçütü",
|
||||
"groupByProject": "Proje",
|
||||
"grouping": {
|
||||
"none": "Yok"
|
||||
},
|
||||
"show": "Göster",
|
||||
"all": "Tümü",
|
||||
"completedOnly": "Yalnızca tamamlananlar",
|
||||
"notCompleted": "Tamamlanmamış",
|
||||
"noProject": "Proje yok",
|
||||
"unknownProject": "Bilinmeyen proje",
|
||||
"tasks": "görevler",
|
||||
"showingItems": "{{total}} öğeden {{current}} tanesi gösteriliyor"
|
||||
},
|
||||
"timeline": {
|
||||
"activityTimeline": "Etkinlik Zaman Çizelgesi",
|
||||
|
|
@ -508,7 +526,7 @@
|
|||
"project": {
|
||||
"name": "Proje Adı",
|
||||
"projectImage": "Proje Görseli",
|
||||
"uploadImageHint": "Projeniz için bir görsel yükleyin (maks 5MB)",
|
||||
"uploadImageHint": "Projeniz için bir görsel yükleyin (maks 10MB)",
|
||||
"browseImage": "Görseli Gözat",
|
||||
"noNotes": "Bu proje için not yok.",
|
||||
"deleteProject": "Projeyi Sil",
|
||||
|
|
@ -765,7 +783,9 @@
|
|||
"in_progress_desc": "Aktif çalışma devam ediyor",
|
||||
"blocked_desc": "Geçici olarak duraklatıldı veya takıldı",
|
||||
"completed_desc": "Tamamlandı ve sona erdi"
|
||||
}
|
||||
},
|
||||
"showMetrics": "Metrikleri göster",
|
||||
"hideMetrics": "Metrikleri gizle"
|
||||
},
|
||||
"projectItem": {
|
||||
"edit": "Düzenle",
|
||||
|
|
@ -1138,4 +1158,4 @@
|
|||
"subtasks": {
|
||||
"placeholder": "Bir alt görev ekle..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -122,7 +122,25 @@
|
|||
"showSubtasks": "Показати підзадачі",
|
||||
"hideSubtasks": "Сховати підзадачі",
|
||||
"edit": "Редагувати задачу",
|
||||
"delete": "Видалити задачу"
|
||||
"delete": "Видалити задачу",
|
||||
"sortTasks": "Сортувати завдання",
|
||||
"sortBy": "Сортувати за",
|
||||
"direction": "Напрямок",
|
||||
"ascending": "За зростанням",
|
||||
"descending": "За спаданням",
|
||||
"groupBy": "Групувати за",
|
||||
"groupByProject": "Проект",
|
||||
"grouping": {
|
||||
"none": "Без групування"
|
||||
},
|
||||
"show": "Показати",
|
||||
"all": "Усі",
|
||||
"completedOnly": "Тільки завершені",
|
||||
"notCompleted": "Незавершені",
|
||||
"noProject": "Без проекту",
|
||||
"unknownProject": "Невідомий проект",
|
||||
"tasks": "завдання",
|
||||
"showingItems": "Показано {{current}} з {{total}} елементів"
|
||||
},
|
||||
"timeline": {
|
||||
"activityTimeline": "Хронологія Активності",
|
||||
|
|
@ -193,7 +211,9 @@
|
|||
"in_progress_desc": "Активна робота триває",
|
||||
"blocked_desc": "Тимчасово призупинено або застрягло",
|
||||
"completed_desc": "Завершено та виконано"
|
||||
}
|
||||
},
|
||||
"showMetrics": "Показати метрики",
|
||||
"hideMetrics": "Сховати метрики"
|
||||
},
|
||||
"projectItem": {
|
||||
"edit": "Редагувати",
|
||||
|
|
@ -436,7 +456,7 @@
|
|||
},
|
||||
"project": {
|
||||
"projectImage": "Зображення проекту",
|
||||
"uploadImageHint": "Завантажте зображення для вашого проекту (макс. 5МБ)",
|
||||
"uploadImageHint": "Завантажте зображення для вашого проекту (макс. 10МБ)",
|
||||
"browseImage": "Обрати зображення",
|
||||
"name": "Назва проекту",
|
||||
"noNotes": "Немає приміток для цього проекту.",
|
||||
|
|
@ -1138,4 +1158,4 @@
|
|||
"subtasks": {
|
||||
"placeholder": "Додати підзадачу..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -122,7 +122,25 @@
|
|||
"showSubtasks": "Hiện các công việc con",
|
||||
"hideSubtasks": "Ẩn các công việc con",
|
||||
"edit": "Chỉnh sửa công việc",
|
||||
"delete": "Xóa công việc"
|
||||
"delete": "Xóa công việc",
|
||||
"sortTasks": "Sắp xếp nhiệm vụ",
|
||||
"sortBy": "Sắp xếp theo",
|
||||
"direction": "Hướng",
|
||||
"ascending": "Tăng dần",
|
||||
"descending": "Giảm dần",
|
||||
"groupBy": "Nhóm theo",
|
||||
"groupByProject": "Dự án",
|
||||
"grouping": {
|
||||
"none": "Không có"
|
||||
},
|
||||
"show": "Hiển thị",
|
||||
"all": "Tất cả",
|
||||
"completedOnly": "Chỉ đã hoàn thành",
|
||||
"notCompleted": "Chưa hoàn thành",
|
||||
"noProject": "Không có dự án",
|
||||
"unknownProject": "Dự án không xác định",
|
||||
"tasks": "nhiệm vụ",
|
||||
"showingItems": "Hiển thị {{current}} trong số {{total}} mục"
|
||||
},
|
||||
"timeline": {
|
||||
"activityTimeline": "Dòng Thời Gian Hoạt Động",
|
||||
|
|
@ -508,7 +526,7 @@
|
|||
"project": {
|
||||
"name": "Tên Dự Án",
|
||||
"projectImage": "Hình Ảnh Dự Án",
|
||||
"uploadImageHint": "Tải lên một hình ảnh cho dự án của bạn (tối đa 5MB)",
|
||||
"uploadImageHint": "Tải lên một hình ảnh cho dự án của bạn (tối đa 10MB)",
|
||||
"browseImage": "Duyệt Hình Ảnh",
|
||||
"noNotes": "Không có ghi chú cho dự án này.",
|
||||
"deleteProject": "Xóa Dự Án",
|
||||
|
|
@ -765,7 +783,9 @@
|
|||
"in_progress_desc": "Công việc đang diễn ra",
|
||||
"blocked_desc": "Tạm dừng hoặc bị mắc kẹt",
|
||||
"completed_desc": "Đã hoàn tất và xong"
|
||||
}
|
||||
},
|
||||
"showMetrics": "Hiển thị số liệu",
|
||||
"hideMetrics": "Ẩn số liệu"
|
||||
},
|
||||
"projectItem": {
|
||||
"edit": "Chỉnh sửa",
|
||||
|
|
@ -1138,4 +1158,4 @@
|
|||
"subtasks": {
|
||||
"placeholder": "Thêm một công việc phụ..."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -122,7 +122,25 @@
|
|||
"showSubtasks": "显示子任务",
|
||||
"hideSubtasks": "隐藏子任务",
|
||||
"edit": "编辑任务",
|
||||
"delete": "删除任务"
|
||||
"delete": "删除任务",
|
||||
"sortTasks": "排序任务",
|
||||
"sortBy": "排序方式",
|
||||
"direction": "方向",
|
||||
"ascending": "升序",
|
||||
"descending": "降序",
|
||||
"groupBy": "分组方式",
|
||||
"groupByProject": "项目",
|
||||
"grouping": {
|
||||
"none": "无"
|
||||
},
|
||||
"show": "显示",
|
||||
"all": "全部",
|
||||
"completedOnly": "仅已完成",
|
||||
"notCompleted": "未完成",
|
||||
"noProject": "无项目",
|
||||
"unknownProject": "未知项目",
|
||||
"tasks": "任务",
|
||||
"showingItems": "显示 {{current}} / {{total}} 项"
|
||||
},
|
||||
"timeline": {
|
||||
"activityTimeline": "活动时间线",
|
||||
|
|
@ -508,7 +526,7 @@
|
|||
"project": {
|
||||
"name": "项目名称",
|
||||
"projectImage": "项目图片",
|
||||
"uploadImageHint": "上传项目图片(最大5MB)",
|
||||
"uploadImageHint": "上传项目图片(最大10MB)",
|
||||
"browseImage": "浏览图片",
|
||||
"noNotes": "此项目没有备注。",
|
||||
"deleteProject": "删除项目",
|
||||
|
|
@ -765,7 +783,9 @@
|
|||
"in_progress_desc": "正在进行的工作",
|
||||
"blocked_desc": "暂时暂停或卡住",
|
||||
"completed_desc": "已完成并结束"
|
||||
}
|
||||
},
|
||||
"showMetrics": "显示指标",
|
||||
"hideMetrics": "隐藏指标"
|
||||
},
|
||||
"projectItem": {
|
||||
"edit": "编辑",
|
||||
|
|
@ -1138,4 +1158,4 @@
|
|||
"subtasks": {
|
||||
"placeholder": "添加子任务..."
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue