Skip to content

Commit 2d8abeb

Browse files
committed
feat: UI polish — labels, layout, summary dashboard, breadcrumbs
- FieldRow: vertical layout (label above value) with dividers - Uppercase labels for all statuses and priorities globally - Epic detail: two-column DetailLayout (description+tasks left, properties right) - Task detail: inline priority select, single epic select, attachments in main column - TaskForm: single epic selector, extraMain slot for attachments - Edit pages: attachments support, remove duplicate submit buttons from PageTopBar - Remove PageTopBar from task tabs (Summary/List/Board/Epics) - Move create buttons next to PaginationBar - Breadcrumbs: context-aware (Board/List) via URL ?from= param - Epic breadcrumbs: Tasks > Epics > ... - Summary dashboard: 6 stat cards (clickable), 6 sections in 3-col grid (By Status, By Priority, By Assignee, By Epic, Recently Updated, Due Soon) with 6-item limit and "View all" links - Column visibility reads ?status= from URL, group reads ?group= from URL
1 parent 0d4607e commit 2d8abeb

20 files changed

Lines changed: 631 additions & 413 deletions

File tree

ui/src/entities/task/config.ts

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import type { TaskStatus, TaskPriority } from './api.ts';
22

33
export const COLUMNS: { status: TaskStatus; label: string; color: string }[] = [
4-
{ status: 'backlog', label: 'Backlog', color: '#616161' },
5-
{ status: 'todo', label: 'To Do', color: '#1976d2' },
6-
{ status: 'in_progress', label: 'In Progress', color: '#f57c00' },
7-
{ status: 'review', label: 'Review', color: '#7b1fa2' },
8-
{ status: 'done', label: 'Done', color: '#388e3c' },
9-
{ status: 'cancelled', label: 'Cancelled', color: '#d32f2f' },
4+
{ status: 'backlog', label: 'BACKLOG', color: '#616161' },
5+
{ status: 'todo', label: 'TO DO', color: '#1976d2' },
6+
{ status: 'in_progress', label: 'IN PROGRESS', color: '#f57c00' },
7+
{ status: 'review', label: 'REVIEW', color: '#7b1fa2' },
8+
{ status: 'done', label: 'DONE', color: '#388e3c' },
9+
{ status: 'cancelled', label: 'CANCELLED', color: '#d32f2f' },
1010
];
1111

1212
export const PRIORITY_COLORS: Record<TaskPriority, string> = {
@@ -33,9 +33,9 @@ export const PRIORITY_BADGE_COLOR: Record<TaskPriority, 'error' | 'warning' | 'p
3333
};
3434

3535
export function statusLabel(status: TaskStatus): string {
36-
return COLUMNS.find(c => c.status === status)?.label ?? status;
36+
return COLUMNS.find(c => c.status === status)?.label ?? status.toUpperCase();
3737
}
3838

3939
export function priorityLabel(priority: TaskPriority): string {
40-
return priority.charAt(0).toUpperCase() + priority.slice(1);
40+
return priority.toUpperCase().replace('_', ' ');
4141
}

ui/src/features/epic-crud/EpicForm.tsx

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,17 +9,17 @@ import type { TaskPriority } from '@/entities/task/index.ts';
99
import { PRIORITY_COLORS } from '@/entities/task/index.ts';
1010

1111
const EPIC_STATUSES: { value: EpicStatus; label: string; color: string }[] = [
12-
{ value: 'open', color: '#1976d2', label: 'Open' },
13-
{ value: 'in_progress', color: '#f57c00', label: 'In Progress' },
14-
{ value: 'done', color: '#388e3c', label: 'Done' },
15-
{ value: 'cancelled', color: '#d32f2f', label: 'Cancelled' },
12+
{ value: 'open', color: '#1976d2', label: 'OPEN' },
13+
{ value: 'in_progress', color: '#f57c00', label: 'IN PROGRESS' },
14+
{ value: 'done', color: '#388e3c', label: 'DONE' },
15+
{ value: 'cancelled', color: '#d32f2f', label: 'CANCELLED' },
1616
];
1717

