Files
zerp/frontend/src/app/tenders/[id]/page.tsx
2026-06-03 13:01:51 +03:00

838 lines
31 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, useRef } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useParams} from 'next/navigation'
import Link from 'next/link'
import { toast } from 'react-hot-toast'
import {
ArrowLeft,
FileText,
Calendar,
Building2,
DollarSign,
History,
Plus,
Loader2,
CheckCircle2,
Upload,
ExternalLink,
MapPin,
} from 'lucide-react'
import ProtectedRoute from '@/components/ProtectedRoute'
import LoadingSpinner from '@/components/LoadingSpinner'
import Modal from '@/components/Modal'
import { tendersAPI, Tender, TenderDirective, CreateDirectiveData } from '@/lib/api/tenders'
import { contactsAPI } from '@/lib/api/contacts'
import { pipelinesAPI } from '@/lib/api/pipelines'
import { useLanguage } from '@/contexts/LanguageContext'
const DIRECTIVE_TYPE_LABELS: Record<string, string> = {
BUY_TERMS: 'Buy terms booklet',
VISIT_CLIENT: 'Visit client',
MEET_COMMITTEE: 'Meet committee',
PREPARE_TO_BID: 'Prepare to bid',
}
const getDisplayFileName = (attachment: any) => {
const name = String(attachment.originalName || attachment.fileName || 'file')
if (!/[ÃÄÅØÙ]/.test(name)) {
return name
}
try {
const bytes = new Uint8Array(
Array.from(name, (char: string) => char.charCodeAt(0) & 0xff)
)
return new TextDecoder('utf-8').decode(bytes)
} catch {
return name
}
}
function TenderDetailContent() {
const searchParams = useSearchParams()
const params = useParams()
const router = useRouter()
const tenderId = params.id as string
const { t } = useLanguage()
const [tender, setTender] = useState<Tender | null>(null)
const [history, setHistory] = useState<any[]>([])
const [loading, setLoading] = useState(true)
type TenderTab = 'info' | 'directives' | 'attachments' | 'history'
const [activeTab, setActiveTab] = useState<TenderTab>('info')
const openTab = (tab: TenderTab) => {
setActiveTab(tab)
router.replace(`/tenders/${params.id}?tab=${tab}`)
}
const [showDirectiveModal, setShowDirectiveModal] = useState(false)
const [showConvertModal, setShowConvertModal] = useState(false)
const [showCompleteModal, setShowCompleteModal] = useState<TenderDirective | null>(null)
const [employees, setEmployees] = useState<any[]>([])
const [contacts, setContacts] = useState<any[]>([])
const [pipelines, setPipelines] = useState<any[]>([])
const [directiveForm, setDirectiveForm] = useState<CreateDirectiveData>({
type: 'BUY_TERMS',
assignedToEmployeeId: '',
notes: '',
})
const [convertForm, setConvertForm] = useState({
contactId: '',
pipelineId: '',
ownerId: '',
})
const [completeNotes, setCompleteNotes] = useState('')
const [directiveTypeValues, setDirectiveTypeValues] = useState<string[]>([])
const [submitting, setSubmitting] = useState(false)
const directiveFileInputRef = useRef<HTMLInputElement>(null)
const [uploadingDirectiveId, setUploadingDirectiveId] = useState<string | null>(null)
const [directiveIdForUpload, setDirectiveIdForUpload] = useState<string | null>(null)
const [uploadingCategory, setUploadingCategory] = useState<string | null>(null)
const termsInputRef = useRef<HTMLInputElement>(null)
const costInputRef = useRef<HTMLInputElement>(null)
const offersInputRef = useRef<HTMLInputElement>(null)
const fetchTender = async () => {
try {
const data = await tendersAPI.getById(tenderId)
setTender(data)
} catch {
toast.error(t('tenders.loadError'))
} finally {
setLoading(false)
}
}
const fetchHistory = async () => {
try {
const data = await tendersAPI.getHistory(tenderId)
setHistory(data)
} catch {}
}
useEffect(() => {
const tabParam = searchParams.get('tab') as TenderTab | null
const allowedTabs: TenderTab[] = ['info', 'directives', 'attachments', 'history']
if (tabParam && allowedTabs.includes(tabParam)) {
setActiveTab(tabParam)
}
}, [searchParams])
useEffect(() => {
fetchTender()
}, [tenderId])
useEffect(() => {
if (tender) fetchHistory()
}, [tender?.id])
useEffect(() => {
tendersAPI.getDirectiveTypeValues().then(setDirectiveTypeValues).catch(() => {})
}, [])
useEffect(() => {
if (showDirectiveModal || showConvertModal) {
// Use the directive-scoped employee list so non-HR users (with
// tenders:directives:create) can populate this dropdown without
// being granted hr:employees:read (which would leak salaries etc.).
tendersAPI
.getAssignableEmployees()
.then((list) => setEmployees(list))
.catch(() => {})
}
if (showConvertModal) {
contactsAPI
.getAll({ pageSize: 500 })
.then((r: any) => setContacts(r.contacts || []))
.catch(() => {})
pipelinesAPI.getAll().then(setPipelines).catch(() => {})
}
}, [showDirectiveModal, showConvertModal])
const handleAddDirective = async (e: React.FormEvent) => {
e.preventDefault()
if (!directiveForm.assignedToEmployeeId) {
toast.error(t('tenders.assignee') + ' ' + t('common.required'))
return
}
setSubmitting(true)
try {
await tendersAPI.createDirective(tenderId, directiveForm)
toast.success('Directive created')
setShowDirectiveModal(false)
setDirectiveForm({ type: 'BUY_TERMS', assignedToEmployeeId: '', notes: '' })
fetchTender()
} catch (err: any) {
toast.error(err.response?.data?.message || 'Failed')
} finally {
setSubmitting(false)
}
}
const handleCompleteDirective = async (e: React.FormEvent) => {
e.preventDefault()
if (!showCompleteModal) return
setSubmitting(true)
try {
await tendersAPI.updateDirective(showCompleteModal.id, {
status: 'COMPLETED',
completionNotes: completeNotes,
})
toast.success('Task completed')
setShowCompleteModal(null)
setCompleteNotes('')
fetchTender()
} catch (err: any) {
toast.error(err.response?.data?.message || 'Failed')
} finally {
setSubmitting(false)
}
}
const handleConvertToDeal = async (e: React.FormEvent) => {
e.preventDefault()
if (!convertForm.contactId || !convertForm.pipelineId) {
toast.error('Contact and Pipeline are required')
return
}
setSubmitting(true)
try {
const deal = await tendersAPI.convertToDeal(tenderId, convertForm)
toast.success('Tender converted to deal')
setShowConvertModal(false)
router.push(`/crm/deals/${deal.id}`)
} catch (err: any) {
toast.error(err.response?.data?.message || 'Failed')
} finally {
setSubmitting(false)
}
}
const handleTenderFileUpload = async (
e: React.ChangeEvent<HTMLInputElement>,
category?: string,
) => {
const files = Array.from(e.target.files || [])
if (!files.length) return
if (category) setUploadingCategory(category)
else setSubmitting(true)
let successCount = 0
let failCount = 0
try {
// Upload files sequentially so a failure of one file doesn't break the rest.
for (const file of files) {
try {
await tendersAPI.uploadTenderAttachment(tenderId, file, category)
successCount++
} catch (err: any) {
failCount++
const msg = err.response?.data?.message || 'Upload failed'
toast.error(`${file.name}: ${msg}`)
}
}
if (successCount > 0) {
toast.success(
files.length === 1
? t('tenders.uploadFile')
: `${successCount}/${files.length}`
)
}
if (successCount > 0) fetchTender()
} finally {
setSubmitting(false)
setUploadingCategory(null)
e.target.value = ''
}
}
const handleDirectiveFileSelect = (directiveId: string) => {
setDirectiveIdForUpload(directiveId)
setTimeout(() => directiveFileInputRef.current?.click(), 0)
}
const handleDirectiveFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || [])
const directiveId = directiveIdForUpload
e.target.value = ''
setDirectiveIdForUpload(null)
if (!files.length || !directiveId) return
setUploadingDirectiveId(directiveId)
let successCount = 0
try {
for (const file of files) {
try {
await tendersAPI.uploadDirectiveAttachment(directiveId, file)
successCount++
} catch (err: any) {
const msg = err.response?.data?.message || 'Upload failed'
toast.error(`${file.name}: ${msg}`)
}
}
if (successCount > 0) {
toast.success(
files.length === 1
? t('tenders.uploadFile')
: `${successCount}/${files.length}`
)
fetchTender()
}
} finally {
setUploadingDirectiveId(null)
}
}
if (loading || !tender) {
return (
<div className="min-h-screen flex items-center justify-center">
<LoadingSpinner />
</div>
)
}
const tabs = [
{ id: 'info', label: t('tenders.titleLabel') || 'Info', icon: FileText },
{ id: 'directives', label: t('tenders.directives'), icon: CheckCircle2 },
{ id: 'attachments', label: t('tenders.attachments'), icon: Upload },
{ id: 'history', label: t('tenders.history'), icon: History },
]
return (
<div className="min-h-screen bg-gray-50">
<div className="max-w-5xl 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="/tenders" className="p-2 hover:bg-gray-200 rounded-lg">
<ArrowLeft className="h-5 w-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-gray-900">
{tender.tenderNumber} {tender.title}
</h1>
<p className="text-sm text-gray-600">{tender.issuingBodyName}</p>
</div>
</div>
{tender.status === 'ACTIVE' && (
<button
onClick={() => setShowConvertModal(true)}
className="flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700"
>
<ExternalLink className="h-4 w-4" />
{t('tenders.convertToDeal')}
</button>
)}
</div>
<div className="bg-white rounded-xl shadow-sm border border-gray-200 overflow-hidden">
<div className="border-b border-gray-200 flex gap-1 p-1">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => openTab(tab.id as TenderTab)}
className={`flex items-center gap-2 px-4 py-2 rounded-lg text-sm font-medium ${
activeTab === tab.id
? 'bg-indigo-100 text-indigo-800'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
<tab.icon className="h-4 w-4" />
{tab.label}
</button>
))}
</div>
<div className="p-6">
{activeTab === 'info' && (
<div className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="flex items-start gap-2">
<Calendar className="h-5 w-5 text-gray-400 mt-0.5" />
<div>
<p className="text-xs text-gray-500">{t('tenders.announcementDate')}</p>
<p>{tender.announcementDate?.split('T')[0]}</p>
</div>
</div>
<div className="flex items-start gap-2">
<Calendar className="h-5 w-5 text-gray-400 mt-0.5" />
<div>
<p className="text-xs text-gray-500">{t('tenders.closingDate')}</p>
<p>{tender.closingDate?.split('T')[0]}</p>
</div>
</div>
<div className="flex items-start gap-2">
<DollarSign className="h-5 w-5 text-gray-400 mt-0.5" />
<div>
<p className="text-xs text-gray-500">{t('tenders.termsValue')}</p>
<p>{Number(tender.termsValue)} SAR</p>
</div>
</div>
<div className="flex items-start gap-2">
<DollarSign className="h-5 w-5 text-gray-400 mt-0.5" />
<div>
<p className="text-xs text-gray-500">التأمينات الأولية</p>
<p>{Number(tender.initialBondValue || tender.bondValue || 0)} SAR</p>
</div>
</div>
<div className="flex items-start gap-2">
<DollarSign className="h-5 w-5 text-gray-400 mt-0.5" />
<div>
<p className="text-xs text-gray-500">التأمينات النهائية</p>
<p>{Number(tender.finalBondValue || 0)} SAR</p>
</div>
</div>
<div className="flex items-start gap-2">
<Calendar className="h-5 w-5 text-gray-400 mt-0.5" />
<div>
<p className="text-xs text-gray-500">زمن الاسترجاع</p>
<p>{tender.finalBondRefundPeriod || '-'}</p>
</div>
</div>
<div className="flex items-start gap-2">
<Calendar className="h-5 w-5 text-gray-400 mt-0.5" />
<div>
<p className="text-xs text-gray-500">زيارة الموقع</p>
<p>{tender.siteVisitRequired ? 'إجبارية' : 'غير إجبارية'}</p>
</div>
</div>
{tender.siteVisitRequired && (
<div className="flex items-start gap-2">
<MapPin className="h-5 w-5 text-gray-400 mt-0.5" />
<div>
<p className="text-xs text-gray-500">مكان الزيارة</p>
<p>{tender.siteVisitLocation || '-'}</p>
</div>
</div>
)}
<div className="flex items-start gap-2">
<MapPin className="h-5 w-5 text-gray-400 mt-0.5" />
<div>
<p className="text-xs text-gray-500">مكان استلام دفتر الشروط</p>
<p>{tender.termsPickupProvince || '-'}</p>
</div>
</div>
</div>
{tender.announcementLink && (
<p>
<a
href={tender.announcementLink}
target="_blank"
rel="noopener noreferrer"
className="text-indigo-600 hover:underline"
>
{t('tenders.announcementLink')}
</a>
</p>
)}
{tender.notes && (
<div>
<p className="text-xs text-gray-500">{t('common.notes')}</p>
<p className="whitespace-pre-wrap">{tender.notes}</p>
</div>
)}
</div>
)}
{activeTab === 'directives' && (
<div>
<div className="flex justify-between items-center mb-4">
<h3 className="font-medium">{t('tenders.directives')}</h3>
<button
onClick={() => setShowDirectiveModal(true)}
className="flex items-center gap-1 text-indigo-600 hover:underline"
>
<Plus className="h-4 w-4" />
{t('tenders.addDirective')}
</button>
</div>
{!tender.directives?.length ? (
<p className="text-gray-500">{t('common.noData')}</p>
) : (
<ul className="space-y-3">
{tender.directives.map((d) => (
<li
key={d.id}
className="border rounded-lg p-4 flex flex-wrap items-center justify-between gap-2"
>
<div>
<p className="font-medium">{DIRECTIVE_TYPE_LABELS[d.type] || d.type}</p>
<p className="text-sm text-gray-600">
{d.assignedToEmployee
? `${d.assignedToEmployee.firstName} ${d.assignedToEmployee.lastName}`
: ''}{' '}
· {d.status}
</p>
{d.completionNotes && (
<p className="text-sm mt-1">{d.completionNotes}</p>
)}
</div>
<div className="flex items-center gap-2">
{d.status !== 'COMPLETED' && d.assignedToEmployee?.user?.id && (
<button
onClick={() => setShowCompleteModal(d)}
className="text-sm text-green-600 hover:underline"
>
{t('tenders.completeTask')}
</button>
)}
<input
type="file"
ref={directiveFileInputRef}
multiple
className="hidden"
onChange={handleDirectiveFileUpload}
/>
<button
type="button"
onClick={() => handleDirectiveFileSelect(d.id)}
disabled={uploadingDirectiveId === d.id}
className="text-sm text-indigo-600 hover:underline flex items-center gap-1"
>
{uploadingDirectiveId === d.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
{t('tenders.uploadFile')}
</button>
</div>
</li>
))}
</ul>
)}
</div>
)}
{activeTab === 'attachments' && (
<div>
{(() => {
const all = (tender.attachments || []) as any[]
const sections: Array<{
key: string
label: string
category: string
ref: React.RefObject<HTMLInputElement>
}> = [
{ key: 'terms', label: 'دفتر الشروط', category: 'TERMS_BOOKLET', ref: termsInputRef },
{ key: 'cost', label: 'cost sheet ', category: 'COST_SHEET', ref: costInputRef },
{ key: 'offers', label: 'proposal', category: 'OFFERS', ref: offersInputRef },
]
// Legacy attachments without a recognized category live under
// the dafter section by default so nothing gets hidden.
const knownCategories = new Set(sections.map((s) => s.category))
const inSection = (a: any, category: string) =>
a.category === category ||
(category === 'TERMS_BOOKLET' && (!a.category || !knownCategories.has(a.category)))
return (
<div className="space-y-6">
{sections.map((section) => {
const items = all.filter((a) => inSection(a, section.category))
const isUploading = uploadingCategory === section.category
return (
<div key={section.key} className="border rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<h3 className="text-base font-semibold text-gray-900">{section.label}</h3>
<div>
<input
type="file"
ref={section.ref}
multiple
className="hidden"
onChange={(e) => handleTenderFileUpload(e, section.category)}
/>
<button
type="button"
onClick={() => section.ref.current?.click()}
disabled={isUploading}
className="flex items-center gap-2 px-3 py-1.5 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50 text-sm"
>
{isUploading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
{t('tenders.uploadFile')}
</button>
</div>
</div>
{items.length === 0 ? (
<p className="text-gray-500 text-sm">{t('common.noData')}</p>
) : (
<ul className="space-y-2">
{items.map((a: any) => (
<li
key={a.id}
className="flex items-center justify-between border rounded px-3 py-2"
>
<a
href={`${process.env.NEXT_PUBLIC_API_URL}/tenders/attachments/${a.id}/view`}
target="_blank"
rel="noopener noreferrer"
className="text-sm text-indigo-600 hover:underline flex items-center gap-1"
>
<ExternalLink className="h-4 w-4" />
{getDisplayFileName(a)}
</a>
<button
onClick={async () => {
if (!confirm('حذف الملف؟')) return
try {
await tendersAPI.deleteAttachment(a.id)
toast.success('تم الحذف')
fetchTender()
} catch {
toast.error('فشل الحذف')
}
}}
className="text-red-600 text-sm hover:underline"
>
حذف
</button>
</li>
))}
</ul>
)}
</div>
)
})}
</div>
)
})()}
</div>
)}
{activeTab === 'history' && (
<div>
{history.length === 0 ? (
<p className="text-gray-500">{t('common.noData')}</p>
) : (
<ul className="space-y-2">
{history.map((h: any) => (
<li key={h.id} className="text-sm border-b border-gray-100 pb-2">
<span className="font-medium">{h.action}</span> · {h.user?.username} ·{' '}
{h.createdAt?.split('T')[0]}
</li>
))}
</ul>
)}
</div>
)}
</div>
</div>
</div>
<Modal
isOpen={showDirectiveModal}
onClose={() => setShowDirectiveModal(false)}
title={t('tenders.addDirective')}
>
<form onSubmit={handleAddDirective} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('tenders.directiveType')}
</label>
<select
value={directiveForm.type}
onChange={(e) => setDirectiveForm({ ...directiveForm, type: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
>
{directiveTypeValues.map((v) => (
<option key={v} value={v}>
{DIRECTIVE_TYPE_LABELS[v] || v}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('tenders.assignee')} *
</label>
<select
value={directiveForm.assignedToEmployeeId}
onChange={(e) =>
setDirectiveForm({ ...directiveForm, assignedToEmployeeId: e.target.value })
}
className="w-full px-3 py-2 border rounded-lg"
required
>
<option value="">Select employee</option>
{employees.map((emp) => (
<option key={emp.id} value={emp.id}>
{emp.firstName} {emp.lastName}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('common.notes')}
</label>
<textarea
value={directiveForm.notes || ''}
onChange={(e) => setDirectiveForm({ ...directiveForm, notes: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
rows={2}
/>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setShowDirectiveModal(false)}
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 disabled:opacity-50 flex items-center gap-2"
>
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
{t('common.save')}
</button>
</div>
</form>
</Modal>
<Modal
isOpen={!!showCompleteModal}
onClose={() => setShowCompleteModal(null)}
title={t('tenders.completeTask')}
>
<form onSubmit={handleCompleteDirective} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">
{t('tenders.completionNotes')}
</label>
<textarea
value={completeNotes}
onChange={(e) => setCompleteNotes(e.target.value)}
className="w-full px-3 py-2 border rounded-lg"
rows={3}
/>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setShowCompleteModal(null)}
className="px-4 py-2 border rounded-lg"
>
{t('common.cancel')}
</button>
<button
type="submit"
disabled={submitting}
className="px-4 py-2 bg-green-600 text-white rounded-lg disabled:opacity-50 flex items-center gap-2"
>
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
{t('common.save')}
</button>
</div>
</form>
</Modal>
<Modal
isOpen={showConvertModal}
onClose={() => setShowConvertModal(false)}
title={t('tenders.convertToDeal')}
>
<form onSubmit={handleConvertToDeal} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Contact *</label>
<select
value={convertForm.contactId}
onChange={(e) => setConvertForm({ ...convertForm, contactId: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
required
>
<option value="">Select contact</option>
{contacts.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Pipeline *</label>
<select
value={convertForm.pipelineId}
onChange={(e) => setConvertForm({ ...convertForm, pipelineId: e.target.value })}
className="w-full px-3 py-2 border rounded-lg"
required
>
<option value="">Select pipeline</option>
{pipelines.map((p) => (
<option key={p.id} value={p.id}>
{p.name}
</option>
))}
</select>
</div>
<div className="flex justify-end gap-2">
<button
type="button"
onClick={() => setShowConvertModal(false)}
className="px-4 py-2 border rounded-lg"
>
{t('common.cancel')}
</button>
<button
type="submit"
disabled={submitting}
className="px-4 py-2 bg-green-600 text-white rounded-lg disabled:opacity-50 flex items-center gap-2"
>
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
{t('tenders.convertToDeal')}
</button>
</div>
</form>
</Modal>
</div>
)
}
export default function TenderDetailPage() {
return (
<ProtectedRoute>
<TenderDetailContent />
</ProtectedRoute>
)
}