This commit is contained in:
2025-12-29 16:58:56 +03:00
commit 524f3ebf23
62 changed files with 30925 additions and 0 deletions

View File

@ -0,0 +1,186 @@
import { useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
DialogActions,
Button,
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
Box,
Alert,
} from '@mui/material';
import { useIdeasStore } from '../../store/ideas';
import { useCreateIdea } from '../../hooks/useIdeas';
import type { CreateIdeaDto, IdeaStatus, IdeaPriority } from '../../types/idea';
const statusOptions: { value: IdeaStatus; label: string }[] = [
{ value: 'backlog', label: 'Backlog' },
{ value: 'todo', label: 'To Do' },
{ value: 'in_progress', label: 'In Progress' },
{ value: 'done', label: 'Done' },
];
const priorityOptions: { value: IdeaPriority; label: string }[] = [
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
{ value: 'critical', label: 'Critical' },
];
const initialFormData: CreateIdeaDto = {
title: '',
description: '',
status: 'backlog',
priority: 'medium',
module: '',
targetAudience: '',
pain: '',
aiRole: '',
verificationMethod: '',
};
export function CreateIdeaModal() {
const { createModalOpen, setCreateModalOpen } = useIdeasStore();
const createIdea = useCreateIdea();
const [formData, setFormData] = useState<CreateIdeaDto>(initialFormData);
const handleClose = () => {
setCreateModalOpen(false);
setFormData(initialFormData);
createIdea.reset();
};
const handleChange = (field: keyof CreateIdeaDto, value: string) => {
setFormData((prev) => ({ ...prev, [field]: value }));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
await createIdea.mutateAsync(formData);
handleClose();
} catch {
// Error is handled by mutation state
}
};
return (
<Dialog open={createModalOpen} onClose={handleClose} maxWidth="sm" fullWidth>
<form onSubmit={handleSubmit}>
<DialogTitle>Create New Idea</DialogTitle>
<DialogContent>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2, mt: 1 }}>
{createIdea.isError && (
<Alert severity="error">
Failed to create idea. Please try again.
</Alert>
)}
<TextField
label="Title"
value={formData.title}
onChange={(e) => handleChange('title', e.target.value)}
required
autoFocus
/>
<TextField
label="Description"
value={formData.description}
onChange={(e) => handleChange('description', e.target.value)}
multiline
rows={3}
/>
<Box sx={{ display: 'flex', gap: 2 }}>
<FormControl fullWidth>
<InputLabel>Status</InputLabel>
<Select
value={formData.status}
label="Status"
onChange={(e) => handleChange('status', e.target.value)}
>
{statusOptions.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>
{opt.label}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl fullWidth>
<InputLabel>Priority</InputLabel>
<Select
value={formData.priority}
label="Priority"
onChange={(e) => handleChange('priority', e.target.value)}
>
{priorityOptions.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>
{opt.label}
</MenuItem>
))}
</Select>
</FormControl>
</Box>
<TextField
label="Module"
value={formData.module}
onChange={(e) => handleChange('module', e.target.value)}
placeholder="e.g., Auth, Dashboard, API"
/>
<TextField
label="Target Audience"
value={formData.targetAudience}
onChange={(e) => handleChange('targetAudience', e.target.value)}
placeholder="Who is this for?"
/>
<TextField
label="Pain Point"
value={formData.pain}
onChange={(e) => handleChange('pain', e.target.value)}
multiline
rows={2}
placeholder="What problem does this solve?"
/>
<TextField
label="AI Role"
value={formData.aiRole}
onChange={(e) => handleChange('aiRole', e.target.value)}
multiline
rows={2}
placeholder="How can AI help with this?"
/>
<TextField
label="Verification Method"
value={formData.verificationMethod}
onChange={(e) => handleChange('verificationMethod', e.target.value)}
multiline
rows={2}
placeholder="How to verify this is done?"
/>
</Box>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Cancel</Button>
<Button
type="submit"
variant="contained"
disabled={!formData.title || createIdea.isPending}
>
{createIdea.isPending ? 'Creating...' : 'Create'}
</Button>
</DialogActions>
</form>
</Dialog>
);
}

View File

@ -0,0 +1 @@
export { CreateIdeaModal } from './CreateIdeaModal';

View File