1818
const PRIORITY_OPTIONS: { value: TaskPriority; label: string }[] = [
19-
{ value: 'critical', label: 'Critical' },
20-
{ value: 'high', label: 'High' },
21-
{ value: 'medium', label: 'Medium' },
22-
{ value: 'low', label: 'Low' },
19+
{ value: 'critical', label: 'CRITICAL' },
20+
{ value: 'high', label: 'HIGH' },
21+
{ value: 'medium', label: 'MEDIUM' },
22+
{ value: 'low', label: 'LOW' },
2323
];
2424

2525
interface EpicFormProps {

ui/src/features/task-crud/QuickCreateDialog.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useState, useEffect, useRef } from 'react';
2-
import { useNavigate, useParams } from 'react-router-dom';
2+
import { useNavigate, useParams, useLocation } from 'react-router-dom';
33
import {
44
Dialog, DialogTitle, DialogContent, DialogActions,
55
Button, TextField, Select, MenuItem, Box, IconButton,
@@ -28,6 +28,7 @@ interface QuickCreateDialogProps {
2828

2929
export function QuickCreateDialog({ open, onClose, onCreated, defaultStatus }: QuickCreateDialogProps) {
3030
const { projectId } = useParams<{ projectId: string }>();
31+
const { pathname } = useLocation();
3132
const navigate = useNavigate();
3233
const { palette } = useTheme();
3334
const titleRef = useRef<HTMLInputElement>(null);
@@ -103,6 +104,8 @@ export function QuickCreateDialog({ open, onClose, onCreated, defaultStatus }: Q
103104
if (epicId) params.set('epicId', epicId);
104105
if (tags.length) params.set('tags', tags.join(','));
105106
onClose();
107+
if (pathname.includes('/tasks/board')) params.set('from', 'board');
108+
else if (pathname.includes('/tasks/list')) params.set('from', 'list');
106109
navigate(`/${projectId}/tasks/new?${params.toString()}`);
107110
};
108111

ui/src/features/task-crud/TaskForm.tsx

Lines changed: 27 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ import { listEpics, linkTaskToEpic, unlinkTaskFromEpic, type Epic } from '@/enti
1111

1212
const STATUS_COLOR: Record<TaskStatus, string> = Object.fromEntries(COLUMNS.map(c => [c.status, c.color])) as Record<TaskStatus, string>;
1313
const PRIORITY_OPTIONS: { value: TaskPriority; label: string }[] = [
14-
{ value: 'critical', label: 'Critical' },
15-
{ value: 'high', label: 'High' },
16-
{ value: 'medium', label: 'Medium' },
17-
{ value: 'low', label: 'Low' },
14+
{ value: 'critical', label: 'CRITICAL' },
15+
{ value: 'high', label: 'HIGH' },
16+
{ value: 'medium', label: 'MEDIUM' },
17+
{ value: 'low', label: 'LOW' },
1818
];
1919
import { listTeam, type TeamMember } from '@/entities/project/api.ts';
2020

@@ -32,9 +32,10 @@ interface TaskFormProps {
3232
onSubmit: (data: { title: string; description: string; status: TaskStatus; priority: TaskPriority; tags: string[]; dueDate?: number | null; estimate?: number | null; assignee?: string | null }) => Promise<void>;
3333
onCancel: () => void;
3434
submitLabel?: string;
35+
extraMain?: React.ReactNode;
3536
}
3637

37-
export function TaskForm({ task, defaults, onSubmit, onCancel, submitLabel = 'Save' }: TaskFormProps) {
38+
export function TaskForm({ task, defaults, onSubmit, onCancel, submitLabel = 'Save', extraMain }: TaskFormProps) {
3839
const { projectId } = useParams<{ projectId: string }>();
3940
const [title, setTitle] = useState('');
4041
const [description, setDescription] = useState('');
@@ -46,8 +47,8 @@ export function TaskForm({ task, defaults, onSubmit, onCancel, submitLabel = 'Sa
4647
const [assignee, setAssignee] = useState<string>('');
4748
const [team, setTeam] = useState<TeamMember[]>([]);
4849
const [epics, setEpics] = useState<Epic[]>([]);
49-
const [selectedEpicIds, setSelectedEpicIds] = useState<string[]>([]);
50-
const [initialEpicIds, setInitialEpicIds] = useState<string[]>([]);
50+
const [selectedEpicId, setSelectedEpicId] = useState<string>('');
51+
const [initialEpicId, setInitialEpicId] = useState<string>('');
5152
const [saving, setSaving] = useState(false);
5253
const [titleError, setTitleError] = useState(false);
5354

@@ -79,9 +80,9 @@ export function TaskForm({ task, defaults, onSubmit, onCancel, submitLabel = 'Sa
7980
useEffect(() => {
8081
if (!projectId || !task) return;
8182
listTaskRelations(projectId, task.id).then(rels => {
82-
const epicIds = rels.filter(r => r.kind === 'belongs_to').map(r => r.toId);
83-
setSelectedEpicIds(epicIds);
84-
setInitialEpicIds(epicIds);
83+
const epicId = rels.find(r => r.kind === 'belongs_to')?.toId ?? '';
84+
setSelectedEpicId(epicId);
85+
setInitialEpicId(epicId);
8586
}).catch(() => {});
8687
}, [projectId, task]);
8788

@@ -103,16 +104,12 @@ export function TaskForm({ task, defaults, onSubmit, onCancel, submitLabel = 'Sa
103104
assignee: assignee || null,
104105
});
105106

106-
// Sync epic links after save
107-
if (projectId) {
107+
// Sync epic link after save
108+
if (projectId && selectedEpicId !== initialEpicId) {
108109
const taskId = task?.id ?? (result as any)?.id;
109110
if (taskId) {
110-
const toLink = selectedEpicIds.filter(id => !initialEpicIds.includes(id));
111-
const toUnlink = initialEpicIds.filter(id => !selectedEpicIds.includes(id));
112-
await Promise.all([
113-
...toLink.map(epicId => linkTaskToEpic(projectId, epicId, taskId)),
114-
...toUnlink.map(epicId => unlinkTaskFromEpic(projectId, epicId, taskId)),
115-
]);
111+
if (initialEpicId) await unlinkTaskFromEpic(projectId, initialEpicId, taskId);
112+
if (selectedEpicId) await linkTaskToEpic(projectId, selectedEpicId, taskId);
116113
}
117114
}
118115
} finally {
@@ -124,6 +121,7 @@ export function TaskForm({ task, defaults, onSubmit, onCancel, submitLabel = 'Sa
124121
<Box component="form" id="task-form" onSubmit={e => { e.preventDefault(); handleSubmit(); }} sx={{ display: 'flex', flexDirection: 'column', gap: 3 }}>
125122
<DetailLayout
126123
main={
124+
<>
127125
<Section title="Details">
128126
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
129127
<AppTextField
@@ -142,6 +140,8 @@ export function TaskForm({ task, defaults, onSubmit, onCancel, submitLabel = 'Sa
142140
</Box>
143141
</Box>
144142
</Section>
143+
{extraMain}
144+
</>
145145
}
146146
sidebar={
147147
<Section title="Properties">
@@ -202,17 +202,19 @@ export function TaskForm({ task, defaults, onSubmit, onCancel, submitLabel = 'Sa
202202
</Box>
203203
{epics.length > 0 && (
204204
<Box>
205-
<FieldLabel>Epics</FieldLabel>
205+
<FieldLabel>Epic</FieldLabel>
206206
<Select
207-
fullWidth multiple value={selectedEpicIds}
208-
onChange={e => setSelectedEpicIds(e.target.value as string[])}
207+
fullWidth value={selectedEpicId}
208+
onChange={e => setSelectedEpicId(e.target.value as string)}
209209
displayEmpty
210-
renderValue={selected => {
211-
if ((selected as string[]).length === 0) return 'No epic';
212-
return (selected as string[]).map(id => epics.find(e => e.id === id)?.title ?? id).join(', ');
210+
renderValue={v => {
211+
if (!v) return 'No epic';
212+
const ep = epics.find(e => e.id === v);
213+
return ep?.title ?? v;
213214
}}
214215
>
215-
{epics.filter(e => e.status === 'open' || e.status === 'in_progress' || selectedEpicIds.includes(e.id)).map(e => (
216+
<MenuItem value="">No epic</MenuItem>
217+
{epics.filter(e => e.status === 'open' || e.status === 'in_progress' || e.id === selectedEpicId).map(e => (
216218
<MenuItem key={e.id} value={e.id}>
217219
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
218220
<FlagIcon sx={{ fontSize: 14, color: e.status === 'open' ? '#1976d2' : e.status === 'in_progress' ? '#f57c00' : '#388e3c' }} />

0 commit comments

Comments
 (0)