mirror of
https://github.com/SamyRai/turash.git
synced 2025-12-26 23:01:33 +00:00
386 lines
15 KiB
TypeScript
386 lines
15 KiB
TypeScript
import React, { useState, useMemo } from 'react';
|
|
import { useParams, useNavigate } from 'react-router-dom';
|
|
import { MainLayout } from '@/components/layout/MainLayout.tsx';
|
|
import PageHeader from '@/components/layout/PageHeader.tsx';
|
|
import MatchCard from '@/components/matches/MatchCard.tsx';
|
|
import Timeline from '@/components/ui/Timeline.tsx';
|
|
import MetricItem from '@/components/ui/MetricItem.tsx';
|
|
import { Container, Stack, Grid, Flex } from '@/components/ui/layout';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card.tsx';
|
|
import Button from '@/components/ui/Button.tsx';
|
|
import Badge from '@/components/ui/Badge.tsx';
|
|
import Select from '@/components/ui/Select.tsx';
|
|
import Textarea from '@/components/ui/Textarea.tsx';
|
|
import { Label, FormField } from '@/components/ui';
|
|
import { useTranslation } from '@/hooks/useI18n.tsx';
|
|
import { useMatch, useUpdateMatchStatus } from '@/hooks/api/useMatchingAPI.ts';
|
|
import { useNavigation } from '@/hooks/useNavigation.tsx';
|
|
import { useAuth } from '@/contexts/AuthContext.tsx';
|
|
import { AlertTriangle, ArrowLeft, CheckCircle, Clock, DollarSign, MapPin, MessageSquare, TrendingUp } from 'lucide-react';
|
|
import type { TimelineEntry } from '@/components/ui/Timeline.tsx';
|
|
|
|
const MatchDetailPage = () => {
|
|
const { id: matchId } = useParams<{ id: string }>();
|
|
const navigate = useNavigate();
|
|
const { t } = useTranslation();
|
|
const { handleBackNavigation, handleFooterNavigate } = useNavigation();
|
|
const { user } = useAuth();
|
|
|
|
const { data: match, isLoading, error } = useMatch(matchId);
|
|
const updateStatusMutation = useUpdateMatchStatus();
|
|
|
|
const [showStatusUpdate, setShowStatusUpdate] = useState(false);
|
|
const [newStatus, setNewStatus] = useState('');
|
|
const [statusNotes, setStatusNotes] = useState('');
|
|
|
|
// Transform match history to timeline format
|
|
const timelineEntries: TimelineEntry[] = useMemo(() => {
|
|
if (!match?.History) return [];
|
|
|
|
return match.History.map((entry, index) => ({
|
|
id: `history-${index}`,
|
|
timestamp: entry.timestamp,
|
|
title: getHistoryTitle(entry.action, entry.new_value || entry.old_value),
|
|
description: entry.notes,
|
|
actor: entry.actor,
|
|
action: entry.action,
|
|
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;
|
|
}
|
|
};
|
|
|
|
const handleStatusUpdate = async () => {
|
|
if (!match || !newStatus || !user) return;
|
|
|
|
try {
|
|
await updateStatusMutation.mutateAsync({
|
|
matchId: match.ID,
|
|
status: newStatus,
|
|
actor: user.email || user.name || 'Unknown',
|
|
notes: statusNotes,
|
|
});
|
|
|
|
setShowStatusUpdate(false);
|
|
setNewStatus('');
|
|
setStatusNotes('');
|
|
} catch (error) {
|
|
console.error('Failed to update match status:', error);
|
|
}
|
|
};
|
|
|
|
const formatCurrency = (value: number) => {
|
|
return new Intl.NumberFormat('en-US', {
|
|
style: 'currency',
|
|
currency: 'EUR',
|
|
minimumFractionDigits: 0,
|
|
maximumFractionDigits: 0,
|
|
}).format(value);
|
|
};
|
|
|
|
const formatScore = (score: number) => {
|
|
return `${(score * 100).toFixed(1)}%`;
|
|
};
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<MainLayout onNavigate={handleFooterNavigate} className="bg-muted/30">
|
|
<Container size="2xl" className="py-8 sm:py-12">
|
|
<div className="animate-pulse">
|
|
<div className="h-8 bg-muted rounded w-1/3 mb-8"></div>
|
|
<div className="h-64 bg-muted rounded mb-8"></div>
|
|
<div className="h-48 bg-muted rounded"></div>
|
|
</div>
|
|
</Container>
|
|
</MainLayout>
|
|
);
|
|
}
|
|
|
|
if (error || !match) {
|
|
return (
|
|
<MainLayout onNavigate={handleFooterNavigate} className="bg-muted/30">
|
|
<Container size="2xl" className="py-8 sm:py-12">
|
|
<PageHeader
|
|
title={t('matchDetail.errorTitle')}
|
|
subtitle={t('matchDetail.errorSubtitle')}
|
|
onBack={handleBackNavigation}
|
|
/>
|
|
<Card>
|
|
<CardContent className="py-12">
|
|
<div className="text-center">
|
|
<p className="text-destructive mb-4">
|
|
{error?.message || t('matchDetail.notFound')}
|
|
</p>
|
|
<Button onClick={handleBackNavigation}>
|
|
<ArrowLeft className="h-4 mr-2 text-current w-4" />
|
|
{t('common.back')}
|
|
</Button>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</Container>
|
|
</MainLayout>
|
|
);
|
|
}
|
|
|
|
const statusOptions = [
|
|
{ value: 'suggested', label: t('matchStatus.suggested') },
|
|
{ value: 'negotiating', label: t('matchStatus.negotiating') },
|
|
{ value: 'reserved', label: t('matchStatus.reserved') },
|
|
{ value: 'contracted', label: t('matchStatus.contracted') },
|
|
{ value: 'live', label: t('matchStatus.live') },
|
|
{ value: 'failed', label: t('matchStatus.failed') },
|
|
{ value: 'cancelled', label: t('matchStatus.cancelled') },
|
|
];
|
|
|
|
return (
|
|
<MainLayout onNavigate={handleFooterNavigate} className="bg-muted/30">
|
|
<Container size="2xl" className="py-8 sm:py-12">
|
|
<PageHeader
|
|
title={t('matchDetail.title')}
|
|
subtitle={`${t('matchDetail.matchId')} ${match.ID}`}
|
|
onBack={handleBackNavigation}
|
|
/>
|
|
|
|
<Stack spacing="2xl">
|
|
{/* Match Overview Card */}
|
|
<MatchCard match={match} />
|
|
|
|
{/* Key Metrics */}
|
|
<Grid cols={{ md: 2, lg: 4 }} gap="md">
|
|
<MetricItem
|
|
icon={<TrendingUp className="h-4 h-5 text-current text-success w-4 w-5" />}
|
|
label={t('matchDetail.compatibilityScore')}
|
|
value={formatScore(match.CompatibilityScore)}
|
|
/>
|
|
<MetricItem
|
|
icon={<DollarSign className="h-4 h-5 text-current text-primary w-4 w-5" />}
|
|
label={t('matchDetail.economicValue')}
|
|
value={formatCurrency(match.EconomicValue)}
|
|
/>
|
|
<MetricItem
|
|
icon={<MapPin className="h-4 h-5 text-current text-warning w-4 w-5" />}
|
|
label={t('matchDetail.distance')}
|
|
value={`${match.DistanceKm.toFixed(1)} km`}
|
|
/>
|
|
<MetricItem
|
|
icon={<CheckCircle className="h-4 h-5 text-current text-muted-foreground w-4 w-5" />}
|
|
label={t('matchDetail.priority')}
|
|
value={match.Priority.toString()}
|
|
/>
|
|
</Grid>
|
|
|
|
{/* Economic Impact & Risk Assessment */}
|
|
<Grid cols={{ md: 2 }} gap="lg">
|
|
{/* Economic Impact */}
|
|
{match.EconomicImpact && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<DollarSign className="h-4 h-5 text-current w-4 w-5" />
|
|
{t('matchDetail.economicImpact')}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Stack spacing="sm">
|
|
{match.EconomicImpact.annual_savings && (
|
|
<div className="flex justify-between items-center py-2 border-b">
|
|
<span className="text-sm font-medium">
|
|
{t('matchDetail.annualSavings')}
|
|
</span>
|
|
<span className="text-sm font-semibold text-success">
|
|
{formatCurrency(match.EconomicImpact.annual_savings)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{match.EconomicImpact.npv && (
|
|
<div className="flex justify-between items-center py-2 border-b">
|
|
<span className="text-sm font-medium">
|
|
{t('matchDetail.npv')}
|
|
</span>
|
|
<span className="text-sm font-semibold">
|
|
{formatCurrency(match.EconomicImpact.npv)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{match.EconomicImpact.irr && (
|
|
<div className="flex justify-between items-center py-2 border-b">
|
|
<span className="text-sm font-medium">
|
|
{t('matchDetail.irr')}
|
|
</span>
|
|
<span className="text-sm font-semibold">
|
|
{formatScore(match.EconomicImpact.irr)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{match.EconomicImpact.payback_years && (
|
|
<div className="flex justify-between items-center py-2 border-b">
|
|
<span className="text-sm font-medium">
|
|
{t('matchDetail.paybackPeriod')}
|
|
</span>
|
|
<span className="text-sm font-semibold">
|
|
{match.EconomicImpact.payback_years.toFixed(1)} years
|
|
</span>
|
|
</div>
|
|
)}
|
|
</Stack>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
|
|
{/* Risk Assessment */}
|
|
{match.RiskAssessment && (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<AlertTriangle className="h-4 h-5 text-current w-4 w-5" />
|
|
{t('matchDetail.riskAssessment')}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Stack spacing="sm">
|
|
{match.RiskAssessment.technical_risk !== undefined && (
|
|
<div className="flex justify-between items-center py-2 border-b">
|
|
<span className="text-sm font-medium">
|
|
{t('matchDetail.technicalRisk')}
|
|
</span>
|
|
<Badge
|
|
variant={
|
|
match.RiskAssessment.technical_risk > 0.7 ? 'destructive' :
|
|
match.RiskAssessment.technical_risk > 0.4 ? 'secondary' : 'default'
|
|
}
|
|
>
|
|
{formatScore(match.RiskAssessment.technical_risk)}
|
|
</Badge>
|
|
</div>
|
|
)}
|
|
{match.RiskAssessment.regulatory_risk !== undefined && (
|
|
<div className="flex justify-between items-center py-2 border-b">
|
|
<span className="text-sm font-medium">
|
|
{t('matchDetail.regulatoryRisk')}
|
|
</span>
|
|
<Badge
|
|
variant={
|
|
match.RiskAssessment.regulatory_risk > 0.7 ? 'destructive' :
|
|
match.RiskAssessment.regulatory_risk > 0.4 ? 'secondary' : 'default'
|
|
}
|
|
>
|
|
{formatScore(match.RiskAssessment.regulatory_risk)}
|
|
</Badge>
|
|
</div>
|
|
)}
|
|
{match.RiskAssessment.market_risk !== undefined && (
|
|
<div className="flex justify-between items-center py-2">
|
|
<span className="text-sm font-medium">
|
|
{t('matchDetail.marketRisk')}
|
|
</span>
|
|
<Badge
|
|
variant={
|
|
match.RiskAssessment.market_risk > 0.7 ? 'destructive' :
|
|
match.RiskAssessment.market_risk > 0.4 ? 'secondary' : 'default'
|
|
}
|
|
>
|
|
{formatScore(match.RiskAssessment.market_risk)}
|
|
</Badge>
|
|
</div>
|
|
)}
|
|
</Stack>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</Grid>
|
|
|
|
{/* Status Update Section */}
|
|
<Card>
|
|
<CardHeader>
|
|
<Flex align="center" justify="between">
|
|
<CardTitle className="flex items-center gap-2">
|
|
<CheckCircle className="h-4 h-5 text-current w-4 w-5" />
|
|
{t('matchDetail.updateStatus')}
|
|
</CardTitle>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => setShowStatusUpdate(!showStatusUpdate)}
|
|
>
|
|
{showStatusUpdate ? t('common.cancel') : t('matchDetail.changeStatus')}
|
|
</Button>
|
|
</Flex>
|
|
</CardHeader>
|
|
{showStatusUpdate && (
|
|
<CardContent>
|
|
<Stack spacing="md">
|
|
<FormField label={t('matchDetail.newStatus')}>
|
|
<Select
|
|
value={newStatus}
|
|
onValueChange={setNewStatus}
|
|
options={statusOptions}
|
|
placeholder={t('matchDetail.selectStatus')}
|
|
/>
|
|
</FormField>
|
|
<FormField label={`${t('matchDetail.notes')} (${t('common.optional')})`}>
|
|
<Textarea
|
|
value={statusNotes}
|
|
onChange={(e) => setStatusNotes(e.target.value)}
|
|
placeholder={t('matchDetail.notesPlaceholder')}
|
|
rows={3}
|
|
/>
|
|
</FormField>
|
|
<Flex gap="sm">
|
|
<Button
|
|
onClick={handleStatusUpdate}
|
|
disabled={!newStatus || updateStatusMutation.isPending}
|
|
>
|
|
{updateStatusMutation.isPending
|
|
? t('common.updating')
|
|
: t('matchDetail.updateStatus')
|
|
}
|
|
</Button>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => {
|
|
setShowStatusUpdate(false);
|
|
setNewStatus('');
|
|
setStatusNotes('');
|
|
}}
|
|
>
|
|
{t('common.cancel')}
|
|
</Button>
|
|
</Flex>
|
|
</Stack>
|
|
</CardContent>
|
|
)}
|
|
</Card>
|
|
|
|
{/* Match History Timeline */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="flex items-center gap-2">
|
|
<Clock className="h-4 h-5 text-current w-4 w-5" />
|
|
{t('matchDetail.matchHistory')}
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Timeline entries={timelineEntries} />
|
|
</CardContent>
|
|
</Card>
|
|
</Stack>
|
|
</Container>
|
|
</MainLayout>
|
|
);
|
|
};
|
|
|
|
export default MatchDetailPage;
|