add broker
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2026-02-07 20:24:31 +03:00
parent b270345e77
commit 4d80480d0f
26 changed files with 694 additions and 37 deletions

View File

@ -11,11 +11,19 @@ import {
Tabs,
Tab,
} from '@mui/material';
import { Add, Logout, Person, Lightbulb, Group } from '@mui/icons-material';
import {
Add,
Logout,
Person,
Lightbulb,
Group,
Settings,
} from '@mui/icons-material';
import { IdeasTable } from './components/IdeasTable';
import { IdeasFilters } from './components/IdeasFilters';
import { CreateIdeaModal } from './components/CreateIdeaModal';
import { TeamPage } from './components/TeamPage';
import { SettingsPage } from './components/SettingsPage';
import { useIdeasStore } from './store/ideas';
import { useAuth } from './hooks/useAuth';
@ -66,6 +74,7 @@ function App() {
<Tabs value={tab} onChange={(_, v: number) => setTab(v)}>
<Tab icon={<Lightbulb />} iconPosition="start" label="Идеи" />
<Tab icon={<Group />} iconPosition="start" label="Команда" />
<Tab icon={<Settings />} iconPosition="start" label="Настройки" />
</Tabs>
</Box>
@ -95,6 +104,8 @@ function App() {
)}
{tab === 1 && <TeamPage />}
{tab === 2 && <SettingsPage />}
</Container>
);
}

View File

