life-manager/codebase/features/tasks/frontend/EditTaskModal.tsx
2026-03-17 17:51:08 -07:00

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>
);
}