424 lines
13 KiB
TypeScript
424 lines
13 KiB
TypeScript
/** @jsxImportSource react */
|
|
|
|
import { useState } from 'react';
|
|
import type { ReactElement } from 'react';
|
|
import styled from 'styled-components';
|
|
import { TaskStatus, TaskPriority, EnergyLevel, PROJECT_TYPE_TABS } from '@life-platform/shared';
|
|
import type { Project, Task, UpdateTaskDto } from '@life-platform/shared';
|
|
import { useUpdateTask } from '@features/tasks/frontend/useTasks';
|
|
import { useSprints } from '@features/projects/frontend/useProjects';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Styled (matching CreateTaskModal pattern)
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const ModalOverlay = styled.div`
|
|
position: fixed;
|
|
inset: 0;
|
|
background: #000000aa;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
z-index: 100;
|
|
`;
|
|
|
|
const ModalBox = styled.div`
|
|
background: ${({ theme }) => theme.colors.surface};
|
|
border: 1px solid ${({ theme }) => theme.colors.border.default};
|
|
border-radius: 8px;
|
|
padding: 24px;
|
|
width: 100%;
|
|
max-width: 480px;
|
|
max-height: 85vh;
|
|
overflow-y: auto;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 14px;
|
|
`;
|
|
|
|
const ModalTitle = styled.h2`
|
|
font-size: 16px;
|
|
font-weight: 700;
|
|
color: ${({ theme }) => theme.colors.text.primary};
|
|
margin: 0;
|
|
font-family: monospace;
|
|
`;
|
|
|
|
const FormField = styled.div`
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 6px;
|
|
`;
|
|
|
|
const Label = styled.label`
|
|
font-size: 11px;
|
|
font-weight: 600;
|
|
text-transform: uppercase;
|
|
letter-spacing: 1px;
|
|
color: ${({ theme }) => theme.colors.text.muted};
|
|
`;
|
|
|
|
const StyledInput = styled.input`
|
|
background: ${({ theme }) => theme.colors.background.primary};
|
|
border: 1px solid ${({ theme }) => theme.colors.border.default};
|
|
border-radius: 4px;
|
|
padding: 8px 12px;
|
|
font-size: 13px;
|
|
color: ${({ theme }) => theme.colors.text.primary};
|
|
outline: none;
|
|
&:focus { border-color: ${({ theme }) => theme.colors.primary.main}; }
|
|
`;
|
|
|
|
const StyledSelect = styled.select`
|
|
background: ${({ theme }) => theme.colors.background.primary};
|
|
border: 1px solid ${({ theme }) => theme.colors.border.default};
|
|
border-radius: 4px;
|
|
padding: 8px 12px;
|
|
font-size: 13px;
|
|
color: ${({ theme }) => theme.colors.text.primary};
|
|
outline: none;
|
|
cursor: pointer;
|
|
&:focus { border-color: ${({ theme }) => theme.colors.primary.main}; }
|
|
`;
|
|
|
|
const StyledTextarea = styled.textarea`
|
|
background: ${({ theme }) => theme.colors.background.primary};
|
|
border: 1px solid ${({ theme }) => theme.colors.border.default};
|
|
border-radius: 4px;
|
|
padding: 8px 12px;
|
|
font-size: 13px;
|
|
color: ${({ theme }) => theme.colors.text.primary};
|
|
outline: none;
|
|
resize: vertical;
|
|
min-height: 60px;
|
|
&:focus { border-color: ${({ theme }) => theme.colors.primary.main}; }
|
|
`;
|
|
|
|
const ChipRow = styled.div`
|
|
display: flex;
|
|
gap: 6px;
|
|
flex-wrap: wrap;
|
|
`;
|
|
|
|
const Chip = styled.button<{ active?: boolean; accentColor?: string }>`
|
|
padding: 4px 10px;
|
|
border-radius: 12px;
|
|
font-size: 11px;
|
|
font-family: monospace;
|
|
cursor: pointer;
|
|
transition: all 0.15s ease;
|
|
background: ${({ active, accentColor, theme }) =>
|
|
active ? (accentColor ?? theme.colors.primary.main) + '22' : 'transparent'};
|
|
border: 1px solid ${({ active, accentColor, theme }) =>
|
|
active ? (accentColor ?? theme.colors.primary.main) : theme.colors.border.default};
|
|
color: ${({ active, accentColor, theme }) =>
|
|
active ? (accentColor ?? theme.colors.primary.main) : theme.colors.text.secondary};
|
|
|
|
&:hover {
|
|
border-color: ${({ accentColor, theme }) => accentColor ?? theme.colors.primary.main};
|
|
}
|
|
`;
|
|
|
|
const InlineRow = styled.div`
|
|
display: flex;
|
|
gap: 12px;
|
|
align-items: flex-end;
|
|
`;
|
|
|
|
const CheckboxLabel = styled.label`
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 8px;
|
|
font-size: 12px;
|
|
color: ${({ theme }) => theme.colors.text.secondary};
|
|
cursor: pointer;
|
|
|
|
input {
|
|
accent-color: ${({ theme }) => theme.colors.primary.main};
|
|
}
|
|
`;
|
|
|
|
const ModalActions = styled.div`
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 8px;
|
|
margin-top: 4px;
|
|
`;
|
|
|
|
const CancelButton = styled.button`
|
|
padding: 8px 16px;
|
|
background: transparent;
|
|
border: 1px solid ${({ theme }) => theme.colors.border.default};
|
|
border-radius: 4px;
|
|
color: ${({ theme }) => theme.colors.text.secondary};
|
|
font-size: 12px;
|
|
cursor: pointer;
|
|
&:hover { border-color: ${({ theme }) => theme.colors.text.muted}; }
|
|
`;
|
|
|
|
const SubmitButton = styled.button`
|
|
padding: 8px 16px;
|
|
background: color-mix(in srgb, ${({ theme }) => theme.colors.primary.main} 13%, transparent);
|
|
border: 1px solid ${({ theme }) => theme.colors.primary.main};
|
|
border-radius: 4px;
|
|
color: ${({ theme }) => theme.colors.primary.main};
|
|
font-size: 12px;
|
|
font-family: monospace;
|
|
cursor: pointer;
|
|
&:hover { background: color-mix(in srgb, ${({ theme }) => theme.colors.primary.main} 20%, transparent); }
|
|
&:disabled { opacity: 0.4; cursor: not-allowed; }
|
|
`;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Color maps
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const PRIORITY_COLORS: Record<string, string> = {
|
|
[TaskPriority.Critical]: '#ff0040',
|
|
[TaskPriority.High]: '#ff6b00',
|
|
[TaskPriority.Medium]: '#ffaa00',
|
|
[TaskPriority.Low]: '#555',
|
|
};
|
|
|
|
const ENERGY_COLORS: Record<string, string> = {
|
|
[EnergyLevel.High]: '#ff6b00',
|
|
[EnergyLevel.Medium]: '#ffaa00',
|
|
[EnergyLevel.Low]: '#00aaff',
|
|
};
|
|
|
|
const STATUS_COLORS: Record<string, string> = {
|
|
[TaskStatus.Backlog]: '#555',
|
|
[TaskStatus.Todo]: '#888',
|
|
[TaskStatus.InProgress]: '#00aaff',
|
|
[TaskStatus.Blocked]: '#ff6b00',
|
|
[TaskStatus.Done]: '#00ff9f',
|
|
[TaskStatus.Cancelled]: '#ff004066',
|
|
};
|
|
|
|
const STATUS_LABELS: Record<string, string> = {
|
|
[TaskStatus.Backlog]: 'Backlog',
|
|
[TaskStatus.Todo]: 'Todo',
|
|
[TaskStatus.InProgress]: 'In Progress',
|
|
[TaskStatus.Blocked]: 'Blocked',
|
|
[TaskStatus.Done]: 'Done',
|
|
[TaskStatus.Cancelled]: 'Cancelled',
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export interface EditTaskModalProps {
|
|
task: Task;
|
|
projects: Project[];
|
|
onClose: () => void;
|
|
}
|
|
|
|
interface EditFormState {
|
|
title: string;
|
|
description: string;
|
|
projectId: string;
|
|
sprintId: string | null;
|
|
status: TaskStatus;
|
|
priority: TaskPriority;
|
|
energyLevel: EnergyLevel;
|
|
dueDate: string;
|
|
estimatedMinutes: number | undefined;
|
|
isQuickWin: boolean;
|
|
}
|
|
|
|
export function EditTaskModal({ task, projects, onClose }: EditTaskModalProps): ReactElement {
|
|
const updateTask = useUpdateTask();
|
|
|
|
const [form, setForm] = useState<EditFormState>({
|
|
title: task.title,
|
|
description: task.description ?? '',
|
|
projectId: task.projectId,
|
|
sprintId: task.sprintId,
|
|
status: task.status,
|
|
priority: task.priority,
|
|
energyLevel: task.energyLevel,
|
|
dueDate: task.dueDate ?? '',
|
|
estimatedMinutes: task.estimatedMinutes ?? undefined,
|
|
isQuickWin: task.isQuickWin,
|
|
});
|
|
|
|
const selectedProject = projects.find((p) => p.id === form.projectId);
|
|
const projectHasSprints = selectedProject && PROJECT_TYPE_TABS[selectedProject.type]?.includes('sprints');
|
|
const { data: sprintsResult } = useSprints({ projectId: form.projectId, status: 'active,planned' });
|
|
const sprints = sprintsResult?.data ?? [];
|
|
|
|
function setField<K extends keyof EditFormState>(key: K, value: EditFormState[K]): void {
|
|
setForm((prev) => ({ ...prev, [key]: value }));
|
|
}
|
|
|
|
function handleSubmit(): void {
|
|
if (!form.title.trim()) return;
|
|
const payload: UpdateTaskDto = {
|
|
title: form.title.trim(),
|
|
description: form.description.trim() || undefined,
|
|
projectId: form.projectId,
|
|
sprintId: form.sprintId,
|
|
status: form.status,
|
|
priority: form.priority,
|
|
energyLevel: form.energyLevel,
|
|
dueDate: form.dueDate || null,
|
|
estimatedMinutes: form.estimatedMinutes || undefined,
|
|
isQuickWin: form.isQuickWin,
|
|
};
|
|
updateTask.mutate({ id: task.id, data: payload }, { onSuccess: onClose });
|
|
}
|
|
|
|
return (
|
|
<ModalOverlay onClick={(e) => e.target === e.currentTarget && onClose()}>
|
|
<ModalBox role="dialog" aria-label="Edit Task">
|
|
<ModalTitle>Edit Task</ModalTitle>
|
|
|
|
<FormField>
|
|
<Label htmlFor="edit-task-title">Title</Label>
|
|
<StyledInput
|
|
id="edit-task-title"
|
|
value={form.title}
|
|
onChange={(e) => setField('title', e.target.value)}
|
|
onKeyDown={(e) => e.key === 'Enter' && handleSubmit()}
|
|
autoFocus
|
|
/>
|
|
</FormField>
|
|
|
|
<FormField>
|
|
<Label htmlFor="edit-task-project">Project</Label>
|
|
<StyledSelect
|
|
id="edit-task-project"
|
|
value={form.projectId}
|
|
onChange={(e) => setForm((prev) => ({ ...prev, projectId: e.target.value, sprintId: null }))}
|
|
>
|
|
{projects.map((p) => (
|
|
<option key={p.id} value={p.id}>{p.name}</option>
|
|
))}
|
|
</StyledSelect>
|
|
</FormField>
|
|
|
|
{projectHasSprints && (
|
|
<FormField>
|
|
<Label htmlFor="edit-task-sprint">Sprint</Label>
|
|
<StyledSelect
|
|
id="edit-task-sprint"
|
|
value={form.sprintId ?? ''}
|
|
onChange={(e) => setField('sprintId', e.target.value || null)}
|
|
>
|
|
<option value="">No Sprint</option>
|
|
{sprints.map((s) => (
|
|
<option key={s.id} value={s.id}>{s.name}</option>
|
|
))}
|
|
</StyledSelect>
|
|
</FormField>
|
|
)}
|
|
|
|
<FormField>
|
|
<Label>Status</Label>
|
|
<ChipRow>
|
|
{([
|
|
TaskStatus.Backlog,
|
|
TaskStatus.Todo,
|
|
TaskStatus.InProgress,
|
|
TaskStatus.Blocked,
|
|
TaskStatus.Done,
|
|
TaskStatus.Cancelled,
|
|
] as const).map((s) => (
|
|
<Chip
|
|
key={s}
|
|
active={form.status === s}
|
|
accentColor={STATUS_COLORS[s]}
|
|
onClick={() => setField('status', s)}
|
|
>
|
|
{STATUS_LABELS[s]}
|
|
</Chip>
|
|
))}
|
|
</ChipRow>
|
|
</FormField>
|
|
|
|
<FormField>
|
|
<Label>Priority</Label>
|
|
<ChipRow>
|
|
{([TaskPriority.Low, TaskPriority.Medium, TaskPriority.High, TaskPriority.Critical] as const).map((p) => (
|
|
<Chip
|
|
key={p}
|
|
active={form.priority === p}
|
|
accentColor={PRIORITY_COLORS[p]}
|
|
onClick={() => setField('priority', p)}
|
|
>
|
|
{p}
|
|
</Chip>
|
|
))}
|
|
</ChipRow>
|
|
</FormField>
|
|
|
|
<FormField>
|
|
<Label>Energy Level</Label>
|
|
<ChipRow>
|
|
{([EnergyLevel.Low, EnergyLevel.Medium, EnergyLevel.High] as const).map((e) => (
|
|
<Chip
|
|
key={e}
|
|
active={form.energyLevel === e}
|
|
accentColor={ENERGY_COLORS[e]}
|
|
onClick={() => setField('energyLevel', e)}
|
|
>
|
|
{e}
|
|
</Chip>
|
|
))}
|
|
</ChipRow>
|
|
</FormField>
|
|
|
|
<InlineRow>
|
|
<FormField style={{ flex: 1 }}>
|
|
<Label htmlFor="edit-task-due">Due Date</Label>
|
|
<StyledInput
|
|
id="edit-task-due"
|
|
type="date"
|
|
value={form.dueDate}
|
|
onChange={(e) => setField('dueDate', e.target.value)}
|
|
/>
|
|
</FormField>
|
|
<FormField style={{ width: '100px' }}>
|
|
<Label htmlFor="edit-task-est">Est. min</Label>
|
|
<StyledInput
|
|
id="edit-task-est"
|
|
type="number"
|
|
min={1}
|
|
value={form.estimatedMinutes ?? ''}
|
|
onChange={(e) => setField('estimatedMinutes', e.target.value ? Number(e.target.value) : undefined)}
|
|
/>
|
|
</FormField>
|
|
</InlineRow>
|
|
|
|
<CheckboxLabel>
|
|
<input
|
|
type="checkbox"
|
|
checked={form.isQuickWin}
|
|
onChange={(e) => setField('isQuickWin', e.target.checked)}
|
|
/>
|
|
Quick win (under 15 min, low effort)
|
|
</CheckboxLabel>
|
|
|
|
<FormField>
|
|
<Label htmlFor="edit-task-desc">Description</Label>
|
|
<StyledTextarea
|
|
id="edit-task-desc"
|
|
value={form.description}
|
|
onChange={(e) => setField('description', e.target.value)}
|
|
/>
|
|
</FormField>
|
|
|
|
<ModalActions>
|
|
<CancelButton onClick={onClose}>Cancel</CancelButton>
|
|
<SubmitButton
|
|
onClick={handleSubmit}
|
|
disabled={updateTask.isPending || !form.title.trim()}
|
|
>
|
|
{updateTask.isPending ? 'Saving...' : 'Save Changes'}
|
|
</SubmitButton>
|
|
</ModalActions>
|
|
</ModalBox>
|
|
</ModalOverlay>
|
|
);
|
|
}
|