Agent turn complete
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2751
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "app-frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@emotion/react": "11.14.0",
|
||||
"@emotion/styled": "11.14.0",
|
||||
"@mui/icons-material": "6.3.0",
|
||||
"@mui/material": "6.3.0",
|
||||
"react": "18.3.1",
|
||||
"react-beautiful-dnd": "13.1.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-router-dom": "6.28.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "18.3.18",
|
||||
"@types/react-beautiful-dnd": "13.1.8",
|
||||
"@types/react-dom": "18.3.5",
|
||||
"@vitejs/plugin-react": "4.3.4",
|
||||
"typescript": "5.7.2",
|
||||
"vite": "6.0.7"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import Container from '@mui/material/Container';
|
||||
import { Route, Routes } from 'react-router-dom';
|
||||
|
||||
import { KanbanBoard } from './components/KanbanBoard';
|
||||
|
||||
// Routes are declared here, in one <Routes>, with RELATIVE paths — main.tsx
|
||||
// supplies the basename. Navigate with <Link to>, never <a href>.
|
||||
// All styling goes in `sx`: MUI v6 dropped the shorthand props.
|
||||
export function App() {
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ py: 4 }}>
|
||||
<Routes>
|
||||
<Route path="/" element={<KanbanBoard />} />
|
||||
</Routes>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import Box from '@mui/material/Box';
|
||||
import Paper from '@mui/material/Paper';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Droppable } from 'react-beautiful-dnd';
|
||||
import { Task } from '../types/task';
|
||||
import { TaskCard } from './TaskCard';
|
||||
|
||||
interface ColumnProps {
|
||||
title: string;
|
||||
tasks: Task[];
|
||||
onTaskClick: (task: Task) => void;
|
||||
}
|
||||
|
||||
const columnColors: Record<string, string> = {
|
||||
'TODO': '#e3f2fd',
|
||||
'IN PROGRESS': '#fff3e0',
|
||||
'QA': '#f3e5f5',
|
||||
'UAT': '#e8f5e9',
|
||||
'PROD': '#fce4ec',
|
||||
};
|
||||
|
||||
export function Column({ title, tasks, onTaskClick }: ColumnProps) {
|
||||
return (
|
||||
<Paper
|
||||
sx={{
|
||||
flex: 1,
|
||||
minWidth: 300,
|
||||
backgroundColor: columnColors[title] || '#f5f5f5',
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
sx={{
|
||||
p: 2,
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.05)',
|
||||
borderBottom: '1px solid rgba(0, 0, 0, 0.1)',
|
||||
}}
|
||||
>
|
||||
<Typography variant="h6" sx={{ fontWeight: 600 }}>
|
||||
{title}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
{tasks.length} task{tasks.length !== 1 ? 's' : ''}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
<Droppable droppableId={title}>
|
||||
{(provided, snapshot) => (
|
||||
<Box
|
||||
ref={provided.innerRef}
|
||||
{...provided.droppableProps}
|
||||
sx={{
|
||||
flex: 1,
|
||||
p: 2,
|
||||
overflowY: 'auto',
|
||||
backgroundColor: snapshot.isDraggingOver ? 'rgba(0, 0, 0, 0.05)' : 'transparent',
|
||||
transition: 'background-color 0.2s ease',
|
||||
minHeight: 400,
|
||||
}}
|
||||
>
|
||||
{tasks.map((task, index) => (
|
||||
<TaskCard
|
||||
key={task.id}
|
||||
task={task}
|
||||
index={index}
|
||||
onClick={onTaskClick}
|
||||
/>
|
||||
))}
|
||||
{provided.placeholder}
|
||||
</Box>
|
||||
)}
|
||||
</Droppable>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
import Button from '@mui/material/Button';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Select from '@mui/material/Select';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import { useState } from 'react';
|
||||
import { Task, PRIORITIES } from '../types/task';
|
||||
|
||||
interface CreateTaskDialogProps {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
onCreate: (task: Omit<Task, 'id' | 'created_at' | 'updated_at'>) => void;
|
||||
}
|
||||
|
||||
export function CreateTaskDialog({ open, onClose, onCreate }: CreateTaskDialogProps) {
|
||||
const [formData, setFormData] = useState({
|
||||
title: '',
|
||||
description: '',
|
||||
priority: 'medium',
|
||||
status: 'TODO',
|
||||
assignee: '',
|
||||
tags: '',
|
||||
time_estimate: '',
|
||||
due_date: '',
|
||||
});
|
||||
|
||||
const handleChange = (field: string, value: any) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
if (formData.title.trim()) {
|
||||
onCreate({
|
||||
title: formData.title,
|
||||
description: formData.description || undefined,
|
||||
priority: formData.priority as any,
|
||||
status: formData.status as any,
|
||||
assignee: formData.assignee || undefined,
|
||||
tags: formData.tags || undefined,
|
||||
time_estimate: formData.time_estimate ? parseFloat(formData.time_estimate) : undefined,
|
||||
due_date: formData.due_date ? new Date(formData.due_date).toISOString() : undefined,
|
||||
});
|
||||
setFormData({
|
||||
title: '',
|
||||
description: '',
|
||||
priority: 'medium',
|
||||
status: 'TODO',
|
||||
assignee: '',
|
||||
tags: '',
|
||||
time_estimate: '',
|
||||
due_date: '',
|
||||
});
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Create New Task</DialogTitle>
|
||||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 2 }}>
|
||||
<TextField
|
||||
label="Title"
|
||||
value={formData.title}
|
||||
onChange={(e) => handleChange('title', e.target.value)}
|
||||
fullWidth
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Description"
|
||||
value={formData.description}
|
||||
onChange={(e) => handleChange('description', e.target.value)}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Priority</InputLabel>
|
||||
<Select
|
||||
value={formData.priority}
|
||||
onChange={(e) => handleChange('priority', e.target.value)}
|
||||
label="Priority"
|
||||
>
|
||||
{PRIORITIES.map((p) => (
|
||||
<MenuItem key={p} value={p}>
|
||||
{p.charAt(0).toUpperCase() + p.slice(1)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
label="Assignee"
|
||||
value={formData.assignee}
|
||||
onChange={(e) => handleChange('assignee', e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Tags (comma-separated)"
|
||||
value={formData.tags}
|
||||
onChange={(e) => handleChange('tags', e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Time Estimate (hours)"
|
||||
type="number"
|
||||
value={formData.time_estimate}
|
||||
onChange={(e) => handleChange('time_estimate', e.target.value)}
|
||||
fullWidth
|
||||
inputProps={{ step: '0.5' }}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Due Date"
|
||||
type="datetime-local"
|
||||
value={formData.due_date}
|
||||
onChange={(e) => handleChange('due_date', e.target.value)}
|
||||
fullWidth
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={handleCreate} variant="contained">
|
||||
Create
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import AddIcon from '@mui/icons-material/Add';
|
||||
import SearchIcon from '@mui/icons-material/Search';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { DragDropContext, DropResult } from 'react-beautiful-dnd';
|
||||
import { api } from '../services/api';
|
||||
import { Task, COLUMNS, TaskListResponse } from '../types/task';
|
||||
import { Column } from './Column';
|
||||
import { TaskModal } from './TaskModal';
|
||||
import { CreateTaskDialog } from './CreateTaskDialog';
|
||||
|
||||
export function KanbanBoard() {
|
||||
const [tasks, setTasks] = useState<Task[]>([]);
|
||||
const [filteredTasks, setFilteredTasks] = useState<Task[]>([]);
|
||||
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [createDialogOpen, setCreateDialogOpen] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedPriority, setSelectedPriority] = useState<string | null>(null);
|
||||
const [selectedTag, setSelectedTag] = useState<string | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Fetch tasks
|
||||
const fetchTasks = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const response = await api.get<TaskListResponse>('/api/tasks');
|
||||
setTasks(response.data.tasks);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to fetch tasks');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchTasks();
|
||||
}, []);
|
||||
|
||||
// Filter tasks based on search, priority, and tag
|
||||
useEffect(() => {
|
||||
let filtered = tasks;
|
||||
|
||||
if (searchQuery) {
|
||||
filtered = filtered.filter(
|
||||
(task) =>
|
||||
task.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
task.description?.toLowerCase().includes(searchQuery.toLowerCase())
|
||||
);
|
||||
}
|
||||
|
||||
if (selectedPriority) {
|
||||
filtered = filtered.filter((task) => task.priority === selectedPriority);
|
||||
}
|
||||
|
||||
if (selectedTag) {
|
||||
filtered = filtered.filter((task) =>
|
||||
task.tags?.split(',').map((t) => t.trim()).includes(selectedTag)
|
||||
);
|
||||
}
|
||||
|
||||
setFilteredTasks(filtered);
|
||||
}, [tasks, searchQuery, selectedPriority, selectedTag]);
|
||||
|
||||
// Group tasks by status
|
||||
const tasksByStatus = COLUMNS.reduce(
|
||||
(acc, column) => {
|
||||
acc[column] = (filteredTasks || []).filter((task) => task.status === column);
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, Task[]>
|
||||
);
|
||||
|
||||
// Handle drag and drop
|
||||
const handleDragEnd = async (result: DropResult) => {
|
||||
const { source, destination, draggableId } = result;
|
||||
|
||||
if (!destination) return;
|
||||
|
||||
if (source.droppableId === destination.droppableId && source.index === destination.index) {
|
||||
return;
|
||||
}
|
||||
|
||||
const taskId = parseInt(draggableId.split('-')[1]);
|
||||
const task = tasks.find((t) => t.id === taskId);
|
||||
|
||||
if (task) {
|
||||
try {
|
||||
await api.put(`/api/tasks/${taskId}`, {
|
||||
...task,
|
||||
status: destination.droppableId,
|
||||
});
|
||||
await fetchTasks();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update task');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle task creation
|
||||
const handleCreateTask = async (newTask: Omit<Task, 'id' | 'created_at' | 'updated_at'>) => {
|
||||
try {
|
||||
await api.post('/api/tasks', newTask);
|
||||
await fetchTasks();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to create task');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle task update
|
||||
const handleUpdateTask = async (updatedTask: Task) => {
|
||||
try {
|
||||
await api.put(`/api/tasks/${updatedTask.id}`, updatedTask);
|
||||
await fetchTasks();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to update task');
|
||||
}
|
||||
};
|
||||
|
||||
// Handle task deletion
|
||||
const handleDeleteTask = async (taskId: number) => {
|
||||
try {
|
||||
await api.delete(`/api/tasks/${taskId}`);
|
||||
await fetchTasks();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to delete task');
|
||||
}
|
||||
};
|
||||
|
||||
// Get all unique tags
|
||||
const allTags = Array.from(
|
||||
new Set(
|
||||
(tasks || [])
|
||||
.flatMap((task) => task.tags?.split(',').map((t) => t.trim()) || [])
|
||||
.filter(Boolean)
|
||||
)
|
||||
);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
|
||||
<Typography>Loading tasks...</Typography>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
|
||||
{/* Header */}
|
||||
<Box>
|
||||
<Typography variant="h3" sx={{ fontWeight: 700, mb: 2 }}>
|
||||
AI Task Kanban Board
|
||||
</Typography>
|
||||
|
||||
{error && (
|
||||
<Box sx={{ p: 2, mb: 2, backgroundColor: '#ffebee', borderRadius: 1, color: '#c62828' }}>
|
||||
{error}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{/* Controls */}
|
||||
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'center', mb: 2 }}>
|
||||
<TextField
|
||||
placeholder="Search tasks..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
size="small"
|
||||
InputProps={{
|
||||
startAdornment: <SearchIcon sx={{ mr: 1, color: 'action.active' }} />,
|
||||
}}
|
||||
sx={{ flex: 1, minWidth: 250 }}
|
||||
/>
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
{['low', 'medium', 'high', 'urgent'].map((priority) => (
|
||||
<Chip
|
||||
key={priority}
|
||||
label={priority}
|
||||
onClick={() => setSelectedPriority(selectedPriority === priority ? null : priority)}
|
||||
variant={selectedPriority === priority ? 'filled' : 'outlined'}
|
||||
color={selectedPriority === priority ? 'primary' : 'default'}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
|
||||
{allTags.length > 0 && (
|
||||
<Box sx={{ display: 'flex', gap: 1, flexWrap: 'wrap' }}>
|
||||
{allTags.map((tag) => (
|
||||
<Chip
|
||||
key={tag}
|
||||
label={tag}
|
||||
onClick={() => setSelectedTag(selectedTag === tag ? null : tag)}
|
||||
variant={selectedTag === tag ? 'filled' : 'outlined'}
|
||||
color={selectedTag === tag ? 'secondary' : 'default'}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Button
|
||||
variant="contained"
|
||||
startIcon={<AddIcon />}
|
||||
onClick={() => setCreateDialogOpen(true)}
|
||||
>
|
||||
New Task
|
||||
</Button>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{/* Kanban Board */}
|
||||
<DragDropContext onDragEnd={handleDragEnd}>
|
||||
<Box sx={{ display: 'flex', gap: 2, overflowX: 'auto', pb: 2 }}>
|
||||
{COLUMNS.map((column) => (
|
||||
<Column
|
||||
key={column}
|
||||
title={column}
|
||||
tasks={tasksByStatus[column]}
|
||||
onTaskClick={(task) => {
|
||||
setSelectedTask(task);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Box>
|
||||
</DragDropContext>
|
||||
|
||||
{/* Modals */}
|
||||
<TaskModal
|
||||
open={modalOpen}
|
||||
task={selectedTask}
|
||||
onClose={() => {
|
||||
setModalOpen(false);
|
||||
setSelectedTask(null);
|
||||
}}
|
||||
onSave={handleUpdateTask}
|
||||
onDelete={handleDeleteTask}
|
||||
/>
|
||||
|
||||
<CreateTaskDialog
|
||||
open={createDialogOpen}
|
||||
onClose={() => setCreateDialogOpen(false)}
|
||||
onCreate={handleCreateTask}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import Box from '@mui/material/Box';
|
||||
import Card from '@mui/material/Card';
|
||||
import CardContent from '@mui/material/CardContent';
|
||||
import Chip from '@mui/material/Chip';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Draggable } from 'react-beautiful-dnd';
|
||||
import { Task } from '../types/task';
|
||||
|
||||
interface TaskCardProps {
|
||||
task: Task;
|
||||
index: number;
|
||||
onClick: (task: Task) => void;
|
||||
}
|
||||
|
||||
const priorityColors: Record<string, 'default' | 'primary' | 'secondary' | 'error' | 'warning' | 'info' | 'success'> = {
|
||||
low: 'default',
|
||||
medium: 'info',
|
||||
high: 'warning',
|
||||
urgent: 'error',
|
||||
};
|
||||
|
||||
export function TaskCard({ task, index, onClick }: TaskCardProps) {
|
||||
const tags = task.tags ? task.tags.split(',').map((t) => t.trim()) : [];
|
||||
|
||||
return (
|
||||
<Draggable draggableId={`task-${task.id}`} index={index}>
|
||||
{(provided, snapshot) => (
|
||||
<Card
|
||||
ref={provided.innerRef}
|
||||
{...provided.draggableProps}
|
||||
{...provided.dragHandleProps}
|
||||
onClick={() => onClick(task)}
|
||||
sx={{
|
||||
mb: 1.5,
|
||||
cursor: 'grab',
|
||||
backgroundColor: snapshot.isDragging ? '#f5f5f5' : '#fff',
|
||||
boxShadow: snapshot.isDragging ? 3 : 1,
|
||||
transition: 'all 0.2s ease',
|
||||
'&:hover': {
|
||||
boxShadow: 2,
|
||||
backgroundColor: '#fafafa',
|
||||
},
|
||||
}}
|
||||
>
|
||||
<CardContent sx={{ pb: 1, '&:last-child': { pb: 1 } }}>
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 600, mb: 0.5 }}>
|
||||
{task.title}
|
||||
</Typography>
|
||||
|
||||
{task.description && (
|
||||
<Typography variant="body2" color="textSecondary" sx={{ mb: 1, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
||||
{task.description}
|
||||
</Typography>
|
||||
)}
|
||||
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 1 }}>
|
||||
<Chip
|
||||
label={task.priority}
|
||||
size="small"
|
||||
color={priorityColors[task.priority] as any}
|
||||
variant="outlined"
|
||||
/>
|
||||
{task.assignee && (
|
||||
<Chip label={task.assignee} size="small" variant="outlined" />
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{tags.length > 0 && (
|
||||
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap', mb: 1 }}>
|
||||
{tags.map((tag) => (
|
||||
<Chip key={tag} label={tag} size="small" variant="filled" sx={{ height: 24 }} />
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{task.due_date && (
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
Due: {new Date(task.due_date).toLocaleDateString()}
|
||||
</Typography>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</Draggable>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import Box from '@mui/material/Box';
|
||||
import Button from '@mui/material/Button';
|
||||
import Dialog from '@mui/material/Dialog';
|
||||
import DialogActions from '@mui/material/DialogActions';
|
||||
import DialogContent from '@mui/material/DialogContent';
|
||||
import DialogTitle from '@mui/material/DialogTitle';
|
||||
import FormControl from '@mui/material/FormControl';
|
||||
import InputLabel from '@mui/material/InputLabel';
|
||||
import MenuItem from '@mui/material/MenuItem';
|
||||
import Select from '@mui/material/Select';
|
||||
import TextField from '@mui/material/TextField';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Task, COLUMNS, PRIORITIES } from '../types/task';
|
||||
|
||||
interface TaskModalProps {
|
||||
open: boolean;
|
||||
task: Task | null;
|
||||
onClose: () => void;
|
||||
onSave: (task: Task) => void;
|
||||
onDelete: (taskId: number) => void;
|
||||
}
|
||||
|
||||
export function TaskModal({ open, task, onClose, onSave, onDelete }: TaskModalProps) {
|
||||
const [formData, setFormData] = useState<Partial<Task>>({});
|
||||
|
||||
useEffect(() => {
|
||||
if (task) {
|
||||
setFormData(task);
|
||||
}
|
||||
}, [task]);
|
||||
|
||||
const handleChange = (field: keyof Task, value: any) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSave = () => {
|
||||
if (formData.id && formData.title) {
|
||||
onSave(formData as Task);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
if (formData.id && window.confirm('Are you sure you want to delete this task?')) {
|
||||
onDelete(formData.id);
|
||||
onClose();
|
||||
}
|
||||
};
|
||||
|
||||
if (!task) return null;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>Task Details</DialogTitle>
|
||||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 2 }}>
|
||||
<TextField
|
||||
label="Title"
|
||||
value={formData.title || ''}
|
||||
onChange={(e) => handleChange('title', e.target.value)}
|
||||
fullWidth
|
||||
required
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Description"
|
||||
value={formData.description || ''}
|
||||
onChange={(e) => handleChange('description', e.target.value)}
|
||||
fullWidth
|
||||
multiline
|
||||
rows={3}
|
||||
/>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Priority</InputLabel>
|
||||
<Select
|
||||
value={formData.priority || 'medium'}
|
||||
onChange={(e) => handleChange('priority', e.target.value)}
|
||||
label="Priority"
|
||||
>
|
||||
{PRIORITIES.map((p) => (
|
||||
<MenuItem key={p} value={p}>
|
||||
{p.charAt(0).toUpperCase() + p.slice(1)}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Status</InputLabel>
|
||||
<Select
|
||||
value={formData.status || 'TODO'}
|
||||
onChange={(e) => handleChange('status', e.target.value)}
|
||||
label="Status"
|
||||
>
|
||||
{COLUMNS.map((col) => (
|
||||
<MenuItem key={col} value={col}>
|
||||
{col}
|
||||
</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
label="Assignee"
|
||||
value={formData.assignee || ''}
|
||||
onChange={(e) => handleChange('assignee', e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Tags (comma-separated)"
|
||||
value={formData.tags || ''}
|
||||
onChange={(e) => handleChange('tags', e.target.value)}
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Time Estimate (hours)"
|
||||
type="number"
|
||||
value={formData.time_estimate || ''}
|
||||
onChange={(e) => handleChange('time_estimate', e.target.value ? parseFloat(e.target.value) : undefined)}
|
||||
fullWidth
|
||||
inputProps={{ step: '0.5' }}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Due Date"
|
||||
type="datetime-local"
|
||||
value={formData.due_date ? new Date(formData.due_date).toISOString().slice(0, 16) : ''}
|
||||
onChange={(e) => handleChange('due_date', e.target.value ? new Date(e.target.value).toISOString() : undefined)}
|
||||
fullWidth
|
||||
InputLabelProps={{ shrink: true }}
|
||||
/>
|
||||
|
||||
{formData.created_at && (
|
||||
<Box>
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
Created: {new Date(formData.created_at).toLocaleString()}
|
||||
</Typography>
|
||||
<br />
|
||||
<Typography variant="caption" color="textSecondary">
|
||||
Updated: {new Date(formData.updated_at || '').toLocaleString()}
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleDelete} color="error">
|
||||
Delete
|
||||
</Button>
|
||||
<Button onClick={onClose}>Cancel</Button>
|
||||
<Button onClick={handleSave} variant="contained">
|
||||
Save
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import CssBaseline from '@mui/material/CssBaseline';
|
||||
import { ThemeProvider } from '@mui/material/styles';
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom/client';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
|
||||
import { App } from './App';
|
||||
import { theme } from './theme';
|
||||
|
||||
// The one entry point. Never add src/index.tsx alongside it.
|
||||
//
|
||||
// The router lives HERE, and its basename is `import.meta.env.BASE_URL` — never
|
||||
// a hard-coded string. The app is served from a sub-path that is only known at
|
||||
// deploy time (`/<project>_frontend/`), and Vite substitutes BASE_URL with it at
|
||||
// build time. A router with no basename, or one with a literal path, sends every
|
||||
// link outside the deployed app and the page goes blank.
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
<ThemeProvider theme={theme}>
|
||||
<CssBaseline />
|
||||
<BrowserRouter basename={import.meta.env.BASE_URL}>
|
||||
<App />
|
||||
</BrowserRouter>
|
||||
</ThemeProvider>
|
||||
</React.StrictMode>,
|
||||
);
|
||||
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* The HTTP client. One place that knows the base URL.
|
||||
*
|
||||
* Deliberately dependency-free: the scaffold installs no axios, so an `api.ts`
|
||||
* that imported it would fail to build. The shape (`.get(...).then(r => r.data)`)
|
||||
* matches what the fullstack skill's examples use.
|
||||
*
|
||||
* VITE_API_URL is the backend BASE, wired at deploy time. Endpoints live under
|
||||
* `/api`. Never hardcode localhost — it works in dev and breaks on deploy.
|
||||
*/
|
||||
const BASE = (import.meta.env.VITE_API_URL ?? '').replace(/\/$/, '');
|
||||
|
||||
export type Res<T> = { data: T; status: number };
|
||||
|
||||
async function request<T>(method: string, path: string, body?: unknown): Promise<Res<T>> {
|
||||
const res = await fetch(`${BASE}${path}`, {
|
||||
method,
|
||||
headers: body === undefined ? undefined : { 'Content-Type': 'application/json' },
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
let detail = `${res.status} ${res.statusText}`;
|
||||
try {
|
||||
const j = (await res.json()) as { detail?: unknown };
|
||||
if (typeof j.detail === 'string') detail = j.detail;
|
||||
} catch {
|
||||
/* keep the status line */
|
||||
}
|
||||
throw new Error(detail);
|
||||
}
|
||||
// 204 has no body — reading it as JSON throws, and a delete that "fails"
|
||||
// because it succeeded is a confusing bug to chase.
|
||||
const data = res.status === 204 ? (undefined as T) : ((await res.json()) as T);
|
||||
return { data, status: res.status };
|
||||
}
|
||||
|
||||
export const api = {
|
||||
get: <T>(path: string) => request<T>('GET', path),
|
||||
post: <T>(path: string, body?: unknown) => request<T>('POST', path, body ?? {}),
|
||||
put: <T>(path: string, body?: unknown) => request<T>('PUT', path, body ?? {}),
|
||||
patch: <T>(path: string, body?: unknown) => request<T>('PATCH', path, body ?? {}),
|
||||
delete: <T>(path: string) => request<T>('DELETE', path),
|
||||
};
|
||||
|
||||
export const apiBaseUrl = () => BASE;
|
||||
@@ -0,0 +1,74 @@
|
||||
/**
|
||||
* Mock-first data resolution.
|
||||
*
|
||||
* The Preview tab runs this frontend ALONE, in a browser sandbox, with no
|
||||
* backend behind it. A UI whose every screen starts with a fetch shows an
|
||||
* error or an empty list there — which is what the app is judged by, seconds
|
||||
* after the build says it finished.
|
||||
*
|
||||
* So every data call goes through `resolve(real, mock)`: it serves the mock
|
||||
* until a health check says the backend is answering, then serves the real
|
||||
* call. No code change, no reload, no flag to flip.
|
||||
*
|
||||
* const todos = await resolve(
|
||||
* () => api.get<Todo[]>('/api/todos').then((r) => r.data),
|
||||
* SEED.todos,
|
||||
* );
|
||||
*
|
||||
* The seed rows live in ./mockData.ts. src/App.tsx (React) / src/views (Vue)
|
||||
* already do this — copy that call, do not invent a second way.
|
||||
*/
|
||||
import { api } from './api';
|
||||
|
||||
/** Re-checked rather than cached forever: the backend appears mid-session, and
|
||||
* a resolver that decided "no backend" once would never notice. */
|
||||
const TTL_MS = 5_000;
|
||||
|
||||
let liveUntil = 0;
|
||||
let live = false;
|
||||
let inFlight: Promise<boolean> | null = null;
|
||||
|
||||
async function probe(): Promise<boolean> {
|
||||
try {
|
||||
const res = await api.get<unknown>('/api/health');
|
||||
return res.status >= 200 && res.status < 300;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether the backend is currently answering. Shared across callers, so ten
|
||||
* components mounting together make one health request, not ten. */
|
||||
export async function backendIsLive(): Promise<boolean> {
|
||||
if (Date.now() < liveUntil) return live;
|
||||
if (!inFlight) {
|
||||
inFlight = probe().then((ok) => {
|
||||
live = ok;
|
||||
liveUntil = Date.now() + TTL_MS;
|
||||
inFlight = null;
|
||||
return ok;
|
||||
});
|
||||
}
|
||||
return inFlight;
|
||||
}
|
||||
|
||||
/**
|
||||
* The real call when the backend is up, the mock otherwise.
|
||||
*
|
||||
* A real call that THROWS also falls back, rather than propagating: a backend
|
||||
* that is up but broken should still leave a usable screen, and the caller
|
||||
* cannot tell the difference anyway.
|
||||
*/
|
||||
export async function resolve<T>(real: () => Promise<T>, mock: T): Promise<T> {
|
||||
if (!(await backendIsLive())) return mock;
|
||||
try {
|
||||
return await real();
|
||||
} catch {
|
||||
return mock;
|
||||
}
|
||||
}
|
||||
|
||||
/** For a status chip: "Demo data" vs "Live". Never leave the user guessing
|
||||
* which one they are looking at. */
|
||||
export const dataSourceLabel = async (): Promise<'live' | 'demo'> =>
|
||||
(await backendIsLive()) ? 'live' : 'demo';
|
||||
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* Seed rows for the Preview tab, which runs the frontend with no backend.
|
||||
* Replace with rows that match the app being built — "foo / bar" reads as broken.
|
||||
*/
|
||||
export type Item = { id: number; name: string; status: string };
|
||||
|
||||
export const SEED: { items: Item[] } = {
|
||||
items: [
|
||||
{ id: 1, name: 'Draft the launch checklist', status: 'in progress' },
|
||||
{ id: 2, name: 'Review onboarding copy', status: 'todo' },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,52 @@
|
||||
import { createTheme } from '@mui/material/styles';
|
||||
|
||||
// The app's design tokens, in one place.
|
||||
//
|
||||
// This is the ONLY file allowed to contain a raw colour literal. Everywhere
|
||||
// else, reach for a token: `sx={{ color: 'text.secondary' }}`,
|
||||
// `sx={{ bgcolor: 'background.paper' }}`, `sx={{ p: 2 }}`. A hex code written
|
||||
// inline is invisible to dark mode, invisible to a later restyle, and drifts
|
||||
// from the value three components over.
|
||||
//
|
||||
// Adjust these values to suit the app being built — a finance dashboard and a
|
||||
// children's reading list should not look identical. Add tokens; do not scatter
|
||||
// them.
|
||||
export const theme = createTheme({
|
||||
palette: {
|
||||
mode: 'light',
|
||||
primary: { main: '#2f5fe0', light: '#6b8bf0', dark: '#1f3f9b', contrastText: '#ffffff' },
|
||||
secondary: { main: '#7b4bd8', light: '#a582e8', dark: '#54309b', contrastText: '#ffffff' },
|
||||
success: { main: '#1f8a5b' },
|
||||
warning: { main: '#b8760a' },
|
||||
error: { main: '#c62b3d' },
|
||||
info: { main: '#0d7490' },
|
||||
background: { default: '#f6f7fb', paper: '#ffffff' },
|
||||
text: { primary: '#151a2d', secondary: '#5a6280' },
|
||||
divider: '#e2e5ef',
|
||||
},
|
||||
|
||||
// 8px rhythm: `sx={{ p: 2 }}` is 16px, `sx={{ gap: 1 }}` is 8px.
|
||||
spacing: 8,
|
||||
|
||||
shape: { borderRadius: 10 },
|
||||
|
||||
typography: {
|
||||
fontFamily: '"Inter", "Segoe UI", system-ui, -apple-system, sans-serif',
|
||||
h1: { fontSize: '2.25rem', fontWeight: 700, letterSpacing: '-0.02em' },
|
||||
h2: { fontSize: '1.75rem', fontWeight: 700, letterSpacing: '-0.015em' },
|
||||
h3: { fontSize: '1.5rem', fontWeight: 600 },
|
||||
h4: { fontSize: '1.25rem', fontWeight: 600 },
|
||||
h5: { fontSize: '1.125rem', fontWeight: 600 },
|
||||
h6: { fontSize: '1rem', fontWeight: 600 },
|
||||
body1: { fontSize: '0.9375rem', lineHeight: 1.6 },
|
||||
body2: { fontSize: '0.875rem', lineHeight: 1.6 },
|
||||
button: { textTransform: 'none', fontWeight: 600 },
|
||||
},
|
||||
|
||||
components: {
|
||||
MuiButton: { defaultProps: { disableElevation: true } },
|
||||
MuiPaper: { defaultProps: { elevation: 0 }, styleOverrides: { root: { backgroundImage: 'none' } } },
|
||||
MuiCard: { defaultProps: { variant: 'outlined' } },
|
||||
MuiTextField: { defaultProps: { size: 'small' } },
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Task types for the kanban board.
|
||||
*/
|
||||
|
||||
export interface Task {
|
||||
id: number;
|
||||
title: string;
|
||||
description?: string;
|
||||
priority: 'low' | 'medium' | 'high' | 'urgent';
|
||||
status: 'TODO' | 'IN PROGRESS' | 'QA' | 'UAT' | 'PROD';
|
||||
assignee?: string;
|
||||
tags?: string;
|
||||
time_estimate?: number;
|
||||
due_date?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface TaskListResponse {
|
||||
tasks: Task[];
|
||||
total: number;
|
||||
}
|
||||
|
||||
export const COLUMNS = ['TODO', 'IN PROGRESS', 'QA', 'UAT', 'PROD'] as const;
|
||||
export const PRIORITIES = ['low', 'medium', 'high', 'urgent'] as const;
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"lib": ["ES2020", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"skipLibCheck": true,
|
||||
"isolatedModules": true,
|
||||
"resolveJsonModule": true,
|
||||
"allowImportingTsExtensions": false,
|
||||
"noEmit": true,
|
||||
"types": ["vite/client"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
// Deploy owns `base` and the /api proxy. Changing them here breaks the deployed
|
||||
// app while dev keeps working — the worst kind of change to make.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
server: {
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: process.env.VITE_API_URL || 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user