add vikunja integration
This commit is contained in:
13
.env.example
13
.env.example
@@ -176,6 +176,19 @@ BOOKSTACK_TOKEN_ID=your_bookstack_token_id
|
|||||||
# WARNING: Keep this secret!
|
# WARNING: Keep this secret!
|
||||||
BOOKSTACK_TOKEN_SECRET=your_bookstack_token_secret
|
BOOKSTACK_TOKEN_SECRET=your_bookstack_token_secret
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# VIKUNJA CONFIGURATION
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
# Vikunja base URL
|
||||||
|
# The URL of your Vikunja instance (without trailing slash)
|
||||||
|
VIKUNJA_URL=https://tasks.feuerwehr-rems.at
|
||||||
|
|
||||||
|
# Vikunja API Token
|
||||||
|
# Create via Vikunja user settings → API Tokens
|
||||||
|
# WARNING: Keep this secret!
|
||||||
|
VIKUNJA_API_TOKEN=your_vikunja_api_token
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
# LOGGING CONFIGURATION (Optional)
|
# LOGGING CONFIGURATION (Optional)
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -82,6 +82,7 @@ import eventsRoutes from './routes/events.routes';
|
|||||||
import bookingRoutes from './routes/booking.routes';
|
import bookingRoutes from './routes/booking.routes';
|
||||||
import notificationRoutes from './routes/notification.routes';
|
import notificationRoutes from './routes/notification.routes';
|
||||||
import bookstackRoutes from './routes/bookstack.routes';
|
import bookstackRoutes from './routes/bookstack.routes';
|
||||||
|
import vikunjaRoutes from './routes/vikunja.routes';
|
||||||
|
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/user', userRoutes);
|
app.use('/api/user', userRoutes);
|
||||||
@@ -97,6 +98,7 @@ app.use('/api/events', eventsRoutes);
|
|||||||
app.use('/api/bookings', bookingRoutes);
|
app.use('/api/bookings', bookingRoutes);
|
||||||
app.use('/api/notifications', notificationRoutes);
|
app.use('/api/notifications', notificationRoutes);
|
||||||
app.use('/api/bookstack', bookstackRoutes);
|
app.use('/api/bookstack', bookstackRoutes);
|
||||||
|
app.use('/api/vikunja', vikunjaRoutes);
|
||||||
|
|
||||||
// 404 handler
|
// 404 handler
|
||||||
app.use(notFoundHandler);
|
app.use(notFoundHandler);
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ interface EnvironmentConfig {
|
|||||||
tokenId: string;
|
tokenId: string;
|
||||||
tokenSecret: string;
|
tokenSecret: string;
|
||||||
};
|
};
|
||||||
|
vikunja: {
|
||||||
|
url: string;
|
||||||
|
apiToken: string;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const environment: EnvironmentConfig = {
|
const environment: EnvironmentConfig = {
|
||||||
@@ -73,6 +77,10 @@ const environment: EnvironmentConfig = {
|
|||||||
tokenId: process.env.BOOKSTACK_TOKEN_ID || '',
|
tokenId: process.env.BOOKSTACK_TOKEN_ID || '',
|
||||||
tokenSecret: process.env.BOOKSTACK_TOKEN_SECRET || '',
|
tokenSecret: process.env.BOOKSTACK_TOKEN_SECRET || '',
|
||||||
},
|
},
|
||||||
|
vikunja: {
|
||||||
|
url: process.env.VIKUNJA_URL || '',
|
||||||
|
apiToken: process.env.VIKUNJA_API_TOKEN || '',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default environment;
|
export default environment;
|
||||||
|
|||||||
94
backend/src/controllers/vikunja.controller.ts
Normal file
94
backend/src/controllers/vikunja.controller.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { Request, Response } from 'express';
|
||||||
|
import vikunjaService from '../services/vikunja.service';
|
||||||
|
import notificationService from '../services/notification.service';
|
||||||
|
import environment from '../config/environment';
|
||||||
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
|
class VikunjaController {
|
||||||
|
async getMyTasks(_req: Request, res: Response): Promise<void> {
|
||||||
|
if (!environment.vikunja.url) {
|
||||||
|
res.status(200).json({ success: true, data: [], configured: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const tasks = await vikunjaService.getMyTasks();
|
||||||
|
res.status(200).json({ success: true, data: tasks, configured: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('VikunjaController.getMyTasks error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Vikunja konnte nicht abgefragt werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getOverdueTasks(req: Request, res: Response): Promise<void> {
|
||||||
|
if (!environment.vikunja.url) {
|
||||||
|
res.status(200).json({ success: true, data: [], configured: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const tasks = await vikunjaService.getOverdueTasks();
|
||||||
|
|
||||||
|
// Side-effect: create notifications for each overdue task (dedup via DB constraint)
|
||||||
|
if (req.user?.id && tasks.length > 0) {
|
||||||
|
const userId = req.user.id;
|
||||||
|
for (const task of tasks) {
|
||||||
|
await notificationService.createNotification({
|
||||||
|
user_id: userId,
|
||||||
|
typ: 'vikunja_task',
|
||||||
|
titel: 'Überfällige Aufgabe',
|
||||||
|
nachricht: task.title,
|
||||||
|
schwere: 'warnung',
|
||||||
|
link: environment.vikunja.url ? `${environment.vikunja.url}/tasks/${task.id}` : undefined,
|
||||||
|
quell_id: String(task.id),
|
||||||
|
quell_typ: 'vikunja_task',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(200).json({ success: true, data: tasks, configured: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('VikunjaController.getOverdueTasks error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Vikunja konnte nicht abgefragt werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async getProjects(_req: Request, res: Response): Promise<void> {
|
||||||
|
if (!environment.vikunja.url) {
|
||||||
|
res.status(200).json({ success: true, data: [], configured: false });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const projects = await vikunjaService.getProjects();
|
||||||
|
res.status(200).json({ success: true, data: projects, configured: true });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('VikunjaController.getProjects error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Vikunja-Projekte konnten nicht geladen werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createTask(req: Request, res: Response): Promise<void> {
|
||||||
|
if (!environment.vikunja.url) {
|
||||||
|
res.status(503).json({ success: false, message: 'Vikunja ist nicht eingerichtet' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { projectId, title, dueDate } = req.body as {
|
||||||
|
projectId?: number;
|
||||||
|
title?: string;
|
||||||
|
dueDate?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!projectId || !title || title.trim().length === 0) {
|
||||||
|
res.status(400).json({ success: false, message: 'Projekt und Titel sind erforderlich' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const task = await vikunjaService.createTask(projectId, title.trim(), dueDate);
|
||||||
|
res.status(201).json({ success: true, data: task });
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('VikunjaController.createTask error', { error });
|
||||||
|
res.status(500).json({ success: false, message: 'Aufgabe konnte nicht erstellt werden' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new VikunjaController();
|
||||||
12
backend/src/routes/vikunja.routes.ts
Normal file
12
backend/src/routes/vikunja.routes.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { Router } from 'express';
|
||||||
|
import vikunjaController from '../controllers/vikunja.controller';
|
||||||
|
import { authenticate } from '../middleware/auth.middleware';
|
||||||
|
|
||||||
|
const router = Router();
|
||||||
|
|
||||||
|
router.get('/tasks', authenticate, vikunjaController.getMyTasks.bind(vikunjaController));
|
||||||
|
router.get('/overdue', authenticate, vikunjaController.getOverdueTasks.bind(vikunjaController));
|
||||||
|
router.get('/projects', authenticate, vikunjaController.getProjects.bind(vikunjaController));
|
||||||
|
router.post('/tasks', authenticate, vikunjaController.createTask.bind(vikunjaController));
|
||||||
|
|
||||||
|
export default router;
|
||||||
112
backend/src/services/vikunja.service.ts
Normal file
112
backend/src/services/vikunja.service.ts
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
import environment from '../config/environment';
|
||||||
|
import logger from '../utils/logger';
|
||||||
|
|
||||||
|
export interface VikunjaTask {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
done: boolean;
|
||||||
|
due_date: string | null;
|
||||||
|
priority: number;
|
||||||
|
project_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VikunjaProject {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildHeaders(): Record<string, string> {
|
||||||
|
return {
|
||||||
|
'Authorization': `Bearer ${environment.vikunja.apiToken}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getMyTasks(): Promise<VikunjaTask[]> {
|
||||||
|
const { vikunja } = environment;
|
||||||
|
if (!vikunja.url) {
|
||||||
|
throw new Error('VIKUNJA_URL is not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get<VikunjaTask[]>(
|
||||||
|
`${vikunja.url}/api/v1/tasks/all`,
|
||||||
|
{ headers: buildHeaders() },
|
||||||
|
);
|
||||||
|
return (response.data ?? []).filter((t) => !t.done);
|
||||||
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
logger.error('Vikunja getMyTasks failed', {
|
||||||
|
status: error.response?.status,
|
||||||
|
statusText: error.response?.statusText,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
logger.error('VikunjaService.getMyTasks failed', { error });
|
||||||
|
throw new Error('Failed to fetch Vikunja tasks');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getOverdueTasks(): Promise<VikunjaTask[]> {
|
||||||
|
const tasks = await getMyTasks();
|
||||||
|
const now = new Date();
|
||||||
|
return tasks.filter((t) => {
|
||||||
|
if (!t.due_date) return false;
|
||||||
|
return new Date(t.due_date) < now;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getProjects(): Promise<VikunjaProject[]> {
|
||||||
|
const { vikunja } = environment;
|
||||||
|
if (!vikunja.url) {
|
||||||
|
throw new Error('VIKUNJA_URL is not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get<VikunjaProject[]>(
|
||||||
|
`${vikunja.url}/api/v1/projects`,
|
||||||
|
{ headers: buildHeaders() },
|
||||||
|
);
|
||||||
|
return response.data ?? [];
|
||||||
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
logger.error('Vikunja getProjects failed', {
|
||||||
|
status: error.response?.status,
|
||||||
|
statusText: error.response?.statusText,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
logger.error('VikunjaService.getProjects failed', { error });
|
||||||
|
throw new Error('Failed to fetch Vikunja projects');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createTask(projectId: number, title: string, dueDate?: string): Promise<VikunjaTask> {
|
||||||
|
const { vikunja } = environment;
|
||||||
|
if (!vikunja.url) {
|
||||||
|
throw new Error('VIKUNJA_URL is not configured');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const body: Record<string, unknown> = { title };
|
||||||
|
if (dueDate) {
|
||||||
|
body.due_date = dueDate;
|
||||||
|
}
|
||||||
|
const response = await axios.put<VikunjaTask>(
|
||||||
|
`${vikunja.url}/api/v1/projects/${projectId}/tasks`,
|
||||||
|
body,
|
||||||
|
{ headers: buildHeaders() },
|
||||||
|
);
|
||||||
|
return response.data;
|
||||||
|
} catch (error) {
|
||||||
|
if (axios.isAxiosError(error)) {
|
||||||
|
logger.error('Vikunja createTask failed', {
|
||||||
|
status: error.response?.status,
|
||||||
|
statusText: error.response?.statusText,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
logger.error('VikunjaService.createTask failed', { error });
|
||||||
|
throw new Error('Failed to create Vikunja task');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { getMyTasks, getOverdueTasks, getProjects, createTask };
|
||||||
175
frontend/src/components/dashboard/VikunjaMyTasksWidget.tsx
Normal file
175
frontend/src/components/dashboard/VikunjaMyTasksWidget.tsx
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Typography,
|
||||||
|
Box,
|
||||||
|
Divider,
|
||||||
|
Skeleton,
|
||||||
|
Chip,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { AssignmentInd } from '@mui/icons-material';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { format, isPast } from 'date-fns';
|
||||||
|
import { de } from 'date-fns/locale';
|
||||||
|
import { vikunjaApi } from '../../services/vikunja';
|
||||||
|
import type { VikunjaTask } from '../../types/vikunja.types';
|
||||||
|
|
||||||
|
const PRIORITY_LABELS: Record<number, { label: string; color: 'default' | 'warning' | 'error' }> = {
|
||||||
|
0: { label: 'Keine', color: 'default' },
|
||||||
|
1: { label: 'Niedrig', color: 'default' },
|
||||||
|
2: { label: 'Mittel', color: 'default' },
|
||||||
|
3: { label: 'Hoch', color: 'warning' },
|
||||||
|
4: { label: 'Kritisch', color: 'error' },
|
||||||
|
5: { label: 'Dringend', color: 'error' },
|
||||||
|
};
|
||||||
|
|
||||||
|
const TaskRow: React.FC<{ task: VikunjaTask; showDivider: boolean; vikunjaUrl: string }> = ({
|
||||||
|
task,
|
||||||
|
showDivider,
|
||||||
|
vikunjaUrl,
|
||||||
|
}) => {
|
||||||
|
const handleClick = () => {
|
||||||
|
window.open(`${vikunjaUrl}/tasks/${task.id}`, '_blank', 'noopener,noreferrer');
|
||||||
|
};
|
||||||
|
|
||||||
|
const dueDateStr = task.due_date
|
||||||
|
? format(new Date(task.due_date), 'dd. MMM', { locale: de })
|
||||||
|
: null;
|
||||||
|
const overdue = task.due_date ? isPast(new Date(task.due_date)) : false;
|
||||||
|
const priority = PRIORITY_LABELS[task.priority] ?? PRIORITY_LABELS[0];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box
|
||||||
|
onClick={handleClick}
|
||||||
|
sx={{
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
justifyContent: 'space-between',
|
||||||
|
py: 1.5,
|
||||||
|
px: 1,
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderRadius: 1,
|
||||||
|
transition: 'background-color 0.15s ease',
|
||||||
|
'&:hover': { bgcolor: 'action.hover' },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Box sx={{ flex: 1, minWidth: 0 }}>
|
||||||
|
<Typography variant="subtitle2" noWrap>
|
||||||
|
{task.title}
|
||||||
|
</Typography>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 0.5, mt: 0.5, flexWrap: 'wrap' }}>
|
||||||
|
{dueDateStr && (
|
||||||
|
<Typography
|
||||||
|
variant="caption"
|
||||||
|
color={overdue ? 'error' : 'text.secondary'}
|
||||||
|
>
|
||||||
|
{dueDateStr}
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
{task.priority > 0 && (
|
||||||
|
<Chip
|
||||||
|
label={priority.label}
|
||||||
|
size="small"
|
||||||
|
color={priority.color}
|
||||||
|
sx={{ height: 18, fontSize: '0.65rem' }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
{showDivider && <Divider />}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const VikunjaMyTasksWidget: React.FC = () => {
|
||||||
|
const { data, isLoading, isError } = useQuery({
|
||||||
|
queryKey: ['vikunja-my-tasks'],
|
||||||
|
queryFn: () => vikunjaApi.getMyTasks(),
|
||||||
|
refetchInterval: 5 * 60 * 1000,
|
||||||
|
retry: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const configured = data?.configured ?? true;
|
||||||
|
const tasks = data?.data ?? [];
|
||||||
|
|
||||||
|
if (!configured) {
|
||||||
|
return (
|
||||||
|
<Card sx={{ height: '100%' }}>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||||
|
<AssignmentInd color="disabled" />
|
||||||
|
<Typography variant="h6" color="text.secondary">
|
||||||
|
Meine Aufgaben
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
|
||||||
|
Vikunja nicht eingerichtet
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
sx={{
|
||||||
|
height: '100%',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
'&:hover': { boxShadow: 3 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||||
|
<AssignmentInd color="primary" />
|
||||||
|
<Typography variant="h6" sx={{ flexGrow: 1 }}>
|
||||||
|
Meine Aufgaben
|
||||||
|
</Typography>
|
||||||
|
{!isLoading && !isError && tasks.length > 0 && (
|
||||||
|
<Chip label={tasks.length} size="small" color="primary" />
|
||||||
|
)}
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<Box>
|
||||||
|
{[1, 2, 3].map((n) => (
|
||||||
|
<Box key={n} sx={{ mb: 1.5 }}>
|
||||||
|
<Skeleton variant="text" width="70%" height={22} />
|
||||||
|
<Skeleton variant="text" width="40%" height={18} />
|
||||||
|
</Box>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isError && (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
|
||||||
|
Vikunja nicht erreichbar
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !isError && tasks.length === 0 && (
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
|
||||||
|
Keine offenen Aufgaben
|
||||||
|
</Typography>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isLoading && !isError && tasks.length > 0 && (
|
||||||
|
<Box>
|
||||||
|
{tasks.map((task, index) => (
|
||||||
|
<TaskRow
|
||||||
|
key={task.id}
|
||||||
|
task={task}
|
||||||
|
showDivider={index < tasks.length - 1}
|
||||||
|
vikunjaUrl={import.meta.env.VITE_VIKUNJA_URL ?? ''}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VikunjaMyTasksWidget;
|
||||||
20
frontend/src/components/dashboard/VikunjaOverdueNotifier.tsx
Normal file
20
frontend/src/components/dashboard/VikunjaOverdueNotifier.tsx
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { vikunjaApi } from '../../services/vikunja';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invisible component — polls /api/vikunja/overdue every 10 minutes.
|
||||||
|
* The backend creates notifications as a side-effect when polled.
|
||||||
|
*/
|
||||||
|
const VikunjaOverdueNotifier: React.FC = () => {
|
||||||
|
useQuery({
|
||||||
|
queryKey: ['vikunja-overdue'],
|
||||||
|
queryFn: () => vikunjaApi.getOverdueTasks(),
|
||||||
|
refetchInterval: 10 * 60 * 1000,
|
||||||
|
retry: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VikunjaOverdueNotifier;
|
||||||
155
frontend/src/components/dashboard/VikunjaQuickAddWidget.tsx
Normal file
155
frontend/src/components/dashboard/VikunjaQuickAddWidget.tsx
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
Typography,
|
||||||
|
Box,
|
||||||
|
TextField,
|
||||||
|
Button,
|
||||||
|
MenuItem,
|
||||||
|
Select,
|
||||||
|
FormControl,
|
||||||
|
InputLabel,
|
||||||
|
Skeleton,
|
||||||
|
SelectChangeEvent,
|
||||||
|
} from '@mui/material';
|
||||||
|
import { AddTask } from '@mui/icons-material';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { vikunjaApi } from '../../services/vikunja';
|
||||||
|
import { useNotification } from '../../contexts/NotificationContext';
|
||||||
|
|
||||||
|
const VikunjaQuickAddWidget: React.FC = () => {
|
||||||
|
const [title, setTitle] = useState('');
|
||||||
|
const [projectId, setProjectId] = useState<number | ''>('');
|
||||||
|
const [dueDate, setDueDate] = useState('');
|
||||||
|
const { showSuccess, showError } = useNotification();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: projectsData, isLoading: projectsLoading } = useQuery({
|
||||||
|
queryKey: ['vikunja-projects'],
|
||||||
|
queryFn: () => vikunjaApi.getProjects(),
|
||||||
|
refetchInterval: 10 * 60 * 1000,
|
||||||
|
retry: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
const configured = projectsData?.configured ?? true;
|
||||||
|
const projects = projectsData?.data ?? [];
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: () =>
|
||||||
|
vikunjaApi.createTask(
|
||||||
|
projectId as number,
|
||||||
|
title,
|
||||||
|
dueDate || undefined,
|
||||||
|
),
|
||||||
|
onSuccess: () => {
|
||||||
|
showSuccess('Aufgabe erstellt');
|
||||||
|
setTitle('');
|
||||||
|
setDueDate('');
|
||||||
|
setProjectId('');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['vikunja-my-tasks'] });
|
||||||
|
},
|
||||||
|
onError: () => {
|
||||||
|
showError('Aufgabe konnte nicht erstellt werden');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!title.trim() || projectId === '') return;
|
||||||
|
mutation.mutate();
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!configured) {
|
||||||
|
return (
|
||||||
|
<Card sx={{ height: '100%' }}>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||||
|
<AddTask color="disabled" />
|
||||||
|
<Typography variant="h6" color="text.secondary">
|
||||||
|
Aufgabe erstellen
|
||||||
|
</Typography>
|
||||||
|
</Box>
|
||||||
|
<Typography variant="body2" color="text.secondary" sx={{ py: 2, textAlign: 'center' }}>
|
||||||
|
Vikunja nicht eingerichtet
|
||||||
|
</Typography>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card
|
||||||
|
sx={{
|
||||||
|
height: '100%',
|
||||||
|
transition: 'all 0.3s ease',
|
||||||
|
'&:hover': { boxShadow: 3 },
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CardContent>
|
||||||
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 2 }}>
|
||||||
|
<AddTask color="primary" />
|
||||||
|
<Typography variant="h6">Aufgabe erstellen</Typography>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{projectsLoading ? (
|
||||||
|
<Box>
|
||||||
|
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} />
|
||||||
|
<Skeleton variant="rectangular" height={40} sx={{ mb: 1.5, borderRadius: 1 }} />
|
||||||
|
<Skeleton variant="rectangular" height={40} sx={{ borderRadius: 1 }} />
|
||||||
|
</Box>
|
||||||
|
) : (
|
||||||
|
<Box component="form" onSubmit={handleSubmit} sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
|
||||||
|
<FormControl fullWidth size="small">
|
||||||
|
<InputLabel>Projekt</InputLabel>
|
||||||
|
<Select
|
||||||
|
value={projectId}
|
||||||
|
label="Projekt"
|
||||||
|
onChange={(e: SelectChangeEvent<number | ''>) =>
|
||||||
|
setProjectId(e.target.value as number | '')
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{projects.map((p) => (
|
||||||
|
<MenuItem key={p.id} value={p.id}>
|
||||||
|
{p.title}
|
||||||
|
</MenuItem>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</FormControl>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
label="Titel"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
inputProps={{ maxLength: 250 }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<TextField
|
||||||
|
fullWidth
|
||||||
|
size="small"
|
||||||
|
label="Fälligkeitsdatum (optional)"
|
||||||
|
type="date"
|
||||||
|
value={dueDate}
|
||||||
|
onChange={(e) => setDueDate(e.target.value)}
|
||||||
|
InputLabelProps={{ shrink: true }}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
variant="contained"
|
||||||
|
size="small"
|
||||||
|
disabled={!title.trim() || projectId === '' || mutation.isPending}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
{mutation.isPending ? 'Wird erstellt…' : 'Erstellen'}
|
||||||
|
</Button>
|
||||||
|
</Box>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VikunjaQuickAddWidget;
|
||||||
@@ -6,3 +6,6 @@ export { default as PersonalWarningsBanner } from './PersonalWarningsBanner';
|
|||||||
export { default as UpcomingEventsWidget } from './UpcomingEventsWidget';
|
export { default as UpcomingEventsWidget } from './UpcomingEventsWidget';
|
||||||
export { default as BookStackRecentWidget } from './BookStackRecentWidget';
|
export { default as BookStackRecentWidget } from './BookStackRecentWidget';
|
||||||
export { default as BookStackSearchWidget } from './BookStackSearchWidget';
|
export { default as BookStackSearchWidget } from './BookStackSearchWidget';
|
||||||
|
export { default as VikunjaMyTasksWidget } from './VikunjaMyTasksWidget';
|
||||||
|
export { default as VikunjaQuickAddWidget } from './VikunjaQuickAddWidget';
|
||||||
|
export { default as VikunjaOverdueNotifier } from './VikunjaOverdueNotifier';
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import EquipmentDashboardCard from '../components/equipment/EquipmentDashboardCa
|
|||||||
import VehicleDashboardCard from '../components/vehicles/VehicleDashboardCard';
|
import VehicleDashboardCard from '../components/vehicles/VehicleDashboardCard';
|
||||||
import BookStackRecentWidget from '../components/dashboard/BookStackRecentWidget';
|
import BookStackRecentWidget from '../components/dashboard/BookStackRecentWidget';
|
||||||
import BookStackSearchWidget from '../components/dashboard/BookStackSearchWidget';
|
import BookStackSearchWidget from '../components/dashboard/BookStackSearchWidget';
|
||||||
|
import VikunjaMyTasksWidget from '../components/dashboard/VikunjaMyTasksWidget';
|
||||||
|
import VikunjaQuickAddWidget from '../components/dashboard/VikunjaQuickAddWidget';
|
||||||
|
import VikunjaOverdueNotifier from '../components/dashboard/VikunjaOverdueNotifier';
|
||||||
function Dashboard() {
|
function Dashboard() {
|
||||||
const { user } = useAuth();
|
const { user } = useAuth();
|
||||||
const canViewAtemschutz = user?.groups?.some(g =>
|
const canViewAtemschutz = user?.groups?.some(g =>
|
||||||
@@ -124,6 +127,27 @@ function Dashboard() {
|
|||||||
</Box>
|
</Box>
|
||||||
</Fade>
|
</Fade>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
||||||
|
{/* Vikunja — My Tasks Widget */}
|
||||||
|
<Box>
|
||||||
|
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '600ms' }}>
|
||||||
|
<Box>
|
||||||
|
<VikunjaMyTasksWidget />
|
||||||
|
</Box>
|
||||||
|
</Fade>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Vikunja — Quick Add Widget */}
|
||||||
|
<Box>
|
||||||
|
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '640ms' }}>
|
||||||
|
<Box>
|
||||||
|
<VikunjaQuickAddWidget />
|
||||||
|
</Box>
|
||||||
|
</Fade>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{/* Vikunja — Overdue Notifier (invisible, polling component) */}
|
||||||
|
<VikunjaOverdueNotifier />
|
||||||
</Box>
|
</Box>
|
||||||
</Container>
|
</Container>
|
||||||
</DashboardLayout>
|
</DashboardLayout>
|
||||||
|
|||||||
42
frontend/src/services/vikunja.ts
Normal file
42
frontend/src/services/vikunja.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { api } from './api';
|
||||||
|
import type {
|
||||||
|
VikunjaTasksResponse,
|
||||||
|
VikunjaProjectsResponse,
|
||||||
|
VikunjaTask,
|
||||||
|
} from '../types/vikunja.types';
|
||||||
|
|
||||||
|
interface ApiResponse<T> {
|
||||||
|
success: boolean;
|
||||||
|
data: T;
|
||||||
|
configured: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const vikunjaApi = {
|
||||||
|
getMyTasks(): Promise<VikunjaTasksResponse> {
|
||||||
|
return api
|
||||||
|
.get<ApiResponse<VikunjaTasksResponse['data']>>('/api/vikunja/tasks')
|
||||||
|
.then((r) => ({ configured: r.data.configured, data: r.data.data }));
|
||||||
|
},
|
||||||
|
|
||||||
|
getOverdueTasks(): Promise<VikunjaTasksResponse> {
|
||||||
|
return api
|
||||||
|
.get<ApiResponse<VikunjaTasksResponse['data']>>('/api/vikunja/overdue')
|
||||||
|
.then((r) => ({ configured: r.data.configured, data: r.data.data }));
|
||||||
|
},
|
||||||
|
|
||||||
|
getProjects(): Promise<VikunjaProjectsResponse> {
|
||||||
|
return api
|
||||||
|
.get<ApiResponse<VikunjaProjectsResponse['data']>>('/api/vikunja/projects')
|
||||||
|
.then((r) => ({ configured: r.data.configured, data: r.data.data }));
|
||||||
|
},
|
||||||
|
|
||||||
|
createTask(projectId: number, title: string, dueDate?: string): Promise<{ data: VikunjaTask }> {
|
||||||
|
return api
|
||||||
|
.post<{ success: boolean; data: VikunjaTask }>('/api/vikunja/tasks', {
|
||||||
|
projectId,
|
||||||
|
title,
|
||||||
|
dueDate,
|
||||||
|
})
|
||||||
|
.then((r) => ({ data: r.data.data }));
|
||||||
|
},
|
||||||
|
};
|
||||||
23
frontend/src/types/vikunja.types.ts
Normal file
23
frontend/src/types/vikunja.types.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
export interface VikunjaTask {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
done: boolean;
|
||||||
|
due_date: string | null;
|
||||||
|
priority: number;
|
||||||
|
project_id: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VikunjaProject {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VikunjaTasksResponse {
|
||||||
|
configured: boolean;
|
||||||
|
data: VikunjaTask[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VikunjaProjectsResponse {
|
||||||
|
configured: boolean;
|
||||||
|
data: VikunjaProject[];
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user