- Express backend with PostgreSQL (JWT auth, full CRUD) - React + Vite + TailwindCSS frontend in Hebrew (RTL) - Features: Digital Booking System, Guest Management, Smart Budget Management - Docker Compose with postgres healthcheck - Auto-runs migrations on startup Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
196 lines
10 KiB
JavaScript
196 lines
10 KiB
JavaScript
import { useState, useEffect } from 'react'
|
||
import { useParams, Link } from 'react-router-dom'
|
||
import { Plus, Trash2, Edit, ArrowRight, TrendingUp, TrendingDown, AlertTriangle } from 'lucide-react'
|
||
import api from '../api'
|
||
|
||
const CATEGORIES = ['אולם', 'קייטרינג', 'מוזיקה/DJ', 'צילום/וידאו', 'פרחים', 'הזמנות', 'הסעות', 'אחר']
|
||
const STATUS_OPTIONS = { planned: 'מתוכנן', paid: 'שולם', partial: 'חלקי', cancelled: 'בוטל' }
|
||
|
||
export default function BudgetPage() {
|
||
const { id } = useParams()
|
||
const [items, setItems] = useState([])
|
||
const [summary, setSummary] = useState(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [showForm, setShowForm] = useState(false)
|
||
const [editItem, setEditItem] = useState(null)
|
||
const [form, setForm] = useState({ category: 'אחר', description: '', estimated_cost: '', actual_cost: '', status: 'planned' })
|
||
|
||
useEffect(() => { loadData() }, [id])
|
||
|
||
async function loadData() {
|
||
try {
|
||
const [itemsRes, summaryRes] = await Promise.all([
|
||
api.get(`/budget/event/${id}`),
|
||
api.get(`/budget/event/${id}/summary`),
|
||
])
|
||
setItems(itemsRes.data)
|
||
setSummary(summaryRes.data)
|
||
} catch (err) { console.error(err) }
|
||
finally { setLoading(false) }
|
||
}
|
||
|
||
function openCreate() {
|
||
setEditItem(null)
|
||
setForm({ category: 'אחר', description: '', estimated_cost: '', actual_cost: '', status: 'planned' })
|
||
setShowForm(true)
|
||
}
|
||
|
||
function openEdit(item) {
|
||
setEditItem(item)
|
||
setForm({ category: item.category, description: item.description || '', estimated_cost: item.estimated_cost || '', actual_cost: item.actual_cost || '', status: item.status })
|
||
setShowForm(true)
|
||
}
|
||
|
||
async function saveItem(e) {
|
||
e.preventDefault()
|
||
try {
|
||
const data = { ...form, estimated_cost: parseFloat(form.estimated_cost) || 0, actual_cost: form.actual_cost ? parseFloat(form.actual_cost) : null }
|
||
if (editItem) {
|
||
await api.put(`/budget/${editItem.id}`, data)
|
||
} else {
|
||
await api.post('/budget', { ...data, event_id: id })
|
||
}
|
||
setShowForm(false)
|
||
loadData()
|
||
} catch (err) { console.error(err) }
|
||
}
|
||
|
||
async function deleteItem(itemId) {
|
||
if (!confirm('מחוק פריט?')) return
|
||
try {
|
||
await api.delete(`/budget/${itemId}`)
|
||
loadData()
|
||
} catch (err) { console.error(err) }
|
||
}
|
||
|
||
if (loading) return <div className="flex justify-center py-20 text-gray-400">טוען...</div>
|
||
|
||
const overBudget = summary?.over_budget
|
||
|
||
return (
|
||
<div>
|
||
<div className="flex items-center gap-2 mb-6 text-sm text-gray-500">
|
||
<Link to="/" className="hover:text-indigo-600 flex items-center gap-1"><ArrowRight className="w-4 h-4" /> הדשבורד</Link>
|
||
<span>/</span>
|
||
<Link to={`/events/${id}`} className="hover:text-indigo-600">האירוע</Link>
|
||
<span>/</span>
|
||
<span className="text-gray-800 font-medium">תקציב</span>
|
||
</div>
|
||
|
||
<div className="flex items-center justify-between mb-4">
|
||
<h1 className="text-2xl font-bold text-gray-900">ניהול תקציב</h1>
|
||
<button onClick={openCreate} className="flex items-center gap-2 bg-indigo-600 hover:bg-indigo-700 text-white px-4 py-2 rounded-lg transition-colors">
|
||
<Plus className="w-4 h-4" /> הוסף הוצאה
|
||
</button>
|
||
</div>
|
||
|
||
{/* Summary */}
|
||
{summary && (
|
||
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-6">
|
||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||
<p className="text-xs text-gray-500 mb-1">תקציב כולל</p>
|
||
<p className="text-xl font-bold text-gray-800">₪{Number(summary.event_budget).toLocaleString('he-IL')}</p>
|
||
</div>
|
||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||
<p className="text-xs text-gray-500 mb-1">הוצאות מתוכננות</p>
|
||
<p className="text-xl font-bold text-blue-600">₪{Number(summary.total_estimated).toLocaleString('he-IL')}</p>
|
||
</div>
|
||
<div className="bg-white rounded-xl border border-gray-200 p-4">
|
||
<p className="text-xs text-gray-500 mb-1">הוצאות בפועל</p>
|
||
<p className="text-xl font-bold text-green-600">₪{Number(summary.total_actual).toLocaleString('he-IL')}</p>
|
||
</div>
|
||
<div className={`rounded-xl border p-4 ${overBudget ? 'bg-red-50 border-red-200' : 'bg-green-50 border-green-200'}`}>
|
||
<p className="text-xs text-gray-500 mb-1">יתרת תקציב</p>
|
||
<div className="flex items-center gap-1">
|
||
{overBudget ? <TrendingDown className="w-4 h-4 text-red-500" /> : <TrendingUp className="w-4 h-4 text-green-500" />}
|
||
<p className={`text-xl font-bold ${overBudget ? 'text-red-600' : 'text-green-600'}`}>
|
||
₪{Number(Math.abs(summary.remaining_budget)).toLocaleString('he-IL')}
|
||
</p>
|
||
</div>
|
||
{overBudget && <p className="text-xs text-red-500 mt-1 flex items-center gap-1"><AlertTriangle className="w-3 h-3" /> חריגה מהתקציב!</p>}
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Form */}
|
||
{showForm && (
|
||
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
|
||
<h2 className="text-lg font-semibold mb-4">{editItem ? 'עריכת הוצאה' : 'הוספת הוצאה'}</h2>
|
||
<form onSubmit={saveItem} className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">קטגוריה *</label>
|
||
<select required value={form.category} onChange={e => setForm({ ...form, category: e.target.value })}
|
||
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500">
|
||
{CATEGORIES.map(c => <option key={c} value={c}>{c}</option>)}
|
||
</select>
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">תיאור</label>
|
||
<input value={form.description} onChange={e => setForm({ ...form, description: e.target.value })}
|
||
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500" />
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">עלות מוערכת (₪) *</label>
|
||
<input type="number" min="0" required value={form.estimated_cost} onChange={e => setForm({ ...form, estimated_cost: e.target.value })}
|
||
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500" />
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">עלות בפועל (₪)</label>
|
||
<input type="number" min="0" value={form.actual_cost} onChange={e => setForm({ ...form, actual_cost: e.target.value })}
|
||
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500" />
|
||
</div>
|
||
<div>
|
||
<label className="block text-sm font-medium text-gray-700 mb-1">סטטוס</label>
|
||
<select value={form.status} onChange={e => setForm({ ...form, status: e.target.value })}
|
||
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500">
|
||
{Object.entries(STATUS_OPTIONS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||
</select>
|
||
</div>
|
||
<div className="sm:col-span-2 flex gap-3">
|
||
<button type="submit" className="bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-2 rounded-lg transition-colors">שמור</button>
|
||
<button type="button" onClick={() => setShowForm(false)} className="border border-gray-300 hover:bg-gray-50 px-6 py-2 rounded-lg transition-colors">ביטול</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
)}
|
||
|
||
{/* Items table */}
|
||
<div className="bg-white rounded-xl border border-gray-200 overflow-hidden">
|
||
<table className="w-full text-sm">
|
||
<thead className="bg-gray-50 border-b border-gray-200">
|
||
<tr>
|
||
<th className="text-right px-4 py-3 font-medium text-gray-600">קטגוריה</th>
|
||
<th className="text-right px-4 py-3 font-medium text-gray-600 hidden sm:table-cell">תיאור</th>
|
||
<th className="text-right px-4 py-3 font-medium text-gray-600">מוערך</th>
|
||
<th className="text-right px-4 py-3 font-medium text-gray-600">בפועל</th>
|
||
<th className="text-right px-4 py-3 font-medium text-gray-600 hidden md:table-cell">סטטוס</th>
|
||
<th className="px-4 py-3"></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="divide-y divide-gray-100">
|
||
{items.length === 0 ? (
|
||
<tr><td colSpan={6} className="text-center py-8 text-gray-400">אין פריטים בתקציב</td></tr>
|
||
) : items.map(item => (
|
||
<tr key={item.id} className="hover:bg-gray-50">
|
||
<td className="px-4 py-3 font-medium">{item.category}</td>
|
||
<td className="px-4 py-3 text-gray-500 hidden sm:table-cell">{item.description || '—'}</td>
|
||
<td className="px-4 py-3 text-blue-600 font-medium">₪{Number(item.estimated_cost).toLocaleString('he-IL')}</td>
|
||
<td className="px-4 py-3 text-green-600 font-medium">{item.actual_cost ? `₪${Number(item.actual_cost).toLocaleString('he-IL')}` : '—'}</td>
|
||
<td className="px-4 py-3 hidden md:table-cell">
|
||
<span className="text-xs px-2 py-1 bg-gray-100 text-gray-600 rounded-full">{STATUS_OPTIONS[item.status] || item.status}</span>
|
||
</td>
|
||
<td className="px-4 py-3">
|
||
<div className="flex gap-1 justify-end">
|
||
<button onClick={() => openEdit(item)} className="p-1.5 hover:bg-indigo-50 rounded text-indigo-600"><Edit className="w-4 h-4" /></button>
|
||
<button onClick={() => deleteItem(item.id)} className="p-1.5 hover:bg-red-50 rounded text-red-500"><Trash2 className="w-4 h-4" /></button>
|
||
</div>
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|