updates for contacts & tenders Modules

This commit is contained in:
yotakii
2026-04-01 15:50:21 +03:00
parent 278d8f6982
commit f101989047
12 changed files with 850 additions and 143 deletions

View File

@@ -10,14 +10,13 @@ import {
Calendar,
Building2,
DollarSign,
User,
History,
Plus,
Loader2,
CheckCircle2,
Upload,
ExternalLink,
AlertCircle,
MapPin,
} from 'lucide-react'
import ProtectedRoute from '@/components/ProtectedRoute'
import LoadingSpinner from '@/components/LoadingSpinner'
@@ -51,8 +50,16 @@ function TenderDetailContent() {
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 [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)
@@ -93,10 +100,17 @@ function TenderDetailContent() {
useEffect(() => {
if (showDirectiveModal || showConvertModal) {
employeesAPI.getAll({ status: 'ACTIVE', pageSize: 500 }).then((r: any) => setEmployees(r.employees || [])).catch(() => {})
employeesAPI
.getAll({ status: 'ACTIVE', pageSize: 500 })
.then((r: any) => setEmployees(r.employees || []))
.catch(() => {})
}
if (showConvertModal) {
contactsAPI.getAll({ pageSize: 500 }).then((r: any) => setContacts(r.contacts || [])).catch(() => {})
contactsAPI
.getAll({ pageSize: 500 })
.then((r: any) => setContacts(r.contacts || []))
.catch(() => {})
pipelinesAPI.getAll().then(setPipelines).catch(() => {})
}
}, [showDirectiveModal, showConvertModal])
@@ -107,6 +121,7 @@ function TenderDetailContent() {
toast.error(t('tenders.assignee') + ' ' + t('common.required'))
return
}
setSubmitting(true)
try {
await tendersAPI.createDirective(tenderId, directiveForm)
@@ -124,9 +139,13 @@ function TenderDetailContent() {
const handleCompleteDirective = async (e: React.FormEvent) => {
e.preventDefault()
if (!showCompleteModal) return
setSubmitting(true)
try {
await tendersAPI.updateDirective(showCompleteModal.id, { status: 'COMPLETED', completionNotes: completeNotes })
await tendersAPI.updateDirective(showCompleteModal.id, {
status: 'COMPLETED',
completionNotes: completeNotes,
})
toast.success('Task completed')
setShowCompleteModal(null)
setCompleteNotes('')
@@ -144,6 +163,7 @@ function TenderDetailContent() {
toast.error('Contact and Pipeline are required')
return
}
setSubmitting(true)
try {
const deal = await tendersAPI.convertToDeal(tenderId, convertForm)
@@ -160,6 +180,7 @@ function TenderDetailContent() {
const handleTenderFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0]
if (!file) return
setSubmitting(true)
try {
await tendersAPI.uploadTenderAttachment(tenderId, file)
@@ -183,7 +204,9 @@ function TenderDetailContent() {
const directiveId = directiveIdForUpload
e.target.value = ''
setDirectiveIdForUpload(null)
if (!file || !directiveId) return
setUploadingDirectiveId(directiveId)
try {
await tendersAPI.uploadDirectiveAttachment(directiveId, file)
@@ -220,10 +243,13 @@ function TenderDetailContent() {
<ArrowLeft className="h-5 w-5" />
</Link>
<div>
<h1 className="text-2xl font-bold text-gray-900">{tender.tenderNumber} {tender.title}</h1>
<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)}
@@ -242,7 +268,9 @@ function TenderDetailContent() {
key={tab.id}
onClick={() => setActiveTab(tab.id as any)}
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'
activeTab === tab.id
? 'bg-indigo-100 text-indigo-800'
: 'text-gray-600 hover:bg-gray-100'
}`}
>
<tab.icon className="h-4 w-4" />
@@ -262,6 +290,7 @@ function TenderDetailContent() {
<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>
@@ -269,6 +298,7 @@ function TenderDetailContent() {
<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>
@@ -276,21 +306,71 @@ function TenderDetailContent() {
<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">{t('tenders.bondValue')}</p>
<p>{Number(tender.bondValue)} SAR</p>
<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">
<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>
@@ -312,19 +392,29 @@ function TenderDetailContent() {
{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">
<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}
{d.assignedToEmployee
? `${d.assignedToEmployee.firstName} ${d.assignedToEmployee.lastName}`
: ''}{' '}
· {d.status}
</p>
{d.completionNotes && <p className="text-sm mt-1">{d.completionNotes}</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
@@ -334,19 +424,25 @@ function TenderDetailContent() {
{t('tenders.completeTask')}
</button>
)}
<input
type="file"
ref={directiveFileInputRef}
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" />}
{uploadingDirectiveId === d.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
{t('tenders.uploadFile')}
</button>
</div>
@@ -371,17 +467,49 @@ function TenderDetailContent() {
disabled={submitting}
className="flex items-center gap-2 px-4 py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 disabled:opacity-50"
>
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
{submitting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Upload className="h-4 w-4" />
)}
{t('tenders.uploadFile')}
</button>
</div>
{!tender.attachments?.length ? (
<p className="text-gray-500">{t('common.noData')}</p>
) : (
<ul className="space-y-2">
{tender.attachments.map((a: any) => (
<li key={a.id} className="text-sm text-gray-700">
{a.originalName || a.fileName}
<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" />
{a.originalName || a.fileName}
</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>
@@ -397,7 +525,8 @@ function TenderDetailContent() {
<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]}
<span className="font-medium">{h.action}</span> · {h.user?.username} ·{' '}
{h.createdAt?.split('T')[0]}
</li>
))}
</ul>
@@ -408,36 +537,54 @@ function TenderDetailContent() {
</div>
</div>
<Modal isOpen={showDirectiveModal} onClose={() => setShowDirectiveModal(false)} title={t('tenders.addDirective')}>
<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>
<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>
<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>
<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 })}
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>
<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>
<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 })}
@@ -445,9 +592,20 @@ function TenderDetailContent() {
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">
<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>
@@ -455,10 +613,16 @@ function TenderDetailContent() {
</form>
</Modal>
<Modal isOpen={!!showCompleteModal} onClose={() => setShowCompleteModal(null)} title={t('tenders.completeTask')}>
<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>
<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)}
@@ -466,9 +630,20 @@ function TenderDetailContent() {
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">
<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>
@@ -476,7 +651,11 @@ function TenderDetailContent() {
</form>
</Modal>
<Modal isOpen={showConvertModal} onClose={() => setShowConvertModal(false)} title={t('tenders.convertToDeal')}>
<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>
@@ -488,10 +667,13 @@ function TenderDetailContent() {
>
<option value="">Select contact</option>
{contacts.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option>
<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
@@ -502,13 +684,26 @@ function TenderDetailContent() {
>
<option value="">Select pipeline</option>
{pipelines.map((p) => (
<option key={p.id} value={p.id}>{p.name}</option>
<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">
<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>
@@ -525,4 +720,4 @@ export default function TenderDetailPage() {
<TenderDetailContent />
</ProtectedRoute>
)
}
}