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

73
frontend/README.md Normal file
View File

@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
frontend/eslint.config.js Normal file
View File

@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
frontend/index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>frontend</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

4328
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

41
frontend/package.json Normal file
View File

@ -0,0 +1,41 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@emotion/react": "^11.14.0",
"@emotion/styled": "^11.14.1",
"@mui/icons-material": "^7.3.6",
"@mui/material": "^7.3.6",
"@tanstack/react-query": "^5.90.14",
"@tanstack/react-table": "^8.21.3",
"axios": "^1.13.2",
"react": "^19.2.0",
"react-dom": "^19.2.0",
"zustand": "^5.0.9"
},
"devDependencies": {
"@eslint/js": "^9.39.1",
"@types/node": "^24.10.1",
"@types/react": "^19.2.5",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^5.1.1",
"eslint": "^9.39.1",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"
}
}

1
frontend/public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

42
frontend/src/App.tsx Normal file
View File

@ -0,0 +1,42 @@
import { Container, Typography, Box, Button } from '@mui/material';
import { Add } from '@mui/icons-material';
import { IdeasTable } from './components/IdeasTable';
import { IdeasFilters } from './components/IdeasFilters';
import { CreateIdeaModal } from './components/CreateIdeaModal';
import { useIdeasStore } from './store/ideas';
function App() {
const { setCreateModalOpen } = useIdeasStore();
return (
<Container maxWidth="xl" sx={{ py: 4 }}>
<Box sx={{ mb: 4, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box>
<Typography variant="h4" component="h1">
Team Planner
</Typography>
<Typography variant="body1" color="text.secondary">
Backlog management for your team
</Typography>
</Box>
<Button
variant="contained"
startIcon={<Add />}
onClick={() => setCreateModalOpen(true)}
>
New Idea
</Button>
</Box>
<Box sx={{ mb: 3 }}>
<IdeasFilters />
</Box>
<IdeasTable />
<CreateIdeaModal />
</Container>
);
}
export default App;

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

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

View File

@ -0,0 +1,71 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ideasApi, type QueryIdeasParams } from '../services/ideas';
import type { CreateIdeaDto, UpdateIdeaDto } from '../types/idea';
import { useIdeasStore } from '../store/ideas';
const QUERY_KEY = 'ideas';
export function useIdeasQuery() {
const { filters, sorting, pagination } = useIdeasStore();
const params: QueryIdeasParams = {
...filters,
...sorting,
...pagination,
};
return useQuery({
queryKey: [QUERY_KEY, params],
queryFn: () => ideasApi.getAll(params),
});
}
export function useIdeaQuery(id: string) {
return useQuery({
queryKey: [QUERY_KEY, id],
queryFn: () => ideasApi.getOne(id),
enabled: !!id,
});
}
export function useModulesQuery() {
return useQuery({
queryKey: [QUERY_KEY, 'modules'],
queryFn: () => ideasApi.getModules(),
staleTime: 60000, // Cache for 1 minute
});
}
export function useCreateIdea() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (dto: CreateIdeaDto) => ideasApi.create(dto),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
},
});
}
export function useUpdateIdea() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ id, dto }: { id: string; dto: UpdateIdeaDto }) =>
ideasApi.update(id, dto),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
},
});
}
export function useDeleteIdea() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (id: string) => ideasApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
},
});
}

32
frontend/src/main.tsx Normal file
View File

@ -0,0 +1,32 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { ThemeProvider, CssBaseline } from '@mui/material'
import { createTheme } from '@mui/material/styles'
import App from './App.tsx'
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5000,
refetchOnWindowFocus: false,
},
},
})
const theme = createTheme({
palette: {
mode: 'light',
},
})
createRoot(document.getElementById('root')!).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={theme}>
<CssBaseline />
<App />
</ThemeProvider>
</QueryClientProvider>
</StrictMode>,
)

View File

@ -0,0 +1,8 @@
import axios from 'axios';
export const api = axios.create({
baseURL: '/api',
headers: {
'Content-Type': 'application/json',
},
});

View File