@ -0,0 +1,126 @@
import { useState, useEffect } from 'react';
import {
Box,
TextField,
FormControl,
InputLabel,
Select,
MenuItem,
Button,
InputAdornment,
} from '@mui/material';
import { Search, Clear } from '@mui/icons-material';
import { useIdeasStore } from '../../store/ideas';
import { useModulesQuery } from '../../hooks/useIdeas';
import type { IdeaStatus, IdeaPriority } from '../../types/idea';
const statusOptions: { value: IdeaStatus; label: string }[] = [
{ value: 'backlog', label: 'Backlog' },
{ value: 'todo', label: 'To Do' },
{ value: 'in_progress', label: 'In Progress' },
{ value: 'done', label: 'Done' },
{ value: 'cancelled', label: 'Cancelled' },
];
const priorityOptions: { value: IdeaPriority; label: string }[] = [
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
{ value: 'critical', label: 'Critical' },
];
export function IdeasFilters() {
const { filters, setFilter, clearFilters } = useIdeasStore();
const { data: modules = [] } = useModulesQuery();
const [searchValue, setSearchValue] = useState(filters.search || '');
// Debounced search
useEffect(() => {
const timer = setTimeout(() => {
setFilter('search', searchValue || undefined);
}, 300);
return () => clearTimeout(timer);
}, [searchValue, setFilter]);
const hasFilters = filters.status || filters.priority || filters.module || filters.search;
return (
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'center' }}>
<TextField
size="small"
placeholder="Search ideas..."
value={searchValue}
onChange={(e) => setSearchValue(e.target.value)}
sx={{ minWidth: 200 }}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<Search fontSize="small" />
</InputAdornment>
),
}}
/>
<FormControl size="small" sx={{ minWidth: 120 }}>
<InputLabel>Status</InputLabel>
<Select
value={filters.status || ''}
label="Status"
onChange={(e) => setFilter('status', e.target.value as IdeaStatus || undefined)}
>
<MenuItem value="">All</MenuItem>
{statusOptions.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>
{opt.label}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 120 }}>
<InputLabel>Priority</InputLabel>
<Select
value={filters.priority || ''}
label="Priority"
onChange={(e) => setFilter('priority', e.target.value as IdeaPriority || undefined)}
>
<MenuItem value="">All</MenuItem>
{priorityOptions.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>
{opt.label}
</MenuItem>
))}
</Select>
</FormControl>
<FormControl size="small" sx={{ minWidth: 120 }}>
<InputLabel>Module</InputLabel>
<Select
value={filters.module || ''}
label="Module"
onChange={(e) => setFilter('module', e.target.value || undefined)}
>
<MenuItem value="">All</MenuItem>
{modules.map((module) => (
<MenuItem key={module} value={module}>
{module}
</MenuItem>
))}
</Select>
</FormControl>
{hasFilters && (
<Button
size="small"
startIcon={<Clear />}
onClick={() => {
clearFilters();
setSearchValue('');
}}
>
Clear
</Button>
)}
</Box>
);
}

View File

@ -0,0 +1 @@
export { IdeasFilters } from './IdeasFilters';

View File

