423 lines
15 KiB
TypeScript
423 lines
15 KiB
TypeScript
import React, { useState, useEffect } from 'react'
|
||
import { FaTimes, FaDollarSign, FaFileAlt } from 'react-icons/fa'
|
||
import {
|
||
CrmOpportunity,
|
||
OpportunityStageEnum,
|
||
LeadSourceEnum,
|
||
OpportunityStatusEnum,
|
||
} from '../../../types/crm'
|
||
import { mockBusinessParties } from '../../../mocks/mockBusinessParties'
|
||
import { mockEmployees } from '../../../mocks/mockEmployees'
|
||
import { BusinessParty } from '../../../types/common'
|
||
import { getOpportunityLeadSourceText, getOpportunityStageText } from '@/utils/erp'
|
||
import { mockCurrencies } from '@/mocks/mockCurrencies'
|
||
|
||
interface OpportunityFormProps {
|
||
isOpen: boolean
|
||
onClose: () => void
|
||
onSave: (opportunity: CrmOpportunity) => void
|
||
opportunity?: CrmOpportunity | null
|
||
mode: 'create' | 'edit'
|
||
}
|
||
|
||
const OpportunityForm: React.FC<OpportunityFormProps> = ({
|
||
isOpen,
|
||
onClose,
|
||
onSave,
|
||
opportunity,
|
||
mode,
|
||
}) => {
|
||
const [formData, setFormData] = useState({
|
||
id: '',
|
||
opportunityNumber: '',
|
||
title: '',
|
||
description: '',
|
||
customerId: '',
|
||
contactId: '',
|
||
stage: OpportunityStageEnum.Qualification,
|
||
probability: 10,
|
||
estimatedValue: 0,
|
||
currency: 'TRY',
|
||
expectedCloseDate: '',
|
||
assignedTo: '',
|
||
teamId: '',
|
||
leadSource: LeadSourceEnum.Website,
|
||
campaignId: '',
|
||
})
|
||
|
||
const [customers] = useState<BusinessParty[]>(mockBusinessParties)
|
||
const [errors, setErrors] = useState<Record<string, string>>({})
|
||
|
||
useEffect(() => {
|
||
if (opportunity && mode === 'edit') {
|
||
setFormData({
|
||
id: opportunity.id,
|
||
opportunityNumber: opportunity.opportunityNumber,
|
||
title: opportunity.title,
|
||
description: opportunity.description || '',
|
||
customerId: opportunity.customerId,
|
||
contactId: opportunity.contactId || '',
|
||
stage: opportunity.stage,
|
||
probability: opportunity.probability,
|
||
estimatedValue: opportunity.estimatedValue,
|
||
currency: opportunity.currency,
|
||
expectedCloseDate: opportunity.expectedCloseDate.toISOString().split('T')[0],
|
||
assignedTo: opportunity.assignedTo,
|
||
teamId: opportunity.teamId || '',
|
||
leadSource: opportunity.leadSource,
|
||
campaignId: opportunity.campaignId || '',
|
||
})
|
||
} else if (mode === 'create') {
|
||
// Generate new opportunity number
|
||
const newNumber = `OPP-${Date.now().toString().slice(-6)}`
|
||
setFormData({
|
||
id: `opp_${Date.now()}`,
|
||
opportunityNumber: newNumber,
|
||
title: '',
|
||
description: '',
|
||
customerId: '',
|
||
contactId: '',
|
||
stage: OpportunityStageEnum.Qualification,
|
||
probability: 10,
|
||
estimatedValue: 0,
|
||
currency: 'TRY',
|
||
expectedCloseDate: '',
|
||
assignedTo: 'Mevcut Kullanıcı',
|
||
teamId: '',
|
||
leadSource: LeadSourceEnum.Website,
|
||
campaignId: '',
|
||
})
|
||
}
|
||
}, [opportunity, mode, isOpen])
|
||
|
||
const handleInputChange = (
|
||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>,
|
||
) => {
|
||
const { name, value } = e.target
|
||
setFormData((prev) => ({
|
||
...prev,
|
||
[name]: value,
|
||
}))
|
||
|
||
// Clear error when user starts typing
|
||
if (errors[name]) {
|
||
setErrors((prev) => ({
|
||
...prev,
|
||
[name]: '',
|
||
}))
|
||
}
|
||
}
|
||
|
||
const validateForm = () => {
|
||
const newErrors: Record<string, string> = {}
|
||
|
||
if (!formData.title.trim()) {
|
||
newErrors.title = 'Fırsat başlığı zorunludur'
|
||
}
|
||
|
||
if (!formData.customerId) {
|
||
newErrors.customerId = 'Müşteri seçimi zorunludur'
|
||
}
|
||
|
||
if (formData.estimatedValue <= 0) {
|
||
newErrors.estimatedValue = "Tahmini değer 0'dan büyük olmalıdır"
|
||
}
|
||
|
||
if (!formData.expectedCloseDate) {
|
||
newErrors.expectedCloseDate = 'Beklenen kapanış tarihi zorunludur'
|
||
}
|
||
|
||
if (formData.probability < 0 || formData.probability > 100) {
|
||
newErrors.probability = 'Olasılık 0-100 arasında olmalıdır'
|
||
}
|
||
|
||
setErrors(newErrors)
|
||
return Object.keys(newErrors).length === 0
|
||
}
|
||
|
||
const handleSubmit = (e: React.FormEvent) => {
|
||
e.preventDefault()
|
||
|
||
if (!validateForm()) {
|
||
return
|
||
}
|
||
|
||
const opportunityData: CrmOpportunity = {
|
||
id: formData.id,
|
||
opportunityNumber: formData.opportunityNumber,
|
||
title: formData.title,
|
||
description: formData.description,
|
||
customerId: formData.customerId,
|
||
contactId: formData.contactId || undefined,
|
||
stage: formData.stage,
|
||
probability: formData.probability,
|
||
estimatedValue: formData.estimatedValue,
|
||
currency: formData.currency,
|
||
expectedCloseDate: new Date(formData.expectedCloseDate),
|
||
assignedTo: formData.assignedTo,
|
||
teamId: formData.teamId || undefined,
|
||
leadSource: formData.leadSource,
|
||
campaignId: formData.campaignId || undefined,
|
||
status:
|
||
formData.stage === OpportunityStageEnum.ClosedWon
|
||
? OpportunityStatusEnum.Won
|
||
: formData.stage === OpportunityStageEnum.ClosedLost
|
||
? OpportunityStatusEnum.Lost
|
||
: OpportunityStatusEnum.Open,
|
||
|
||
activities: [],
|
||
competitors: [],
|
||
creationTime: opportunity?.creationTime || new Date(),
|
||
lastModificationTime: new Date(),
|
||
}
|
||
|
||
onSave(opportunityData)
|
||
onClose()
|
||
}
|
||
|
||
if (!isOpen) return null
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||
<div className="bg-white rounded-lg w-full max-w-4xl max-h-[90vh] overflow-y-auto m-4">
|
||
{/* Header */}
|
||
<div className="flex items-center justify-between p-4 border-b">
|
||
<h2 className="text-lg font-semibold text-gray-900">
|
||
{mode === 'create' ? 'Yeni Fırsat Oluştur' : 'Fırsat Düzenle'}
|
||
</h2>
|
||
<button onClick={onClose} className="text-gray-400 hover:text-gray-600">
|
||
<FaTimes className="w-5 h-5" />
|
||
</button>
|
||
</div>
|
||
|
||
{/* Form */}
|
||
<form onSubmit={handleSubmit} className="p-4">
|
||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||
{/* Basic Information */}
|
||
<div className="space-y-3">
|
||
<h3 className="text-base font-medium text-gray-900 flex items-center gap-2">
|
||
<FaFileAlt className="w-5 h-5" />
|
||
Temel Bilgiler
|
||
</h3>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Fırsat Numarası
|
||
</label>
|
||
<input
|
||
type="text"
|
||
name="opportunityNumber"
|
||
value={formData.opportunityNumber}
|
||
onChange={handleInputChange}
|
||
disabled
|
||
className="w-full px-3 py-1.5 text-sm border border-gray-300 rounded-md bg-gray-50 text-gray-500"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Fırsat Başlığı *
|
||
</label>
|
||
<input
|
||
type="text"
|
||
name="title"
|
||
value={formData.title}
|
||
onChange={handleInputChange}
|
||
className={`w-full px-3 py-1.5 text-sm border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||
errors.title ? 'border-red-500' : 'border-gray-300'
|
||
}`}
|
||
placeholder="Fırsat başlığını girin"
|
||
/>
|
||
{errors.title && <p className="text-red-500 text-sm mt-1">{errors.title}</p>}
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">Açıklama</label>
|
||
<textarea
|
||
name="description"
|
||
value={formData.description}
|
||
onChange={handleInputChange}
|
||
rows={4}
|
||
className="w-full px-3 py-1.5 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
placeholder="Fırsat açıklaması"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">Müşteri *</label>
|
||
<select
|
||
name="customerId"
|
||
value={formData.customerId}
|
||
onChange={handleInputChange}
|
||
className={`w-full px-3 py-1.5 text-sm border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||
errors.customerId ? 'border-red-500' : 'border-gray-300'
|
||
}`}
|
||
>
|
||
<option value="">Müşteri seçin</option>
|
||
{customers.map((customer) => (
|
||
<option key={customer.id} value={customer.id}>
|
||
{customer.name}
|
||
</option>
|
||
))}
|
||
</select>
|
||
{errors.customerId && (
|
||
<p className="text-red-500 text-sm mt-1">{errors.customerId}</p>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">Sorumlu</label>
|
||
<select
|
||
value={formData.assignedTo || ''}
|
||
onChange={handleInputChange}
|
||
className="w-full px-3 py-1.5 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
>
|
||
<option value="">Sorumlu seçin</option>
|
||
{mockEmployees.map((employee) => (
|
||
<option key={employee.id} value={employee.id}>
|
||
{employee.fullName}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Sales Information */}
|
||
<div className="space-y-3">
|
||
<h3 className="text-base font-medium text-gray-900 flex items-center gap-2">
|
||
<FaDollarSign className="w-5 h-5" />
|
||
Satış Bilgileri
|
||
</h3>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">Aşama</label>
|
||
<select
|
||
name="stage"
|
||
value={formData.stage}
|
||
onChange={handleInputChange}
|
||
className="w-full px-3 py-1.5 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
>
|
||
{Object.values(OpportunityStageEnum).map((stage) => (
|
||
<option key={stage} value={stage}>
|
||
{getOpportunityStageText(stage)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Olasılık (%) *
|
||
</label>
|
||
<input
|
||
type="number"
|
||
name="probability"
|
||
value={formData.probability}
|
||
onChange={handleInputChange}
|
||
min="0"
|
||
max="100"
|
||
className={`w-full px-3 py-1.5 text-sm border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||
errors.probability ? 'border-red-500' : 'border-gray-300'
|
||
}`}
|
||
/>
|
||
{errors.probability && (
|
||
<p className="text-red-500 text-sm mt-1">{errors.probability}</p>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Tahmini Değer *
|
||
</label>
|
||
<input
|
||
type="number"
|
||
name="estimatedValue"
|
||
value={formData.estimatedValue}
|
||
onChange={handleInputChange}
|
||
min="0"
|
||
step="0.01"
|
||
className={`w-full px-3 py-1.5 text-sm border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||
errors.estimatedValue ? 'border-red-500' : 'border-gray-300'
|
||
}`}
|
||
placeholder="0.00"
|
||
/>
|
||
{errors.estimatedValue && (
|
||
<p className="text-red-500 text-sm mt-1">{errors.estimatedValue}</p>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">Para Birimi</label>
|
||
<select
|
||
name="currency"
|
||
value={formData.currency}
|
||
onChange={handleInputChange}
|
||
className="w-full px-3 py-1.5 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
>
|
||
{mockCurrencies.map((currency) => (
|
||
<option key={currency.value} value={currency.value}>
|
||
{currency.value} - {currency.label}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Beklenen Kapanış Tarihi *
|
||
</label>
|
||
<input
|
||
type="date"
|
||
name="expectedCloseDate"
|
||
value={formData.expectedCloseDate}
|
||
onChange={handleInputChange}
|
||
className={`w-full px-3 py-1.5 text-sm border rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 ${
|
||
errors.expectedCloseDate ? 'border-red-500' : 'border-gray-300'
|
||
}`}
|
||
/>
|
||
{errors.expectedCloseDate && (
|
||
<p className="text-red-500 text-sm mt-1">{errors.expectedCloseDate}</p>
|
||
)}
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">Kaynak</label>
|
||
<select
|
||
name="leadSource"
|
||
value={formData.leadSource}
|
||
onChange={handleInputChange}
|
||
className="w-full px-3 py-1.5 text-sm border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||
>
|
||
{Object.values(LeadSourceEnum).map((source) => (
|
||
<option key={source} value={source}>
|
||
{getOpportunityLeadSourceText(source)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Form Actions */}
|
||
<div className="flex justify-end gap-2 mt-6 pt-4 border-t">
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="px-4 py-1.5 text-sm text-gray-700 bg-gray-100 rounded-md hover:bg-gray-200 transition-colors"
|
||
>
|
||
İptal
|
||
</button>
|
||
<button
|
||
type="submit"
|
||
className="px-4 py-1.5 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors"
|
||
>
|
||
{mode === 'create' ? 'Oluştur' : 'Güncelle'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default OpportunityForm
|