This commit is contained in:
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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
|
||||
|
||||
110
frontend/src/components/SettingsPage/SettingsPage.tsx
Normal file
110
frontend/src/components/SettingsPage/SettingsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
frontend/src/components/SettingsPage/index.ts
Normal file
1
frontend/src/components/SettingsPage/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { SettingsPage } from './SettingsPage';
|
||||
33
frontend/src/hooks/useSettings.ts
Normal file
33
frontend/src/hooks/useSettings.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
14
frontend/src/services/settings.ts
Normal file
14
frontend/src/services/settings.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
4
frontend/src/types/settings.ts
Normal file
4
frontend/src/types/settings.ts
Normal file
@ -0,0 +1,4 @@
|
||||
export interface UserSettings {
|
||||
hoursPerDay?: number;
|
||||
daysPerWeek?: number;
|
||||
}
|
||||
75
frontend/src/utils/estimate.ts
Normal file
75
frontend/src/utils/estimate.ts
Normal 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);
|
||||
}
|
||||
Reference in New Issue
Block a user