@ -0,0 +1,54 @@
import { api } from './api';
import type { Idea, CreateIdeaDto, UpdateIdeaDto, IdeaStatus, IdeaPriority } from '../types/idea';
export interface QueryIdeasParams {
status?: IdeaStatus;
priority?: IdeaPriority;
module?: string;
search?: string;
sortBy?: string;
sortOrder?: 'ASC' | 'DESC';
page?: number;
limit?: number;
}
export interface PaginatedResponse<T> {
data: T[];
meta: {
total: number;
page: number;
limit: number;
totalPages: number;
};
}
export const ideasApi = {
getAll: async (params?: QueryIdeasParams): Promise<PaginatedResponse<Idea>> => {
const { data } = await api.get<PaginatedResponse<Idea>>('/ideas', { params });
return data;
},
getOne: async (id: string): Promise<Idea> => {
const { data } = await api.get<Idea>(`/ideas/${id}`);
return data;
},
create: async (dto: CreateIdeaDto): Promise<Idea> => {
const { data } = await api.post<Idea>('/ideas', dto);
return data;
},
update: async (id: string, dto: UpdateIdeaDto): Promise<Idea> => {
const { data } = await api.patch<Idea>(`/ideas/${id}`, dto);
return data;
},
delete: async (id: string): Promise<void> => {
await api.delete(`/ideas/${id}`);
},
getModules: async (): Promise<string[]> => {
const { data } = await api.get<string[]>('/ideas/modules');
return data;
},
};

View File

@ -0,0 +1,76 @@
import { create } from 'zustand';
import type { IdeaStatus, IdeaPriority } from '../types/idea';
interface IdeasFilters {
status?: IdeaStatus;
priority?: IdeaPriority;
module?: string;
search?: string;
}
interface IdeasSorting {
sortBy: string;
sortOrder: 'ASC' | 'DESC';
}
interface IdeasPagination {
page: number;
limit: number;
}
interface IdeasStore {
// Filters
filters: IdeasFilters;
setFilter: <K extends keyof IdeasFilters>(key: K, value: IdeasFilters[K]) => void;
clearFilters: () => void;
// Sorting
sorting: IdeasSorting;
setSorting: (sortBy: string, sortOrder?: 'ASC' | 'DESC') => void;
// Pagination
pagination: IdeasPagination;
setPage: (page: number) => void;
setLimit: (limit: number) => void;
// UI State
createModalOpen: boolean;
setCreateModalOpen: (open: boolean) => void;
}
const initialFilters: IdeasFilters = {};
const initialSorting: IdeasSorting = { sortBy: 'createdAt', sortOrder: 'DESC' };
const initialPagination: IdeasPagination = { page: 1, limit: 20 };
export const useIdeasStore = create<IdeasStore>((set) => ({
// Filters
filters: initialFilters,
setFilter: (key, value) =>
set((state) => ({
filters: { ...state.filters, [key]: value || undefined },
pagination: { ...state.pagination, page: 1 }, // Reset page on filter change
})),
clearFilters: () =>
set({ filters: initialFilters, pagination: { ...initialPagination } }),
// Sorting
sorting: initialSorting,
setSorting: (sortBy, sortOrder) =>
set((state) => ({
sorting: {
sortBy,
sortOrder: sortOrder ?? (state.sorting.sortBy === sortBy && state.sorting.sortOrder === 'ASC' ? 'DESC' : 'ASC'),
},
})),
// Pagination
pagination: initialPagination,
setPage: (page) =>
set((state) => ({ pagination: { ...state.pagination, page } })),
setLimit: (limit) =>
set((state) => ({ pagination: { ...state.pagination, limit, page: 1 } })),
// UI State
createModalOpen: false,
setCreateModalOpen: (open) => set({ createModalOpen: open }),
}));

View File

@ -0,0 +1,36 @@
export type IdeaStatus = 'backlog' | 'todo' | 'in_progress' | 'done' | 'cancelled';
export type IdeaPriority = 'low' | 'medium' | 'high' | 'critical';
export interface Idea {
id: string;
title: string;
description: string | null;
status: IdeaStatus;
priority: IdeaPriority;
module: string | null;
targetAudience: string | null;
pain: string | null;
aiRole: string | null;
verificationMethod: string | null;
color: string | null;
order: number;
createdAt: string;
updatedAt: string;
}
export interface CreateIdeaDto {
title: string;
description?: string;
status?: IdeaStatus;
priority?: IdeaPriority;
module?: string;
targetAudience?: string;
pain?: string;
aiRole?: string;
verificationMethod?: string;
color?: string;
}
export interface UpdateIdeaDto extends Partial<CreateIdeaDto> {
order?: number;
}

View File

@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
frontend/tsconfig.json Normal file
View File

@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

16
frontend/vite.config.ts Normal file
View File

@ -0,0 +1,16 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 4000,
proxy: {
'/api': {
target: 'http://localhost:4001',
changeOrigin: true,
},
},
},
})