This commit is contained in:
beabigegg
2025-08-29 19:02:19 +08:00
parent b0c86302ff
commit f3f2b7d596
17 changed files with 1632 additions and 157 deletions

View File

@@ -12,6 +12,8 @@ import {
Avatar,
AvatarGroup,
IconButton,
Skeleton,
CircularProgress,
} from '@mui/material';
import {
Assignment,
@@ -216,137 +218,168 @@ const DashboardPage = () => {
</Box>
{/* 統計卡片 */}
<Grid container spacing={3} sx={{ mb: 4 }}>
<Grid item xs={12} sm={6} md={3}>
<motion.div variants={itemVariants}>
<Card
sx={{
background: actualTheme === 'dark'
? 'linear-gradient(135deg, #1f2937 0%, #374151 100%)'
: 'linear-gradient(135deg, #ffffff 0%, #f9fafb 100%)',
border: `1px solid ${actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'}`,
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: '0 8px 25px rgba(0, 0, 0, 0.15)',
},
}}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography color="text.secondary" gutterBottom>
</Typography>
<Typography variant="h4" sx={{ fontWeight: 700 }}>
{stats.total}
</Typography>
</Box>
<Assignment sx={{ fontSize: 40, color: '#6b7280', opacity: 0.8 }} />
</Box>
</CardContent>
</Card>
</motion.div>
{loading ? (
<Grid container spacing={3} sx={{ mb: 4 }}>
{Array.from({ length: 4 }).map((_, index) => (
<Grid item xs={12} sm={6} md={3} key={index}>
<motion.div variants={itemVariants}>
<Card
sx={{
background: actualTheme === 'dark'
? 'linear-gradient(135deg, #1f2937 0%, #374151 100%)'
: 'linear-gradient(135deg, #ffffff 0%, #f9fafb 100%)',
border: `1px solid ${actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'}`,
}}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Skeleton variant="text" width={60} height={20} sx={{ mb: 1 }} />
<Skeleton variant="text" width={40} height={40} />
</Box>
<Skeleton variant="circular" width={40} height={40} />
</Box>
</CardContent>
</Card>
</motion.div>
</Grid>
))}
</Grid>
) : (
<Grid container spacing={3} sx={{ mb: 4 }}>
<Grid item xs={12} sm={6} md={3}>
<motion.div variants={itemVariants}>
<Card
sx={{
background: actualTheme === 'dark'
? 'linear-gradient(135deg, #1f2937 0%, #374151 100%)'
: 'linear-gradient(135deg, #ffffff 0%, #f9fafb 100%)',
border: `1px solid ${actualTheme === 'dark'
? 'rgba(255, 255, 255, 0.1)'
: 'rgba(0, 0, 0, 0.1)'}`,
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: '0 8px 25px rgba(0, 0, 0, 0.15)',
},
}}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography color="text.secondary" gutterBottom>
</Typography>
<Typography variant="h4" sx={{ fontWeight: 700 }}>
{stats.total}
</Typography>
</Box>
<Assignment sx={{ fontSize: 40, color: '#6b7280', opacity: 0.8 }} />
</Box>
</CardContent>
</Card>
</motion.div>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<motion.div variants={itemVariants}>
<Card
sx={{
background: actualTheme === 'dark'
? 'linear-gradient(135deg, #1e3a8a 0%, #3b82f6 100%)'
: 'linear-gradient(135deg, #3b82f6 0%, #60a5fa 100%)',
color: 'white',
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: '0 8px 25px rgba(59, 130, 246, 0.3)',
},
}}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography sx={{ color: 'rgba(255, 255, 255, 0.8)' }} gutterBottom>
</Typography>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white' }}>
{stats.doing}
</Typography>
<Grid item xs={12} sm={6} md={3}>
<motion.div variants={itemVariants}>
<Card
sx={{
background: actualTheme === 'dark'
? 'linear-gradient(135deg, #1e3a8a 0%, #3b82f6 100%)'
: 'linear-gradient(135deg, #3b82f6 0%, #60a5fa 100%)',
color: 'white',
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: '0 8px 25px rgba(59, 130, 246, 0.3)',
},
}}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography sx={{ color: 'rgba(255, 255, 255, 0.8)' }} gutterBottom>
</Typography>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white' }}>
{stats.doing}
</Typography>
</Box>
<Schedule sx={{ fontSize: 40, color: 'rgba(255, 255, 255, 0.8)' }} />
</Box>
<Schedule sx={{ fontSize: 40, color: 'rgba(255, 255, 255, 0.8)' }} />
</Box>
</CardContent>
</Card>
</motion.div>
</Grid>
</CardContent>
</Card>
</motion.div>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<motion.div variants={itemVariants}>
<Card
sx={{
background: actualTheme === 'dark'
? 'linear-gradient(135deg, #065f46 0%, #10b981 100%)'
: 'linear-gradient(135deg, #10b981 0%, #34d399 100%)',
color: 'white',
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: '0 8px 25px rgba(16, 185, 129, 0.3)',
},
}}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography sx={{ color: 'rgba(255, 255, 255, 0.8)' }} gutterBottom>
</Typography>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white' }}>
{stats.completed}
</Typography>
<Grid item xs={12} sm={6} md={3}>
<motion.div variants={itemVariants}>
<Card
sx={{
background: actualTheme === 'dark'
? 'linear-gradient(135deg, #065f46 0%, #10b981 100%)'
: 'linear-gradient(135deg, #10b981 0%, #34d399 100%)',
color: 'white',
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: '0 8px 25px rgba(16, 185, 129, 0.3)',
},
}}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography sx={{ color: 'rgba(255, 255, 255, 0.8)' }} gutterBottom>
</Typography>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white' }}>
{stats.completed}
</Typography>
</Box>
<CheckCircle sx={{ fontSize: 40, color: 'rgba(255, 255, 255, 0.8)' }} />
</Box>
<CheckCircle sx={{ fontSize: 40, color: 'rgba(255, 255, 255, 0.8)' }} />
</Box>
</CardContent>
</Card>
</motion.div>
</Grid>
</CardContent>
</Card>
</motion.div>
</Grid>
<Grid item xs={12} sm={6} md={3}>
<motion.div variants={itemVariants}>
<Card
sx={{
background: actualTheme === 'dark'
? 'linear-gradient(135deg, #991b1b 0%, #ef4444 100%)'
: 'linear-gradient(135deg, #ef4444 0%, #f87171 100%)',
color: 'white',
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: '0 8px 25px rgba(239, 68, 68, 0.3)',
},
}}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography sx={{ color: 'rgba(255, 255, 255, 0.8)' }} gutterBottom>
</Typography>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white' }}>
{stats.overdue}
</Typography>
<Grid item xs={12} sm={6} md={3}>
<motion.div variants={itemVariants}>
<Card
sx={{
background: actualTheme === 'dark'
? 'linear-gradient(135deg, #991b1b 0%, #ef4444 100%)'
: 'linear-gradient(135deg, #ef4444 0%, #f87171 100%)',
color: 'white',
transition: 'transform 0.2s ease, box-shadow 0.2s ease',
'&:hover': {
transform: 'translateY(-2px)',
boxShadow: '0 8px 25px rgba(239, 68, 68, 0.3)',
},
}}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Box>
<Typography sx={{ color: 'rgba(255, 255, 255, 0.8)' }} gutterBottom>
</Typography>
<Typography variant="h4" sx={{ fontWeight: 700, color: 'white' }}>
{stats.overdue}
</Typography>
</Box>
<Warning sx={{ fontSize: 40, color: 'rgba(255, 255, 255, 0.8)' }} />
</Box>
<Warning sx={{ fontSize: 40, color: 'rgba(255, 255, 255, 0.8)' }} />
</Box>
</CardContent>
</Card>
</motion.div>
</CardContent>
</Card>
</motion.div>
</Grid>
</Grid>
</Grid>
)}
{/* 主要內容區域 */}
<Grid container spacing={3}>

