508 lines
19 KiB
TypeScript
508 lines
19 KiB
TypeScript
import React, { useState, useEffect } from 'react'
|
||
import { FaSave, FaTimes, FaPlus, FaTrash, FaFileAlt } from 'react-icons/fa'
|
||
import {
|
||
FiInvoice,
|
||
FiInvoiceItem,
|
||
InvoiceTypeEnum,
|
||
InvoiceStatusEnum,
|
||
PaymentStatusEnum,
|
||
} from '../../../types/fi'
|
||
import { mockCurrentAccounts } from '../../../mocks/mockCurrentAccounts'
|
||
import { getInvoiceTypeText } from '@/utils/erp'
|
||
|
||
interface InvoiceFormProps {
|
||
invoice?: FiInvoice
|
||
onSave: (invoice: Partial<FiInvoice>) => void
|
||
onCancel: () => void
|
||
isVisible: boolean
|
||
}
|
||
|
||
const InvoiceForm: React.FC<InvoiceFormProps> = ({ invoice, onSave, onCancel, isVisible }) => {
|
||
const [formData, setFormData] = useState<Partial<FiInvoice>>({
|
||
invoiceNumber: '',
|
||
invoiceType: InvoiceTypeEnum.Sales,
|
||
currentAccountId: '',
|
||
invoiceDate: new Date(),
|
||
dueDate: new Date(),
|
||
deliveryDate: new Date(),
|
||
subtotal: 0,
|
||
taxAmount: 0,
|
||
discountAmount: 0,
|
||
totalAmount: 0,
|
||
paidAmount: 0,
|
||
remainingAmount: 0,
|
||
currency: 'TRY',
|
||
status: InvoiceStatusEnum.Draft,
|
||
paymentStatus: PaymentStatusEnum.Unpaid,
|
||
items: [],
|
||
waybillNumber: '',
|
||
notes: '',
|
||
})
|
||
|
||
const [newItem, setNewItem] = useState<Partial<FiInvoiceItem>>({
|
||
description: '',
|
||
quantity: 1,
|
||
unitPrice: 0,
|
||
taxRate: 18,
|
||
discountRate: 0,
|
||
unit: 'Adet',
|
||
})
|
||
|
||
useEffect(() => {
|
||
if (invoice) {
|
||
setFormData({
|
||
...invoice,
|
||
invoiceDate: new Date(invoice.invoiceDate),
|
||
dueDate: new Date(invoice.dueDate),
|
||
deliveryDate: invoice.deliveryDate ? new Date(invoice.deliveryDate) : new Date(),
|
||
})
|
||
} else {
|
||
// Generate new invoice number
|
||
const now = new Date()
|
||
const year = now.getFullYear()
|
||
const month = String(now.getMonth() + 1).padStart(2, '0')
|
||
const invoiceNumber = `FT${year}${month}${String(Math.floor(Math.random() * 10000)).padStart(
|
||
4,
|
||
'0',
|
||
)}`
|
||
|
||
setFormData((prev) => ({
|
||
...prev,
|
||
invoiceNumber,
|
||
invoiceDate: now,
|
||
dueDate: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), // 30 days
|
||
deliveryDate: now,
|
||
}))
|
||
}
|
||
}, [invoice])
|
||
|
||
const calculateItemTotal = (item: Partial<FiInvoiceItem>) => {
|
||
const subtotal = (item.quantity || 0) * (item.unitPrice || 0)
|
||
const discountAmount = subtotal * ((item.discountRate || 0) / 100)
|
||
const taxableAmount = subtotal - discountAmount
|
||
const taxAmount = taxableAmount * ((item.taxRate || 0) / 100)
|
||
return taxableAmount + taxAmount
|
||
}
|
||
|
||
const calculateInvoiceTotals = (items: FiInvoiceItem[]) => {
|
||
const subtotal = items.reduce((sum, item) => sum + item.quantity * item.unitPrice, 0)
|
||
const discountAmount = items.reduce((sum, item) => sum + item.discountAmount, 0)
|
||
const taxAmount = items.reduce((sum, item) => sum + item.taxAmount, 0)
|
||
const totalAmount = items.reduce((sum, item) => sum + item.lineTotal, 0)
|
||
|
||
return {
|
||
subtotal,
|
||
discountAmount,
|
||
taxAmount,
|
||
totalAmount,
|
||
}
|
||
}
|
||
|
||
const handleAddItem = () => {
|
||
if (!newItem.description || !newItem.quantity || !newItem.unitPrice) {
|
||
alert('Lütfen ürün bilgilerini doldurun')
|
||
return
|
||
}
|
||
|
||
const item: FiInvoiceItem = {
|
||
id: Date.now().toString(),
|
||
invoiceId: formData.id || '',
|
||
description: newItem.description!,
|
||
quantity: newItem.quantity!,
|
||
unitPrice: newItem.unitPrice!,
|
||
unit: newItem.unit || 'Adet',
|
||
taxRate: newItem.taxRate || 18,
|
||
discountRate: newItem.discountRate || 0,
|
||
lineTotal: calculateItemTotal(newItem),
|
||
discountAmount:
|
||
(newItem.quantity || 0) * (newItem.unitPrice || 0) * ((newItem.discountRate || 0) / 100),
|
||
taxAmount:
|
||
((newItem.quantity || 0) * (newItem.unitPrice || 0) -
|
||
(newItem.quantity || 0) *
|
||
(newItem.unitPrice || 0) *
|
||
((newItem.discountRate || 0) / 100)) *
|
||
((newItem.taxRate || 0) / 100),
|
||
netAmount: calculateItemTotal(newItem),
|
||
}
|
||
|
||
const updatedItems = [...(formData.items || []), item]
|
||
const totals = calculateInvoiceTotals(updatedItems)
|
||
|
||
setFormData((prev) => ({
|
||
...prev,
|
||
items: updatedItems,
|
||
...totals,
|
||
remainingAmount: totals.totalAmount - (prev.paidAmount || 0),
|
||
}))
|
||
|
||
setNewItem({
|
||
description: '',
|
||
quantity: 1,
|
||
unitPrice: 0,
|
||
taxRate: 18,
|
||
discountRate: 0,
|
||
unit: 'Adet',
|
||
})
|
||
}
|
||
|
||
const handleRemoveItem = (itemId: string) => {
|
||
const updatedItems = formData.items?.filter((item) => item.id !== itemId) || []
|
||
const totals = calculateInvoiceTotals(updatedItems)
|
||
|
||
setFormData((prev) => ({
|
||
...prev,
|
||
items: updatedItems,
|
||
...totals,
|
||
remainingAmount: totals.totalAmount - (prev.paidAmount || 0),
|
||
}))
|
||
}
|
||
|
||
const handleSubmit = (e: React.FormEvent) => {
|
||
e.preventDefault()
|
||
if (!formData.currentAccountId) {
|
||
alert('Lütfen cari hesap seçin')
|
||
return
|
||
}
|
||
if (!formData.items?.length) {
|
||
alert('Lütfen en az bir ürün ekleyin')
|
||
return
|
||
}
|
||
|
||
onSave({
|
||
...formData,
|
||
id: invoice?.id || Date.now().toString(),
|
||
creationTime: invoice?.creationTime || new Date(),
|
||
lastModificationTime: new Date(),
|
||
})
|
||
}
|
||
|
||
const formatCurrency = (amount: number) => {
|
||
return amount.toLocaleString('tr-TR', {
|
||
style: 'currency',
|
||
currency: 'TRY',
|
||
minimumFractionDigits: 2,
|
||
})
|
||
}
|
||
|
||
if (!isVisible) return null
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center p-4">
|
||
<div className="bg-white rounded-lg shadow-xl max-w-5xl w-full max-h-[90vh] overflow-y-auto">
|
||
<div className="p-4 border-b border-gray-200">
|
||
<div className="flex items-center justify-between">
|
||
<div className="flex items-center gap-2.5">
|
||
<FaFileAlt className="w-5 h-5 text-blue-600" />
|
||
<h2 className="text-lg font-semibold text-gray-900">
|
||
{invoice ? 'Fatura Düzenle' : 'Yeni Fatura'}
|
||
</h2>
|
||
</div>
|
||
<button
|
||
onClick={onCancel}
|
||
className="p-2 hover:bg-gray-100 rounded-md transition-colors"
|
||
>
|
||
<FaTimes className="w-5 h-5 text-gray-500" />
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<form onSubmit={handleSubmit} className="p-4">
|
||
{/* Basic Information */}
|
||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">Fatura No</label>
|
||
<input
|
||
type="text"
|
||
value={formData.invoiceNumber}
|
||
onChange={(e) => setFormData({ ...formData, invoiceNumber: e.target.value })}
|
||
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"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">Fatura Türü</label>
|
||
<select
|
||
value={formData.invoiceType}
|
||
onChange={(e) =>
|
||
setFormData({
|
||
...formData,
|
||
invoiceType: e.target.value as InvoiceTypeEnum,
|
||
})
|
||
}
|
||
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(InvoiceTypeEnum).map((type) => (
|
||
<option key={type} value={type}>
|
||
{getInvoiceTypeText(type)}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">İrsaliye No</label>
|
||
<input
|
||
type="text"
|
||
value={formData.waybillNumber || ''}
|
||
onChange={(e) => setFormData({ ...formData, waybillNumber: e.target.value })}
|
||
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"
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">Cari Hesap</label>
|
||
<select
|
||
value={formData.currentAccountId}
|
||
onChange={(e) => setFormData({ ...formData, currentAccountId: e.target.value })}
|
||
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"
|
||
required
|
||
>
|
||
<option value="">Cari Hesap Seçin</option>
|
||
{mockCurrentAccounts.map((account) => (
|
||
<option key={account.id} value={account.id}>
|
||
{account.accountCode}
|
||
</option>
|
||
))}
|
||
</select>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">Fatura Tarihi</label>
|
||
<input
|
||
type="date"
|
||
value={formData.invoiceDate?.toISOString().split('T')[0]}
|
||
onChange={(e) =>
|
||
setFormData({
|
||
...formData,
|
||
invoiceDate: new Date(e.target.value),
|
||
})
|
||
}
|
||
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"
|
||
required
|
||
/>
|
||
</div>
|
||
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-2">Vade Tarihi</label>
|
||
<input
|
||
type="date"
|
||
value={formData.dueDate?.toISOString().split('T')[0]}
|
||
onChange={(e) =>
|
||
setFormData({
|
||
...formData,
|
||
dueDate: new Date(e.target.value),
|
||
})
|
||
}
|
||
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"
|
||
required
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Invoice Items */}
|
||
<div className="mb-6">
|
||
<h3 className="text-base font-semibold text-gray-900 mb-3">Fatura Kalemleri</h3>
|
||
|
||
{/* Add New Item */}
|
||
<div className="bg-gray-50 p-3 rounded-lg mb-3">
|
||
<div className="grid grid-cols-1 md:grid-cols-6 gap-3">
|
||
<div className="md:col-span-2">
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Ürün/Hizmet Açıklaması
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={newItem.description || ''}
|
||
onChange={(e) => setNewItem({ ...newItem, description: e.target.value })}
|
||
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="Ürün/Hizmet açıklaması"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">Miktar</label>
|
||
<input
|
||
type="number"
|
||
value={newItem.quantity || ''}
|
||
onChange={(e) =>
|
||
setNewItem({
|
||
...newItem,
|
||
quantity: parseFloat(e.target.value) || 0,
|
||
})
|
||
}
|
||
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"
|
||
step="0.01"
|
||
min="0"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
||
Birim Fiyat
|
||
</label>
|
||
<input
|
||
type="number"
|
||
value={newItem.unitPrice || ''}
|
||
onChange={(e) =>
|
||
setNewItem({
|
||
...newItem,
|
||
unitPrice: parseFloat(e.target.value) || 0,
|
||
})
|
||
}
|
||
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"
|
||
step="0.01"
|
||
min="0"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">KDV (%)</label>
|
||
<select
|
||
value={newItem.taxRate || 20}
|
||
onChange={(e) =>
|
||
setNewItem({
|
||
...newItem,
|
||
taxRate: parseFloat(e.target.value),
|
||
})
|
||
}
|
||
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={0}>0%</option>
|
||
<option value={1}>1%</option>
|
||
<option value={8}>8%</option>
|
||
<option value={18}>18%</option>
|
||
<option value={20}>20%</option>
|
||
</select>
|
||
</div>
|
||
<div className="flex items-end">
|
||
<button
|
||
type="button"
|
||
onClick={handleAddItem}
|
||
className="w-full px-3 py-1.5 text-sm bg-blue-600 text-white rounded-md hover:bg-blue-700 transition-colors flex items-center justify-center gap-2"
|
||
>
|
||
<FaPlus className="w-4 h-4" />
|
||
Ekle
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Items List */}
|
||
{formData.items && formData.items.length > 0 && (
|
||
<div className="border border-gray-200 rounded-lg overflow-hidden">
|
||
<table className="w-full">
|
||
<thead className="bg-gray-50">
|
||
<tr>
|
||
<th className="px-3 py-2 text-left text-xs font-medium text-gray-700 uppercase">
|
||
Açıklama
|
||
</th>
|
||
<th className="px-3 py-2 text-right text-xs font-medium text-gray-700 uppercase">
|
||
Miktar
|
||
</th>
|
||
<th className="px-3 py-2 text-right text-xs font-medium text-gray-700 uppercase">
|
||
Birim Fiyat
|
||
</th>
|
||
<th className="px-3 py-2 text-right text-xs font-medium text-gray-700 uppercase">
|
||
KDV %
|
||
</th>
|
||
<th className="px-3 py-2 text-right text-xs font-medium text-gray-700 uppercase">
|
||
Toplam
|
||
</th>
|
||
<th className="px-3 py-2 text-center text-xs font-medium text-gray-700 uppercase">
|
||
İşlem
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-200">
|
||
{formData.items.map((item) => (
|
||
<tr key={item.id} className="text-sm">
|
||
<td className="px-3 py-2 text-gray-900">{item.description}</td>
|
||
<td className="px-3 py-2 text-gray-900 text-right">
|
||
{item.quantity.toLocaleString('tr-TR')}
|
||
</td>
|
||
<td className="px-3 py-2 text-gray-900 text-right">
|
||
{formatCurrency(item.unitPrice)}
|
||
</td>
|
||
<td className="px-3 py-2 text-gray-900 text-right">%{item.taxRate}</td>
|
||
<td className="px-3 py-2 text-gray-900 text-right font-medium">
|
||
{formatCurrency(item.lineTotal)}
|
||
</td>
|
||
<td className="px-3 py-2 text-center">
|
||
<button
|
||
type="button"
|
||
onClick={() => handleRemoveItem(item.id)}
|
||
className="p-1 text-red-600 hover:bg-red-50 rounded"
|
||
>
|
||
<FaTrash className="w-4 h-4" />
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Totals */}
|
||
<div className="bg-gray-50 p-4 rounded-lg mb-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-2">Notlar</label>
|
||
<textarea
|
||
value={formData.notes || ''}
|
||
onChange={(e) => setFormData({ ...formData, notes: e.target.value })}
|
||
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="Fatura ile ilgili notlar..."
|
||
/>
|
||
</div>
|
||
<div className="space-y-2 text-sm">
|
||
<div className="flex justify-between items-center">
|
||
<span className="text-sm text-gray-600">Ara Toplam:</span>
|
||
<span className="font-medium">{formatCurrency(formData.subtotal || 0)}</span>
|
||
</div>
|
||
<div className="flex justify-between items-center">
|
||
<span className="text-sm text-gray-600">İndirim:</span>
|
||
<span className="font-medium text-red-600">
|
||
-{formatCurrency(formData.discountAmount || 0)}
|
||
</span>
|
||
</div>
|
||
<div className="flex justify-between items-center">
|
||
<span className="text-sm text-gray-600">KDV:</span>
|
||
<span className="font-medium">{formatCurrency(formData.taxAmount || 0)}</span>
|
||
</div>
|
||
<div className="border-t pt-3">
|
||
<div className="flex justify-between items-center">
|
||
<span className="text-base font-semibold text-gray-900">Genel Toplam:</span>
|
||
<span className="text-base font-bold text-blue-600">
|
||
{formatCurrency(formData.totalAmount || 0)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Actions */}
|
||
<div className="flex justify-end gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={onCancel}
|
||
className="px-4 py-1.5 text-sm border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 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 flex items-center gap-2"
|
||
>
|
||
<FaSave className="w-4 h-4" />
|
||
{invoice ? 'Güncelle' : 'Kaydet'}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
export default InvoiceForm
|