mirror of
https://github.com/SamyRai/turash.git
synced 2025-12-26 23:01:33 +00:00
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 1m37s
CI/CD Pipeline / frontend-build (push) Has been skipped
CI/CD Pipeline / e2e-test (push) Has been skipped
- Replace all 'any' types with proper TypeScript interfaces - Fix React hooks setState in useEffect issues with lazy initialization - Remove unused variables and imports across all files - Fix React Compiler memoization dependency issues - Add comprehensive i18n translation keys for admin interfaces - Apply consistent prettier formatting throughout codebase - Clean up unused bulk editing functionality - Improve type safety and code quality across frontend Files changed: 39 - ImpactMetrics.tsx: Fixed any types and interfaces - AdminVerificationQueuePage.tsx: Added i18n keys, removed unused vars - LocalizationUIPage.tsx: Fixed memoization, added translations - LocalizationDataPage.tsx: Added type safety and translations - And 35+ other files with various lint fixes
611 lines
22 KiB
TypeScript
611 lines
22 KiB
TypeScript
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 type { BackendMatch } from '@/schemas/backend/match';
|
|
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';
|
|
import {
|
|
AlertTriangle,
|
|
ArrowLeft,
|
|
ArrowRight,
|
|
CheckCircle,
|
|
Clock,
|
|
DollarSign,
|
|
FileText,
|
|
MapPin,
|
|
MessageSquare,
|
|
TrendingUp,
|
|
} from 'lucide-react';
|
|
import React, { useCallback, useMemo, useState } from 'react';
|
|
import { useNavigate, useParams } from 'react-router-dom';
|
|
import { formatCurrency } from '../lib/fin';
|
|
|
|
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('');
|
|
|
|
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 [];
|
|
|
|
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, getHistoryTitle]);
|
|
|
|
// 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('');
|
|
};
|
|
|
|
// central fin.formatCurrency
|
|
|
|
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');
|
|
case 'negotiating':
|
|
return t('matchNegotiation.proposeContract');
|
|
case 'reserved':
|
|
return t('matchNegotiation.finalizeContract');
|
|
case 'contracted':
|
|
return t('matchNegotiation.activateMatch');
|
|
default:
|
|
return t('matchNegotiation.updateStatus');
|
|
}
|
|
};
|
|
|
|
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')}
|
|
subtitle={t('matchNegotiation.errorSubtitle')}
|
|
onBack={handleBackNavigation}
|
|
/>
|
|
<Card>
|
|
<CardContent className="py-12">
|
|
<div className="text-center">
|
|
<p className="text-destructive mb-4">
|
|
{error?.message || t('matchNegotiation.notFound')}
|
|
</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')}
|
|
subtitle={`${t('matchNegotiation.matchId')} ${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')}
|
|
</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')}
|
|
</Button>
|
|
|
|
<Button variant="outline" onClick={() => navigate(`/matching/${match.ID}`)}>
|
|
<FileText className="h-4 mr-2 text-current w-4" />
|
|
{t('matchNegotiation.viewDetails')}
|
|
</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')}
|
|
value={calculateNegotiationDays(match)}
|
|
/>
|
|
</Grid>
|
|
|
|
{/* Negotiation Progress */}
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>{t('matchNegotiation.negotiationProgress')}</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')}
|
|
</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')}
|
|
</span>
|
|
<span className="text-sm font-semibold text-success">
|
|
{t('matchNegotiation.co2TonnesPerYear', {
|
|
value: match.EconomicImpact.co2_avoided_tonnes.toFixed(1),
|
|
})}
|
|
</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">
|
|
{t('matchDetail.paybackYears', {
|
|
years: match.EconomicImpact.payback_years.toFixed(1),
|
|
})}
|
|
</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('_', ' ')} {t('common.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')}
|
|
</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')}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Stack spacing="md">
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2">
|
|
{t('matchNegotiation.newStatus')}
|
|
</label>
|
|
<Select
|
|
value={newStatus}
|
|
onValueChange={setNewStatus}
|
|
options={statusOptions}
|
|
placeholder={t('matchNegotiation.selectStatus')}
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2">
|
|
{t('matchNegotiation.notes')}
|
|
</label>
|
|
<Textarea
|
|
value={statusNotes}
|
|
onChange={(e) => setStatusNotes(e.target.value)}
|
|
placeholder={t('matchNegotiation.notesPlaceholder')}
|
|
rows={3}
|
|
/>
|
|
</div>
|
|
<Flex gap="sm">
|
|
<Button
|
|
onClick={handleStatusUpdate}
|
|
disabled={!newStatus || updateStatusMutation.isPending}
|
|
className="flex-1"
|
|
>
|
|
{updateStatusMutation.isPending
|
|
? t('common.updating')
|
|
: t('matchNegotiation.updateStatus')}
|
|
</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')}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<Stack spacing="md">
|
|
<div>
|
|
<label className="block text-sm font-medium mb-2">
|
|
{t('matchNegotiation.message')}
|
|
</label>
|
|
<Textarea
|
|
value={messageText}
|
|
onChange={(e) => setMessageText(e.target.value)}
|
|
placeholder={t('matchNegotiation.messagePlaceholder')}
|
|
rows={4}
|
|
/>
|
|
</div>
|
|
<Flex gap="sm">
|
|
<Button
|
|
onClick={handleSendMessage}
|
|
disabled={!messageText.trim()}
|
|
className="flex-1"
|
|
>
|
|
{t('matchNegotiation.sendMessage')}
|
|
</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: BackendMatch }> = ({ match }) => {
|
|
const { t } = useTranslation();
|
|
|
|
const steps = [
|
|
{ key: 'suggested', label: t('matchStatus.suggested'), completed: true },
|
|
{
|
|
key: 'negotiating',
|
|
label: t('matchStatus.negotiating'),
|
|
completed: ['negotiating', 'reserved', 'contracted', 'live'].includes(match.Status),
|
|
},
|
|
{
|
|
key: 'reserved',
|
|
label: t('matchStatus.reserved'),
|
|
completed: ['reserved', 'contracted', 'live'].includes(match.Status),
|
|
},
|
|
{
|
|
key: 'contracted',
|
|
label: t('matchStatus.contracted'),
|
|
completed: ['contracted', 'live'].includes(match.Status),
|
|
},
|
|
{ key: 'live', label: t('matchStatus.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 = (): 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: (key: string, replacements?: Record<string, string | number>) => string
|
|
): string => {
|
|
switch (status) {
|
|
case 'suggested':
|
|
return t('matchNegotiation.statusDesc.suggested');
|
|
case 'negotiating':
|
|
return t('matchNegotiation.statusDesc.negotiating');
|
|
case 'reserved':
|
|
return t('matchNegotiation.statusDesc.reserved');
|
|
case 'contracted':
|
|
return t('matchNegotiation.statusDesc.contracted');
|
|
case 'live':
|
|
return t('matchNegotiation.statusDesc.live');
|
|
case 'failed':
|
|
return t('matchNegotiation.statusDesc.failed');
|
|
case 'cancelled':
|
|
return t('matchNegotiation.statusDesc.cancelled');
|
|
default:
|
|
return '';
|
|
}
|
|
};
|
|
|
|
export default MatchNegotiationPage;
|