From d2d7ee27d52981cdabdba9acd1c45906d2982ac0 Mon Sep 17 00:00:00 2001 From: Fullstack Developer Date: Sat, 21 Feb 2026 18:55:40 +0000 Subject: [PATCH] feat: initial scaffold with Event Management, Participant Management, and Budget Management - Express backend with JWT auth, PostgreSQL, full CRUD APIs - React + Vite + TailwindCSS frontend with RTL Hebrew UI - Event Creation & Management (create/edit/delete/list events) - Participant Management (add/edit/delete/status tracking per event) - Budget Management (income/expense tracking with balance summary) - Docker Compose setup with PostgreSQL - /health endpoint with commit-id and DB status Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 5 + Dockerfile | 24 ++ client/index.html | 13 + client/package.json | 28 ++ client/postcss.config.js | 6 + client/src/App.jsx | 39 +++ client/src/components/BudgetTab.jsx | 226 +++++++++++++ client/src/components/EventFormModal.jsx | 145 +++++++++ client/src/components/Layout.jsx | 39 +++ client/src/components/ParticipantsTab.jsx | 238 ++++++++++++++ client/src/index.css | 13 + client/src/lib/api.js | 52 +++ client/src/lib/auth.js | 15 + client/src/main.jsx | 10 + client/src/pages/EventDetailPage.jsx | 121 +++++++ client/src/pages/EventsPage.jsx | 182 +++++++++++ client/src/pages/LoginPage.jsx | 87 +++++ client/src/pages/RegisterPage.jsx | 102 ++++++ client/tailwind.config.js | 21 ++ client/vite.config.js | 17 + docker-compose.yml | 36 +++ package.json | 18 ++ server.js | 378 ++++++++++++++++++++++ 23 files changed, 1815 insertions(+) create mode 100644 .gitignore 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/components/BudgetTab.jsx create mode 100644 client/src/components/EventFormModal.jsx create mode 100644 client/src/components/Layout.jsx create mode 100644 client/src/components/ParticipantsTab.jsx create mode 100644 client/src/index.css create mode 100644 client/src/lib/api.js create mode 100644 client/src/lib/auth.js create mode 100644 client/src/main.jsx create mode 100644 client/src/pages/EventDetailPage.jsx create mode 100644 client/src/pages/EventsPage.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 docker-compose.yml create mode 100644 package.json create mode 100644 server.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..06a4918 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +client/node_modules/ +client/dist/ +.env +.saac/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..18bbe08 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,24 @@ +FROM node:20-alpine + +WORKDIR /app + +# Install backend dependencies +COPY package.json ./ +RUN npm install + +# Build frontend +COPY client/package.json client/ +RUN cd client && npm install + +COPY client/ client/ +RUN cd client && npm run build + +# Copy server +COPY server.js ./ + +EXPOSE 3000 + +ENV NODE_ENV=production +ENV PORT=3000 + +CMD ["node", "server.js"] diff --git a/client/index.html b/client/index.html new file mode 100644 index 0000000..322cd59 --- /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..4676c8b --- /dev/null +++ b/client/package.json @@ -0,0 +1,28 @@ +{ + "name": "airewit-client", + "private": true, + "version": "0.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.1", + "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..fcae13c --- /dev/null +++ b/client/src/App.jsx @@ -0,0 +1,39 @@ +import React, { useState, useEffect } from 'react'; +import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom'; +import { isLoggedIn } from './lib/auth'; +import LoginPage from './pages/LoginPage'; +import RegisterPage from './pages/RegisterPage'; +import EventsPage from './pages/EventsPage'; +import EventDetailPage from './pages/EventDetailPage'; +import Layout from './components/Layout'; + +function ProtectedRoute({ children }) { + if (!isLoggedIn()) return ; + return children; +} + +export default function App() { + return ( + + + } /> + } /> + + + + + + } /> + + + + + + } /> + } /> + + + ); +} diff --git a/client/src/components/BudgetTab.jsx b/client/src/components/BudgetTab.jsx new file mode 100644 index 0000000..42aa328 --- /dev/null +++ b/client/src/components/BudgetTab.jsx @@ -0,0 +1,226 @@ +import React, { useState, useEffect } from 'react'; +import { api } from '../lib/api'; +import { Plus, Trash2, Edit, Check, X, TrendingUp, TrendingDown, Wallet } from 'lucide-react'; + +export default function BudgetTab({ eventId }) { + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(true); + const [showForm, setShowForm] = useState(false); + const [editingId, setEditingId] = useState(null); + const [form, setForm] = useState({ title: '', type: 'expense', amount: '', description: '' }); + const [error, setError] = useState(''); + const [saving, setSaving] = useState(false); + + useEffect(() => { + loadBudget(); + }, [eventId]); + + async function loadBudget() { + try { + const data = await api.getBudget(eventId); + setItems(data); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + } + + const totalIncome = items.filter(i => i.type === 'income').reduce((s, i) => s + Number(i.amount), 0); + const totalExpenses = items.filter(i => i.type === 'expense').reduce((s, i) => s + Number(i.amount), 0); + const balance = totalIncome - totalExpenses; + + function startAdd() { + setEditingId(null); + setForm({ title: '', type: 'expense', amount: '', description: '' }); + setShowForm(true); + } + + function startEdit(item) { + setEditingId(item.id); + setForm({ title: item.title, type: item.type, amount: item.amount, description: item.description || '' }); + setShowForm(false); + } + + function cancelForm() { + setShowForm(false); + setEditingId(null); + setForm({ title: '', type: 'expense', amount: '', description: '' }); + setError(''); + } + + async function handleSave() { + if (!form.title.trim() || !form.amount) { + setError('שם וסכום נדרשים'); + return; + } + if (Number(form.amount) <= 0) { + setError('הסכום חייב להיות חיובי'); + return; + } + setSaving(true); + setError(''); + try { + const payload = { ...form, amount: Number(form.amount) }; + if (editingId) { + const updated = await api.updateBudgetItem(eventId, editingId, payload); + setItems(items.map(i => i.id === editingId ? updated : i)); + } else { + const created = await api.createBudgetItem(eventId, payload); + setItems([...items, created]); + } + cancelForm(); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function handleDelete(id) { + if (!confirm('למחוק את הפריט?')) return; + try { + await api.deleteBudgetItem(eventId, id); + setItems(items.filter(i => i.id !== id)); + } catch (err) { + alert(err.message); + } + } + + const FormFields = () => ( +
+ setForm({ ...form, title: e.target.value })} + placeholder="שם הפריט *" + className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-sky-500" /> + + setForm({ ...form, amount: e.target.value })} + placeholder="סכום (₪) *" min="0.01" step="0.01" + className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-sky-500" /> + setForm({ ...form, description: e.target.value })} + placeholder="תיאור" + className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-sky-500" /> +
+ ); + + if (loading) return
טוען...
; + + return ( +
+ {/* Summary */} +
+
+ +
₪{totalIncome.toLocaleString()}
+
הכנסות
+
+
+ +
₪{totalExpenses.toLocaleString()}
+
הוצאות
+
+
= 0 ? 'bg-sky-50 border-sky-200' : 'bg-orange-50 border-orange-200'}`}> + = 0 ? 'text-sky-600' : 'text-orange-500'}`} /> +
= 0 ? 'text-sky-700' : 'text-orange-600'}`}> + ₪{Math.abs(balance).toLocaleString()} +
+
= 0 ? 'text-sky-600' : 'text-orange-500'}`}> + {balance >= 0 ? 'יתרה' : 'גירעון'} +
+
+
+ +
+

