Initial fullstack scaffold: Events, Guests, Budget, Bookings
- 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>
This commit is contained in:
21
Dockerfile
Normal file
21
Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install backend deps
|
||||||
|
COPY package*.json ./
|
||||||
|
RUN npm install --production
|
||||||
|
|
||||||
|
# Build frontend
|
||||||
|
COPY client/package*.json ./client/
|
||||||
|
RUN cd client && npm install
|
||||||
|
|
||||||
|
COPY client/ ./client/
|
||||||
|
RUN cd client && npm run build
|
||||||
|
|
||||||
|
# Copy backend source
|
||||||
|
COPY server.js db.js migrate.js ./
|
||||||
|
COPY routes/ ./routes/
|
||||||
|
|
||||||
|
# Run migrations then start server
|
||||||
|
CMD node migrate.js && node server.js
|
||||||
13
client/index.html
Normal file
13
client/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="he" dir="rtl">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>אירועית - ניהול אירועים</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
31
client/package.json
Normal file
31
client/package.json
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
{
|
||||||
|
"name": "airewit-client",
|
||||||
|
"private": true,
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^18.2.0",
|
||||||
|
"react-dom": "^18.2.0",
|
||||||
|
"react-router-dom": "^6.21.0",
|
||||||
|
"zustand": "^4.4.7",
|
||||||
|
"@tanstack/react-query": "^5.17.0",
|
||||||
|
"axios": "^1.6.3",
|
||||||
|
"lucide-react": "^0.303.0",
|
||||||
|
"clsx": "^2.1.0",
|
||||||
|
"tailwind-merge": "^2.2.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^18.2.43",
|
||||||
|
"@types/react-dom": "^18.2.17",
|
||||||
|
"@vitejs/plugin-react": "^4.2.1",
|
||||||
|
"autoprefixer": "^10.4.16",
|
||||||
|
"postcss": "^8.4.32",
|
||||||
|
"tailwindcss": "^3.4.0",
|
||||||
|
"vite": "^5.0.8"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
client/postcss.config.js
Normal file
6
client/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
39
client/src/App.jsx
Normal file
39
client/src/App.jsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'
|
||||||
|
import LoginPage from './pages/LoginPage'
|
||||||
|
import RegisterPage from './pages/RegisterPage'
|
||||||
|
import DashboardPage from './pages/DashboardPage'
|
||||||
|
import EventPage from './pages/EventPage'
|
||||||
|
import GuestsPage from './pages/GuestsPage'
|
||||||
|
import BudgetPage from './pages/BudgetPage'
|
||||||
|
import BookingsPage from './pages/BookingsPage'
|
||||||
|
import Layout from './components/Layout'
|
||||||
|
|
||||||
|
function PrivateRoute({ children }) {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
return token ? children : <Navigate to="/login" replace />
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/register" element={<RegisterPage />} />
|
||||||
|
<Route
|
||||||
|
path="/"
|
||||||
|
element={
|
||||||
|
<PrivateRoute>
|
||||||
|
<Layout />
|
||||||
|
</PrivateRoute>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route index element={<DashboardPage />} />
|
||||||
|
<Route path="events/:id" element={<EventPage />} />
|
||||||
|
<Route path="events/:id/guests" element={<GuestsPage />} />
|
||||||
|
<Route path="events/:id/budget" element={<BudgetPage />} />
|
||||||
|
<Route path="events/:id/bookings" element={<BookingsPage />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
)
|
||||||
|
}
|
||||||
25
client/src/api.js
Normal file
25
client/src/api.js
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
|
||||||
|
const api = axios.create({
|
||||||
|
baseURL: '/api',
|
||||||
|
})
|
||||||
|
|
||||||
|
api.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('token')
|
||||||
|
if (token) config.headers.Authorization = `Bearer ${token}`
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
api.interceptors.response.use(
|
||||||
|
(res) => res,
|
||||||
|
(err) => {
|
||||||
|
if (err.response?.status === 401) {
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
localStorage.removeItem('user')
|
||||||
|
window.location.href = '/login'
|
||||||
|
}
|
||||||
|
return Promise.reject(err)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
export default api
|
||||||
41
client/src/components/Layout.jsx
Normal file
41
client/src/components/Layout.jsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { Outlet, Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { CalendarDays, LogOut, Menu } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const user = JSON.parse(localStorage.getItem('user') || '{}')
|
||||||
|
const [mobileOpen, setMobileOpen] = useState(false)
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
localStorage.removeItem('token')
|
||||||
|
localStorage.removeItem('user')
|
||||||
|
navigate('/login')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex flex-col">
|
||||||
|
<header className="bg-indigo-700 text-white shadow-md">
|
||||||
|
<div className="max-w-7xl mx-auto px-4 py-3 flex items-center justify-between">
|
||||||
|
<Link to="/" className="flex items-center gap-2 text-xl font-bold">
|
||||||
|
<CalendarDays className="w-6 h-6" />
|
||||||
|
אירועית
|
||||||
|
</Link>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<span className="text-indigo-200 text-sm hidden sm:block">שלום, {user.name}</span>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className="flex items-center gap-1 text-sm hover:text-indigo-200 transition-colors"
|
||||||
|
>
|
||||||
|
<LogOut className="w-4 h-4" />
|
||||||
|
<span className="hidden sm:inline">יציאה</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<main className="flex-1 max-w-7xl mx-auto w-full px-4 py-6">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
12
client/src/index.css
Normal file
12
client/src/index.css
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
@tailwind base;
|
||||||
|
@tailwind components;
|
||||||
|
@tailwind utilities;
|
||||||
|
|
||||||
|
:root {
|
||||||
|
font-family: 'Segoe UI', system-ui, -apple-system, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
direction: rtl;
|
||||||
|
background-color: #f9fafb;
|
||||||
|
}
|
||||||
10
client/src/main.jsx
Normal file
10
client/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
185
client/src/pages/BookingsPage.jsx
Normal file
185
client/src/pages/BookingsPage.jsx
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useParams, Link } from 'react-router-dom'
|
||||||
|
import { Plus, Trash2, Edit, ArrowRight, Phone } from 'lucide-react'
|
||||||
|
import api from '../api'
|
||||||
|
|
||||||
|
const SUPPLIER_TYPES = ['קייטרינג', 'מוזיקה', 'צילום', 'וידאו', 'פרחים', 'אולם', 'הסעות', 'אינסטלציה', 'תאורה', 'אחר']
|
||||||
|
const STATUS_OPTIONS = { pending: 'ממתין', confirmed: 'מאושר', cancelled: 'בוטל', completed: 'הושלם' }
|
||||||
|
const STATUS_COLORS = {
|
||||||
|
pending: 'bg-yellow-100 text-yellow-700',
|
||||||
|
confirmed: 'bg-green-100 text-green-700',
|
||||||
|
cancelled: 'bg-red-100 text-red-600',
|
||||||
|
completed: 'bg-gray-100 text-gray-600',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BookingsPage() {
|
||||||
|
const { id } = useParams()
|
||||||
|
const [bookings, setBookings] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
const [editBooking, setEditBooking] = useState(null)
|
||||||
|
const [form, setForm] = useState({ supplier_name: '', supplier_type: 'אחר', contact_info: '', cost: '', status: 'pending', notes: '' })
|
||||||
|
|
||||||
|
useEffect(() => { loadBookings() }, [id])
|
||||||
|
|
||||||
|
async function loadBookings() {
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/bookings/event/${id}`)
|
||||||
|
setBookings(res.data)
|
||||||
|
} catch (err) { console.error(err) }
|
||||||
|
finally { setLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setEditBooking(null)
|
||||||
|
setForm({ supplier_name: '', supplier_type: 'אחר', contact_info: '', cost: '', status: 'pending', notes: '' })
|
||||||
|
setShowForm(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(b) {
|
||||||
|
setEditBooking(b)
|
||||||
|
setForm({ supplier_name: b.supplier_name, supplier_type: b.supplier_type || 'אחר', contact_info: b.contact_info || '', cost: b.cost || '', status: b.status, notes: b.notes || '' })
|
||||||
|
setShowForm(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBooking(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
try {
|
||||||
|
const data = { ...form, cost: parseFloat(form.cost) || 0 }
|
||||||
|
if (editBooking) {
|
||||||
|
await api.put(`/bookings/${editBooking.id}`, data)
|
||||||
|
} else {
|
||||||
|
await api.post('/bookings', { ...data, event_id: id })
|
||||||
|
}
|
||||||
|
setShowForm(false)
|
||||||
|
loadBookings()
|
||||||
|
} catch (err) { console.error(err) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteBooking(bookingId) {
|
||||||
|
if (!confirm('מחוק הזמנה?')) return
|
||||||
|
try {
|
||||||
|
await api.delete(`/bookings/${bookingId}`)
|
||||||
|
setBookings(bookings.filter(b => b.id !== bookingId))
|
||||||
|
} catch (err) { console.error(err) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalCost = bookings.reduce((s, b) => s + parseFloat(b.cost || 0), 0)
|
||||||
|
const confirmedCount = bookings.filter(b => b.status === 'confirmed').length
|
||||||
|
|
||||||
|
if (loading) return <div className="flex justify-center py-20 text-gray-400">טוען...</div>
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-3 gap-4 mb-6">
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-4 text-center">
|
||||||
|
<p className="text-2xl font-bold text-gray-800">{bookings.length}</p>
|
||||||
|
<p className="text-xs text-gray-500">סה"כ הזמנות</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-4 text-center">
|
||||||
|
<p className="text-2xl font-bold text-green-600">{confirmedCount}</p>
|
||||||
|
<p className="text-xs text-gray-500">מאושרות</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-4 text-center">
|
||||||
|
<p className="text-2xl font-bold text-indigo-600">₪{Number(totalCost).toLocaleString('he-IL')}</p>
|
||||||
|
<p className="text-xs text-gray-500">עלות כוללת</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">{editBooking ? 'עריכת הזמנה' : 'הוספת הזמנה'}</h2>
|
||||||
|
<form onSubmit={saveBooking} 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>
|
||||||
|
<input required value={form.supplier_name} onChange={e => setForm({ ...form, supplier_name: 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.supplier_type} onChange={e => setForm({ ...form, supplier_type: 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">
|
||||||
|
{SUPPLIER_TYPES.map(t => <option key={t} value={t}>{t}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">פרטי קשר</label>
|
||||||
|
<input value={form.contact_info} onChange={e => setForm({ ...form, contact_info: 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.cost} onChange={e => setForm({ ...form, 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>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">הערות</label>
|
||||||
|
<input value={form.notes} onChange={e => setForm({ ...form, notes: 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 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bookings list */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
{bookings.length === 0 ? (
|
||||||
|
<div className="col-span-2 text-center py-20 text-gray-400">אין הזמנות ספקים עדיין</div>
|
||||||
|
) : bookings.map(b => (
|
||||||
|
<div key={b.id} className="bg-white rounded-xl border border-gray-200 p-4">
|
||||||
|
<div className="flex items-start justify-between mb-2">
|
||||||
|
<div>
|
||||||
|
<p className="font-semibold text-gray-900">{b.supplier_name}</p>
|
||||||
|
<p className="text-sm text-gray-500">{b.supplier_type}</p>
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs px-2 py-1 rounded-full font-medium ${STATUS_COLORS[b.status] || 'bg-gray-100 text-gray-600'}`}>
|
||||||
|
{STATUS_OPTIONS[b.status] || b.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{b.contact_info && (
|
||||||
|
<p className="text-sm text-gray-500 flex items-center gap-1 mb-2">
|
||||||
|
<Phone className="w-3 h-3" /> {b.contact_info}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<p className="font-bold text-indigo-600">₪{Number(b.cost).toLocaleString('he-IL')}</p>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button onClick={() => openEdit(b)} className="p-1.5 hover:bg-indigo-50 rounded text-indigo-600"><Edit className="w-4 h-4" /></button>
|
||||||
|
<button onClick={() => deleteBooking(b.id)} className="p-1.5 hover:bg-red-50 rounded text-red-500"><Trash2 className="w-4 h-4" /></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{b.notes && <p className="text-xs text-gray-400 mt-2">{b.notes}</p>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
195
client/src/pages/BudgetPage.jsx
Normal file
195
client/src/pages/BudgetPage.jsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
203
client/src/pages/DashboardPage.jsx
Normal file
203
client/src/pages/DashboardPage.jsx
Normal file
@@ -0,0 +1,203 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { Plus, Calendar, Users, Wallet, Tag } from 'lucide-react'
|
||||||
|
import api from '../api'
|
||||||
|
|
||||||
|
const EVENT_TYPES = {
|
||||||
|
wedding: 'חתונה',
|
||||||
|
bar_mitzvah: 'בר/בת מצווה',
|
||||||
|
birthday: 'יום הולדת',
|
||||||
|
corporate: 'אירוע עסקי',
|
||||||
|
general: 'כללי',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABELS = {
|
||||||
|
planned: 'מתוכנן',
|
||||||
|
active: 'פעיל',
|
||||||
|
completed: 'הסתיים',
|
||||||
|
cancelled: 'בוטל',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLORS = {
|
||||||
|
planned: 'bg-blue-100 text-blue-700',
|
||||||
|
active: 'bg-green-100 text-green-700',
|
||||||
|
completed: 'bg-gray-100 text-gray-600',
|
||||||
|
cancelled: 'bg-red-100 text-red-600',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DashboardPage() {
|
||||||
|
const [events, setEvents] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
const [form, setForm] = useState({ name: '', date: '', location: '', event_type: 'general', budget: '' })
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadEvents()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
async function loadEvents() {
|
||||||
|
try {
|
||||||
|
const res = await api.get('/events')
|
||||||
|
setEvents(res.data)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createEvent(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setSaving(true)
|
||||||
|
try {
|
||||||
|
await api.post('/events', { ...form, budget: parseFloat(form.budget) || 0 })
|
||||||
|
setForm({ name: '', date: '', location: '', event_type: 'general', budget: '' })
|
||||||
|
setShowForm(false)
|
||||||
|
loadEvents()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.error || 'שגיאה ביצירת אירוע')
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <div className="flex justify-center py-20 text-gray-400">טוען...</div>
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">האירועים שלי</h1>
|
||||||
|
<button
|
||||||
|
onClick={() => setShowForm(!showForm)}
|
||||||
|
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>
|
||||||
|
|
||||||
|
{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">יצירת אירוע חדש</h2>
|
||||||
|
{error && <div className="bg-red-50 text-red-600 rounded-lg px-4 py-2 mb-4 text-sm">{error}</div>}
|
||||||
|
<form onSubmit={createEvent} 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>
|
||||||
|
<input
|
||||||
|
required
|
||||||
|
value={form.name}
|
||||||
|
onChange={e => setForm({ ...form, name: 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="datetime-local"
|
||||||
|
required
|
||||||
|
value={form.date}
|
||||||
|
onChange={e => setForm({ ...form, date: 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
|
||||||
|
value={form.location}
|
||||||
|
onChange={e => setForm({ ...form, location: 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.event_type}
|
||||||
|
onChange={e => setForm({ ...form, event_type: 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(EVENT_TYPES).map(([k, v]) => (
|
||||||
|
<option key={k} value={k}>{v}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">תקציב (₪)</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
min="0"
|
||||||
|
value={form.budget}
|
||||||
|
onChange={e => setForm({ ...form, budget: 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 className="sm:col-span-2 flex gap-3">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={saving}
|
||||||
|
className="bg-indigo-600 hover:bg-indigo-700 text-white px-6 py-2 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{saving ? 'שומר...' : 'צור אירוע'}
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{events.length === 0 ? (
|
||||||
|
<div className="text-center py-20 text-gray-400">
|
||||||
|
<Calendar className="w-16 h-16 mx-auto mb-4 opacity-30" />
|
||||||
|
<p className="text-lg">אין אירועים עדיין</p>
|
||||||
|
<p className="text-sm mt-1">לחץ על "אירוע חדש" כדי להתחיל</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{events.map(event => (
|
||||||
|
<Link
|
||||||
|
key={event.id}
|
||||||
|
to={`/events/${event.id}`}
|
||||||
|
className="bg-white rounded-xl shadow-sm border border-gray-200 p-5 hover:shadow-md transition-shadow"
|
||||||
|
>
|
||||||
|
<div className="flex items-start justify-between mb-3">
|
||||||
|
<h3 className="font-semibold text-gray-900">{event.name}</h3>
|
||||||
|
<span className={`text-xs px-2 py-1 rounded-full font-medium ${STATUS_COLORS[event.status] || 'bg-gray-100 text-gray-600'}`}>
|
||||||
|
{STATUS_LABELS[event.status] || event.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1 text-sm text-gray-500">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Calendar className="w-4 h-4" />
|
||||||
|
{new Date(event.date).toLocaleDateString('he-IL', { dateStyle: 'medium' })}
|
||||||
|
</div>
|
||||||
|
{event.location && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Tag className="w-4 h-4" />
|
||||||
|
{event.location}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{event.budget > 0 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Wallet className="w-4 h-4" />
|
||||||
|
₪{Number(event.budget).toLocaleString('he-IL')}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 pt-3 border-t border-gray-100 text-xs text-indigo-600 font-medium">
|
||||||
|
{EVENT_TYPES[event.event_type] || event.event_type}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
194
client/src/pages/EventPage.jsx
Normal file
194
client/src/pages/EventPage.jsx
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useParams, Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { Users, Wallet, CalendarDays, Trash2, Edit, ArrowRight, BookOpen } from 'lucide-react'
|
||||||
|
import api from '../api'
|
||||||
|
|
||||||
|
const EVENT_TYPES = {
|
||||||
|
wedding: 'חתונה', bar_mitzvah: 'בר/בת מצווה', birthday: 'יום הולדת',
|
||||||
|
corporate: 'אירוע עסקי', general: 'כללי',
|
||||||
|
}
|
||||||
|
const STATUS_OPTIONS = ['planned', 'active', 'completed', 'cancelled']
|
||||||
|
const STATUS_LABELS = { planned: 'מתוכנן', active: 'פעיל', completed: 'הסתיים', cancelled: 'בוטל' }
|
||||||
|
|
||||||
|
export default function EventPage() {
|
||||||
|
const { id } = useParams()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [event, setEvent] = useState(null)
|
||||||
|
const [stats, setStats] = useState(null)
|
||||||
|
const [editing, setEditing] = useState(false)
|
||||||
|
const [form, setForm] = useState({})
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadEvent()
|
||||||
|
}, [id])
|
||||||
|
|
||||||
|
async function loadEvent() {
|
||||||
|
try {
|
||||||
|
const [eRes, sRes] = await Promise.all([
|
||||||
|
api.get(`/events/${id}`),
|
||||||
|
api.get(`/events/${id}/stats`),
|
||||||
|
])
|
||||||
|
setEvent(eRes.data)
|
||||||
|
setStats(sRes.data)
|
||||||
|
setForm({
|
||||||
|
name: eRes.data.name,
|
||||||
|
date: eRes.data.date?.slice(0, 16),
|
||||||
|
location: eRes.data.location || '',
|
||||||
|
event_type: eRes.data.event_type,
|
||||||
|
budget: eRes.data.budget,
|
||||||
|
status: eRes.data.status,
|
||||||
|
notes: eRes.data.notes || '',
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEvent(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
try {
|
||||||
|
const res = await api.put(`/events/${id}`, { ...form, budget: parseFloat(form.budget) || 0 })
|
||||||
|
setEvent(res.data)
|
||||||
|
setEditing(false)
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteEvent() {
|
||||||
|
if (!confirm('האם אתה בטוח שברצונך למחוק את האירוע?')) return
|
||||||
|
try {
|
||||||
|
await api.delete(`/events/${id}`)
|
||||||
|
navigate('/')
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <div className="flex justify-center py-20 text-gray-400">טוען...</div>
|
||||||
|
if (!event) return <div className="text-center py-20 text-gray-400">האירוע לא נמצא</div>
|
||||||
|
|
||||||
|
const totalGuests = stats?.guests?.reduce((s, r) => s + parseInt(r.count), 0) || 0
|
||||||
|
const confirmedGuests = stats?.guests?.find(r => r.rsvp_status === 'confirmed')?.count || 0
|
||||||
|
|
||||||
|
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>
|
||||||
|
<span className="text-gray-800 font-medium">{event.name}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-gray-200 p-6 mb-6">
|
||||||
|
{!editing ? (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-start justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">{event.name}</h1>
|
||||||
|
<p className="text-gray-500">{EVENT_TYPES[event.event_type]} · {STATUS_LABELS[event.status]}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
className="flex items-center gap-1 text-sm border border-gray-300 hover:bg-gray-50 px-3 py-1.5 rounded-lg"
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4" /> עריכה
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={deleteEvent}
|
||||||
|
className="flex items-center gap-1 text-sm border border-red-200 text-red-600 hover:bg-red-50 px-3 py-1.5 rounded-lg"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" /> מחיקה
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 text-sm text-gray-600">
|
||||||
|
<div><span className="font-medium block">תאריך</span>{new Date(event.date).toLocaleString('he-IL')}</div>
|
||||||
|
<div><span className="font-medium block">מיקום</span>{event.location || '—'}</div>
|
||||||
|
<div><span className="font-medium block">תקציב</span>₪{Number(event.budget).toLocaleString('he-IL')}</div>
|
||||||
|
{event.notes && <div className="col-span-2"><span className="font-medium block">הערות</span>{event.notes}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<form onSubmit={saveEvent} 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>
|
||||||
|
<input required value={form.name} onChange={e => setForm({ ...form, name: 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="datetime-local" required value={form.date} onChange={e => setForm({ ...form, date: 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 value={form.location} onChange={e => setForm({ ...form, location: 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">
|
||||||
|
{STATUS_OPTIONS.map(s => <option key={s} value={s}>{STATUS_LABELS[s]}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">תקציב (₪)</label>
|
||||||
|
<input type="number" min="0" value={form.budget} onChange={e => setForm({ ...form, budget: 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 value={form.notes} onChange={e => setForm({ ...form, notes: 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 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={() => setEditing(false)} className="border border-gray-300 hover:bg-gray-50 px-6 py-2 rounded-lg transition-colors">ביטול</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 mb-6">
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-4 text-center">
|
||||||
|
<p className="text-3xl font-bold text-indigo-600">{totalGuests}</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">סה"כ מוזמנים</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-4 text-center">
|
||||||
|
<p className="text-3xl font-bold text-green-600">{confirmedGuests}</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">אישרו הגעה</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl border border-gray-200 p-4 text-center">
|
||||||
|
<p className="text-3xl font-bold text-orange-600">
|
||||||
|
₪{Number(stats?.budget?.estimated || 0).toLocaleString('he-IL')}
|
||||||
|
</p>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">הוצאות מתוכננות</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick links */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
||||||
|
<Link to={`/events/${id}/guests`} className="bg-white rounded-xl border border-gray-200 p-5 hover:shadow-md transition-shadow flex items-center gap-3">
|
||||||
|
<Users className="w-8 h-8 text-indigo-500" />
|
||||||
|
<div><p className="font-semibold">ניהול מוזמנים</p><p className="text-sm text-gray-500">הוסף, ערוך ונהל מוזמנים</p></div>
|
||||||
|
</Link>
|
||||||
|
<Link to={`/events/${id}/budget`} className="bg-white rounded-xl border border-gray-200 p-5 hover:shadow-md transition-shadow flex items-center gap-3">
|
||||||
|
<Wallet className="w-8 h-8 text-green-500" />
|
||||||
|
<div><p className="font-semibold">ניהול תקציב</p><p className="text-sm text-gray-500">עקוב אחרי הוצאות</p></div>
|
||||||
|
</Link>
|
||||||
|
<Link to={`/events/${id}/bookings`} className="bg-white rounded-xl border border-gray-200 p-5 hover:shadow-md transition-shadow flex items-center gap-3">
|
||||||
|
<BookOpen className="w-8 h-8 text-orange-500" />
|
||||||
|
<div><p className="font-semibold">הזמנות ספקים</p><p className="text-sm text-gray-500">נהל ספקים ושירותים</p></div>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
217
client/src/pages/GuestsPage.jsx
Normal file
217
client/src/pages/GuestsPage.jsx
Normal file
@@ -0,0 +1,217 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useParams, Link } from 'react-router-dom'
|
||||||
|
import { Plus, Trash2, Edit, ArrowRight, Search } from 'lucide-react'
|
||||||
|
import api from '../api'
|
||||||
|
|
||||||
|
const RSVP_OPTIONS = { pending: 'ממתין', confirmed: 'אישר', declined: 'סירב', maybe: 'אולי' }
|
||||||
|
const RSVP_COLORS = {
|
||||||
|
pending: 'bg-yellow-100 text-yellow-700',
|
||||||
|
confirmed: 'bg-green-100 text-green-700',
|
||||||
|
declined: 'bg-red-100 text-red-600',
|
||||||
|
maybe: 'bg-blue-100 text-blue-700',
|
||||||
|
}
|
||||||
|
|
||||||
|
const DIETARY = ['ללא', 'צמחוני', 'טבעוני', 'ללא גלוטן', 'כשר מהדרין', 'כשר רגיל', 'ללא לקטוז', 'אחר']
|
||||||
|
|
||||||
|
export default function GuestsPage() {
|
||||||
|
const { id } = useParams()
|
||||||
|
const [guests, setGuests] = useState([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [showForm, setShowForm] = useState(false)
|
||||||
|
const [editGuest, setEditGuest] = useState(null)
|
||||||
|
const [search, setSearch] = useState('')
|
||||||
|
const [form, setForm] = useState({ name: '', phone: '', email: '', rsvp_status: 'pending', table_number: '', seat_number: '', dietary_restriction: '', notes: '' })
|
||||||
|
|
||||||
|
useEffect(() => { loadGuests() }, [id])
|
||||||
|
|
||||||
|
async function loadGuests() {
|
||||||
|
try {
|
||||||
|
const res = await api.get(`/guests/event/${id}`)
|
||||||
|
setGuests(res.data)
|
||||||
|
} catch (err) { console.error(err) }
|
||||||
|
finally { setLoading(false) }
|
||||||
|
}
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setEditGuest(null)
|
||||||
|
setForm({ name: '', phone: '', email: '', rsvp_status: 'pending', table_number: '', seat_number: '', dietary_restriction: '', notes: '' })
|
||||||
|
setShowForm(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(g) {
|
||||||
|
setEditGuest(g)
|
||||||
|
setForm({ name: g.name, phone: g.phone || '', email: g.email || '', rsvp_status: g.rsvp_status, table_number: g.table_number || '', seat_number: g.seat_number || '', dietary_restriction: g.dietary_restriction || '', notes: g.notes || '' })
|
||||||
|
setShowForm(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveGuest(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
try {
|
||||||
|
if (editGuest) {
|
||||||
|
await api.put(`/guests/${editGuest.id}`, form)
|
||||||
|
} else {
|
||||||
|
await api.post('/guests', { ...form, event_id: id })
|
||||||
|
}
|
||||||
|
setShowForm(false)
|
||||||
|
loadGuests()
|
||||||
|
} catch (err) { console.error(err) }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteGuest(guestId) {
|
||||||
|
if (!confirm('מחוק מוזמן?')) return
|
||||||
|
try {
|
||||||
|
await api.delete(`/guests/${guestId}`)
|
||||||
|
setGuests(guests.filter(g => g.id !== guestId))
|
||||||
|
} catch (err) { console.error(err) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = guests.filter(g =>
|
||||||
|
g.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
(g.phone || '').includes(search)
|
||||||
|
)
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
total: guests.length,
|
||||||
|
confirmed: guests.filter(g => g.rsvp_status === 'confirmed').length,
|
||||||
|
pending: guests.filter(g => g.rsvp_status === 'pending').length,
|
||||||
|
declined: guests.filter(g => g.rsvp_status === 'declined').length,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loading) return <div className="flex justify-center py-20 text-gray-400">טוען...</div>
|
||||||
|
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* Stats */}
|
||||||
|
<div className="grid grid-cols-4 gap-3 mb-6">
|
||||||
|
{[
|
||||||
|
{ label: 'סה"כ', val: stats.total, cls: 'text-gray-700' },
|
||||||
|
{ label: 'אישרו', val: stats.confirmed, cls: 'text-green-600' },
|
||||||
|
{ label: 'ממתינים', val: stats.pending, cls: 'text-yellow-600' },
|
||||||
|
{ label: 'סירבו', val: stats.declined, cls: 'text-red-600' },
|
||||||
|
].map(s => (
|
||||||
|
<div key={s.label} className="bg-white rounded-xl border border-gray-200 p-3 text-center">
|
||||||
|
<p className={`text-2xl font-bold ${s.cls}`}>{s.val}</p>
|
||||||
|
<p className="text-xs text-gray-500">{s.label}</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">{editGuest ? 'עריכת מוזמן' : 'הוספת מוזמן'}</h2>
|
||||||
|
<form onSubmit={saveGuest} 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>
|
||||||
|
<input required value={form.name} onChange={e => setForm({ ...form, name: 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 value={form.phone} onChange={e => setForm({ ...form, phone: 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="email" value={form.email} onChange={e => setForm({ ...form, email: 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">סטטוס RSVP</label>
|
||||||
|
<select value={form.rsvp_status} onChange={e => setForm({ ...form, rsvp_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(RSVP_OPTIONS).map(([k, v]) => <option key={k} value={k}>{v}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">מספר שולחן</label>
|
||||||
|
<input type="number" min="1" value={form.table_number} onChange={e => setForm({ ...form, table_number: 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.dietary_restriction} onChange={e => setForm({ ...form, dietary_restriction: 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">
|
||||||
|
{DIETARY.map(d => <option key={d} value={d === 'ללא' ? '' : d}>{d}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="sm:col-span-2">
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">הערות</label>
|
||||||
|
<input value={form.notes} onChange={e => setForm({ ...form, notes: 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 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Search */}
|
||||||
|
<div className="relative mb-4">
|
||||||
|
<Search className="absolute right-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400" />
|
||||||
|
<input
|
||||||
|
value={search}
|
||||||
|
onChange={e => setSearch(e.target.value)}
|
||||||
|
placeholder="חיפוש לפי שם או טלפון..."
|
||||||
|
className="w-full border border-gray-300 rounded-lg pr-9 pl-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 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 hidden md:table-cell">שולחן</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">
|
||||||
|
{filtered.length === 0 ? (
|
||||||
|
<tr><td colSpan={6} className="text-center py-8 text-gray-400">אין מוזמנים</td></tr>
|
||||||
|
) : filtered.map(g => (
|
||||||
|
<tr key={g.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-3 font-medium">{g.name}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-500 hidden sm:table-cell">{g.phone || '—'}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`text-xs px-2 py-1 rounded-full font-medium ${RSVP_COLORS[g.rsvp_status] || 'bg-gray-100'}`}>
|
||||||
|
{RSVP_OPTIONS[g.rsvp_status] || g.rsvp_status}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-500 hidden md:table-cell">{g.table_number || '—'}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-500 hidden md:table-cell">{g.dietary_restriction || '—'}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex gap-1 justify-end">
|
||||||
|
<button onClick={() => openEdit(g)} className="p-1.5 hover:bg-indigo-50 rounded text-indigo-600"><Edit className="w-4 h-4" /></button>
|
||||||
|
<button onClick={() => deleteGuest(g.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>
|
||||||
|
)
|
||||||
|
}
|
||||||
69
client/src/pages/LoginPage.jsx
Normal file
69
client/src/pages/LoginPage.jsx
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate, Link } from 'react-router-dom'
|
||||||
|
import api from '../api'
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [form, setForm] = useState({ email: '', password: '' })
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await api.post('/auth/login', form)
|
||||||
|
localStorage.setItem('token', res.data.token)
|
||||||
|
localStorage.setItem('user', JSON.stringify(res.data.user))
|
||||||
|
navigate('/')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.error || 'שגיאת התחברות')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
|
<div className="bg-white rounded-2xl shadow-lg p-8 w-full max-w-md">
|
||||||
|
<h1 className="text-2xl font-bold text-center text-indigo-700 mb-2">אירועית</h1>
|
||||||
|
<p className="text-center text-gray-500 mb-6">התחברות לחשבון</p>
|
||||||
|
{error && <div className="bg-red-50 text-red-600 rounded-lg px-4 py-2 mb-4 text-sm">{error}</div>}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">אימייל</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
required
|
||||||
|
value={form.email}
|
||||||
|
onChange={e => setForm({ ...form, email: 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="password"
|
||||||
|
required
|
||||||
|
value={form.password}
|
||||||
|
onChange={e => setForm({ ...form, password: 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>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-medium py-2 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'מתחבר...' : 'התחבר'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="text-center text-sm text-gray-500 mt-4">
|
||||||
|
אין לך חשבון?{' '}
|
||||||
|
<Link to="/register" className="text-indigo-600 hover:underline">הרשמה</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
80
client/src/pages/RegisterPage.jsx
Normal file
80
client/src/pages/RegisterPage.jsx
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useNavigate, Link } from 'react-router-dom'
|
||||||
|
import api from '../api'
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [form, setForm] = useState({ email: '', name: '', password: '' })
|
||||||
|
const [error, setError] = useState('')
|
||||||
|
const [loading, setLoading] = useState(false)
|
||||||
|
|
||||||
|
async function handleSubmit(e) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError('')
|
||||||
|
setLoading(true)
|
||||||
|
try {
|
||||||
|
const res = await api.post('/auth/register', form)
|
||||||
|
localStorage.setItem('token', res.data.token)
|
||||||
|
localStorage.setItem('user', JSON.stringify(res.data.user))
|
||||||
|
navigate('/')
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.error || 'שגיאת הרשמה')
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||||
|
<div className="bg-white rounded-2xl shadow-lg p-8 w-full max-w-md">
|
||||||
|
<h1 className="text-2xl font-bold text-center text-indigo-700 mb-2">אירועית</h1>
|
||||||
|
<p className="text-center text-gray-500 mb-6">יצירת חשבון חדש</p>
|
||||||
|
{error && <div className="bg-red-50 text-red-600 rounded-lg px-4 py-2 mb-4 text-sm">{error}</div>}
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">שם מלא</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
required
|
||||||
|
value={form.name}
|
||||||
|
onChange={e => setForm({ ...form, name: 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="email"
|
||||||
|
required
|
||||||
|
value={form.email}
|
||||||
|
onChange={e => setForm({ ...form, email: 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="password"
|
||||||
|
required
|
||||||
|
minLength={6}
|
||||||
|
value={form.password}
|
||||||
|
onChange={e => setForm({ ...form, password: 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>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
className="w-full bg-indigo-600 hover:bg-indigo-700 text-white font-medium py-2 rounded-lg transition-colors disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{loading ? 'נרשם...' : 'הרשמה'}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="text-center text-sm text-gray-500 mt-4">
|
||||||
|
יש לך חשבון?{' '}
|
||||||
|
<Link to="/login" className="text-indigo-600 hover:underline">התחבר</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
13
client/tailwind.config.js
Normal file
13
client/tailwind.config.js
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
/** @type {import('tailwindcss').Config} */
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: '#4F46E5',
|
||||||
|
'primary-dark': '#4338CA',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
}
|
||||||
17
client/vite.config.js
Normal file
17
client/vite.config.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:3000',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
7
db.js
Normal file
7
db.js
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
const { Pool } = require('pg');
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL || 'postgresql://postgres:postgres@postgres:5432/airewit',
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = pool;
|
||||||
36
docker-compose.yml
Normal file
36
docker-compose.yml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
expose:
|
||||||
|
- "3000"
|
||||||
|
environment:
|
||||||
|
- PORT=3000
|
||||||
|
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/airewit
|
||||||
|
- JWT_SECRET=airewit-jwt-secret-2026
|
||||||
|
- NODE_ENV=production
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
postgres:
|
||||||
|
image: postgres:15-alpine
|
||||||
|
environment:
|
||||||
|
- POSTGRES_USER=postgres
|
||||||
|
- POSTGRES_PASSWORD=postgres
|
||||||
|
- POSTGRES_DB=airewit
|
||||||
|
expose:
|
||||||
|
- "5432"
|
||||||
|
volumes:
|
||||||
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres -d airewit"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
pgdata:
|
||||||
81
migrate.js
Normal file
81
migrate.js
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
const pool = require('./db');
|
||||||
|
|
||||||
|
const migrations = [
|
||||||
|
`CREATE EXTENSION IF NOT EXISTS "uuid-ossp"`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
password VARCHAR(255) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS events (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
date TIMESTAMP NOT NULL,
|
||||||
|
location VARCHAR(500),
|
||||||
|
event_type VARCHAR(100) DEFAULT 'general',
|
||||||
|
budget DECIMAL(12,2) DEFAULT 0,
|
||||||
|
status VARCHAR(50) DEFAULT 'planned',
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS guests (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
event_id UUID REFERENCES events(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(255) NOT NULL,
|
||||||
|
phone VARCHAR(50),
|
||||||
|
email VARCHAR(255),
|
||||||
|
rsvp_status VARCHAR(50) DEFAULT 'pending',
|
||||||
|
table_number INTEGER,
|
||||||
|
seat_number INTEGER,
|
||||||
|
dietary_restriction VARCHAR(255),
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS budget_items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
event_id UUID REFERENCES events(id) ON DELETE CASCADE,
|
||||||
|
category VARCHAR(100) NOT NULL,
|
||||||
|
description VARCHAR(500),
|
||||||
|
estimated_cost DECIMAL(12,2) DEFAULT 0,
|
||||||
|
actual_cost DECIMAL(12,2),
|
||||||
|
status VARCHAR(50) DEFAULT 'planned',
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
`CREATE TABLE IF NOT EXISTS bookings (
|
||||||
|
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
|
||||||
|
event_id UUID REFERENCES events(id) ON DELETE CASCADE,
|
||||||
|
supplier_name VARCHAR(255) NOT NULL,
|
||||||
|
supplier_type VARCHAR(100),
|
||||||
|
contact_info VARCHAR(255),
|
||||||
|
cost DECIMAL(12,2) DEFAULT 0,
|
||||||
|
status VARCHAR(50) DEFAULT 'pending',
|
||||||
|
notes TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_events_user_id ON events(user_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_guests_event_id ON guests(event_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_budget_items_event_id ON budget_items(event_id)`,
|
||||||
|
`CREATE INDEX IF NOT EXISTS idx_bookings_event_id ON bookings(event_id)`,
|
||||||
|
];
|
||||||
|
|
||||||
|
async function runMigrations() {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
for (const sql of migrations) {
|
||||||
|
await client.query(sql);
|
||||||
|
console.log('Migration OK:', sql.substring(0, 60) + '...');
|
||||||
|
}
|
||||||
|
console.log('All migrations completed successfully');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Migration failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
runMigrations();
|
||||||
23
package.json
Normal file
23
package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "airewit",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Event management platform - אירועית",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "nodemon server.js",
|
||||||
|
"migrate": "node migrate.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"pg": "^8.11.3",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"dotenv": "^16.3.1",
|
||||||
|
"uuid": "^9.0.0",
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"jsonwebtoken": "^9.0.2"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"nodemon": "^3.0.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
74
routes/auth.js
Normal file
74
routes/auth.js
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const pool = require('../db');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'airewit-secret-key-2026';
|
||||||
|
|
||||||
|
// Register
|
||||||
|
router.post('/register', async (req, res) => {
|
||||||
|
const { email, name, password } = req.body;
|
||||||
|
if (!email || !name || !password) {
|
||||||
|
return res.status(400).json({ error: 'Missing required fields' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const hashed = await bcrypt.hash(password, 10);
|
||||||
|
const result = await pool.query(
|
||||||
|
'INSERT INTO users (email, name, password) VALUES ($1, $2, $3) RETURNING id, email, name, created_at',
|
||||||
|
[email, name, hashed]
|
||||||
|
);
|
||||||
|
const user = result.rows[0];
|
||||||
|
const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' });
|
||||||
|
res.status(201).json({ token, user });
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === '23505') {
|
||||||
|
return res.status(409).json({ error: 'Email already registered' });
|
||||||
|
}
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Login
|
||||||
|
router.post('/login', async (req, res) => {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
if (!email || !password) {
|
||||||
|
return res.status(400).json({ error: 'Missing email or password' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await pool.query('SELECT * FROM users WHERE email = $1', [email]);
|
||||||
|
if (result.rows.length === 0) {
|
||||||
|
return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
}
|
||||||
|
const user = result.rows[0];
|
||||||
|
const valid = await bcrypt.compare(password, user.password);
|
||||||
|
if (!valid) {
|
||||||
|
return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
}
|
||||||
|
const token = jwt.sign({ userId: user.id }, JWT_SECRET, { expiresIn: '7d' });
|
||||||
|
res.json({ token, user: { id: user.id, email: user.email, name: user.name } });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Middleware
|
||||||
|
function authMiddleware(req, res, next) {
|
||||||
|
const auth = req.headers.authorization;
|
||||||
|
if (!auth || !auth.startsWith('Bearer ')) {
|
||||||
|
return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const token = auth.split(' ')[1];
|
||||||
|
const payload = jwt.verify(token, JWT_SECRET);
|
||||||
|
req.userId = payload.userId;
|
||||||
|
next();
|
||||||
|
} catch {
|
||||||
|
res.status(401).json({ error: 'Invalid token' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
|
module.exports.authMiddleware = authMiddleware;
|
||||||
75
routes/bookings.js
Normal file
75
routes/bookings.js
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const pool = require('../db');
|
||||||
|
const { authMiddleware } = require('./auth');
|
||||||
|
|
||||||
|
// Get all bookings for an event
|
||||||
|
router.get('/event/:eventId', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT b.* FROM bookings b
|
||||||
|
JOIN events e ON b.event_id = e.id
|
||||||
|
WHERE b.event_id = $1 AND e.user_id = $2
|
||||||
|
ORDER BY b.created_at DESC`,
|
||||||
|
[req.params.eventId, req.userId]
|
||||||
|
);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create booking
|
||||||
|
router.post('/', authMiddleware, async (req, res) => {
|
||||||
|
const { event_id, supplier_name, supplier_type, contact_info, cost, status, notes } = req.body;
|
||||||
|
if (!event_id || !supplier_name) return res.status(400).json({ error: 'event_id and supplier_name are required' });
|
||||||
|
try {
|
||||||
|
const eventCheck = await pool.query('SELECT id FROM events WHERE id=$1 AND user_id=$2', [event_id, req.userId]);
|
||||||
|
if (eventCheck.rows.length === 0) return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO bookings (event_id, supplier_name, supplier_type, contact_info, cost, status, notes)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
|
||||||
|
[event_id, supplier_name, supplier_type, contact_info, cost || 0, status || 'pending', notes]
|
||||||
|
);
|
||||||
|
res.status(201).json(result.rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update booking
|
||||||
|
router.put('/:id', authMiddleware, async (req, res) => {
|
||||||
|
const { supplier_name, supplier_type, contact_info, cost, status, notes } = req.body;
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE bookings SET supplier_name=$1, supplier_type=$2, contact_info=$3, cost=$4, status=$5, notes=$6
|
||||||
|
WHERE id=$7 AND event_id IN (SELECT id FROM events WHERE user_id=$8) RETURNING *`,
|
||||||
|
[supplier_name, supplier_type, contact_info, cost, status, notes, req.params.id, req.userId]
|
||||||
|
);
|
||||||
|
if (result.rows.length === 0) return res.status(404).json({ error: 'Booking not found' });
|
||||||
|
res.json(result.rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete booking
|
||||||
|
router.delete('/:id', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`DELETE FROM bookings WHERE id=$1 AND event_id IN (SELECT id FROM events WHERE user_id=$2) RETURNING id`,
|
||||||
|
[req.params.id, req.userId]
|
||||||
|
);
|
||||||
|
if (result.rows.length === 0) return res.status(404).json({ error: 'Booking not found' });
|
||||||
|
res.json({ message: 'Booking deleted' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
109
routes/budget.js
Normal file
109
routes/budget.js
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const pool = require('../db');
|
||||||
|
const { authMiddleware } = require('./auth');
|
||||||
|
|
||||||
|
// Get all budget items for an event
|
||||||
|
router.get('/event/:eventId', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT b.* FROM budget_items b
|
||||||
|
JOIN events e ON b.event_id = e.id
|
||||||
|
WHERE b.event_id = $1 AND e.user_id = $2
|
||||||
|
ORDER BY b.category, b.created_at ASC`,
|
||||||
|
[req.params.eventId, req.userId]
|
||||||
|
);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get budget summary for event
|
||||||
|
router.get('/event/:eventId/summary', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const [itemsRes, eventRes] = await Promise.all([
|
||||||
|
pool.query(
|
||||||
|
`SELECT category,
|
||||||
|
SUM(estimated_cost) as estimated,
|
||||||
|
SUM(COALESCE(actual_cost, 0)) as actual,
|
||||||
|
COUNT(*) as count
|
||||||
|
FROM budget_items b
|
||||||
|
JOIN events e ON b.event_id = e.id
|
||||||
|
WHERE b.event_id = $1 AND e.user_id = $2
|
||||||
|
GROUP BY category`,
|
||||||
|
[req.params.eventId, req.userId]
|
||||||
|
),
|
||||||
|
pool.query('SELECT budget FROM events WHERE id=$1 AND user_id=$2', [req.params.eventId, req.userId]),
|
||||||
|
]);
|
||||||
|
const totalEstimated = itemsRes.rows.reduce((s, r) => s + parseFloat(r.estimated || 0), 0);
|
||||||
|
const totalActual = itemsRes.rows.reduce((s, r) => s + parseFloat(r.actual || 0), 0);
|
||||||
|
const eventBudget = eventRes.rows[0]?.budget || 0;
|
||||||
|
res.json({
|
||||||
|
event_budget: parseFloat(eventBudget),
|
||||||
|
total_estimated: totalEstimated,
|
||||||
|
total_actual: totalActual,
|
||||||
|
remaining_budget: parseFloat(eventBudget) - totalEstimated,
|
||||||
|
over_budget: totalEstimated > parseFloat(eventBudget),
|
||||||
|
categories: itemsRes.rows,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create budget item
|
||||||
|
router.post('/', authMiddleware, async (req, res) => {
|
||||||
|
const { event_id, category, description, estimated_cost, actual_cost, status } = req.body;
|
||||||
|
if (!event_id || !category) return res.status(400).json({ error: 'event_id and category are required' });
|
||||||
|
try {
|
||||||
|
const eventCheck = await pool.query('SELECT id FROM events WHERE id=$1 AND user_id=$2', [event_id, req.userId]);
|
||||||
|
if (eventCheck.rows.length === 0) return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO budget_items (event_id, category, description, estimated_cost, actual_cost, status)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
|
||||||
|
[event_id, category, description, estimated_cost || 0, actual_cost, status || 'planned']
|
||||||
|
);
|
||||||
|
res.status(201).json(result.rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update budget item
|
||||||
|
router.put('/:id', authMiddleware, async (req, res) => {
|
||||||
|
const { category, description, estimated_cost, actual_cost, status } = req.body;
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE budget_items SET category=$1, description=$2, estimated_cost=$3, actual_cost=$4, status=$5
|
||||||
|
WHERE id=$6 AND event_id IN (SELECT id FROM events WHERE user_id=$7) RETURNING *`,
|
||||||
|
[category, description, estimated_cost, actual_cost, status, req.params.id, req.userId]
|
||||||
|
);
|
||||||
|
if (result.rows.length === 0) return res.status(404).json({ error: 'Budget item not found' });
|
||||||
|
res.json(result.rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete budget item
|
||||||
|
router.delete('/:id', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`DELETE FROM budget_items WHERE id=$1 AND event_id IN (SELECT id FROM events WHERE user_id=$2) RETURNING id`,
|
||||||
|
[req.params.id, req.userId]
|
||||||
|
);
|
||||||
|
if (result.rows.length === 0) return res.status(404).json({ error: 'Budget item not found' });
|
||||||
|
res.json({ message: 'Budget item deleted' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
103
routes/events.js
Normal file
103
routes/events.js
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const pool = require('../db');
|
||||||
|
const { authMiddleware } = require('./auth');
|
||||||
|
|
||||||
|
// Get all events for user
|
||||||
|
router.get('/', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
'SELECT * FROM events WHERE user_id = $1 ORDER BY date ASC',
|
||||||
|
[req.userId]
|
||||||
|
);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get single event
|
||||||
|
router.get('/:id', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
'SELECT * FROM events WHERE id = $1 AND user_id = $2',
|
||||||
|
[req.params.id, req.userId]
|
||||||
|
);
|
||||||
|
if (result.rows.length === 0) return res.status(404).json({ error: 'Event not found' });
|
||||||
|
res.json(result.rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create event
|
||||||
|
router.post('/', authMiddleware, async (req, res) => {
|
||||||
|
const { name, date, location, event_type, budget, notes } = req.body;
|
||||||
|
if (!name || !date) return res.status(400).json({ error: 'Name and date are required' });
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO events (user_id, name, date, location, event_type, budget, notes)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING *`,
|
||||||
|
[req.userId, name, date, location, event_type || 'general', budget || 0, notes]
|
||||||
|
);
|
||||||
|
res.status(201).json(result.rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update event
|
||||||
|
router.put('/:id', authMiddleware, async (req, res) => {
|
||||||
|
const { name, date, location, event_type, budget, status, notes } = req.body;
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE events SET name=$1, date=$2, location=$3, event_type=$4, budget=$5, status=$6, notes=$7
|
||||||
|
WHERE id=$8 AND user_id=$9 RETURNING *`,
|
||||||
|
[name, date, location, event_type, budget, status, notes, req.params.id, req.userId]
|
||||||
|
);
|
||||||
|
if (result.rows.length === 0) return res.status(404).json({ error: 'Event not found' });
|
||||||
|
res.json(result.rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete event
|
||||||
|
router.delete('/:id', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
'DELETE FROM events WHERE id=$1 AND user_id=$2 RETURNING id',
|
||||||
|
[req.params.id, req.userId]
|
||||||
|
);
|
||||||
|
if (result.rows.length === 0) return res.status(404).json({ error: 'Event not found' });
|
||||||
|
res.json({ message: 'Event deleted' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get event stats
|
||||||
|
router.get('/:id/stats', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const [guestsRes, budgetRes, bookingsRes] = await Promise.all([
|
||||||
|
pool.query('SELECT rsvp_status, COUNT(*) FROM guests WHERE event_id=$1 GROUP BY rsvp_status', [req.params.id]),
|
||||||
|
pool.query('SELECT SUM(estimated_cost) as estimated, SUM(actual_cost) as actual FROM budget_items WHERE event_id=$1', [req.params.id]),
|
||||||
|
pool.query('SELECT SUM(cost) as total_bookings FROM bookings WHERE event_id=$1', [req.params.id]),
|
||||||
|
]);
|
||||||
|
res.json({
|
||||||
|
guests: guestsRes.rows,
|
||||||
|
budget: budgetRes.rows[0],
|
||||||
|
bookings: bookingsRes.rows[0],
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
118
routes/guests.js
Normal file
118
routes/guests.js
Normal file
@@ -0,0 +1,118 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const router = express.Router();
|
||||||
|
const pool = require('../db');
|
||||||
|
const { authMiddleware } = require('./auth');
|
||||||
|
|
||||||
|
// Get all guests for an event
|
||||||
|
router.get('/event/:eventId', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT g.* FROM guests g
|
||||||
|
JOIN events e ON g.event_id = e.id
|
||||||
|
WHERE g.event_id = $1 AND e.user_id = $2
|
||||||
|
ORDER BY g.name ASC`,
|
||||||
|
[req.params.eventId, req.userId]
|
||||||
|
);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get single guest
|
||||||
|
router.get('/:id', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT g.* FROM guests g
|
||||||
|
JOIN events e ON g.event_id = e.id
|
||||||
|
WHERE g.id = $1 AND e.user_id = $2`,
|
||||||
|
[req.params.id, req.userId]
|
||||||
|
);
|
||||||
|
if (result.rows.length === 0) return res.status(404).json({ error: 'Guest not found' });
|
||||||
|
res.json(result.rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create guest
|
||||||
|
router.post('/', authMiddleware, async (req, res) => {
|
||||||
|
const { event_id, name, phone, email, rsvp_status, table_number, seat_number, dietary_restriction, notes } = req.body;
|
||||||
|
if (!event_id || !name) return res.status(400).json({ error: 'event_id and name are required' });
|
||||||
|
try {
|
||||||
|
// Verify event belongs to user
|
||||||
|
const eventCheck = await pool.query('SELECT id FROM events WHERE id=$1 AND user_id=$2', [event_id, req.userId]);
|
||||||
|
if (eventCheck.rows.length === 0) return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO guests (event_id, name, phone, email, rsvp_status, table_number, seat_number, dietary_restriction, notes)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING *`,
|
||||||
|
[event_id, name, phone, email, rsvp_status || 'pending', table_number, seat_number, dietary_restriction, notes]
|
||||||
|
);
|
||||||
|
res.status(201).json(result.rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Update guest
|
||||||
|
router.put('/:id', authMiddleware, async (req, res) => {
|
||||||
|
const { name, phone, email, rsvp_status, table_number, seat_number, dietary_restriction, notes } = req.body;
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE guests SET name=$1, phone=$2, email=$3, rsvp_status=$4, table_number=$5, seat_number=$6,
|
||||||
|
dietary_restriction=$7, notes=$8
|
||||||
|
WHERE id=$9 AND event_id IN (SELECT id FROM events WHERE user_id=$10) RETURNING *`,
|
||||||
|
[name, phone, email, rsvp_status, table_number, seat_number, dietary_restriction, notes, req.params.id, req.userId]
|
||||||
|
);
|
||||||
|
if (result.rows.length === 0) return res.status(404).json({ error: 'Guest not found' });
|
||||||
|
res.json(result.rows[0]);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Delete guest
|
||||||
|
router.delete('/:id', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`DELETE FROM guests WHERE id=$1 AND event_id IN (SELECT id FROM events WHERE user_id=$2) RETURNING id`,
|
||||||
|
[req.params.id, req.userId]
|
||||||
|
);
|
||||||
|
if (result.rows.length === 0) return res.status(404).json({ error: 'Guest not found' });
|
||||||
|
res.json({ message: 'Guest deleted' });
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bulk import guests
|
||||||
|
router.post('/bulk', authMiddleware, async (req, res) => {
|
||||||
|
const { event_id, guests } = req.body;
|
||||||
|
if (!event_id || !Array.isArray(guests)) return res.status(400).json({ error: 'event_id and guests array required' });
|
||||||
|
try {
|
||||||
|
const eventCheck = await pool.query('SELECT id FROM events WHERE id=$1 AND user_id=$2', [event_id, req.userId]);
|
||||||
|
if (eventCheck.rows.length === 0) return res.status(403).json({ error: 'Forbidden' });
|
||||||
|
|
||||||
|
const inserted = [];
|
||||||
|
for (const g of guests) {
|
||||||
|
const r = await pool.query(
|
||||||
|
`INSERT INTO guests (event_id, name, phone, email, rsvp_status, dietary_restriction)
|
||||||
|
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
|
||||||
|
[event_id, g.name, g.phone, g.email, g.rsvp_status || 'pending', g.dietary_restriction]
|
||||||
|
);
|
||||||
|
inserted.push(r.rows[0]);
|
||||||
|
}
|
||||||
|
res.status(201).json(inserted);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = router;
|
||||||
37
server.js
Normal file
37
server.js
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const path = require('path');
|
||||||
|
require('dotenv').config();
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get('/health', (req, res) => {
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
app: 'airewit',
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
commit: process.env.COMMIT_SHA || 'local'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// API routes
|
||||||
|
app.use('/api/events', require('./routes/events'));
|
||||||
|
app.use('/api/guests', require('./routes/guests'));
|
||||||
|
app.use('/api/bookings', require('./routes/bookings'));
|
||||||
|
app.use('/api/budget', require('./routes/budget'));
|
||||||
|
app.use('/api/auth', require('./routes/auth'));
|
||||||
|
|
||||||
|
// Serve React frontend
|
||||||
|
app.use(express.static(path.join(__dirname, 'client/dist')));
|
||||||
|
app.get('*', (req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, 'client/dist/index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, () => {
|
||||||
|
console.log(`אירועית server running on port ${PORT}`);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user