@ -0,0 +1,143 @@
import { useState, useEffect, useRef } from 'react';
import {
TextField,
Select,
MenuItem,
Box,
ClickAwayListener,
} from '@mui/material';
import type { Idea, IdeaStatus, IdeaPriority } from '../../types/idea';
import { useUpdateIdea } from '../../hooks/useIdeas';
interface EditableCellProps {
idea: Idea;
field: keyof Idea;
value: string | null;
type?: 'text' | 'select';
options?: { value: string; label: string }[];
renderDisplay: (value: string | null) => React.ReactNode;
}
export function EditableCell({
idea,
field,
value,
type = 'text',
options,
renderDisplay,
}: EditableCellProps) {
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(value || '');
const inputRef = useRef<HTMLInputElement>(null);
const updateIdea = useUpdateIdea();
useEffect(() => {
if (isEditing && inputRef.current) {
inputRef.current.focus();
inputRef.current.select();
}
}, [isEditing]);
const handleDoubleClick = () => {
setIsEditing(true);
setEditValue(value || '');
};
const handleSave = async () => {
setIsEditing(false);
if (editValue !== value) {
await updateIdea.mutateAsync({
id: idea.id,
dto: { [field]: editValue || null },
});
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSave();
} else if (e.key === 'Escape') {
setIsEditing(false);
setEditValue(value || '');
}
};
if (isEditing) {
if (type === 'select' && options) {
return (
<ClickAwayListener onClickAway={handleSave}>
<Select
size="small"
value={editValue}
onChange={(e) => {
setEditValue(e.target.value);
setTimeout(() => {
updateIdea.mutate({
id: idea.id,
dto: { [field]: e.target.value },
});
setIsEditing(false);
}, 0);
}}
onKeyDown={handleKeyDown}
autoFocus
sx={{ minWidth: 100 }}
>
{options.map((opt) => (
<MenuItem key={opt.value} value={opt.value}>
{opt.label}
</MenuItem>
))}
</Select>
</ClickAwayListener>
);
}
return (
<ClickAwayListener onClickAway={handleSave}>
<TextField
inputRef={inputRef}
size="small"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={handleSave}
sx={{ minWidth: 100 }}
/>
</ClickAwayListener>
);
}
return (
<Box
onDoubleClick={handleDoubleClick}
sx={{
cursor: 'pointer',
minHeight: 24,
'&:hover': {
backgroundColor: 'action.hover',
borderRadius: 0.5,
},
}}
>
{renderDisplay(value)}
</Box>
);
}
// Status options
export const statusOptions: { value: IdeaStatus; label: string }[] = [
{ value: 'backlog', label: 'Backlog' },
{ value: 'todo', label: 'To Do' },
{ value: 'in_progress', label: 'In Progress' },
{ value: 'done', label: 'Done' },
{ value: 'cancelled', label: 'Cancelled' },
];
// Priority options
export const priorityOptions: { value: IdeaPriority; label: string }[] = [
{ value: 'low', label: 'Low' },
{ value: 'medium', label: 'Medium' },
{ value: 'high', label: 'High' },
{ value: 'critical', label: 'Critical' },
];

View File

@ -0,0 +1,182 @@
import { useMemo } from 'react';
import {
useReactTable,
getCoreRowModel,
flexRender,
} from '@tanstack/react-table';
import {
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
TableSortLabel,
Skeleton,
Box,
Typography,
TablePagination,
} from '@mui/material';
import { Inbox } from '@mui/icons-material';
import { useIdeasQuery, useDeleteIdea } from '../../hooks/useIdeas';
import { useIdeasStore } from '../../store/ideas';
import { createColumns } from './columns';
const SKELETON_COLUMNS_COUNT = 7;
export function IdeasTable() {
const { data, isLoading, isError } = useIdeasQuery();
const deleteIdea = useDeleteIdea();
const { sorting, setSorting, pagination, setPage, setLimit } = useIdeasStore();
const columns = useMemo(
() => createColumns((id) => deleteIdea.mutate(id)),
[deleteIdea]
);
const table = useReactTable({
data: data?.data ?? [],
columns,
getCoreRowModel: getCoreRowModel(),
manualSorting: true,
manualPagination: true,
});
const handleSort = (columnId: string) => {
setSorting(columnId);
};
const handleChangePage = (_: unknown, newPage: number) => {
setPage(newPage + 1);
};
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
setLimit(parseInt(event.target.value, 10));
};
if (isError) {
return (
<Box sx={{ p: 4, textAlign: 'center' }}>
<Typography color="error">Failed to load ideas</Typography>
</Box>
);
}
return (
<Paper sx={{ width: '100%', overflow: 'hidden' }}>
<TableContainer>
<Table stickyHeader size="small">
<TableHead>
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
{headerGroup.headers.map((header) => (
<TableCell
key={header.id}
sx={{
fontWeight: 600,
backgroundColor: 'grey.100',
width: header.getSize(),
}}
>
{header.column.getCanSort() ? (
<TableSortLabel
active={sorting.sortBy === header.id}
direction={
sorting.sortBy === header.id
? (sorting.sortOrder.toLowerCase() as 'asc' | 'desc')
: 'asc'
}
onClick={() => handleSort(header.id)}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
)}
</TableSortLabel>
) : (
flexRender(
header.column.columnDef.header,
header.getContext()
)
)}
</TableCell>
))}
</TableRow>
))}
</TableHead>
<TableBody>
{isLoading ? (
Array.from({ length: 5 }).map((_, index) => (
<TableRow key={index}>
{Array.from({ length: SKELETON_COLUMNS_COUNT }).map((_, colIndex) => (
<TableCell key={colIndex}>
<Skeleton variant="text" />
</TableCell>
))}
</TableRow>
))
) : table.getRowModel().rows.length === 0 ? (
<TableRow>
<TableCell colSpan={SKELETON_COLUMNS_COUNT}>
<Box
sx={{
py: 8,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
color: 'text.secondary',
}}
>
<Inbox sx={{ fontSize: 48, mb: 2, opacity: 0.5 }} />
<Typography variant="h6">No ideas yet</Typography>
<Typography variant="body2">
Create your first idea to get started
</Typography>
</Box>
</TableCell>
</TableRow>
) : (
table.getRowModel().rows.map((row) => (
<TableRow
key={row.id}
hover
sx={{
backgroundColor: row.original.color
? `${row.original.color}15`
: undefined,
'&:hover': {
backgroundColor: row.original.color
? `${row.original.color}25`
: undefined,
},
}}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
)}
</TableCell>
))}
</TableRow>
))
)}
</TableBody>
</Table>
</TableContainer>
{data && (
<TablePagination
component="div"
count={data.meta.total}
page={pagination.page - 1}
rowsPerPage={pagination.limit}
onPageChange={handleChangePage}
onRowsPerPageChange={handleChangeRowsPerPage}
rowsPerPageOptions={[10, 20, 50, 100]}
/>
)}
</Paper>
);
}

