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,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';