View File

@@ -0,0 +1,351 @@
'use client';
import React, { useState, useEffect } from 'react';
import {
Box,
Container,
Typography,
Card,
CardContent,
Chip,
IconButton,
Button,
TextField,
InputAdornment,
Grid,
Skeleton,
Alert,
Tooltip,
Badge,
Avatar,
AvatarGroup,
} from '@mui/material';
import {
Search,
Public as PublicIcon,
PersonAdd,
PersonRemove,
Star,
StarBorder,
FilterList,
Refresh,
Visibility,
} from '@mui/icons-material';
import { useRouter } from 'next/navigation';
import { todosApi } from '@/lib/api';
import { Todo, TodoFilter } from '@/types';
import { toast } from 'react-hot-toast';
import TodoDialog from '@/components/todos/TodoDialog';
import TodoFilters from '@/components/todos/TodoFilters';
import DashboardLayout from '@/components/layout/DashboardLayout';
export default function PublicTodosPage() {
const router = useRouter();
const [todos, setTodos] = useState<Todo[]>([]);
const [loading, setLoading] = useState(true);
const [searchTerm, setSearchTerm] = useState('');
const [filters, setFilters] = useState<TodoFilter>({});
const [showFilters, setShowFilters] = useState(false);
const [selectedTodo, setSelectedTodo] = useState<Todo | null>(null);
const [showTodoDialog, setShowTodoDialog] = useState(false);
const [followingTodos, setFollowingTodos] = useState<Set<string>>(new Set());
useEffect(() => {
fetchPublicTodos();
fetchFollowingStatus();
}, [filters, searchTerm]);
const fetchPublicTodos = async () => {
try {
setLoading(true);
const response = await todosApi.getPublicTodos({
...filters,
search: searchTerm,
});
setTodos(response.todos);
} catch (error) {
toast.error('載入公開任務失敗');
} finally {
setLoading(false);
}
};
const fetchFollowingStatus = async () => {
try {
const response = await todosApi.getFollowingTodos();
const followingIds = new Set(response.todos.map(t => t.id));
setFollowingTodos(followingIds);
} catch (error) {
console.error('Failed to fetch following status:', error);
}
};
const handleToggleFollow = async (todo: Todo) => {
try {
const isFollowing = followingTodos.has(todo.id);
if (isFollowing) {
await todosApi.unfollowTodo(todo.id);
setFollowingTodos(prev => {
const newSet = new Set(prev);
newSet.delete(todo.id);
return newSet;
});
toast.success('已取消追蹤');
} else {
await todosApi.followTodo(todo.id);
setFollowingTodos(prev => new Set([...prev, todo.id]));
toast.success('已開始追蹤');
}
} catch (error) {
toast.error('操作失敗');
}
};
const handleViewTodo = (todo: Todo) => {
setSelectedTodo(todo);
setShowTodoDialog(true);
};
const getStatusColor = (status: string) => {
const colors = {
NEW: 'default',
DOING: 'primary',
BLOCKED: 'error',
DONE: 'success',
};
return colors[status as keyof typeof colors] || 'default';
};
const getPriorityColor = (priority: string) => {
const colors = {
LOW: 'default',
MEDIUM: 'info',
HIGH: 'warning',
URGENT: 'error',
};
return colors[priority as keyof typeof colors] || 'default';
};
return (
<DashboardLayout>
<Container maxWidth="xl" sx={{ py: 3 }}>
{/* Header */}
<Box sx={{ mb: 3, display: 'flex', alignItems: 'center', gap: 2 }}>
<PublicIcon sx={{ fontSize: 32, color: 'primary.main' }} />
<Typography variant="h4" sx={{ flexGrow: 1 }}>
</Typography>
<Button
variant="outlined"
startIcon={<Refresh />}
onClick={fetchPublicTodos}
>
</Button>
</Box>
{/* Search and Filters */}
<Card sx={{ mb: 3 }}>
<CardContent>
<Box sx={{ display: 'flex', gap: 2, mb: 2 }}>
<TextField
fullWidth
placeholder="搜尋公開任務..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Search />
</InputAdornment>
),
}}
/>
<Button
variant="outlined"
startIcon={<FilterList />}
onClick={() => setShowFilters(!showFilters)}
>
</Button>
</Box>
{showFilters && (
<TodoFilters
onClose={() => setShowFilters(false)}
onApply={setFilters}
initialFilters={filters}
/>
)}
</CardContent>
</Card>
{/* Todos List */}
{loading ? (
<Grid container spacing={2}>
{[1, 2, 3].map((i) => (
<Grid item xs={12} md={6} lg={4} key={i}>
<Skeleton variant="rectangular" height={200} />
</Grid>
))}
</Grid>
) : todos.length === 0 ? (
<Alert severity="info"></Alert>
) : (
<Grid container spacing={2}>
{todos.map((todo) => (
<Grid item xs={12} md={6} lg={4} key={todo.id}>
<Card
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
transition: 'transform 0.2s',
'&:hover': {
transform: 'translateY(-4px)',
boxShadow: 3,
},
}}
>
<CardContent sx={{ flexGrow: 1 }}>
{/* Title and Status */}
<Box sx={{ display: 'flex', alignItems: 'flex-start', mb: 2 }}>
<Box sx={{ flexGrow: 1 }}>
<Typography
variant="h6"
sx={{
cursor: 'pointer',
'&:hover': { color: 'primary.main' },
}}
onClick={() => handleViewTodo(todo)}
>
{todo.starred && <Star sx={{ fontSize: 18, color: '#fbbf24', mr: 0.5 }} />}
{todo.title}
</Typography>
<Typography variant="body2" color="text.secondary">
: {todo.creator_display_name || todo.creator_ad}
</Typography>
</Box>
<Tooltip title={followingTodos.has(todo.id) ? '取消追蹤' : '追蹤'}>
<IconButton
size="small"
onClick={() => handleToggleFollow(todo)}
color={followingTodos.has(todo.id) ? 'primary' : 'default'}
>
{followingTodos.has(todo.id) ? <PersonRemove /> : <PersonAdd />}
</IconButton>
</Tooltip>
</Box>
{/* Description */}
{todo.description && (
<Typography
variant="body2"
color="text.secondary"
sx={{
mb: 2,
overflow: 'hidden',
textOverflow: 'ellipsis',
display: '-webkit-box',
WebkitLineClamp: 2,
WebkitBoxOrient: 'vertical',
}}
>
{todo.description}
</Typography>
)}
{/* Chips */}
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap', mb: 2 }}>
<Chip
label={todo.status}
size="small"
color={getStatusColor(todo.status) as any}
/>
<Chip
label={todo.priority}
size="small"
color={getPriorityColor(todo.priority) as any}
variant="outlined"
/>
{todo.due_date && (
<Chip
label={`到期: ${new Date(todo.due_date).toLocaleDateString()}`}
size="small"
variant="outlined"
/>
)}
</Box>
{/* Tags */}
{todo.tags && todo.tags.length > 0 && (
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 2 }}>
{todo.tags.map((tag, index) => (
<Chip
key={index}
label={tag}
size="small"
variant="outlined"
sx={{ borderRadius: 1 }}
/>
))}
</Box>
)}
{/* Followers */}
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant="caption" color="text.secondary">
:
</Typography>
{todo.followers.length > 0 ? (
<AvatarGroup max={3} sx={{ '& .MuiAvatar-root': { width: 24, height: 24, fontSize: 12 } }}>
{todo.followers_details?.map((follower) => (
<Tooltip key={follower.ad_account} title={follower.display_name}>
<Avatar sx={{ bgcolor: 'primary.main' }}>
{follower.display_name.charAt(0)}
</Avatar>
</Tooltip>
))}
</AvatarGroup>
) : (
<Typography variant="caption" color="text.secondary">
</Typography>
)}
</Box>
</CardContent>
{/* Actions */}
<Box sx={{ p: 2, pt: 0, display: 'flex', gap: 1 }}>
<Button
fullWidth
variant="outlined"
startIcon={<Visibility />}
onClick={() => handleViewTodo(todo)}
>
</Button>
</Box>
</Card>
</Grid>
))}
</Grid>
)}
{/* Todo Dialog */}
{selectedTodo && (
<TodoDialog
open={showTodoDialog}
onClose={() => {
setShowTodoDialog(false);
setSelectedTodo(null);
}}
todo={selectedTodo}
readOnly={true}
/>
)}
</Container>
</DashboardLayout>
);
}