פריטי תקציב

+ +
+ + {error && ( +
+ {error} +
+ )} + + {showForm && ( +
+

פריט חדש

+ +
+ + +
+
+ )} + + {items.length === 0 && !showForm ? ( +
+ +

אין פריטי תקציב עדיין

+
+ ) : ( +
+ {items.map(item => ( +
+ {editingId === item.id ? ( +
+ + {error &&
{error}
} +
+ + +
+
+ ) : ( +
+
+
+ {item.title} + + {item.type === 'income' ? 'הכנסה' : 'הוצאה'} + +
+ {item.description &&
{item.description}
} +
+
+ + {item.type === 'income' ? '+' : '-'}₪{Number(item.amount).toLocaleString()} + +
+ + +
+
+
+ )} +
+ ))} +
+ )} +
+ ); +} diff --git a/client/src/components/EventFormModal.jsx b/client/src/components/EventFormModal.jsx new file mode 100644 index 0000000..35a9acf --- /dev/null +++ b/client/src/components/EventFormModal.jsx @@ -0,0 +1,145 @@ +import React, { useState } from 'react'; +import { X } from 'lucide-react'; + +export default function EventFormModal({ event, onClose, onSave }) { + const [form, setForm] = useState({ + title: event?.title || '', + description: event?.description || '', + event_date: event?.event_date ? event.event_date.slice(0, 16) : '', + location: event?.location || '', + max_participants: event?.max_participants || '', + budget: event?.budget || '', + }); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + async function handleSubmit(e) { + e.preventDefault(); + setError(''); + if (!form.title.trim()) { setError('שם האירוע נדרש'); return; } + if (!form.event_date) { setError('תאריך האירוע נדרש'); return; } + + setLoading(true); + try { + await onSave({ + ...form, + max_participants: form.max_participants ? Number(form.max_participants) : null, + budget: form.budget ? Number(form.budget) : 0, + }); + } catch (err) { + setError(err.message); + } finally { + setLoading(false); + } + } + + return ( +
+
+
+

+ {event ? 'עריכת אירוע' : 'אירוע חדש'} +

+ +
+ +
+
+ + setForm({ ...form, title: e.target.value })} + className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-sky-500" + placeholder="חתונה, ימי הולדת, כנס..." + /> +
+ +
+ +