add deploy
Some checks reported errors
continuous-integration/drone/push Build was killed

This commit is contained in:
2025-12-31 09:50:51 +03:00
parent 524f3ebf23
commit 85c4a36e17
40 changed files with 855 additions and 199 deletions

4
frontend/.prettierrc Normal file
View File

@ -0,0 +1,4 @@
{
"singleQuote": true,
"trailingComma": "all"
}

40
frontend/Dockerfile Normal file
View File

@ -0,0 +1,40 @@
# Build stage
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
# Install dependencies
RUN npm install
# Copy source code
COPY . .
# Build argument for API URL (optional, defaults to empty for production)
# Empty value means use relative paths, which works with nginx proxy
ARG VITE_API_URL=""
ENV VITE_API_URL=$VITE_API_URL
# Build the application
RUN npm run build
# Production stage
FROM nginx:alpine
# Copy built files from builder
COPY --from=builder /app/dist /usr/share/nginx/html
# Copy nginx configuration
COPY nginx.conf /etc/nginx/nginx.conf
# Expose port
EXPOSE 80
# Health check
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s \
CMD wget --quiet --tries=1 --spider http://localhost/health || exit 1
# Start nginx
CMD ["nginx", "-g", "daemon off;"]

View File

@ -6,18 +6,36 @@ import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
globalIgnores(['dist', 'coverage', '*.config.ts', '*.config.js']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
...tseslint.configs.strictTypeChecked,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
parserOptions: {
projectService: true,
tsconfigRootDir: import.meta.dirname,
},
},
rules: {
'@typescript-eslint/no-unnecessary-condition': 'error',
'@typescript-eslint/prefer-nullish-coalescing': 'error',
'@typescript-eslint/no-confusing-void-expression': 'off',
'@typescript-eslint/no-misused-promises': [
'error',
{
checksVoidReturn: {
attributes: false,
},
},
],
'react-hooks/set-state-in-effect': 'warn',
},
},
])

62
frontend/nginx.conf Normal file
View File

@ -0,0 +1,62 @@
events {}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Logging
access_log /var/log/nginx/access.log;
error_log /var/log/nginx/error.log;
# Performance
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/json application/javascript;
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Health check endpoint for k8s
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
# Proxy API requests to backend
location /api/ {
proxy_pass http://team-planner-backend-service:4001;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_cache_bypass $http_upgrade;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# SPA fallback - all routes go to index.html
location / {
try_files $uri $uri/ /index.html;
}
# Static assets with caching
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}
}

View File

@ -6,8 +6,10 @@
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
"lint": "tsc -b --noEmit && eslint . && prettier --check \"src/**/*.{ts,tsx,json,css,md}\"",
"format": "prettier --write \"src/**/*.{ts,tsx,json,css,md}\"",
"preview": "vite preview",
"clean": "rm -rf dist node_modules"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@ -34,6 +36,7 @@
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.24",
"globals": "^16.5.0",
"prettier": "^3.4.2",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.4",
"vite": "^7.2.4"

View File