View File

@@ -11,6 +11,9 @@ import {
Fade,
Chip,
Card,
Skeleton,
CircularProgress,
Backdrop,
} from '@mui/material';
import {
Add,
@@ -42,6 +45,16 @@ const TodosPage = () => {
const [viewMode, setViewMode] = useState<ViewMode>('list');
const [filterMode, setFilterMode] = useState<FilterMode>('all');
const [showFilters, setShowFilters] = useState(false);
const [appliedFilters, setAppliedFilters] = useState({
status: [] as string[],
priority: [] as string[],
assignee: '',
dateFrom: null as any,
dateTo: null as any,
starred: false,
overdue: false,
dueSoon: false,
});
const [showSearch, setShowSearch] = useState(false);
const [selectedTodos, setSelectedTodos] = useState<string[]>([]);
const [searchQuery, setSearchQuery] = useState('');
@@ -142,10 +155,68 @@ const TodosPage = () => {
case 'following':
return todo.followers?.includes(currentUser.ad_account) || false;
default:
return true;
break; // 繼續其他篩選
}
}
// 進階篩選
// 狀態篩選
if (appliedFilters.status.length > 0 && !appliedFilters.status.includes(todo.status)) {
return false;
}
// 優先級篩選
if (appliedFilters.priority.length > 0 && !appliedFilters.priority.includes(todo.priority)) {
return false;
}
// 指派人篩選
if (appliedFilters.assignee && currentUser) {
switch (appliedFilters.assignee) {
case 'me':
if (!todo.responsible_users?.includes(currentUser.ad_account)) return false;
break;
case 'created_by_me':
if (todo.creator_ad !== currentUser.ad_account) return false;
break;
case 'followed_by_me':
if (!todo.followers?.includes(currentUser.ad_account)) return false;
break;
}
}
// 日期篩選
if (appliedFilters.dateFrom || appliedFilters.dateTo) {
if (!todo.due_date) return false;
const dueDate = new Date(todo.due_date);
if (appliedFilters.dateFrom && dueDate < new Date(appliedFilters.dateFrom)) return false;
if (appliedFilters.dateTo && dueDate > new Date(appliedFilters.dateTo)) return false;
}
// 星號篩選
if (appliedFilters.starred && !todo.starred) {
return false;
}
// 逾期篩選
if (appliedFilters.overdue) {
if (!todo.due_date) return false;
const dueDate = new Date(todo.due_date);
const today = new Date();
today.setHours(0, 0, 0, 0);
if (dueDate >= today || todo.status === 'DONE') return false;
}
// 即將到期篩選
if (appliedFilters.dueSoon) {
if (!todo.due_date || todo.status === 'DONE') return false;
const dueDate = new Date(todo.due_date);
const today = new Date();
const threeDaysFromNow = new Date();
threeDaysFromNow.setDate(today.getDate() + 3);
if (dueDate < today || dueDate > threeDaysFromNow) return false;
}
return true;
});
@@ -544,7 +615,11 @@ const TodosPage = () => {
exit={{ opacity: 0, height: 0, marginBottom: 0 }}
transition={{ duration: 0.3 }}
>
<TodoFilters onClose={() => setShowFilters(false)} />
<TodoFilters
onClose={() => setShowFilters(false)}
onApply={setAppliedFilters}
initialFilters={appliedFilters}
/>
</motion.div>
)}
</AnimatePresence>
@@ -572,8 +647,28 @@ const TodosPage = () => {
{/* 主要內容區域 */}
<motion.div variants={itemVariants}>
<Fade in={true} timeout={500}>
<Box>
{viewMode === 'list' ? (
<Box sx={{ position: 'relative', minHeight: '400px' }}>
{loading ? (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
{Array.from({ length: 3 }).map((_, index) => (
<Card key={index} sx={{ p: 3 }}>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mb: 2 }}>
<Skeleton variant="rectangular" width={20} height={20} />
<Skeleton variant="text" width="60%" height={32} />
<Skeleton variant="circular" width={24} height={24} sx={{ ml: 'auto' }} />
</Box>
<Skeleton variant="text" width="80%" height={20} sx={{ mb: 1 }} />
<Skeleton variant="text" width="40%" height={20} sx={{ mb: 2 }} />
<Box sx={{ display: 'flex', gap: 1, mb: 2 }}>
<Skeleton variant="rounded" width={60} height={24} />
<Skeleton variant="rounded" width={50} height={24} />
<Skeleton variant="rounded" width={70} height={24} />
</Box>
<Skeleton variant="text" width="30%" height={16} />
</Card>
))}
</Box>
) : viewMode === 'list' ? (
<TodoList
todos={filteredTodos}
selectedTodos={selectedTodos}

View File

@@ -186,6 +186,50 @@ export const todosApi = {
message: request.note,
});
},
getPublicTodos: async (filters?: TodoFilter): Promise<TodosResponse> => {
const params = new URLSearchParams();
if (filters) {
Object.entries(filters).forEach(([key, value]) => {
if (value !== undefined && value !== null && value !== '') {
if (key === 'tags' && Array.isArray(value)) {
value.forEach(tag => params.append('tags', tag));
} else {
params.append(key, value.toString());
}
}
});
}
const response = await api.get(`/api/todos/public?${params.toString()}`);
return response.data;
},
getFollowingTodos: async (page = 1, perPage = 20): Promise<TodosResponse> => {
const response = await api.get(`/api/todos/following?page=${page}&per_page=${perPage}`);
return response.data;
},
updateTodoVisibility: async (id: string, isPublic: boolean): Promise<{ message: string; is_public: boolean }> => {
const response = await api.patch(`/api/todos/${id}/visibility`, { is_public: isPublic });
return response.data;
},
followTodo: async (id: string): Promise<{ message: string }> => {
const response = await api.post(`/api/todos/${id}/follow`);
return response.data;
},
unfollowTodo: async (id: string): Promise<{ message: string }> => {
const response = await api.delete(`/api/todos/${id}/follow`);
return response.data;
},
toggleStar: async (id: string): Promise<{ message: string; starred: boolean }> => {
const response = await api.patch(`/api/todos/${id}/star`);
return response.data;
},
};
// Users API

View File

@@ -19,6 +19,8 @@ export interface Todo {
creator_display_name?: string;
creator_email?: string;
starred: boolean;
is_public: boolean;
tags: string[];
responsible_users: string[];
followers: string[];
responsible_users_details?: UserDetail[];
@@ -32,6 +34,8 @@ export interface TodoCreate {
priority?: 'LOW' | 'MEDIUM' | 'HIGH' | 'URGENT';
due_date?: string;
starred?: boolean;
is_public?: boolean;
tags?: string[];
responsible_users?: string[];
followers?: string[];
}
@@ -47,7 +51,8 @@ export interface TodoFilter {
due_from?: string;
due_to?: string;
search?: string;
view?: 'all' | 'created' | 'responsible' | 'following';
view?: 'all' | 'created' | 'responsible' | 'following' | 'public';
tags?: string[];
}
// User Types