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

363 lines
11 KiB
TypeScript

/** @jsxImportSource react */
import { useState } from 'react';
import type { ReactElement } from 'react';
import styled from 'styled-components';
import { TaskPriority, EnergyLevel, PROJECT_TYPE_TABS } from '@life-platform/shared';
import type { Project, CreateTaskDto } from '@life-platform/shared';
import { useCreateTask } from '@features/tasks/frontend/useTasks';
import { useSprints } from '@features/projects/frontend/useProjects';
// ---------------------------------------------------------------------------
// Styled (matching CreateHabitModal 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; }
`;
// ---------------------------------------------------------------------------
// Priority colors
// ---------------------------------------------------------------------------
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',
};
// ---------------------------------------------------------------------------
// Component
// ---------------------------------------------------------------------------
export interface CreateTaskModalProps {
projects: Project[];
onClose: () => void;
}
export function CreateTaskModal({ projects, onClose }: CreateTaskModalProps): ReactElement {
const createTask = useCreateTask();
const [form, setForm] = useState<CreateTaskDto>({
projectId: projects[0]?.id ?? '',
title: '',
description: '',
priority: TaskPriority.Medium,
energyLevel: EnergyLevel.Medium,
dueDate: '',
estimatedMinutes: undefined,
isQuickWin: false,
});
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 CreateTaskDto>(key: K, value: CreateTaskDto[K]): void {
setForm((prev) => ({ ...prev, [key]: value }));
}
function handleSubmit(): void {
if (!form.title.trim() || !form.projectId) return;
const payload: CreateTaskDto = {
...form,
title: form.title.trim(),
description: form.description?.trim() || undefined,
dueDate: form.dueDate || undefined,
estimatedMinutes: form.estimatedMinutes || undefined,
sprintId: form.sprintId || undefined,
};
createTask.mutate(payload, { onSuccess: onClose });
}
return (
<ModalOverlay onClick={(e) => e.target === e.currentTarget && onClose()}>
<ModalBox role="dialog" aria-label="Create Task">
<ModalTitle>New Task</ModalTitle>
<FormField>
<Label htmlFor="task-title">Title</Label>
<StyledInput
id="task-title"
value={form.title}
onChange={(e) => setField('title', e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleSubmit()}
autoFocus
/>
</FormField>
<FormField>
<Label htmlFor="task-project">Project</Label>
<StyledSelect
id="task-project"
value={form.projectId}
onChange={(e) => setForm((prev) => ({ ...prev, projectId: e.target.value, sprintId: undefined }))}
>
{projects.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</StyledSelect>
</FormField>
{projectHasSprints && (
<FormField>
<Label htmlFor="task-sprint">Sprint</Label>
<StyledSelect
id="task-sprint"
value={form.sprintId ?? ''}
onChange={(e) => setField('sprintId', e.target.value || undefined)}
>
<option value="">No Sprint</option>
{sprints.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option>
))}
</StyledSelect>
</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="task-due">Due Date</Label>
<StyledInput
id="task-due"
type="date"
value={form.dueDate ?? ''}
onChange={(e) => setField('dueDate', e.target.value)}
/>
</FormField>
<FormField style={{ width: '100px' }}>
<Label htmlFor="task-est">Est. min</Label>
<StyledInput
id="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 ?? false}
onChange={(e) => setField('isQuickWin', e.target.checked)}
/>
Quick win (under 15 min, low effort)
</CheckboxLabel>
<FormField>
<Label htmlFor="task-desc">Description</Label>
<StyledTextarea
id="task-desc"
value={form.description ?? ''}
onChange={(e) => setField('description', e.target.value)}
/>
</FormField>
<ModalActions>
<CancelButton onClick={onClose}>Cancel</CancelButton>
<SubmitButton
onClick={handleSubmit}
disabled={createTask.isPending || !form.title.trim() || !form.projectId}
>
{createTask.isPending ? 'Creating...' : 'Create Task'}
</SubmitButton>
</ModalActions>
</ModalBox>
</ModalOverlay>
);
}