turash/bugulma/frontend/pages/MatchNegotiationPage.tsx
Damir Mukimov 6347f42e20
Consolidate repositories: Remove nested frontend .git and merge into main repository
- Remove nested git repository from bugulma/frontend/.git
- Add all frontend files to main repository tracking
- Convert from separate frontend/backend repos to unified monorepo
- Preserve all frontend code and development history as tracked files
- Eliminate nested repository complexity for simpler development workflow

This creates a proper monorepo structure with frontend and backend
coexisting in the same repository for easier development and deployment.
2025-11-25 06:02:57 +01:00

588 lines
22 KiB
TypeScript

import React, { useMemo, useState } from 'react';
import { useNavigate, useParams } from 'react-router-dom';
import { AlertTriangle, ArrowLeft, ArrowRight, CheckCircle, Clock, DollarSign, FileText, MapPin, MessageSquare, TrendingUp } from 'lucide-react';
import { MainLayout } from '@/components/layout/MainLayout.tsx';
import PageHeader from '@/components/layout/PageHeader.tsx';
import MatchCard from '@/components/matches/MatchCard.tsx';
import Badge from '@/components/ui/Badge.tsx';
import Button from '@/components/ui/Button.tsx';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/Card.tsx';
import MetricItem from '@/components/ui/MetricItem.tsx';
import Select from '@/components/ui/Select.tsx';
import Textarea from '@/components/ui/Textarea.tsx';
import type { TimelineEntry } from '@/components/ui/Timeline.tsx';
import Timeline from '@/components/ui/Timeline.tsx';
import { Container, Flex, Grid, Stack } from '@/components/ui/layout';
import { useAuth } from '@/contexts/AuthContext.tsx';
import { useMatch, useUpdateMatchStatus } from '@/hooks/api/useMatchingAPI.ts';
import { useTranslation } from '@/hooks/useI18n.tsx';
import { useNavigation } from '@/hooks/useNavigation.tsx';
const MatchNegotiationPage = () => {
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('');
const [showMessageModal, setShowMessageModal] = useState(false);
const [messageText, setMessageText] = 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_changed':
return t('matchNegotiation.statusChanged', 'Status Changed');
case 'comment':
return t('matchNegotiation.commentAdded', 'Comment Added');
case 'update':
return t('matchNegotiation.matchUpdated', 'Match Updated');
default:
return value || action;
}
};
// Get allowed next statuses based on current status
const allowedNextStatuses = useMemo(() => {
if (!match) return [];
const statusMap: Record<string, string[]> = {
'suggested': ['negotiating', 'reserved', 'failed', 'cancelled'],
'negotiating': ['reserved', 'contracted', 'failed', 'cancelled'],
'reserved': ['contracted', 'negotiating', 'failed', 'cancelled'],
'contracted': ['live', 'failed', 'cancelled'],
'live': ['failed', 'cancelled'],
'failed': [],
'cancelled': [],
};
return statusMap[match.Status] || [];
}, [match]);
const statusOptions = allowedNextStatuses.map(status => ({
value: status,
label: t(`matchStatus.${status}`, status),
}));
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 handleSendMessage = async () => {
// TODO: Implement message sending functionality
// This would require a messaging API endpoint
console.log('Sending message:', messageText);
setShowMessageModal(false);
setMessageText('');
};
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)}%`;
};
const getStatusBadgeVariant = (status: string) => {
switch (status) {
case 'live':
return 'default';
case 'contracted':
return 'default';
case 'negotiating':
return 'secondary';
case 'reserved':
return 'outline';
case 'failed':
return 'destructive';
case 'cancelled':
return 'destructive';
default:
return 'outline';
}
};
const getNextActionText = (currentStatus: string) => {
switch (currentStatus) {
case 'suggested':
return t('matchNegotiation.startNegotiation', 'Start Negotiation');
case 'negotiating':
return t('matchNegotiation.proposeContract', 'Propose Contract');
case 'reserved':
return t('matchNegotiation.finalizeContract', 'Finalize Contract');
case 'contracted':
return t('matchNegotiation.activateMatch', 'Activate Match');
default:
return t('matchNegotiation.updateStatus', 'Update Status');
}
};
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('matchNegotiation.errorTitle', 'Negotiation Not Found')}
subtitle={t('matchNegotiation.errorSubtitle', 'The requested negotiation could not be found')}
onBack={handleBackNavigation}
/>
<Card>
<CardContent className="py-12">
<div className="text-center">
<p className="text-destructive mb-4">
{error?.message || t('matchNegotiation.notFound', 'Negotiation not found')}
</p>
<Button onClick={handleBackNavigation}>
<ArrowLeft className="h-4 mr-2 text-current w-4" />
{t('common.back')}
</Button>
</div>
</CardContent>
</Card>
</Container>
</MainLayout>
);
}
return (
<MainLayout onNavigate={handleFooterNavigate} className="bg-muted/30">
<Container size="2xl" className="py-8 sm:py-12">
<PageHeader
title={t('matchNegotiation.title', 'Match Negotiation')}
subtitle={`${t('matchNegotiation.matchId', 'Match')} ${match.ID}`}
onBack={handleBackNavigation}
/>
<Stack spacing="2xl">
{/* Current Status & Actions */}
<Card>
<CardHeader>
<Flex align="center" justify="between">
<div>
<CardTitle className="flex items-center gap-2">
<CheckCircle className="h-4 h-5 text-current w-4 w-5" />
{t('matchNegotiation.currentStatus', 'Current Status')}
</CardTitle>
<div className="mt-2">
<Badge variant={getStatusBadgeVariant(match.Status)} className="text-base px-3 py-1">
{t(`matchStatus.${match.Status}`, match.Status)}
</Badge>
</div>
</div>
<Flex gap="sm">
{allowedNextStatuses.length > 0 && (
<Button
onClick={() => setShowStatusUpdate(true)}
disabled={updateStatusMutation.isPending}
>
<ArrowRight className="h-4 mr-2 text-current w-4" />
{getNextActionText(match.Status)}
</Button>
)}
<Button
variant="outline"
onClick={() => setShowMessageModal(true)}
>
<MessageSquare className="h-4 mr-2 text-current w-4" />
{t('matchNegotiation.sendMessage', 'Send Message')}
</Button>
<Button
variant="outline"
onClick={() => navigate(`/matching/${match.ID}`)}
>
<FileText className="h-4 mr-2 text-current w-4" />
{t('matchNegotiation.viewDetails', 'View Details')}
</Button>
</Flex>
</Flex>
</CardHeader>
</Card>
{/* Match Overview */}
<MatchCard match={match} showFullDetails={false} />
{/* Key Negotiation 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={<Clock className="h-4 h-5 text-current text-muted-foreground w-4 w-5" />}
label={t('matchNegotiation.daysInNegotiation', 'Days in Negotiation')}
value={calculateNegotiationDays(match)}
/>
</Grid>
{/* Negotiation Progress */}
<Card>
<CardHeader>
<CardTitle>{t('matchNegotiation.negotiationProgress', 'Negotiation Progress')}</CardTitle>
</CardHeader>
<CardContent>
<NegotiationProgress match={match} />
</CardContent>
</Card>
{/* 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('matchNegotiation.potentialImpact', 'Potential Impact')}
</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.co2_avoided_tonnes && (
<div className="flex justify-between items-center py-2 border-b">
<span className="text-sm font-medium">
{t('matchNegotiation.co2Avoided', 'CO₂ Avoided')}
</span>
<span className="text-sm font-semibold text-success">
{match.EconomicImpact.co2_avoided_tonnes.toFixed(1)} t/year
</span>
</div>
)}
{match.EconomicImpact.payback_years && (
<div className="flex justify-between items-center py-2">
<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>
<div className="space-y-3">
{Object.entries(match.RiskAssessment).map(([key, value]) => {
if (typeof value !== 'number') return null;
return (
<div key={key} className="flex items-center justify-between">
<span className="text-sm capitalize">
{key.replace('_', ' ')} Risk
</span>
<Badge
variant={
value > 0.7 ? 'destructive' :
value > 0.4 ? 'secondary' : 'default'
}
>
{formatScore(value)}
</Badge>
</div>
);
})}
</div>
</CardContent>
</Card>
)}
</Grid>
{/* Negotiation History Timeline */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="h-4 h-5 text-current w-4 w-5" />
{t('matchNegotiation.negotiationHistory', 'Negotiation History')}
</CardTitle>
</CardHeader>
<CardContent>
<Timeline entries={timelineEntries} />
</CardContent>
</Card>
</Stack>
{/* Status Update Modal */}
{showStatusUpdate && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t('matchNegotiation.updateStatus', 'Update Match Status')}</CardTitle>
</CardHeader>
<CardContent>
<Stack spacing="md">
<div>
<label className="block text-sm font-medium mb-2">
{t('matchNegotiation.newStatus', 'New Status')}
</label>
<Select
value={newStatus}
onValueChange={setNewStatus}
options={statusOptions}
placeholder={t('matchNegotiation.selectStatus', 'Select new status')}
/>
</div>
<div>
<label className="block text-sm font-medium mb-2">
{t('matchNegotiation.notes', 'Notes')}
</label>
<Textarea
value={statusNotes}
onChange={(e) => setStatusNotes(e.target.value)}
placeholder={t('matchNegotiation.notesPlaceholder', 'Add notes about this status change...')}
rows={3}
/>
</div>
<Flex gap="sm">
<Button
onClick={handleStatusUpdate}
disabled={!newStatus || updateStatusMutation.isPending}
className="flex-1"
>
{updateStatusMutation.isPending
? t('common.updating', 'Updating...')
: t('matchNegotiation.updateStatus', 'Update Status')
}
</Button>
<Button
variant="outline"
onClick={() => {
setShowStatusUpdate(false);
setNewStatus('');
setStatusNotes('');
}}
>
{t('common.cancel')}
</Button>
</Flex>
</Stack>
</CardContent>
</Card>
</div>
)}
{/* Message Modal */}
{showMessageModal && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center p-4 z-50">
<Card className="w-full max-w-md">
<CardHeader>
<CardTitle>{t('matchNegotiation.sendMessage', 'Send Message')}</CardTitle>
</CardHeader>
<CardContent>
<Stack spacing="md">
<div>
<label className="block text-sm font-medium mb-2">
{t('matchNegotiation.message', 'Message')}
</label>
<Textarea
value={messageText}
onChange={(e) => setMessageText(e.target.value)}
placeholder={t('matchNegotiation.messagePlaceholder', 'Type your message...')}
rows={4}
/>
</div>
<Flex gap="sm">
<Button
onClick={handleSendMessage}
disabled={!messageText.trim()}
className="flex-1"
>
{t('matchNegotiation.sendMessage', 'Send Message')}
</Button>
<Button
variant="outline"
onClick={() => {
setShowMessageModal(false);
setMessageText('');
}}
>
{t('common.cancel')}
</Button>
</Flex>
</Stack>
</CardContent>
</Card>
</div>
)}
</Container>
</MainLayout>
);
};
// Helper component for negotiation progress visualization
const NegotiationProgress: React.FC<{ match: any }> = ({ match }) => {
const { t } = useTranslation();
const steps = [
{ key: 'suggested', label: t('matchStatus.suggested', 'Suggested'), completed: true },
{ key: 'negotiating', label: t('matchStatus.negotiating', 'Negotiating'), completed: ['negotiating', 'reserved', 'contracted', 'live'].includes(match.Status) },
{ key: 'reserved', label: t('matchStatus.reserved', 'Reserved'), completed: ['reserved', 'contracted', 'live'].includes(match.Status) },
{ key: 'contracted', label: t('matchStatus.contracted', 'Contracted'), completed: ['contracted', 'live'].includes(match.Status) },
{ key: 'live', label: t('matchStatus.live', 'Live'), completed: match.Status === 'live' },
];
const currentStepIndex = steps.findIndex(step => step.key === match.Status);
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
{steps.map((step, index) => (
<React.Fragment key={step.key}>
<div className="flex flex-col items-center">
<div
className={`w-8 h-8 rounded-full flex items-center justify-center text-xs font-medium ${
step.completed
? 'bg-primary text-primary-foreground'
: index === currentStepIndex
? 'bg-primary/20 text-primary border-2 border-primary'
: 'bg-muted text-muted-foreground'
}`}
>
{index + 1}
</div>
<span className={`text-xs mt-2 text-center ${
step.completed ? 'text-foreground' : 'text-muted-foreground'
}`}>
{step.label}
</span>
</div>
{index < steps.length - 1 && (
<div
className={`flex-1 h-0.5 mx-2 mt-4 ${
steps[index + 1].completed ? 'bg-primary' : 'bg-muted'
}`}
/>
)}
</React.Fragment>
))}
</div>
{/* Current status description */}
<div className="text-center">
<p className="text-sm text-muted-foreground">
{getStatusDescription(match.Status, t)}
</p>
</div>
</div>
);
};
// Helper function to calculate negotiation days
const calculateNegotiationDays = (match: any): string => {
// For now, return a placeholder. In real implementation, you'd calculate from match creation date
return '3 days';
};
// Helper function for status descriptions
const getStatusDescription = (status: string, t: any): string => {
switch (status) {
case 'suggested':
return t('matchNegotiation.statusDesc.suggested', 'Match has been suggested and is awaiting review');
case 'negotiating':
return t('matchNegotiation.statusDesc.negotiating', 'Parties are actively negotiating terms');
case 'reserved':
return t('matchNegotiation.statusDesc.reserved', 'Match has been reserved for exclusive negotiation');
case 'contracted':
return t('matchNegotiation.statusDesc.contracted', 'Contract has been signed and agreed');
case 'live':
return t('matchNegotiation.statusDesc.live', 'Match is active and operational');
case 'failed':
return t('matchNegotiation.statusDesc.failed', 'Match negotiation failed');
case 'cancelled':
return t('matchNegotiation.statusDesc.cancelled', 'Match was cancelled');
default:
return '';
}
};
export default MatchNegotiationPage;