Files
zerp/frontend/src/app/tenders/page.tsx
2026-03-25 12:04:27 +03:00

447 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useState, useEffect, useCallback } from 'react'
import ProtectedRoute from '@/components/ProtectedRoute'
import Modal from '@/components/Modal'
import LoadingSpinner from '@/components/LoadingSpinner'
import Link from 'next/link'
import { toast } from 'react-hot-toast'
import {
FileText,
Plus,
Search,
Calendar,
Building2,
DollarSign,
AlertCircle,
ArrowLeft,
Eye,
Loader2,
} from 'lucide-react'
import { tendersAPI, Tender, CreateTenderData, TenderFilters } from '@/lib/api/tenders'
import { useLanguage } from '@/contexts/LanguageContext'
const SOURCE_LABELS: Record<string, string> = {
GOVERNMENT_SITE: 'Government site',
OFFICIAL_GAZETTE: 'Official gazette',
PERSONAL: 'Personal relations',
PARTNER: 'Partner companies',
WHATSAPP_TELEGRAM: 'WhatsApp/Telegram',
PORTAL: 'Tender portals',
EMAIL: 'Email',
MANUAL: 'Manual entry',
}
const ANNOUNCEMENT_LABELS: Record<string, string> = {
FIRST: 'First announcement',
RE_ANNOUNCEMENT_2: 'Re-announcement 2nd',
RE_ANNOUNCEMENT_3: 'Re-announcement 3rd',
RE_ANNOUNCEMENT_4: 'Re-announcement 4th',
}
function TendersContent() {
const { t } = useLanguage()
const [tenders, setTenders] = useState<Tender[]>([])
const [loading, setLoading] = useState(true)
const [currentPage, setCurrentPage] = useState(1)
const [totalPages, setTotalPages] = useState(1)
const [total, setTotal] = useState(0)
const pageSize = 10
const [searchTerm, setSearchTerm] = useState('')
const [selectedStatus, setSelectedStatus] = useState('all')
const [showCreateModal, setShowCreateModal] = useState(false)
const [formData, setFormData] = useState<CreateTenderData>({
tenderNumber: '',
issuingBodyName: '',
title: '',
termsValue: 0,
bondValue: 0,
announcementDate: '',
closingDate: '',
source: 'MANUAL',
announcementType: 'FIRST',
})
const [formErrors, setFormErrors] = useState<Record<string, string>>({})
const [submitting, setSubmitting] = useState(false)
const [possibleDuplicates, setPossibleDuplicates] = useState<Tender[]>([])
const [showDuplicateWarning, setShowDuplicateWarning] = useState(false)
const [sourceValues, setSourceValues] = useState<string[]>([])
const [announcementTypeValues, setAnnouncementTypeValues] = useState<string[]>([])
const fetchTenders = useCallback(async () => {
setLoading(true)
try {
const filters: TenderFilters = { page: currentPage, pageSize }
if (searchTerm) filters.search = searchTerm
if (selectedStatus !== 'all') filters.status = selectedStatus
const data = await tendersAPI.getAll(filters)
setTenders(data.tenders)
setTotal(data.total)
setTotalPages(data.totalPages)
} catch {
toast.error(t('tenders.loadError') || 'Failed to load tenders')
} finally {
setLoading(false)
}
}, [currentPage, searchTerm, selectedStatus, t])
useEffect(() => {
fetchTenders()
}, [fetchTenders])
useEffect(() => {
tendersAPI.getSourceValues().then(setSourceValues).catch(() => {})
tendersAPI.getAnnouncementTypeValues().then(setAnnouncementTypeValues).catch(() => {})
}, [])
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault()
const errors: Record<string, string> = {}
if (!formData.tenderNumber?.trim()) errors.tenderNumber = t('common.required')
if (!formData.issuingBodyName?.trim()) errors.issuingBodyName = t('common.required')
if (!formData.title?.trim()) errors.title = t('common.required')
if (!formData.announcementDate) errors.announcementDate = t('common.required')
if (!formData.closingDate) errors.closingDate = t('common.required')
//if (Number(formData.termsValue) < 0) errors.termsValue = t('common.required')
if (Number(formData.bondValue) < 0) errors.bondValue = t('common.required')
setFormErrors(errors)
if (Object.keys(errors).length > 0) return
setSubmitting(true)
try {
const result = await tendersAPI.create(formData)
if (result.possibleDuplicates && result.possibleDuplicates.length > 0) {
setPossibleDuplicates(result.possibleDuplicates)
setShowDuplicateWarning(true)
toast(t('tenders.duplicateWarning') || 'Possible duplicates found. Please review.', { icon: '⚠️' })
} else {
toast.success(t('tenders.createSuccess') || 'Tender created successfully')
setShowCreateModal(false)
resetForm()
fetchTenders()
}
} catch (err: any) {
toast.error(err.response?.data?.message || 'Failed to create tender')
} finally {
setSubmitting(false)
}
}
const resetForm = () => {
setFormData({
tenderNumber: '',
issuingBodyName: '',
title: '',
termsValue: 0,
bondValue: 0,
announcementDate: '',
closingDate: '',
source: 'MANUAL',
announcementType: 'FIRST',
})
setFormErrors({})
setPossibleDuplicates([])
setShowDuplicateWarning(false)
}
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="mb-6 flex items-center justify-between">
<div className="flex items-center gap-3">
<Link
href="/dashboard"
className="p-2 hover:bg-gray-200 rounded-lg transition-colors"
>
<ArrowLeft className="h-5 w-5" />
</Link>
<div className="flex items-center gap-2">
<FileText className="h-8 w-8 text-indigo-600" />
<div>
<h1 className="text-2xl font-bold text-gray-900">{t('nav.tenders') || 'Tenders'}</h1>
<p className="text-sm text-gray-600">{t('tenders.subtitle') || 'Tender Management'}</p>
</div>
</div>
</div>
<button
onClick={() => { setShowCreateModal(true); resetForm(); }}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700"
>
<Plus className="h-5 w-5" />
{t('tenders.addTender') || 'Add Tender'}
</button>
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div className="p-4 border-b border-gray-200 flex flex-wrap gap-4 items-center">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
type="text"
placeholder={t('tenders.searchPlaceholder') || 'Search by number, title, issuing body...'}
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500"
/>
</div>
<select
value={selectedStatus}
onChange={(e) => setSelectedStatus(e.target.value)}
className="px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-indigo-500"
>
<option value="all">{t('common.all') || 'All status'}</option>
<option value="ACTIVE">Active</option>
<option value="CONVERTED_TO_DEAL">Converted</option>
<option value="CANCELLED">Cancelled</option>
</select>
</div>
{loading ? (
<div className="flex justify-center py-12">
<LoadingSpinner />
</div>
) : tenders.length === 0 ? (
<div className="text-center py-12 text-gray-500">
{t('tenders.noTenders') || 'No tenders found.'}
</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">{t('tenders.tenderNumber') || 'Number'}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">{t('tenders.title') || 'Title'}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">{t('tenders.issuingBody') || 'Issuing body'}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">{t('tenders.closingDate') || 'Closing date'}</th>
<th className="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">{t('common.status')}</th>
<th className="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">{t('common.actions')}</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{tenders.map((tender) => (
<tr key={tender.id} className="hover:bg-gray-50">
<td className="px-4 py-3 text-sm font-medium text-gray-900">{tender.tenderNumber}</td>
<td className="px-4 py-3 text-sm text-gray-900">{tender.title}</td>
<td className="px-4 py-3 text-sm text-gray-600">{tender.issuingBodyName}</td>
<td className="px-4 py-3 text-sm text-gray-600">{tender.closingDate?.split('T')[0]}</td>
<td className="px-4 py-3">
<span className={`px-2 py-1 text-xs rounded-full ${
tender.status === 'ACTIVE' ? 'bg-green-100 text-green-800' :
tender.status === 'CONVERTED_TO_DEAL' ? 'bg-blue-100 text-blue-800' : 'bg-gray-100 text-gray-800'
}`}>
{tender.status}
</span>
</td>
<td className="px-4 py-3 text-right">
<Link
href={`/tenders/${tender.id}`}
className="inline-flex items-center gap-1 text-indigo-600 hover:underline"
>
<Eye className="h-4 w-4" />
{t('common.view') || 'View'}
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{totalPages > 1 && (
<div className="px-4 py-3 border-t border-gray-200 flex items-center justify-between">
<p className="text-sm text-gray-600">
{t('common.showing') || 'Showing'} {(currentPage - 1) * pageSize + 1}{Math.min(currentPage * pageSize, total)} {t('common.of') || 'of'} {total}
</p>
<div className="flex gap-2">
<button
disabled={currentPage <= 1}
onClick={() => setCurrentPage((p) => p - 1)}
className="px-3 py-1 border rounded disabled:opacity-50"
>
{t('crm.paginationPrevious') || 'Previous'}
</button>
<button
disabled={currentPage >= totalPages}
onClick={() => setCurrentPage((p) => p + 1)}
className="px-3 py-1 border rounded disabled:opacity-50"
>
{t('crm.paginationNext') || 'Next'}
</button>
</div>
</div>
)}
</div>
</div>
<Modal
isOpen={showCreateModal}
onClose={() => { setShowCreateModal(false); setShowDuplicateWarning(false); resetForm(); }}
title={t('tenders.addTender') || 'Add Tender'}
>
<form onSubmit={handleCreate} className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('tenders.tenderNumber')} *</label>
<input
type="text"
value={formData.tenderNumber}
onChange={(e) => setFormData({ ...formData, tenderNumber: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
{formErrors.tenderNumber && <p className="text-red-500 text-xs mt-1">{formErrors.tenderNumber}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('tenders.issuingBody')} *</label>
<input
type="text"
value={formData.issuingBodyName}
onChange={(e) => setFormData({ ...formData, issuingBodyName: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
{formErrors.issuingBodyName && <p className="text-red-500 text-xs mt-1">{formErrors.issuingBodyName}</p>}
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('tenders.titleLabel')} *</label>
<input
type="text"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
{formErrors.title && <p className="text-red-500 text-xs mt-1">{formErrors.title}</p>}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('tenders.termsValue')} *</label>
<input
type="number"
value={formData.termsValue || ''}
onChange={(e) => setFormData({ ...formData, termsValue: Number(e.target.value) || 0 })}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('tenders.bondValue')} *</label>
<input
type="number"
min={0}
value={formData.bondValue || ''}
onChange={(e) => setFormData({ ...formData, bondValue: Number(e.target.value) || 0 })}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('tenders.announcementDate')} *</label>
<input
type="date"
value={formData.announcementDate}
onChange={(e) => setFormData({ ...formData, announcementDate: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
{formErrors.announcementDate && <p className="text-red-500 text-xs mt-1">{formErrors.announcementDate}</p>}
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('tenders.closingDate')} *</label>
<input
type="date"
value={formData.closingDate}
onChange={(e) => setFormData({ ...formData, closingDate: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
{formErrors.closingDate && <p className="text-red-500 text-xs mt-1">{formErrors.closingDate}</p>}
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('tenders.source')}</label>
<select
value={formData.source}
onChange={(e) => setFormData({ ...formData, source: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
>
{sourceValues.map((s) => (
<option key={s} value={s}>{SOURCE_LABELS[s] || s}</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('tenders.announcementType')}</label>
<select
value={formData.announcementType}
onChange={(e) => setFormData({ ...formData, announcementType: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
>
{announcementTypeValues.map((a) => (
<option key={a} value={a}>{ANNOUNCEMENT_LABELS[a] || a}</option>
))}
</select>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('tenders.announcementLink')}</label>
<input
type="url"
value={formData.announcementLink || ''}
onChange={(e) => setFormData({ ...formData, announcementLink: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">{t('common.notes')}</label>
<textarea
value={formData.notes || ''}
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
rows={2}
/>
</div>
{showDuplicateWarning && possibleDuplicates.length > 0 && (
<div className="p-3 bg-amber-50 border border-amber-200 rounded-lg flex items-start gap-2">
<AlertCircle className="h-5 w-5 text-amber-600 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-amber-800">{t('tenders.duplicateWarning') || 'Possible duplicates found'}</p>
<ul className="text-sm text-amber-700 mt-1 list-disc list-inside">
{possibleDuplicates.slice(0, 3).map((d) => (
<li key={d.id}>
<Link href={`/tenders/${d.id}`} className="underline">{d.tenderNumber} - {d.title}</Link>
</li>
))}
</ul>
</div>
</div>
)}
<div className="flex justify-end gap-2 pt-4">
<button
type="button"
onClick={() => { setShowCreateModal(false); resetForm(); }}
className="px-4 py-2 border rounded-lg"
>
{t('common.cancel')}
</button>
<button
type="submit"
disabled={submitting}
className="px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 flex items-center gap-2"
>
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
{t('common.save')}
</button>
</div>
</form>
</Modal>
</div>
)
}
export default function TendersPage() {
return (
<ProtectedRoute>
<TendersContent />
</ProtectedRoute>
)
}