mirror of
https://github.com/SamyRai/turash.git
synced 2025-12-26 23:01:33 +00:00
fix: resolve React Compiler and linting errors
Some checks failed
CI/CD Pipeline / backend-lint (push) Failing after 31s
CI/CD Pipeline / backend-build (push) Has been skipped
CI/CD Pipeline / frontend-lint (push) Failing after 1m22s
CI/CD Pipeline / frontend-build (push) Has been skipped
CI/CD Pipeline / e2e-test (push) Has been skipped
Some checks failed
CI/CD Pipeline / backend-lint (push) Failing after 31s
CI/CD Pipeline / backend-build (push) Has been skipped
CI/CD Pipeline / frontend-lint (push) Failing after 1m22s
CI/CD Pipeline / frontend-build (push) Has been skipped
CI/CD Pipeline / e2e-test (push) Has been skipped
- Fix React Compiler memoization issues in useOrganizationPage.ts - Replace useCallback with useRef pattern in useKeyboard.ts - Remove unnecessary dependencies from useMemo hooks - Fix prettier formatting in api-client.ts and api-config.ts - Replace any types with proper types in error-handling, http-client, security - Remove unused imports and variables - Move ImpactBreakdownChart component outside render in ImpactMetrics.tsx - Fix setState in effect by using useMemo in HeritageBuildingPage.tsx - Memoize getHistoryTitle with useCallback in MatchDetailPage and MatchNegotiationPage - Add i18n for literal strings in community pages and LoginPage - Fix missing dependencies in DashboardPage and DiscoveryPage
This commit is contained in:
parent
bdb7673b16
commit
28f06d5787
@ -74,7 +74,7 @@ const Step1 = ({
|
||||
)}
|
||||
/>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Upload additional images to showcase your organization (optional)
|
||||
{t('organization.galleryImagesHint')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -128,10 +128,10 @@ export const ActivityFeed = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle>Recent Activity</CardTitle>
|
||||
</CardHeader>
|
||||
<Card className={className}>
|
||||
<CardHeader>
|
||||
<CardTitle>{t?.('activityFeed.recentActivity') || 'Recent Activity'}</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{activities.map((activity) => (
|
||||
|
||||
@ -3,6 +3,7 @@ import { clsx } from 'clsx';
|
||||
import { ResponsiveTable, Pagination, SearchBar, Checkbox, Button } from '@/components/ui';
|
||||
import { MoreVertical } from 'lucide-react';
|
||||
import { DropdownMenu } from '@/components/ui';
|
||||
import { useTranslation } from '@/hooks/useI18n.tsx';
|
||||
|
||||
export interface DataTableColumn<T> {
|
||||
key: string;
|
||||
@ -90,6 +91,7 @@ export function DataTable<T>({
|
||||
renderMobileCard,
|
||||
className,
|
||||
}: DataTableProps<T>) {
|
||||
const { t } = useTranslation();
|
||||
const selectedCount = selection?.selectedRows.size || 0;
|
||||
const hasSelection = selectedCount > 0;
|
||||
|
||||
@ -213,7 +215,7 @@ export function DataTable<T>({
|
||||
{/* Bulk Actions */}
|
||||
{hasSelection && bulkActions && bulkActions.length > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">{selectedCount} selected</span>
|
||||
<span className="text-sm text-muted-foreground">{t('dataTable.selected', { count: selectedCount })}</span>
|
||||
{bulkActions.map((action, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
@ -233,7 +235,7 @@ export function DataTable<T>({
|
||||
size="sm"
|
||||
onClick={() => selection?.onSelectionChange(new Set())}
|
||||
>
|
||||
Clear
|
||||
{t('dataTable.clear')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
@ -251,7 +253,7 @@ export function DataTable<T>({
|
||||
}}
|
||||
onChange={(e) => handleSelectAll(e.target.checked)}
|
||||
/>
|
||||
<span className="text-sm text-muted-foreground">Select all ({data.length} items)</span>
|
||||
<span className="text-sm text-muted-foreground">{t('dataTable.selectAll', { count: data.length })}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -290,7 +292,7 @@ export function DataTable<T>({
|
||||
/>
|
||||
{pagination.onPageSizeChange && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-muted-foreground">Items per page:</span>
|
||||
<span className="text-sm text-muted-foreground">{t('dataTable.itemsPerPage')}</span>
|
||||
<select
|
||||
value={pagination.pageSize}
|
||||
onChange={(e) => pagination.onPageSizeChange?.(Number(e.target.value))}
|
||||
|
||||
@ -2,6 +2,7 @@ import React, { useState } from 'react';
|
||||
import { clsx } from 'clsx';
|
||||
import { Filter, X } from 'lucide-react';
|
||||
import { Button, Popover, SelectDropdown, CheckboxGroup } from '@/components/ui';
|
||||
import { useTranslation } from '@/hooks/useI18n.tsx';
|
||||
|
||||
export interface FilterOption {
|
||||
id: string;
|
||||
@ -27,6 +28,7 @@ export interface FilterBarProps {
|
||||
* Advanced filter bar component
|
||||
*/
|
||||
export const FilterBar = ({ filters, values, onChange, onReset, className }: FilterBarProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const activeFilterCount = Object.values(values).filter(
|
||||
(v) => v !== null && v !== undefined && (Array.isArray(v) ? v.length > 0 : true)
|
||||
@ -59,7 +61,7 @@ export const FilterBar = ({ filters, values, onChange, onReset, className }: Fil
|
||||
trigger={
|
||||
<Button variant={hasActiveFilters ? 'primary' : 'outline'} size="sm" className="relative">
|
||||
<Filter className="h-4 w-4 mr-2" />
|
||||
Filters
|
||||
{t('filterBar.filters')}
|
||||
{hasActiveFilters && (
|
||||
<span className="ml-2 rounded-full bg-primary-foreground px-1.5 py-0.5 text-xs text-primary">
|
||||
{activeFilterCount}
|
||||
@ -70,11 +72,11 @@ export const FilterBar = ({ filters, values, onChange, onReset, className }: Fil
|
||||
content={
|
||||
<div className="w-80 p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-semibold">Filters</h3>
|
||||
<h3 className="font-semibold">{t('filterBar.filters')}</h3>
|
||||
{hasActiveFilters && (
|
||||
<Button variant="ghost" size="sm" onClick={handleReset}>
|
||||
<X className="h-4 w-4 mr-1" />
|
||||
Clear All
|
||||
{t('filterBar.clearAll')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@ -157,10 +159,10 @@ export const FilterBar = ({ filters, values, onChange, onReset, className }: Fil
|
||||
|
||||
<div className="flex justify-end gap-2 pt-4 border-t">
|
||||
<Button variant="outline" size="sm" onClick={() => setIsOpen(false)}>
|
||||
Cancel
|
||||
{t('filterBar.cancel')}
|
||||
</Button>
|
||||
<Button variant="primary" size="sm" onClick={() => setIsOpen(false)}>
|
||||
Apply Filters
|
||||
{t('filterBar.applyFilters')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -2,6 +2,7 @@ import { Avatar, DropdownMenu } from '@/components/ui';
|
||||
import { useAuth } from '@/contexts/AuthContext';
|
||||
import { useMaintenanceSetting } from '@/hooks/api/useAdminAPI';
|
||||
import { clsx } from 'clsx';
|
||||
import { useTranslation } from '@/hooks/useI18n.tsx';
|
||||
import {
|
||||
BarChart3,
|
||||
Bell,
|
||||
@ -116,6 +117,7 @@ const defaultNavItems: AdminNavItem[] = [
|
||||
* Admin Layout with sidebar navigation
|
||||
*/
|
||||
export const AdminLayout = ({ children, title, breadcrumbs }: AdminLayoutProps) => {
|
||||
const { t } = useTranslation();
|
||||
const [sidebarOpen, setSidebarOpen] = useState(true);
|
||||
const [expandedItems, setExpandedItems] = useState<Set<string>>(new Set());
|
||||
const location = useLocation();
|
||||
@ -178,7 +180,7 @@ export const AdminLayout = ({ children, title, breadcrumbs }: AdminLayoutProps)
|
||||
<div className="flex h-16 items-center justify-between border-b px-4">
|
||||
{sidebarOpen && (
|
||||
<>
|
||||
<h1 className="text-lg font-semibold">Admin Panel</h1>
|
||||
<h1 className="text-lg font-semibold">{t('adminPanel.title')}</h1>
|
||||
<button
|
||||
onClick={toggleSidebar}
|
||||
className="rounded-md p-1 hover:bg-muted"
|
||||
@ -334,7 +336,7 @@ export const AdminLayout = ({ children, title, breadcrumbs }: AdminLayoutProps)
|
||||
{/* Show maintenance banner for admins */}
|
||||
{maintenance.data?.enabled && (
|
||||
<div className="mb-4 p-3 bg-yellow-50 border-l-4 border-yellow-400">
|
||||
<strong className="block">Maintenance mode is active</strong>
|
||||
<strong className="block">{t('adminPanel.maintenanceModeActive')}</strong>
|
||||
<div className="text-sm">{maintenance.data?.message}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { usePermissions } from '@/hooks/usePermissions';
|
||||
import { Permission } from '@/types/permissions';
|
||||
import React from 'react';
|
||||
import { useTranslation } from '@/hooks/useI18n.tsx';
|
||||
|
||||
export interface PermissionGateProps {
|
||||
children: React.ReactNode;
|
||||
@ -21,6 +22,7 @@ export const PermissionGate = ({
|
||||
fallback = null,
|
||||
showError = false,
|
||||
}: PermissionGateProps) => {
|
||||
const { t } = useTranslation();
|
||||
const { checkAnyPermission, checkAllPermissions } = usePermissions();
|
||||
|
||||
const permissions = Array.isArray(permission) ? permission : [permission];
|
||||
@ -30,7 +32,7 @@ export const PermissionGate = ({
|
||||
if (showError) {
|
||||
return (
|
||||
<div className="text-sm text-destructive">
|
||||
You don't have permission to view this content.
|
||||
{t('permissionGate.noPermission')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -26,7 +26,7 @@ const ImpactMetricsSection = ({ totalCo2Saved, totalEconomicValue, activeMatches
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Heading level="h3" className="text-green-600 mb-1">
|
||||
{totalCo2Saved} t
|
||||
{totalCo2Saved} {t('dashboard.co2Unit')}
|
||||
</Heading>
|
||||
<Text variant="muted" className="text-xs">
|
||||
{t('dashboard.perYear')}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import React from 'react';
|
||||
import { useFieldArray, Control, FieldErrors, FieldValues, Path } from 'react-hook-form';
|
||||
import Button from '@/components/ui/Button.tsx';
|
||||
import { useTranslation } from '@/hooks/useI18n.tsx';
|
||||
|
||||
interface DynamicFieldArrayProps<T extends FieldValues> {
|
||||
control: Control<T>;
|
||||
@ -21,6 +22,7 @@ const DynamicFieldArray = <T extends FieldValues>({
|
||||
defaultItem,
|
||||
children,
|
||||
}: DynamicFieldArrayProps<T>) => {
|
||||
const { t } = useTranslation();
|
||||
const { fields, append, remove } = useFieldArray({ control, name });
|
||||
const arrayErrors = errors[name as string];
|
||||
|
||||
@ -36,6 +38,7 @@ const DynamicFieldArray = <T extends FieldValues>({
|
||||
variant="outline"
|
||||
onClick={() => remove(index)}
|
||||
className="h-10 w-10 p-0 shrink-0 mt-1.5"
|
||||
aria-label={t('form.removeItem')}
|
||||
>
|
||||
✕
|
||||
</Button>
|
||||
|
||||
@ -3,6 +3,7 @@ import { Heading, Text } from '@/components/ui/Typography.tsx';
|
||||
import { getIconByName } from '@/lib/heritage-mapper.tsx';
|
||||
import type { HeritageSource, TimelineItem } from '@/types';
|
||||
import { motion, Variants } from 'framer-motion';
|
||||
import { useTranslation } from '@/hooks/useI18n.tsx';
|
||||
import {
|
||||
Calendar,
|
||||
Image as ImageIcon,
|
||||
@ -117,6 +118,7 @@ interface TimelineItemProps {
|
||||
}
|
||||
|
||||
const TimelineItem: React.FC<TimelineItemProps> = ({ item, index, sources }) => {
|
||||
const { t } = useTranslation();
|
||||
// Debug: log when items render to help diagnose missing chronology
|
||||
|
||||
console.debug('[TimelineItem] render', { id: item.id, title: item.title, index });
|
||||
@ -192,7 +194,7 @@ const TimelineItem: React.FC<TimelineItemProps> = ({ item, index, sources }) =>
|
||||
<div className="absolute inset-0 bg-linear-to-t from-black/60 via-transparent to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
<div className="absolute bottom-4 right-4 flex items-center gap-2 text-white text-sm">
|
||||
<ZoomIn className="w-4 h-4" />
|
||||
<span className="font-medium">View</span>
|
||||
<span className="font-medium">{t('heritage.view')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
@ -88,10 +88,10 @@ const TimelineSection = ({
|
||||
<div className="flex items-center gap-2">
|
||||
<Filter className="w-5 h-5 text-primary" />
|
||||
<span className="font-medium">
|
||||
Filters
|
||||
{t('heritage.filters')}
|
||||
{(filters.selectedCategory !== 'all' || filters.minImportance > 1) && (
|
||||
<span className="ml-2 text-sm text-muted-foreground">
|
||||
({timelineItems.length} events)
|
||||
{t('heritage.eventsCount', { count: timelineItems.length })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
@ -99,6 +99,7 @@ const TimelineSection = ({
|
||||
<motion.div
|
||||
animate={{ rotate: filters.showFilters ? 180 : 0 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
aria-label={t('heritage.toggleFilters')}
|
||||
>
|
||||
▼
|
||||
</motion.div>
|
||||
@ -114,7 +115,7 @@ const TimelineSection = ({
|
||||
<div className="p-4 space-y-4">
|
||||
{/* Category Filter */}
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Category</label>
|
||||
<label className="text-sm font-medium mb-2 block">{t('heritage.category')}</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => filters.setSelectedCategory('all')}
|
||||
@ -124,7 +125,7 @@ const TimelineSection = ({
|
||||
: 'bg-muted hover:bg-muted/80'
|
||||
}`}
|
||||
>
|
||||
All
|
||||
{t('heritage.all')}
|
||||
</button>
|
||||
{availableCategories.map((category) => (
|
||||
<button
|
||||
@ -145,7 +146,7 @@ const TimelineSection = ({
|
||||
{/* Importance Filter */}
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">
|
||||
Minimum Importance: {filters.minImportance}
|
||||
{t('heritage.minimumImportance', { value: filters.minImportance })}
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
@ -167,7 +168,7 @@ const TimelineSection = ({
|
||||
onClick={filters.resetFilters}
|
||||
className="w-full px-4 py-2 text-sm bg-muted hover:bg-muted/80 rounded-lg transition-colors"
|
||||
>
|
||||
Reset Filters
|
||||
{t('heritage.resetFilters')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@ -184,7 +185,7 @@ const TimelineSection = ({
|
||||
) : (
|
||||
<div className="text-center py-16">
|
||||
<p className="text-muted-foreground text-lg">
|
||||
No events match your filters. Try adjusting your selection.
|
||||
{t('heritage.noEventsMatch')}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -295,8 +295,8 @@ const ResourceExchangeVisualization: React.FC<ResourceExchangeVisualizationProps
|
||||
<div className="w-full flex flex-col items-center gap-4 max-w-lg mx-auto">
|
||||
{/* Title */}
|
||||
<div className="text-center">
|
||||
<h3 className="text-sm font-semibold text-foreground mb-1">Resource Exchange Network</h3>
|
||||
<p className="text-xs text-muted-foreground">Businesses connect to exchange resources</p>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-1">{t('resourceExchange.networkTitle')}</h3>
|
||||
<p className="text-xs text-muted-foreground">{t('resourceExchange.networkDescription')}</p>
|
||||
</div>
|
||||
|
||||
{/* SVG Canvas for network visualization */}
|
||||
@ -648,7 +648,7 @@ const ResourceExchangeVisualization: React.FC<ResourceExchangeVisualizationProps
|
||||
>
|
||||
{sectorConnections.length}
|
||||
</motion.text>
|
||||
<title>{sectorConnections.length} resource exchange connections</title>
|
||||
<title>{t('resourceExchange.connectionsCount', { count: sectorConnections.length })}</title>
|
||||
</g>
|
||||
)}
|
||||
</g>
|
||||
@ -754,7 +754,7 @@ const ResourceExchangeVisualization: React.FC<ResourceExchangeVisualizationProps
|
||||
|
||||
{/* Legend - moved below the animation */}
|
||||
<div className="flex gap-4 items-center bg-background/80 backdrop-blur-sm px-4 py-2 rounded-full border shadow-lg">
|
||||
<span className="text-xs text-muted-foreground font-medium">Resource Exchanges:</span>
|
||||
<span className="text-xs text-muted-foreground font-medium">{t('resourceExchange.resourceExchanges')}</span>
|
||||
{RESOURCE_TYPES.map((resource) => {
|
||||
const Icon = resource.icon;
|
||||
return (
|
||||
|
||||
@ -64,13 +64,13 @@ const HistoricalSidebarPreview = () => {
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h4 className="text-base font-semibold mb-3">Местоположение и статус</h4>
|
||||
<h4 className="text-base font-semibold mb-3">{t('mapSidebar.locationAndStatus')}</h4>
|
||||
<dl>
|
||||
<InfoLine label="Адрес" value={landmark.address} />
|
||||
<InfoLine label="Текущий статус" value={landmark.currentStatus} />
|
||||
{relatedOrg && (
|
||||
<>
|
||||
<dt className="text-xs text-muted-foreground">Связанная организация</dt>
|
||||
<dt className="text-xs text-muted-foreground">{t('mapSidebar.relatedOrganization')}</dt>
|
||||
<dd className="text-sm font-medium">
|
||||
{relatedOrg.Name}
|
||||
<Button
|
||||
@ -90,7 +90,7 @@ const HistoricalSidebarPreview = () => {
|
||||
<Separator />
|
||||
|
||||
<div>
|
||||
<h4 className="text-base font-semibold mb-3">Исторический контекст</h4>
|
||||
<h4 className="text-base font-semibold mb-3">{t('mapSidebar.historicalContext')}</h4>
|
||||
<dl>
|
||||
<InfoLine label="Основатель/Владелец" value={landmark.builder} />
|
||||
<InfoLine label="Архитектор" value={landmark.architect} />
|
||||
|
||||
@ -103,7 +103,7 @@ const MatchLine = React.memo<{
|
||||
<span className="text-muted-foreground">
|
||||
{t('matchesMap.distance', 'Distance')}:
|
||||
</span>
|
||||
<div className="font-medium">{match.DistanceKm.toFixed(1)} km</div>
|
||||
<div className="font-medium">{t('matchesMap.distanceValue', { distance: match.DistanceKm.toFixed(1) })}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ import { Package, Wrench } from 'lucide-react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { renderToStaticMarkup } from 'react-dom/server';
|
||||
import { Marker, Popup } from 'react-leaflet';
|
||||
import { useTranslation } from '@/hooks/useI18n.tsx';
|
||||
|
||||
interface ProductServiceMarkersProps {
|
||||
showProducts: boolean;
|
||||
@ -19,6 +20,7 @@ const ProductMarker = React.memo<{
|
||||
isSelected: boolean;
|
||||
onSelect: (match: DiscoveryMatch) => void;
|
||||
}>(({ match, isSelected, onSelect }) => {
|
||||
const { t } = useTranslation();
|
||||
const position: LatLngTuple = useMemo(() => {
|
||||
if (!match.product?.location) return [0, 0];
|
||||
return [match.product.location.latitude, match.product.location.longitude];
|
||||
@ -89,7 +91,7 @@ const ProductMarker = React.memo<{
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
<span className="font-medium">€{match.product.unit_price.toFixed(2)}</span>
|
||||
{match.product.moq > 0 && (
|
||||
<span className="text-muted-foreground">MOQ: {match.product.moq}</span>
|
||||
<span className="text-muted-foreground">{t('productService.moq', { value: match.product.moq })}</span>
|
||||
)}
|
||||
</div>
|
||||
{match.organization && (
|
||||
|
||||
@ -20,12 +20,12 @@ export const useOrganizationPage = (organization: Organization | undefined) => {
|
||||
const handleAnalyzeSymbiosis = useCallback(() => {
|
||||
if (!isAuthenticated) return;
|
||||
ai.handleAnalyzeSymbiosis();
|
||||
}, [isAuthenticated, ai.handleAnalyzeSymbiosis]);
|
||||
}, [isAuthenticated, ai]);
|
||||
|
||||
const handleFetchWebIntelligence = useCallback(() => {
|
||||
if (!isAuthenticated) return;
|
||||
ai.handleFetchWebIntelligence();
|
||||
}, [isAuthenticated, ai.handleFetchWebIntelligence]);
|
||||
}, [isAuthenticated, ai]);
|
||||
|
||||
return {
|
||||
symbiosisState: ai.symbiosisState,
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect } from 'react';
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
interface UseKeyboardOptions {
|
||||
enabled?: boolean;
|
||||
@ -13,15 +13,23 @@ export function useKeyboard(
|
||||
options: UseKeyboardOptions = {}
|
||||
) {
|
||||
const { enabled = true, target = document } = options;
|
||||
const handlerRef = useRef(handler);
|
||||
|
||||
const memoizedHandler = useCallback(handler, [handler]);
|
||||
// Update handler ref when handler changes
|
||||
useEffect(() => {
|
||||
handlerRef.current = handler;
|
||||
}, [handler]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
|
||||
target.addEventListener('keydown', memoizedHandler);
|
||||
return () => target.removeEventListener('keydown', memoizedHandler);
|
||||
}, [enabled, target, memoizedHandler]);
|
||||
const wrappedHandler = (event: KeyboardEvent) => {
|
||||
handlerRef.current(event);
|
||||
};
|
||||
|
||||
target.addEventListener('keydown', wrappedHandler);
|
||||
return () => target.removeEventListener('keydown', wrappedHandler);
|
||||
}, [enabled, target]);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -166,7 +166,7 @@ export const useOrganizationFilter = (
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
}, [organizations, debouncedSearchTerm, selectedSectors, sortOption, t]);
|
||||
}, [organizations, debouncedSearchTerm, selectedSectors, sortOption]);
|
||||
|
||||
return filteredAndSortedOrgs;
|
||||
};
|
||||
|
||||
@ -89,10 +89,7 @@ function parseApiResponse<T>(response: Response, schema: z.ZodSchema<T>): Promis
|
||||
/**
|
||||
* Schema-validated API GET request
|
||||
*/
|
||||
export async function apiGetValidated<T>(
|
||||
endpoint: string,
|
||||
schema: z.ZodSchema<T>
|
||||
): Promise<T> {
|
||||
export async function apiGetValidated<T>(endpoint: string, schema: z.ZodSchema<T>): Promise<T> {
|
||||
const data = await apiGet<unknown>(endpoint);
|
||||
const result = schema.safeParse(data);
|
||||
if (result.success) {
|
||||
|
||||
@ -3,7 +3,6 @@
|
||||
* Centralizes API endpoint configuration for consistent routing across all services
|
||||
*/
|
||||
|
||||
|
||||
// API Version Configuration
|
||||
export const API_CONFIG = {
|
||||
VERSION: 'v1',
|
||||
|
||||
@ -350,17 +350,20 @@ export class ErrorHandler {
|
||||
*/
|
||||
private sendToSentry(error: AppError): void {
|
||||
// Placeholder for Sentry integration
|
||||
if (typeof window !== 'undefined' && (window as any).Sentry) {
|
||||
(window as any).Sentry.captureException(error.originalError || new Error(error.message), {
|
||||
tags: {
|
||||
category: error.category,
|
||||
severity: error.severity,
|
||||
},
|
||||
extra: {
|
||||
errorId: error.id,
|
||||
context: error.context,
|
||||
},
|
||||
});
|
||||
if (typeof window !== 'undefined') {
|
||||
const sentry = (window as Window & { Sentry?: { captureException: (error: Error, context?: Record<string, unknown>) => void } }).Sentry;
|
||||
if (sentry) {
|
||||
sentry.captureException(error.originalError || new Error(error.message), {
|
||||
tags: {
|
||||
category: error.category,
|
||||
severity: error.severity,
|
||||
},
|
||||
extra: {
|
||||
errorId: error.id,
|
||||
context: error.context,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -369,11 +372,14 @@ export class ErrorHandler {
|
||||
*/
|
||||
private sendToAnalytics(error: AppError): void {
|
||||
// Placeholder for analytics integration
|
||||
if (typeof window !== 'undefined' && (window as any).gtag) {
|
||||
(window as any).gtag('event', 'exception', {
|
||||
description: error.message,
|
||||
fatal: error.severity === ErrorSeverity.CRITICAL,
|
||||
});
|
||||
if (typeof window !== 'undefined') {
|
||||
const gtag = (window as Window & { gtag?: (event: string, name: string, params: Record<string, unknown>) => void }).gtag;
|
||||
if (gtag) {
|
||||
gtag('event', 'exception', {
|
||||
description: error.message,
|
||||
fatal: error.severity === ErrorSeverity.CRITICAL,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -401,7 +401,7 @@ export const httpClient = {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
let errorData: any;
|
||||
let errorData: { error?: string } | string;
|
||||
try {
|
||||
errorData = await response.json();
|
||||
} catch {
|
||||
|
||||
@ -141,7 +141,7 @@ export class InputSanitizer {
|
||||
export class SecurityMonitor {
|
||||
private static violations: SecurityViolation[] = [];
|
||||
|
||||
static logViolation(type: string, details: Record<string, any>): void {
|
||||
static logViolation(type: string, details: Record<string, unknown>): void {
|
||||
const violation: SecurityViolation = {
|
||||
type,
|
||||
details,
|
||||
@ -182,7 +182,7 @@ export class SecurityMonitor {
|
||||
|
||||
interface SecurityViolation {
|
||||
type: string;
|
||||
details: Record<string, any>;
|
||||
details: Record<string, unknown>;
|
||||
timestamp: string;
|
||||
userAgent: string;
|
||||
url: string;
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
* Provides enhanced type safety with runtime validation and better generic constraints
|
||||
*/
|
||||
|
||||
import { z, ZodSchema, ZodTypeAny } from 'zod';
|
||||
import { z, ZodSchema } from 'zod';
|
||||
|
||||
// Type-safe API response wrapper
|
||||
export interface ApiResponse<T> {
|
||||
|
||||
@ -53,6 +53,7 @@ export const en = {
|
||||
organization: {
|
||||
logo: 'Logo',
|
||||
galleryImages: 'Gallery Images',
|
||||
galleryImagesHint: 'Upload additional images to showcase your organization (optional)',
|
||||
},
|
||||
hero: {
|
||||
kicker: 'Open Beta',
|
||||
@ -202,6 +203,28 @@ export const en = {
|
||||
activityFeed: {
|
||||
recentActivity: 'Recent Activity',
|
||||
},
|
||||
dataTable: {
|
||||
selected: '{{count}} selected',
|
||||
clear: 'Clear',
|
||||
selectAll: 'Select all ({{count}} items)',
|
||||
itemsPerPage: 'Items per page:',
|
||||
},
|
||||
filterBar: {
|
||||
filters: 'Filters',
|
||||
clearAll: 'Clear All',
|
||||
cancel: 'Cancel',
|
||||
applyFilters: 'Apply Filters',
|
||||
},
|
||||
adminPanel: {
|
||||
title: 'Admin Panel',
|
||||
maintenanceModeActive: 'Maintenance mode is active',
|
||||
},
|
||||
permissionGate: {
|
||||
noPermission: "You don't have permission to view this content.",
|
||||
},
|
||||
form: {
|
||||
removeItem: 'Remove item',
|
||||
},
|
||||
time: {
|
||||
justNow: 'just now',
|
||||
minutesAgo_one: '{{count}} minute ago',
|
||||
@ -343,6 +366,9 @@ export const en = {
|
||||
fetchWebIntelButton: 'Get Summary',
|
||||
webIntelSources: 'Sources:',
|
||||
viewOrganization: 'Go to organization',
|
||||
locationAndStatus: 'Местоположение и статус',
|
||||
relatedOrganization: 'Связанная организация',
|
||||
historicalContext: 'Исторический контекст',
|
||||
},
|
||||
},
|
||||
chatbot: {
|
||||
@ -802,6 +828,15 @@ export const en = {
|
||||
floorArea: 'Floor Area',
|
||||
location: 'Location',
|
||||
coordinates: 'Coordinates',
|
||||
view: 'View',
|
||||
filters: 'Filters',
|
||||
eventsCount: '({{count}} events)',
|
||||
toggleFilters: 'Toggle filters',
|
||||
category: 'Category',
|
||||
all: 'All',
|
||||
minimumImportance: 'Minimum Importance: {{value}}',
|
||||
resetFilters: 'Reset Filters',
|
||||
noEventsMatch: 'No events match your filters. Try adjusting your selection.',
|
||||
sources: 'Sources & References',
|
||||
},
|
||||
similarOrganizations: {
|
||||
@ -1094,6 +1129,7 @@ export const en = {
|
||||
noMatches: 'No matches found',
|
||||
adjustFilters: 'Try adjusting your filters',
|
||||
hideFilters: 'Hide',
|
||||
distanceValue: '{{distance}} km',
|
||||
showFilters: 'Filters',
|
||||
matchId: 'Match ID',
|
||||
compatibility: 'Compatibility',
|
||||
@ -1153,6 +1189,7 @@ export const en = {
|
||||
resourceFlows: 'Resource Flows',
|
||||
matches: 'Matches',
|
||||
co2Saved: 'CO₂ Saved',
|
||||
co2Unit: 't',
|
||||
perYear: 'per year',
|
||||
economicValue: 'Economic Value',
|
||||
created: 'created annually',
|
||||
@ -1493,4 +1530,13 @@ export const en = {
|
||||
lastYear: 'Last Year',
|
||||
allTime: 'All Time',
|
||||
},
|
||||
resourceExchange: {
|
||||
networkTitle: 'Resource Exchange Network',
|
||||
networkDescription: 'Businesses connect to exchange resources',
|
||||
connectionsCount: '{{count}} resource exchange connections',
|
||||
resourceExchanges: 'Resource Exchanges:',
|
||||
},
|
||||
productService: {
|
||||
moq: 'MOQ: {{value}}',
|
||||
},
|
||||
};
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import { useTranslation } from '@/hooks/useI18n';
|
||||
|
||||
const CommunityEventsPage = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Community Events</h1>
|
||||
<h1 className="text-3xl font-bold mb-6">{t('community.events.title')}</h1>
|
||||
<div className="bg-muted p-8 rounded-lg text-center">
|
||||
<p className="text-lg text-muted-foreground">
|
||||
Community events calendar coming soon! Find sustainability workshops, networking events,
|
||||
and environmental awareness campaigns.
|
||||
{t('community.events.description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import { useTranslation } from '@/hooks/useI18n';
|
||||
|
||||
const CommunityImpactPage = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Community Impact Dashboard</h1>
|
||||
<h1 className="text-3xl font-bold mb-6">{t('community.impact.title')}</h1>
|
||||
<div className="bg-muted p-8 rounded-lg text-center">
|
||||
<p className="text-lg text-muted-foreground">
|
||||
Community impact dashboard coming soon! Track environmental and economic benefits of
|
||||
industrial symbiosis.
|
||||
{t('community.impact.description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
import { useTranslation } from '@/hooks/useI18n';
|
||||
|
||||
const CommunityNewsPage = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Community News</h1>
|
||||
<h1 className="text-3xl font-bold mb-6">{t('community.news.title')}</h1>
|
||||
<div className="bg-muted p-8 rounded-lg text-center">
|
||||
<p className="text-lg text-muted-foreground">
|
||||
Community news feature coming soon! Stay tuned for local sustainability news and updates.
|
||||
{t('community.news.description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -1,11 +1,13 @@
|
||||
import { useTranslation } from '@/hooks/useI18n';
|
||||
|
||||
const CommunityStoriesPage = () => {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<div className="container mx-auto px-4 py-8">
|
||||
<h1 className="text-3xl font-bold mb-6">Community Stories</h1>
|
||||
<h1 className="text-3xl font-bold mb-6">{t('community.stories.title')}</h1>
|
||||
<div className="bg-muted p-8 rounded-lg text-center">
|
||||
<p className="text-lg text-muted-foreground">
|
||||
Success stories and case studies coming soon! Read about successful resource connections
|
||||
and sustainability achievements.
|
||||
{t('community.stories.description')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -118,6 +118,7 @@ const DashboardPage = () => {
|
||||
proposalsData,
|
||||
userOrganizations,
|
||||
pendingProposals,
|
||||
proposals.length,
|
||||
]);
|
||||
|
||||
// Filter activities based on selected filter
|
||||
@ -144,7 +145,6 @@ const DashboardPage = () => {
|
||||
// Number formatting handled by shared utilities inside components
|
||||
|
||||
const isLoading = isLoadingDashboard || isLoadingPlatform || isLoadingMatching || isLoadingImpact;
|
||||
const hasError = dashboardError || platformError || matchingError || impactError;
|
||||
|
||||
return (
|
||||
<MainLayout onNavigate={handleFooterNavigate} className="bg-muted/30">
|
||||
|
||||
@ -16,7 +16,7 @@ import { Heading, Text } from '@/components/ui/Typography';
|
||||
import { useTranslation } from '@/hooks/useI18n';
|
||||
import { useNavigation } from '@/hooks/useNavigation';
|
||||
import { universalSearch, type DiscoveryMatch, type SearchQuery } from '@/services/discovery-api';
|
||||
import { MapPin, Package, Users, Wrench } from 'lucide-react';
|
||||
import { Package, Users, Wrench } from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
|
||||
@ -92,6 +92,7 @@ export default function DiscoveryPage() {
|
||||
// This is not a "search" so hasActiveSearch will be false
|
||||
handleSearch({ limit: 20 });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const renderMatchCard = (match: DiscoveryMatch) => {
|
||||
|
||||
@ -12,11 +12,10 @@ import {
|
||||
Ruler,
|
||||
User,
|
||||
} from 'lucide-react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { MainLayout } from '@/components/layout/MainLayout.tsx';
|
||||
import Badge from '@/components/ui/Badge.tsx';
|
||||
import Spinner from '@/components/ui/Spinner';
|
||||
import { Heading, Text } from '@/components/ui/Typography.tsx';
|
||||
import { LoadingState } from '@/components/ui/LoadingState.tsx';
|
||||
import { useHeritageSites } from '@/hooks/api/useHeritageSitesAPI';
|
||||
@ -28,13 +27,12 @@ const HeritageBuildingPage = () => {
|
||||
const navigate = useNavigate();
|
||||
const { data: heritageSites, isLoading } = useHeritageSites();
|
||||
const { t } = useTranslation();
|
||||
const [building, setBuilding] = useState<BackendHeritageSite | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
const building = useMemo(() => {
|
||||
if (heritageSites && id) {
|
||||
const foundBuilding = heritageSites.find((site) => String(site.ID) === id);
|
||||
setBuilding(foundBuilding || null);
|
||||
return heritageSites.find((site) => String(site.ID) === id) || null;
|
||||
}
|
||||
return null;
|
||||
}, [heritageSites, id]);
|
||||
|
||||
if (isLoading) {
|
||||
@ -338,7 +336,7 @@ const HeritageBuildingPage = () => {
|
||||
{t('heritage.floorArea') || 'Floor Area'}
|
||||
</Text>
|
||||
<Text variant="muted" className="text-sm">
|
||||
{building.FloorAreaM2} m²
|
||||
{t('heritage.floorAreaValue', { value: building.FloorAreaM2 })}
|
||||
</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -11,6 +11,57 @@ import { Award, Briefcase, DollarSign, Globe, Target, TrendingUp, Zap } from 'lu
|
||||
import { useMemo } from 'react';
|
||||
import { formatCurrency, formatNumber } from '../lib/fin';
|
||||
|
||||
// Types for impact metrics data
|
||||
interface TopImpactingMatch {
|
||||
id?: string;
|
||||
description?: string;
|
||||
resource_type?: string;
|
||||
co2_impact?: number;
|
||||
economic_impact?: number;
|
||||
}
|
||||
|
||||
interface YearlyProjection {
|
||||
co2_projected?: number;
|
||||
economic_projected?: number;
|
||||
}
|
||||
|
||||
// Simple visualization component for impact breakdown
|
||||
const ImpactBreakdownChart = ({
|
||||
data,
|
||||
title,
|
||||
color = 'hsl(var(--primary))',
|
||||
}: {
|
||||
data: Record<string, number>;
|
||||
title: string;
|
||||
color?: string;
|
||||
}) => {
|
||||
const entries = Object.entries(data);
|
||||
const maxValue = Math.max(...entries.map(([, value]) => value));
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">{title}</h4>
|
||||
<div className="space-y-2">
|
||||
{entries.map(([key, value]) => (
|
||||
<div key={key} className="flex items-center gap-3">
|
||||
<span className="text-sm min-w-20 truncate capitalize">{key.replace('_', ' ')}</span>
|
||||
<div className="flex-1 bg-muted rounded-full h-3">
|
||||
<div
|
||||
className="h-3 rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${(value / maxValue) * 100}%`,
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium min-w-16 text-right">{formatNumber(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const ImpactMetrics = () => {
|
||||
const { t } = useTranslation();
|
||||
const { handleBackNavigation, handleFooterNavigate } = useNavigation();
|
||||
@ -54,45 +105,6 @@ const ImpactMetrics = () => {
|
||||
};
|
||||
}, [impactMetrics, platformStats]);
|
||||
|
||||
// using central fin utilities
|
||||
|
||||
// Simple visualization component for impact breakdown
|
||||
const ImpactBreakdownChart = ({
|
||||
data,
|
||||
title,
|
||||
color = 'hsl(var(--primary))',
|
||||
}: {
|
||||
data: Record<string, number>;
|
||||
title: string;
|
||||
color?: string;
|
||||
}) => {
|
||||
const entries = Object.entries(data);
|
||||
const maxValue = Math.max(...entries.map(([_, value]) => value));
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">{title}</h4>
|
||||
<div className="space-y-2">
|
||||
{entries.map(([key, value]) => (
|
||||
<div key={key} className="flex items-center gap-3">
|
||||
<span className="text-sm min-w-20 truncate capitalize">{key.replace('_', ' ')}</span>
|
||||
<div className="flex-1 bg-muted rounded-full h-3">
|
||||
<div
|
||||
className="h-3 rounded-full transition-all duration-500"
|
||||
style={{
|
||||
width: `${(value / maxValue) * 100}%`,
|
||||
backgroundColor: color,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-sm font-medium min-w-16 text-right">{formatNumber(value)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// Impact category icons
|
||||
const getCategoryIcon = (category: string) => {
|
||||
switch (category.toLowerCase()) {
|
||||
@ -315,7 +327,7 @@ const ImpactMetrics = () => {
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-4">
|
||||
{impact.topImpactingMatches.slice(0, 5).map((match: any, index: number) => (
|
||||
{impact.topImpactingMatches.slice(0, 5).map((match: TopImpactingMatch, index: number) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-4 border rounded-lg"
|
||||
@ -325,13 +337,13 @@ const ImpactMetrics = () => {
|
||||
{index + 1}
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium">{match.description || `Match ${match.id}`}</p>
|
||||
<p className="font-medium">{match.description || `Match ${match.id || index}`}</p>
|
||||
<p className="text-sm text-muted-foreground">{match.resource_type}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-semibold text-green-600">
|
||||
{formatNumber(match.co2_impact || 0)} t CO₂
|
||||
{t('impactMetrics.co2Tonnes', { value: formatNumber(match.co2_impact || 0) })}
|
||||
</p>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{formatCurrency(match.economic_impact || 0)}
|
||||
@ -382,7 +394,7 @@ const ImpactMetrics = () => {
|
||||
{Object.keys(impact.yearlyProjections).length > 0 ? (
|
||||
<div className="space-y-3">
|
||||
{Object.entries(impact.yearlyProjections).map(
|
||||
([year, projection]: [string, any]) => (
|
||||
([year, projection]: [string, YearlyProjection]) => (
|
||||
<div
|
||||
key={year}
|
||||
className="flex justify-between items-center p-3 bg-muted/50 rounded"
|
||||
@ -390,7 +402,7 @@ const ImpactMetrics = () => {
|
||||
<span className="font-medium">{year}</span>
|
||||
<div className="text-right">
|
||||
<div className="font-semibold text-green-600">
|
||||
{formatNumber(projection.co2_projected || 0)} t CO₂
|
||||
{t('impactMetrics.co2Tonnes', { value: formatNumber(projection.co2_projected || 0) })}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{formatCurrency(projection.economic_projected || 0)}
|
||||
@ -455,8 +467,7 @@ const ImpactMetrics = () => {
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-sm text-purple-700">
|
||||
{t('impactMetrics.activeConnections')}
|
||||
.replace('{{ count }}', impact.activeMatchesCount.toString())
|
||||
{t('impactMetrics.activeConnections', { count: impact.activeMatchesCount })}
|
||||
</p>
|
||||
</div>
|
||||
</Stack>
|
||||
|
||||
@ -105,7 +105,7 @@ const LoginPage = () => {
|
||||
{isDevelopment && (
|
||||
<div className="mb-6 p-4 bg-muted rounded-lg border border-primary/20">
|
||||
<p className="text-sm font-medium mb-3 text-foreground">
|
||||
Quick Login (Development)
|
||||
{t('login.quickLogin')}
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
{TEST_USERS.map((testUser) => (
|
||||
@ -129,7 +129,7 @@ const LoginPage = () => {
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 pt-3 border-t border-border">
|
||||
<p className="text-xs text-muted-foreground mb-2">Test Credentials:</p>
|
||||
<p className="text-xs text-muted-foreground mb-2">{t('login.testCredentials')}</p>
|
||||
<div className="space-y-1 text-xs font-mono">
|
||||
{TEST_USERS.map((user) => (
|
||||
<div key={user.email} className="flex items-center gap-2">
|
||||
|
||||
@ -24,13 +24,12 @@ import {
|
||||
MapPin,
|
||||
TrendingUp,
|
||||
} from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { formatCurrency } from '../lib/fin';
|
||||
|
||||
const MatchDetailPage = () => {
|
||||
const { id: matchId } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { t } = useTranslation();
|
||||
const { handleBackNavigation, handleFooterNavigate } = useNavigation();
|
||||
const { user } = useAuth();
|
||||
@ -42,6 +41,19 @@ const MatchDetailPage = () => {
|
||||
const [newStatus, setNewStatus] = useState('');
|
||||
const [statusNotes, setStatusNotes] = useState('');
|
||||
|
||||
const getHistoryTitle = useCallback((action: string, value?: string) => {
|
||||
switch (action) {
|
||||
case 'status_change':
|
||||
return t('matchDetail.statusChanged');
|
||||
case 'comment':
|
||||
return t('matchDetail.commentAdded');
|
||||
case 'update':
|
||||
return t('matchDetail.matchUpdated');
|
||||
default:
|
||||
return value || action;
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
// Transform match history to timeline format
|
||||
const timelineEntries: TimelineEntry[] = useMemo(() => {
|
||||
if (!match?.History) return [];
|
||||
@ -56,20 +68,7 @@ const MatchDetailPage = () => {
|
||||
oldValue: entry.old_value,
|
||||
newValue: entry.new_value,
|
||||
}));
|
||||
}, [match?.History]);
|
||||
|
||||
const getHistoryTitle = (action: string, value?: string) => {
|
||||
switch (action) {
|
||||
case 'status_change':
|
||||
return t('matchDetail.statusChanged');
|
||||
case 'comment':
|
||||
return t('matchDetail.commentAdded');
|
||||
case 'update':
|
||||
return t('matchDetail.matchUpdated');
|
||||
default:
|
||||
return value || action;
|
||||
}
|
||||
};
|
||||
}, [match?.History, getHistoryTitle]);
|
||||
|
||||
const handleStatusUpdate = async () => {
|
||||
if (!match || !newStatus || !user) return;
|
||||
@ -229,7 +228,7 @@ const MatchDetailPage = () => {
|
||||
{t('matchDetail.paybackPeriod')}
|
||||
</span>
|
||||
<span className="text-sm font-semibold">
|
||||
{match.EconomicImpact.payback_years.toFixed(1)} years
|
||||
{t('matchDetail.paybackYears', { years: match.EconomicImpact.payback_years.toFixed(1) })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@ -26,7 +26,7 @@ import {
|
||||
MessageSquare,
|
||||
TrendingUp,
|
||||
} from 'lucide-react';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { formatCurrency } from '../lib/fin';
|
||||
|
||||
@ -46,6 +46,19 @@ const MatchNegotiationPage = () => {
|
||||
const [showMessageModal, setShowMessageModal] = useState(false);
|
||||
const [messageText, setMessageText] = useState('');
|
||||
|
||||
const getHistoryTitle = useCallback((action: string, value?: string) => {
|
||||
switch (action) {
|
||||
case 'status_changed':
|
||||
return t('matchNegotiation.statusChanged');
|
||||
case 'comment':
|
||||
return t('matchNegotiation.commentAdded');
|
||||
case 'update':
|
||||
return t('matchNegotiation.matchUpdated');
|
||||
default:
|
||||
return value || action;
|
||||
}
|
||||
}, [t]);
|
||||
|
||||
// Transform match history to timeline format
|
||||
const timelineEntries: TimelineEntry[] = useMemo(() => {
|
||||
if (!match?.History) return [];
|
||||
@ -60,20 +73,7 @@ const MatchNegotiationPage = () => {
|
||||
oldValue: entry.old_value,
|
||||
newValue: entry.new_value,
|
||||
}));
|
||||
}, [match?.History]);
|
||||
|
||||
const getHistoryTitle = (action: string, value?: string) => {
|
||||
switch (action) {
|
||||
case 'status_changed':
|
||||
return t('matchNegotiation.statusChanged');
|
||||
case 'comment':
|
||||
return t('matchNegotiation.commentAdded');
|
||||
case 'update':
|
||||
return t('matchNegotiation.matchUpdated');
|
||||
default:
|
||||
return value || action;
|
||||
}
|
||||
};
|
||||
}, [match?.History, getHistoryTitle]);
|
||||
|
||||
// Get allowed next statuses based on current status
|
||||
const allowedNextStatuses = useMemo(() => {
|
||||
@ -325,7 +325,7 @@ const MatchNegotiationPage = () => {
|
||||
{t('matchNegotiation.co2Avoided')}
|
||||
</span>
|
||||
<span className="text-sm font-semibold text-success">
|
||||
{match.EconomicImpact.co2_avoided_tonnes.toFixed(1)} t/year
|
||||
{t('matchNegotiation.co2TonnesPerYear', { value: match.EconomicImpact.co2_avoided_tonnes.toFixed(1) })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
@ -335,7 +335,7 @@ const MatchNegotiationPage = () => {
|
||||
{t('matchDetail.paybackPeriod')}
|
||||
</span>
|
||||
<span className="text-sm font-semibold">
|
||||
{match.EconomicImpact.payback_years.toFixed(1)} years
|
||||
{t('matchDetail.paybackYears', { years: match.EconomicImpact.payback_years.toFixed(1) })}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user