From e003c7146dc7c1045d3cf8a627f0267371679ac8 Mon Sep 17 00:00:00 2001 From: Fullstack Developer Date: Sat, 21 Feb 2026 18:28:03 +0000 Subject: [PATCH] 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 --- Dockerfile | 21 +++ client/index.html | 13 ++ client/package.json | 31 +++++ client/postcss.config.js | 6 + client/src/App.jsx | 39 ++++++ client/src/api.js | 25 ++++ client/src/components/Layout.jsx | 41 ++++++ client/src/index.css | 12 ++ client/src/main.jsx | 10 ++ client/src/pages/BookingsPage.jsx | 185 ++++++++++++++++++++++++ client/src/pages/BudgetPage.jsx | 195 ++++++++++++++++++++++++++ client/src/pages/DashboardPage.jsx | 203 +++++++++++++++++++++++++++ client/src/pages/EventPage.jsx | 194 ++++++++++++++++++++++++++ client/src/pages/GuestsPage.jsx | 217 +++++++++++++++++++++++++++++ client/src/pages/LoginPage.jsx | 69 +++++++++ client/src/pages/RegisterPage.jsx | 80 +++++++++++ client/tailwind.config.js | 13 ++ client/vite.config.js | 17 +++ db.js | 7 + docker-compose.yml | 36 +++++ migrate.js | 81 +++++++++++ package.json | 23 +++ routes/auth.js | 74 ++++++++++ routes/bookings.js | 75 ++++++++++ routes/budget.js | 109 +++++++++++++++ routes/events.js | 103 ++++++++++++++ routes/guests.js | 118 ++++++++++++++++ server.js | 37 +++++ 28 files changed, 2034 insertions(+) create mode 100644 Dockerfile create mode 100644 client/index.html create mode 100644 client/package.json create mode 100644 client/postcss.config.js create mode 100644 client/src/App.jsx create mode 100644 client/src/api.js create mode 100644 client/src/components/Layout.jsx create mode 100644 client/src/index.css create mode 100644 client/src/main.jsx create mode 100644 client/src/pages/BookingsPage.jsx create mode 100644 client/src/pages/BudgetPage.jsx create mode 100644 client/src/pages/DashboardPage.jsx create mode 100644 client/src/pages/EventPage.jsx create mode 100644 client/src/pages/GuestsPage.jsx create mode 100644 client/src/pages/LoginPage.jsx create mode 100644 client/src/pages/RegisterPage.jsx create mode 100644 client/tailwind.config.js create mode 100644 client/vite.config.js create mode 100644 db.js create mode 100644 docker-compose.yml create mode 100644 migrate.js create mode 100644 package.json create mode 100644 routes/auth.js create mode 100644 routes/bookings.js create mode 100644 routes/budget.js create mode 100644 routes/events.js create mode 100644 routes/guests.js create mode 100644 server.js diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..9689476 --- /dev/null +++ b/Dockerfile @@ -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 diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..d69425f --- /dev/null +++ b/client/index.html @@ -0,0 +1,13 @@ + + + + + + + אירועית - ניהול אירועים + + +
+ + + diff --git a/client/package.json b/client/package.json new file mode 100644 index 0000000..f56f727 --- /dev/null +++ b/client/package.json @@ -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" + } +} diff --git a/client/postcss.config.js b/client/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/client/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/client/src/App.jsx b/client/src/App.jsx new file mode 100644 index 0000000..21282c6 --- /dev/null +++ b/client/src/App.jsx @@ -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 : +} + +export default function App() { + return ( + + + } /> + } /> + + + + } + > + } /> + } /> + } /> + } /> + } /> + + + + ) +} diff --git a/client/src/api.js b/client/src/api.js new file mode 100644 index 0000000..32d757e --- /dev/null +++ b/client/src/api.js @@ -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 diff --git a/client/src/components/Layout.jsx b/client/src/components/Layout.jsx new file mode 100644 index 0000000..7d71471 --- /dev/null +++ b/client/src/components/Layout.jsx @@ -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 ( +
+
+
+ + + אירועית + +
+ שלום, {user.name} + +
+
+
+
+ +
+
+ ) +} diff --git a/client/src/index.css b/client/src/index.css new file mode 100644 index 0000000..8a51b0d --- /dev/null +++ b/client/src/index.css @@ -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; +} diff --git a/client/src/main.jsx b/client/src/main.jsx new file mode 100644 index 0000000..5cc5991 --- /dev/null +++ b/client/src/main.jsx @@ -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( + + + , +) diff --git a/client/src/pages/BookingsPage.jsx b/client/src/pages/BookingsPage.jsx new file mode 100644 index 0000000..3fc6d22 --- /dev/null +++ b/client/src/pages/BookingsPage.jsx @@ -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
טוען...
+ + return ( +
+
+ הדשבורד + / + האירוע + / + הזמנות ספקים +
+ +
+

הזמנות ספקים

+ +
+ + {/* Stats */} +
+
+

{bookings.length}

+

סה"כ הזמנות

+
+
+

{confirmedCount}

+

מאושרות

+
+
+

₪{Number(totalCost).toLocaleString('he-IL')}

+

עלות כוללת

+
+
+ + {/* Form */} + {showForm && ( +
+

{editBooking ? 'עריכת הזמנה' : 'הוספת הזמנה'}

+
+
+ + 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" /> +
+
+ + +
+
+ + 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" /> +
+
+ + 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" /> +
+
+ + +
+
+ + 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" /> +
+
+ + +
+
+
+ )} + + {/* Bookings list */} +
+ {bookings.length === 0 ? ( +
אין הזמנות ספקים עדיין
+ ) : bookings.map(b => ( +
+
+
+

{b.supplier_name}

+

{b.supplier_type}

+
+ + {STATUS_OPTIONS[b.status] || b.status} + +
+ {b.contact_info && ( +

+ {b.contact_info} +

+ )} +
+

₪{Number(b.cost).toLocaleString('he-IL')}

+
+ + +
+
+ {b.notes &&

{b.notes}

} +
+ ))} +
+
+ ) +} diff --git a/client/src/pages/BudgetPage.jsx b/client/src/pages/BudgetPage.jsx new file mode 100644 index 0000000..df400c3 --- /dev/null +++ b/client/src/pages/BudgetPage.jsx @@ -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
טוען...
+ + const overBudget = summary?.over_budget + + return ( +
+
+ הדשבורד + / + האירוע + / + תקציב +
+ +
+

ניהול תקציב

+ +
+ + {/* Summary */} + {summary && ( +
+
+

תקציב כולל

+

₪{Number(summary.event_budget).toLocaleString('he-IL')}

+
+
+

הוצאות מתוכננות

+

₪{Number(summary.total_estimated).toLocaleString('he-IL')}

+
+
+

הוצאות בפועל

+

₪{Number(summary.total_actual).toLocaleString('he-IL')}

+
+
+

יתרת תקציב

+
+ {overBudget ? : } +

+ ₪{Number(Math.abs(summary.remaining_budget)).toLocaleString('he-IL')} +

+
+ {overBudget &&

חריגה מהתקציב!

} +
+
+ )} + + {/* Form */} + {showForm && ( +
+

{editItem ? 'עריכת הוצאה' : 'הוספת הוצאה'}

+
+
+ + +
+
+ + 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" /> +
+
+ + 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" /> +
+
+ + 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" /> +
+
+ + +
+
+ + +
+
+
+ )} + + {/* Items table */} +
+ + + + + + + + + + + + + {items.length === 0 ? ( + + ) : items.map(item => ( + + + + + + + + + ))} + +
קטגוריהתיאורמוערךבפועלסטטוס
אין פריטים בתקציב
{item.category}{item.description || '—'}₪{Number(item.estimated_cost).toLocaleString('he-IL')}{item.actual_cost ? `₪${Number(item.actual_cost).toLocaleString('he-IL')}` : '—'} + {STATUS_OPTIONS[item.status] || item.status} + +
+ + +
+
+
+
+ ) +} diff --git a/client/src/pages/DashboardPage.jsx b/client/src/pages/DashboardPage.jsx new file mode 100644 index 0000000..ad70b1c --- /dev/null +++ b/client/src/pages/DashboardPage.jsx @@ -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
טוען...
+ + return ( +
+
+

האירועים שלי

+ +
+ + {showForm && ( +
+

יצירת אירוע חדש

+ {error &&
{error}
} +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + +
+
+ + 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" + /> +
+
+ + +
+
+
+ )} + + {events.length === 0 ? ( +
+ +

אין אירועים עדיין

+

לחץ על "אירוע חדש" כדי להתחיל

+
+ ) : ( +
+ {events.map(event => ( + +
+

{event.name}

+ + {STATUS_LABELS[event.status] || event.status} + +
+
+
+ + {new Date(event.date).toLocaleDateString('he-IL', { dateStyle: 'medium' })} +
+ {event.location && ( +
+ + {event.location} +
+ )} + {event.budget > 0 && ( +
+ + ₪{Number(event.budget).toLocaleString('he-IL')} +
+ )} +
+
+ {EVENT_TYPES[event.event_type] || event.event_type} +
+ + ))} +
+ )} +
+ ) +} diff --git a/client/src/pages/EventPage.jsx b/client/src/pages/EventPage.jsx new file mode 100644 index 0000000..92d6277 --- /dev/null +++ b/client/src/pages/EventPage.jsx @@ -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
טוען...
+ if (!event) return
האירוע לא נמצא
+ + 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 ( +
+
+ + הדשבורד + + / + {event.name} +
+ +
+ {!editing ? ( +
+
+
+

{event.name}

+

{EVENT_TYPES[event.event_type]} · {STATUS_LABELS[event.status]}

+
+
+ + +
+
+
+
תאריך{new Date(event.date).toLocaleString('he-IL')}
+
מיקום{event.location || '—'}
+
תקציב₪{Number(event.budget).toLocaleString('he-IL')}
+ {event.notes &&
הערות{event.notes}
} +
+
+ ) : ( +
+
+ + 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" /> +
+
+ + 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" /> +
+
+ + 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" /> +
+
+ + +
+
+ + 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" /> +
+
+ + 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" /> +
+
+ + +
+
+ )} +
+ + {/* Stats */} +
+
+

{totalGuests}

+

סה"כ מוזמנים

+
+
+

{confirmedGuests}

+

אישרו הגעה

+
+
+

+ ₪{Number(stats?.budget?.estimated || 0).toLocaleString('he-IL')} +

+

הוצאות מתוכננות

+
+
+ + {/* Quick links */} +
+ + +

ניהול מוזמנים

הוסף, ערוך ונהל מוזמנים

+ + + +

ניהול תקציב

עקוב אחרי הוצאות

+ + + +

הזמנות ספקים

נהל ספקים ושירותים

+ +
+
+ ) +} diff --git a/client/src/pages/GuestsPage.jsx b/client/src/pages/GuestsPage.jsx new file mode 100644 index 0000000..4ee03d3 --- /dev/null +++ b/client/src/pages/GuestsPage.jsx @@ -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
טוען...
+ + return ( +
+
+ הדשבורד + / + האירוע + / + מוזמנים +
+ +
+

ניהול מוזמנים

+ +
+ + {/* Stats */} +
+ {[ + { 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 => ( +
+

{s.val}

+

{s.label}

+
+ ))} +
+ + {/* Form */} + {showForm && ( +
+

{editGuest ? 'עריכת מוזמן' : 'הוספת מוזמן'}

+
+
+ + 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" /> +
+
+ + 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" /> +
+
+ + 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" /> +
+
+ + +
+
+ + 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" /> +
+
+ + +
+
+ + 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" /> +
+
+ + +
+
+
+ )} + + {/* Search */} +
+ + 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" + /> +
+ + {/* Table */} +
+ + + + + + + + + + + + + {filtered.length === 0 ? ( + + ) : filtered.map(g => ( + + + + + + + + + ))} + +
שםטלפוןסטטוסשולחןתזונה
אין מוזמנים
{g.name}{g.phone || '—'} + + {RSVP_OPTIONS[g.rsvp_status] || g.rsvp_status} + + {g.table_number || '—'}{g.dietary_restriction || '—'} +
+ + +
+
+
+
+ ) +} diff --git a/client/src/pages/LoginPage.jsx b/client/src/pages/LoginPage.jsx new file mode 100644 index 0000000..c4549ec --- /dev/null +++ b/client/src/pages/LoginPage.jsx @@ -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 ( +
+
+

אירועית

+

התחברות לחשבון

+ {error &&
{error}
} +
+
+ + 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" + /> +
+
+ + 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" + /> +
+ +
+

+ אין לך חשבון?{' '} + הרשמה +

+
+
+ ) +} diff --git a/client/src/pages/RegisterPage.jsx b/client/src/pages/RegisterPage.jsx new file mode 100644 index 0000000..d31cf91 --- /dev/null +++ b/client/src/pages/RegisterPage.jsx @@ -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 ( +
+
+

אירועית

+

יצירת חשבון חדש

+ {error &&
{error}
} +
+
+ + 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" + /> +
+
+ + 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" + /> +
+
+ + 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" + /> +
+ +
+

+ יש לך חשבון?{' '} + התחבר +

+
+
+ ) +} diff --git a/client/tailwind.config.js b/client/tailwind.config.js new file mode 100644 index 0000000..6ca3075 --- /dev/null +++ b/client/tailwind.config.js @@ -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: [], +} diff --git a/client/vite.config.js b/client/vite.config.js new file mode 100644 index 0000000..4f387b4 --- /dev/null +++ b/client/vite.config.js @@ -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', + }, + }, +}) diff --git a/db.js b/db.js new file mode 100644 index 0000000..79bd41d --- /dev/null +++ b/db.js @@ -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; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3711f61 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/migrate.js b/migrate.js new file mode 100644 index 0000000..96fda99 --- /dev/null +++ b/migrate.js @@ -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(); diff --git a/package.json b/package.json new file mode 100644 index 0000000..6d9928c --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/routes/auth.js b/routes/auth.js new file mode 100644 index 0000000..58b8646 --- /dev/null +++ b/routes/auth.js @@ -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; diff --git a/routes/bookings.js b/routes/bookings.js new file mode 100644 index 0000000..e6c247e --- /dev/null +++ b/routes/bookings.js @@ -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; diff --git a/routes/budget.js b/routes/budget.js new file mode 100644 index 0000000..343fa64 --- /dev/null +++ b/routes/budget.js @@ -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; diff --git a/routes/events.js b/routes/events.js new file mode 100644 index 0000000..ca9dff0 --- /dev/null +++ b/routes/events.js @@ -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; diff --git a/routes/guests.js b/routes/guests.js new file mode 100644 index 0000000..d5f5f6f --- /dev/null +++ b/routes/guests.js @@ -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; diff --git a/server.js b/server.js new file mode 100644 index 0000000..e979187 --- /dev/null +++ b/server.js @@ -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}`); +});