add vikunja integration
This commit is contained in:
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 BookStackRecentWidget } from './BookStackRecentWidget';
|
||||
export { default as BookStackSearchWidget } from './BookStackSearchWidget';
|
||||
export { default as VikunjaMyTasksWidget } from './VikunjaMyTasksWidget';
|
||||
export { default as VikunjaQuickAddWidget } from './VikunjaQuickAddWidget';
|
||||
export { default as VikunjaOverdueNotifier } from './VikunjaOverdueNotifier';
|
||||
|
||||
Reference in New Issue
Block a user