@ -28,6 +28,11 @@ import {
} from '@mui/icons-material';
import type { EstimateResult } from '../../services/ai';
import type { IdeaComplexity } from '../../types/idea';
import {
formatEstimate,
type EstimateConfig,
DEFAULT_ESTIMATE_CONFIG,
} from '../../utils/estimate';
interface AiEstimateModalProps {
open: boolean;
@ -35,6 +40,7 @@ interface AiEstimateModalProps {
result: EstimateResult | null;
isLoading: boolean;
error: Error | null;
estimateConfig?: EstimateConfig;
}
const complexityLabels: Record<IdeaComplexity, string> = {
@ -56,24 +62,13 @@ const complexityColors: Record<
veryComplex: 'error',
};
function formatHours(hours: number): string {
if (hours < 8) {
return `${String(hours)} ч`;
}
const days = Math.floor(hours / 8);
const remainingHours = hours % 8;
if (remainingHours === 0) {
return `${String(days)} д`;
}
return `${String(days)} д ${String(remainingHours)} ч`;
}
export function AiEstimateModal({
open,
onClose,
result,
isLoading,
error,
estimateConfig = DEFAULT_ESTIMATE_CONFIG,
}: AiEstimateModalProps) {
return (
<Dialog
@ -125,7 +120,7 @@ export function AiEstimateModal({
>
<AccessTime color="primary" />
<Typography variant="h4" component="span">
{formatHours(result.totalHours)}
{formatEstimate(result.totalHours, estimateConfig)}
</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
@ -175,7 +170,7 @@ export function AiEstimateModal({
>
<TableCell>{item.role}</TableCell>
<TableCell align="right">
{formatHours(item.hours)}
{formatEstimate(item.hours, estimateConfig)}
</TableCell>
</TableRow>
))}

View File

@ -24,6 +24,11 @@ import type {
UpdateIdeaDto,
} from '../../types/idea';
import { statusOptions, priorityOptions } from '../IdeasTable/constants';
import {
formatEstimate,
type EstimateConfig,
DEFAULT_ESTIMATE_CONFIG,
} from '../../utils/estimate';
interface IdeaDetailModalProps {
open: boolean;
@ -33,6 +38,7 @@ interface IdeaDetailModalProps {
isSaving: boolean;
onOpenSpecification: (idea: Idea) => void;
onOpenEstimate: (idea: Idea) => void;
estimateConfig?: EstimateConfig;
}
const statusColors: Record<
@ -61,15 +67,6 @@ function formatDate(dateString: string | null): string {
return new Date(dateString).toLocaleString('ru-RU');
}
function formatHours(hours: number | null): string {
if (!hours) return '—';
if (hours < 8) return `${String(hours)} ч`;
const days = Math.floor(hours / 8);
const remainingHours = hours % 8;
if (remainingHours === 0) return `${String(days)} д`;
return `${String(days)} д ${String(remainingHours)} ч`;
}
export function IdeaDetailModal({
open,
onClose,
@ -78,6 +75,7 @@ export function IdeaDetailModal({
isSaving,
onOpenSpecification,
onOpenEstimate,
estimateConfig = DEFAULT_ESTIMATE_CONFIG,
}: IdeaDetailModalProps) {
const [isEditing, setIsEditing] = useState(false);
const [formData, setFormData] = useState<UpdateIdeaDto>({});
@ -394,7 +392,7 @@ export function IdeaDetailModal({
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography data-testid="idea-detail-estimate">
{formatHours(idea.estimatedHours)}
{formatEstimate(idea.estimatedHours, estimateConfig)}
</Typography>
{idea.complexity && (
<Chip label={idea.complexity} size="small" variant="outlined" />

View File

@ -59,6 +59,7 @@ import { SpecificationModal } from '../SpecificationModal';
import { IdeaDetailModal } from '../IdeaDetailModal';
import type { EstimateResult } from '../../services/ai';
import type { Idea, UpdateIdeaDto } from '../../types/idea';
import { useEstimateConfig } from '../../hooks/useSettings';
const SKELETON_COLUMNS_COUNT = 13;
@ -73,6 +74,7 @@ export function IdeasTable() {
const restoreSpecificationFromHistory = useRestoreSpecificationFromHistory();
const { sorting, setSorting, pagination, setPage, setLimit } =
useIdeasStore();
const estimateConfig = useEstimateConfig();
// ID активно перетаскиваемого элемента
const [activeId, setActiveId] = useState<string | null>(null);
@ -274,6 +276,7 @@ export function IdeasTable() {
expandedId,
estimatingId,
generatingSpecificationId,
estimateConfig,
}),
[
deleteIdea,
@ -283,6 +286,7 @@ export function IdeasTable() {
handleEstimate,
handleSpecification,
handleViewDetails,
estimateConfig,
],
);
@ -549,6 +553,7 @@ export function IdeasTable() {
result={estimateResult}
isLoading={estimateIdea.isPending && !estimateResult}
error={estimateIdea.error}
estimateConfig={estimateConfig}
/>
<SpecificationModal
open={specificationModalOpen}
@ -574,6 +579,7 @@ export function IdeasTable() {
isSaving={updateIdea.isPending}
onOpenSpecification={handleOpenSpecificationFromDetail}
onOpenEstimate={handleOpenEstimateFromDetail}
estimateConfig={estimateConfig}
/>
</Paper>
);

View File

@ -26,6 +26,11 @@ import { EditableCell } from './EditableCell';
import { ColorPickerCell } from './ColorPickerCell';
import { statusOptions, priorityOptions } from './constants';
import { DragHandle } from './DraggableRow';
import {
formatEstimate,
type EstimateConfig,
DEFAULT_ESTIMATE_CONFIG,
} from '../../utils/estimate';
const columnHelper = createColumnHelper<Idea>();
@ -69,14 +74,6 @@ const complexityColors: Record<
veryComplex: 'error',
};
function formatHoursShort(hours: number): string {
if (hours < 8) {
return `${String(hours)}ч`;
}
const days = Math.floor(hours / 8);
return `${String(days)}д`;
}
interface ColumnsConfig {
onDelete: (id: string) => void;
onToggleComments: (id: string) => void;
@ -87,6 +84,7 @@ interface ColumnsConfig {
expandedId: string | null;
estimatingId: string | null;
generatingSpecificationId: string | null;
estimateConfig?: EstimateConfig;
}
export const createColumns = ({
@ -99,6 +97,7 @@ export const createColumns = ({
expandedId,
estimatingId,
generatingSpecificationId,
estimateConfig = DEFAULT_ESTIMATE_CONFIG,
}: ColumnsConfig) => [
columnHelper.display({
id: 'drag',
@ -302,7 +301,7 @@ export const createColumns = ({
>
<AccessTime fontSize="small" color="action" />
<Typography variant="body2">
{formatHoursShort(idea.estimatedHours)}
{formatEstimate(idea.estimatedHours, estimateConfig)}
</Typography>
{idea.complexity && (
<Chip

View File

@ -0,0 +1,110 @@
import { useState, useEffect } from 'react';
import {
Box,
Typography,
TextField,
Button,
Paper,
Alert,
} from '@mui/material';
import { useSettingsQuery, useUpdateSettings } from '../../hooks/useSettings';
import { DEFAULT_ESTIMATE_CONFIG } from '../../utils/estimate';
export function SettingsPage() {
const { data: settings, isLoading } = useSettingsQuery();
const updateSettings = useUpdateSettings();
const [hoursPerDay, setHoursPerDay] = useState(
String(DEFAULT_ESTIMATE_CONFIG.hoursPerDay),
);
const [daysPerWeek, setDaysPerWeek] = useState(
String(DEFAULT_ESTIMATE_CONFIG.daysPerWeek),
);
useEffect(() => {
if (settings) {
setHoursPerDay(
String(settings.hoursPerDay ?? DEFAULT_ESTIMATE_CONFIG.hoursPerDay),
);
setDaysPerWeek(
String(settings.daysPerWeek ?? DEFAULT_ESTIMATE_CONFIG.daysPerWeek),
);
}
}, [settings]);
const handleSave = () => {
const hpd = Number(hoursPerDay);
const dpw = Number(daysPerWeek);
if (hpd > 0 && hpd <= 24 && dpw > 0 && dpw <= 7) {
updateSettings.mutate({ hoursPerDay: hpd, daysPerWeek: dpw });
}
};
const hpdNum = Number(hoursPerDay);
const dpwNum = Number(daysPerWeek);
const isValid =
hpdNum > 0 && hpdNum <= 24 && dpwNum > 0 && dpwNum <= 7;
if (isLoading) return null;
return (
<Box sx={{ maxWidth: 500 }}>
<Typography variant="h5" sx={{ mb: 3 }}>
Настройки
</Typography>
<Paper sx={{ p: 3 }}>
<Typography variant="h6" sx={{ mb: 2 }}>
Формат оценки трудозатрат
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
Эти значения используются для конвертации оценок из формата «1w 3d 7h»
в часы и обратно.
</Typography>
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
<TextField
label="Часов в рабочем дне"
type="number"
value={hoursPerDay}
onChange={(e) => setHoursPerDay(e.target.value)}
slotProps={{ htmlInput: { min: 1, max: 24 } }}
helperText="От 1 до 24"
error={!hpdNum || hpdNum < 1 || hpdNum > 24}
size="small"
/>
<TextField
label="Рабочих дней в неделе"
type="number"
value={daysPerWeek}
onChange={(e) => setDaysPerWeek(e.target.value)}
slotProps={{ htmlInput: { min: 1, max: 7 } }}
helperText="От 1 до 7"
error={!dpwNum || dpwNum < 1 || dpwNum > 7}
size="small"
/>
</Box>
<Box sx={{ mt: 3, display: 'flex', alignItems: 'center', gap: 2 }}>
<Button
variant="contained"
onClick={handleSave}
disabled={!isValid || updateSettings.isPending}
>
Сохранить
</Button>
{updateSettings.isSuccess && (
<Alert severity="success" sx={{ py: 0 }}>
Сохранено
</Alert>
)}
{updateSettings.isError && (
<Alert severity="error" sx={{ py: 0 }}>
Ошибка сохранения
</Alert>
)}
</Box>
</Paper>
</Box>
);
}

View File

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

View File

@ -0,0 +1,33 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { settingsApi } from '../services/settings';
import type { UserSettings } from '../types/settings';
import {
DEFAULT_ESTIMATE_CONFIG,
type EstimateConfig,
} from '../utils/estimate';
export function useSettingsQuery() {
return useQuery({
queryKey: ['settings'],
queryFn: settingsApi.get,
});
}
export function useUpdateSettings() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (patch: Partial<UserSettings>) => settingsApi.update(patch),
onSuccess: () => {
void queryClient.invalidateQueries({ queryKey: ['settings'] });
},
});
}
export function useEstimateConfig(): EstimateConfig {
const { data } = useSettingsQuery();
return {
hoursPerDay: data?.hoursPerDay ?? DEFAULT_ESTIMATE_CONFIG.hoursPerDay,
daysPerWeek: data?.daysPerWeek ?? DEFAULT_ESTIMATE_CONFIG.daysPerWeek,
};
}

View File

@ -0,0 +1,14 @@
import { api } from './api';
import type { UserSettings } from '../types/settings';
export const settingsApi = {
get: async (): Promise<UserSettings> => {
const response = await api.get<UserSettings>('/api/settings');
return response.data;
},
update: async (patch: Partial<UserSettings>): Promise<UserSettings> => {
const response = await api.put<UserSettings>('/api/settings', patch);
return response.data;
},
};

View File

@ -0,0 +1,4 @@
export interface UserSettings {
hoursPerDay?: number;
daysPerWeek?: number;
}

View File

@ -0,0 +1,75 @@
export interface EstimateConfig {
hoursPerDay: number;
daysPerWeek: number;
}
export const DEFAULT_ESTIMATE_CONFIG: EstimateConfig = {
hoursPerDay: 8,
daysPerWeek: 5,
};
/**
* Parse estimate string like "1w 3d 7h" into total hours.
* Also accepts a plain number (treated as hours for backwards compatibility).
* Returns null for empty/invalid input.
*/
export function parseEstimate(
input: string,
config: EstimateConfig = DEFAULT_ESTIMATE_CONFIG,
): number | null {
const trimmed = input.trim();
if (!trimmed) return null;
// Plain number → hours
if (/^\d+(\.\d+)?$/.test(trimmed)) {
return Number(trimmed);
}
const weekMatch = /(\d+)\s*w/i.exec(trimmed);
const dayMatch = /(\d+)\s*d/i.exec(trimmed);
const hourMatch = /(\d+)\s*h/i.exec(trimmed);
if (!weekMatch && !dayMatch && !hourMatch) return null;
const weeks = weekMatch ? Number(weekMatch[1]) : 0;
const days = dayMatch ? Number(dayMatch[1]) : 0;
const hours = hourMatch ? Number(hourMatch[1]) : 0;
return weeks * config.daysPerWeek * config.hoursPerDay +
days * config.hoursPerDay +
hours;
}
/**
* Format hours into "1w 3d 7h" string.
* Returns "—" for null/0.
*/
export function formatEstimate(
hours: number | null | undefined,
config: EstimateConfig = DEFAULT_ESTIMATE_CONFIG,
): string {
if (!hours) return '—';
const hoursPerWeek = config.daysPerWeek * config.hoursPerDay;
const weeks = Math.floor(hours / hoursPerWeek);
let remaining = hours % hoursPerWeek;
const days = Math.floor(remaining / config.hoursPerDay);
remaining = remaining % config.hoursPerDay;
const parts: string[] = [];
if (weeks > 0) parts.push(`${String(weeks)}w`);
if (days > 0) parts.push(`${String(days)}d`);
if (remaining > 0) parts.push(`${String(remaining)}h`);
return parts.length > 0 ? parts.join(' ') : '—';
}
/**
* Short format for table cells — same as formatEstimate.
*/
export function formatEstimateShort(
hours: number | null | undefined,
config: EstimateConfig = DEFAULT_ESTIMATE_CONFIG,
): string {
return formatEstimate(hours, config);
}