@ -10,7 +10,14 @@ function App() {
return (
<Container maxWidth="xl" sx={{ py: 4 }}>
<Box sx={{ mb: 4, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Box
sx={{
mb: 4,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Box>
<Typography variant="h4" component="h1">
Team Planner

View File

@ -69,7 +69,12 @@ export function CreateIdeaModal() {
};
return (
<Dialog open={createModalOpen} onClose={handleClose} maxWidth="sm" fullWidth>
<Dialog
open={createModalOpen}
onClose={handleClose}
maxWidth="sm"
fullWidth
>
<form onSubmit={handleSubmit}>
<DialogTitle>Create New Idea</DialogTitle>
<DialogContent>
@ -163,7 +168,9 @@ export function CreateIdeaModal() {
<TextField
label="Verification Method"
value={formData.verificationMethod}
onChange={(e) => handleChange('verificationMethod', e.target.value)}
onChange={(e) =>
handleChange('verificationMethod', e.target.value)
}
multiline
rows={2}
placeholder="How to verify this is done?"

View File

@ -32,7 +32,7 @@ const priorityOptions: { value: IdeaPriority; label: string }[] = [
export function IdeasFilters() {
const { filters, setFilter, clearFilters } = useIdeasStore();
const { data: modules = [] } = useModulesQuery();
const [searchValue, setSearchValue] = useState(filters.search || '');
const [searchValue, setSearchValue] = useState(filters.search ?? '');
// Debounced search
useEffect(() => {
@ -42,31 +42,40 @@ export function IdeasFilters() {
return () => clearTimeout(timer);
}, [searchValue, setFilter]);
const hasFilters = filters.status || filters.priority || filters.module || filters.search;
const hasFilters = Boolean(
filters.status ?? filters.priority ?? filters.module ?? filters.search,
);
return (
<Box sx={{ display: 'flex', gap: 2, flexWrap: 'wrap', alignItems: 'center' }}>
<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>
),
slotProps={{
input: {
startAdornment: (
<InputAdornment position="start">
<Search fontSize="small" />
</InputAdornment>
),
},
}}
/>
<FormControl size="small" sx={{ minWidth: 120 }}>
<InputLabel>Status</InputLabel>
<Select
value={filters.status || ''}
<Select<IdeaStatus | ''>
value={filters.status ?? ''}
label="Status"
onChange={(e) => setFilter('status', e.target.value as IdeaStatus || undefined)}
onChange={(e) => {
const val = e.target.value;
setFilter('status', val === '' ? undefined : val);
}}
>
<MenuItem value="">All</MenuItem>
{statusOptions.map((opt) => (
@ -79,10 +88,13 @@ export function IdeasFilters() {
<FormControl size="small" sx={{ minWidth: 120 }}>
<InputLabel>Priority</InputLabel>
<Select
value={filters.priority || ''}
<Select<IdeaPriority | ''>
value={filters.priority ?? ''}
label="Priority"
onChange={(e) => setFilter('priority', e.target.value as IdeaPriority || undefined)}
onChange={(e) => {
const val = e.target.value;
setFilter('priority', val === '' ? undefined : val);
}}
>
<MenuItem value="">All</MenuItem>
{priorityOptions.map((opt) => (
@ -96,7 +108,7 @@ export function IdeasFilters() {
<FormControl size="small" sx={{ minWidth: 120 }}>
<InputLabel>Module</InputLabel>
<Select
value={filters.module || ''}
value={filters.module ?? ''}
label="Module"
onChange={(e) => setFilter('module', e.target.value || undefined)}
>

View File

@ -6,7 +6,7 @@ import {
Box,
ClickAwayListener,
} from '@mui/material';
import type { Idea, IdeaStatus, IdeaPriority } from '../../types/idea';
import type { Idea } from '../../types/idea';
import { useUpdateIdea } from '../../hooks/useIdeas';
interface EditableCellProps {
@ -27,7 +27,7 @@ export function EditableCell({
renderDisplay,
}: EditableCellProps) {
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState(value || '');
const [editValue, setEditValue] = useState(value ?? '');
const inputRef = useRef<HTMLInputElement>(null);
const updateIdea = useUpdateIdea();
@ -40,7 +40,7 @@ export function EditableCell({
const handleDoubleClick = () => {
setIsEditing(true);
setEditValue(value || '');
setEditValue(value ?? '');
};
const handleSave = async () => {
@ -55,10 +55,10 @@ export function EditableCell({
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter') {
handleSave();
void handleSave();
} else if (e.key === 'Escape') {
setIsEditing(false);
setEditValue(value || '');
setEditValue(value ?? '');
}
};
@ -124,20 +124,3 @@ export function EditableCell({
</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

@ -28,13 +28,15 @@ const SKELETON_COLUMNS_COUNT = 7;
export function IdeasTable() {
const { data, isLoading, isError } = useIdeasQuery();
const deleteIdea = useDeleteIdea();
const { sorting, setSorting, pagination, setPage, setLimit } = useIdeasStore();
const { sorting, setSorting, pagination, setPage, setLimit } =
useIdeasStore();
const columns = useMemo(
() => createColumns((id) => deleteIdea.mutate(id)),
[deleteIdea]
[deleteIdea],
);
// eslint-disable-next-line react-hooks/incompatible-library
const table = useReactTable({
data: data?.data ?? [],
columns,
@ -51,7 +53,9 @@ export function IdeasTable() {
setPage(newPage + 1);
};
const handleChangeRowsPerPage = (event: React.ChangeEvent<HTMLInputElement>) => {
const handleChangeRowsPerPage = (
event: React.ChangeEvent<HTMLInputElement>,
) => {
setLimit(parseInt(event.target.value, 10));
};
@ -84,20 +88,22 @@ export function IdeasTable() {
active={sorting.sortBy === header.id}
direction={
sorting.sortBy === header.id
? (sorting.sortOrder.toLowerCase() as 'asc' | 'desc')
? (sorting.sortOrder.toLowerCase() as
| 'asc'
| 'desc')
: 'asc'
}
onClick={() => handleSort(header.id)}
>
{flexRender(
header.column.columnDef.header,
header.getContext()
header.getContext(),
)}
</TableSortLabel>
) : (
flexRender(
header.column.columnDef.header,
header.getContext()
header.getContext(),
)
)}
</TableCell>
@ -109,11 +115,13 @@ export function IdeasTable() {
{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>
))}
{Array.from({ length: SKELETON_COLUMNS_COUNT }).map(
(_, colIndex) => (
<TableCell key={colIndex}>
<Skeleton variant="text" />
</TableCell>
),
)}
</TableRow>
))
) : table.getRowModel().rows.length === 0 ? (
@ -156,7 +164,7 @@ export function IdeasTable() {
<TableCell key={cell.id}>
{flexRender(
cell.column.columnDef.cell,
cell.getContext()
cell.getContext(),
)}
</TableCell>
))}

View File

@ -2,11 +2,15 @@ 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';
import { EditableCell } from './EditableCell';
import { statusOptions, priorityOptions } from './constants';
const columnHelper = createColumnHelper<Idea>();
const statusColors: Record<IdeaStatus, 'default' | 'primary' | 'secondary' | 'success' | 'error'> = {
const statusColors: Record<
IdeaStatus,
'default' | 'primary' | 'secondary' | 'success' | 'error'
> = {
backlog: 'default',
todo: 'primary',
in_progress: 'secondary',
@ -14,7 +18,10 @@ const statusColors: Record<IdeaStatus, 'default' | 'primary' | 'secondary' | 'su
cancelled: 'error',
};
const priorityColors: Record<IdeaPriority, 'default' | 'info' | 'warning' | 'error'> = {
const priorityColors: Record<
IdeaPriority,
'default' | 'info' | 'warning' | 'error'
> = {
low: 'default',
medium: 'info',
high: 'warning',
@ -30,7 +37,7 @@ export const createColumns = (onDelete: (id: string) => void) => [
field="title"
value={info.getValue()}
renderDisplay={(value) => (
<Box sx={{ fontWeight: 500 }}>{value || '—'}</Box>
<Box sx={{ fontWeight: 500 }}>{value ?? '—'}</Box>
)}
/>
),
@ -40,7 +47,8 @@ export const createColumns = (onDelete: (id: string) => void) => [
header: 'Status',
cell: (info) => {
const status = info.getValue();
const label = statusOptions.find((s) => s.value === status)?.label || status;
const label =
statusOptions.find((s) => s.value === status)?.label ?? status;
return (
<EditableCell
idea={info.row.original}
@ -60,7 +68,8 @@ export const createColumns = (onDelete: (id: string) => void) => [
header: 'Priority',
cell: (info) => {
const priority = info.getValue();
const label = priorityOptions.find((p) => p.value === priority)?.label || priority;
const label =
priorityOptions.find((p) => p.value === priority)?.label ?? priority;
return (
<EditableCell
idea={info.row.original}
@ -88,7 +97,7 @@ export const createColumns = (onDelete: (id: string) => void) => [
idea={info.row.original}
field="module"
value={info.getValue()}
renderDisplay={(value) => value || '—'}
renderDisplay={(value) => value ?? '—'}
/>
),
size: 120,
@ -100,7 +109,7 @@ export const createColumns = (onDelete: (id: string) => void) => [
idea={info.row.original}
field="targetAudience"
value={info.getValue()}
renderDisplay={(value) => value || '—'}
renderDisplay={(value) => value ?? '—'}
/>
),
size: 150,

View File

@ -0,0 +1,16 @@
import type { IdeaStatus, IdeaPriority } from '../../types/idea';
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' },
];
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

@ -42,7 +42,7 @@ export function useCreateIdea() {
return useMutation({
mutationFn: (dto: CreateIdeaDto) => ideasApi.create(dto),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
void queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
},
});
}
@ -54,7 +54,7 @@ export function useUpdateIdea() {
mutationFn: ({ id, dto }: { id: string; dto: UpdateIdeaDto }) =>
ideasApi.update(id, dto),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
void queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
},
});
}
@ -65,7 +65,7 @@ export function useDeleteIdea() {
return useMutation({
mutationFn: (id: string) => ideasApi.delete(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
void queryClient.invalidateQueries({ queryKey: [QUERY_KEY] });
},
});
}

View File

@ -1,9 +1,9 @@
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'
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: {
@ -12,15 +12,20 @@ const queryClient = new QueryClient({
refetchOnWindowFocus: false,
},
},
})
});
const theme = createTheme({
palette: {
mode: 'light',
},
})
});
createRoot(document.getElementById('root')!).render(
const rootElement = document.getElementById('root');
if (!rootElement) {
throw new Error('Root element not found');
}
createRoot(rootElement).render(
<StrictMode>
<QueryClientProvider client={queryClient}>
<ThemeProvider theme={theme}>
@ -29,4 +34,4 @@ createRoot(document.getElementById('root')!).render(
</ThemeProvider>
</QueryClientProvider>
</StrictMode>,
)
);

View File

@ -1,5 +1,11 @@
import { api } from './api';
import type { Idea, CreateIdeaDto, UpdateIdeaDto, IdeaStatus, IdeaPriority } from '../types/idea';
import type {
Idea,
CreateIdeaDto,
UpdateIdeaDto,
IdeaStatus,
IdeaPriority,
} from '../types/idea';
export interface QueryIdeasParams {
status?: IdeaStatus;
@ -23,8 +29,12 @@ export interface PaginatedResponse<T> {
}
export const ideasApi = {
getAll: async (params?: QueryIdeasParams): Promise<PaginatedResponse<Idea>> => {
const { data } = await api.get<PaginatedResponse<Idea>>('/ideas', { params });
getAll: async (
params?: QueryIdeasParams,
): Promise<PaginatedResponse<Idea>> => {
const { data } = await api.get<PaginatedResponse<Idea>>('/ideas', {
params,
});
return data;
},

View File

@ -21,7 +21,10 @@ interface IdeasPagination {
interface IdeasStore {
// Filters
filters: IdeasFilters;
setFilter: <K extends keyof IdeasFilters>(key: K, value: IdeasFilters[K]) => void;
setFilter: <K extends keyof IdeasFilters>(
key: K,
value: IdeasFilters[K],
) => void;
clearFilters: () => void;
// Sorting
@ -59,7 +62,11 @@ export const useIdeasStore = create<IdeasStore>((set) => ({
set((state) => ({
sorting: {
sortBy,
sortOrder: sortOrder ?? (state.sorting.sortBy === sortBy && state.sorting.sortOrder === 'ASC' ? 'DESC' : 'ASC'),
sortOrder:
sortOrder ??
(state.sorting.sortBy === sortBy && state.sorting.sortOrder === 'ASC'
? 'DESC'
: 'ASC'),
},
})),

View File

@ -1,4 +1,9 @@
export type IdeaStatus = 'backlog' | 'todo' | 'in_progress' | 'done' | 'cancelled';
export type IdeaStatus =
| 'backlog'
| 'todo'
| 'in_progress'
| 'done'
| 'cancelled';
export type IdeaPriority = 'low' | 'medium' | 'high' | 'critical';
export interface Idea {