View File

@ -0,0 +1,140 @@
import { createColumnHelper } from '@tanstack/react-table';
import { Chip, Box, IconButton } from '@mui/material';
import { Delete } from '@mui/icons-material';
import type { Idea, IdeaStatus, IdeaPriority } from '../../types/idea';
import { EditableCell, statusOptions, priorityOptions } from './EditableCell';
const columnHelper = createColumnHelper<Idea>();
const statusColors: Record<IdeaStatus, 'default' | 'primary' | 'secondary' | 'success' | 'error'> = {
backlog: 'default',
todo: 'primary',
in_progress: 'secondary',
done: 'success',
cancelled: 'error',
};
const priorityColors: Record<IdeaPriority, 'default' | 'info' | 'warning' | 'error'> = {
low: 'default',
medium: 'info',
high: 'warning',
critical: 'error',
};
export const createColumns = (onDelete: (id: string) => void) => [
columnHelper.accessor('title', {
header: 'Title',
cell: (info) => (
<EditableCell
idea={info.row.original}
field="title"
value={info.getValue()}
renderDisplay={(value) => (
<Box sx={{ fontWeight: 500 }}>{value || '—'}</Box>
)}
/>
),
size: 250,
}),
columnHelper.accessor('status', {
header: 'Status',
cell: (info) => {
const status = info.getValue();
const label = statusOptions.find((s) => s.value === status)?.label || status;
return (
<EditableCell
idea={info.row.original}
field="status"
value={status}
type="select"
options={statusOptions}
renderDisplay={() => (
<Chip label={label} color={statusColors[status]} size="small" />
)}
/>
);
},
size: 140,
}),
columnHelper.accessor('priority', {
header: 'Priority',
cell: (info) => {
const priority = info.getValue();
const label = priorityOptions.find((p) => p.value === priority)?.label || priority;
return (
<EditableCell
idea={info.row.original}
field="priority"
value={priority}
type="select"
options={priorityOptions}
renderDisplay={() => (
<Chip
label={label}
color={priorityColors[priority]}
size="small"
variant="outlined"
/>
)}
/>
);
},
size: 120,
}),
columnHelper.accessor('module', {
header: 'Module',
cell: (info) => (
<EditableCell
idea={info.row.original}
field="module"
value={info.getValue()}
renderDisplay={(value) => value || '—'}
/>
),
size: 120,
}),
columnHelper.accessor('targetAudience', {
header: 'Target Audience',
cell: (info) => (
<EditableCell
idea={info.row.original}
field="targetAudience"
value={info.getValue()}
renderDisplay={(value) => value || '—'}
/>
),
size: 150,
}),
columnHelper.accessor('description', {
header: 'Description',
cell: (info) => {
const value = info.getValue();
return (
<EditableCell
idea={info.row.original}
field="description"
value={value}
renderDisplay={(val) => {
if (!val) return '—';
return val.length > 80 ? `${val.slice(0, 80)}...` : val;
}}
/>
);
},
size: 200,
}),
columnHelper.display({
id: 'actions',
header: '',
cell: (info) => (
<IconButton
size="small"
onClick={() => onDelete(info.row.original.id)}
sx={{ opacity: 0.5, '&:hover': { opacity: 1 } }}
>
<Delete fontSize="small" />
</IconButton>
),
size: 50,
}),
];

View File

@ -0,0 +1 @@
export { IdeasTable } from './IdeasTable';