363 lines
11 KiB
TypeScript
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>
|
|
);
|
|
}
|