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 <noreply@anthropic.com>
This commit is contained in:
Fullstack Developer
2026-02-21 18:55:40 +00:00
parent ae6f833207
commit d2d7ee27d5
23 changed files with 1815 additions and 0 deletions

13
client/index.html Normal file
View 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>

28
client/package.json Normal file
View File

@@ -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"
}
}

6
client/postcss.config.js Normal file
View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

39
client/src/App.jsx Normal file
View File

@@ -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 <Navigate to="/login" replace />;
return children;
}
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route path="/" element={
<ProtectedRoute>
<Layout>
<EventsPage />
</Layout>
</ProtectedRoute>
} />
<Route path="/events/:id" element={
<ProtectedRoute>
<Layout>
<EventDetailPage />
</Layout>
</ProtectedRoute>
} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</BrowserRouter>
);
}

View File

@@ -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 = () => (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<input type="text" value={form.title} onChange={e => 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" />
<select value={form.type} onChange={e => setForm({ ...form, type: e.target.value })}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-sky-500">
<option value="expense">הוצאה</option>
<option value="income">הכנסה</option>
</select>
<input type="number" value={form.amount} onChange={e => 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" />
<input type="text" value={form.description} onChange={e => 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" />
</div>
);
if (loading) return <div className="text-center text-gray-500 py-8">טוען...</div>;
return (
<div>
{/* Summary */}
<div className="grid grid-cols-3 gap-4 mb-6">
<div className="bg-green-50 border border-green-200 rounded-xl p-4 text-center">
<TrendingUp className="w-5 h-5 text-green-600 mx-auto mb-1" />
<div className="text-lg font-bold text-green-700">{totalIncome.toLocaleString()}</div>
<div className="text-xs text-green-600">הכנסות</div>
</div>
<div className="bg-red-50 border border-red-200 rounded-xl p-4 text-center">
<TrendingDown className="w-5 h-5 text-red-500 mx-auto mb-1" />
<div className="text-lg font-bold text-red-600">{totalExpenses.toLocaleString()}</div>
<div className="text-xs text-red-500">הוצאות</div>
</div>
<div className={`border rounded-xl p-4 text-center ${balance >= 0 ? 'bg-sky-50 border-sky-200' : 'bg-orange-50 border-orange-200'}`}>
<Wallet className={`w-5 h-5 mx-auto mb-1 ${balance >= 0 ? 'text-sky-600' : 'text-orange-500'}`} />
<div className={`text-lg font-bold ${balance >= 0 ? 'text-sky-700' : 'text-orange-600'}`}>
{Math.abs(balance).toLocaleString()}
</div>
<div className={`text-xs ${balance >= 0 ? 'text-sky-600' : 'text-orange-500'}`}>
{balance >= 0 ? 'יתרה' : 'גירעון'}
</div>
</div>
</div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-gray-800">פריטי תקציב</h2>
<button onClick={startAdd}
className="flex items-center gap-2 bg-sky-600 text-white px-4 py-2 rounded-lg hover:bg-sky-700 transition-colors text-sm font-medium">
<Plus className="w-4 h-4" />
הוסף פריט
</button>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 rounded-lg px-3 py-2 text-sm mb-4">
{error}
</div>
)}
{showForm && (
<div className="bg-sky-50 border border-sky-200 rounded-xl p-4 mb-4">
<h3 className="font-medium text-gray-700 mb-3">פריט חדש</h3>
<FormFields />
<div className="flex gap-2 mt-3">
<button onClick={handleSave} disabled={saving}
className="flex items-center gap-1 bg-sky-600 text-white px-4 py-1.5 rounded-lg text-sm hover:bg-sky-700 disabled:opacity-50">
<Check className="w-3.5 h-3.5" /> שמור
</button>
<button onClick={cancelForm}
className="flex items-center gap-1 border border-gray-300 text-gray-600 px-4 py-1.5 rounded-lg text-sm hover:bg-gray-50">
<X className="w-3.5 h-3.5" /> ביטול
</button>
</div>
</div>
)}
{items.length === 0 && !showForm ? (
<div className="text-center py-12 text-gray-400">
<Wallet className="w-10 h-10 mx-auto mb-3 opacity-50" />
<p>אין פריטי תקציב עדיין</p>
</div>
) : (
<div className="space-y-2">
{items.map(item => (
<div key={item.id} className={`border rounded-xl p-4 ${item.type === 'income' ? 'border-green-200 bg-green-50' : 'border-red-100 bg-red-50'}`}>
{editingId === item.id ? (
<div>
<FormFields />
{error && <div className="text-red-600 text-sm mt-2">{error}</div>}
<div className="flex gap-2 mt-3">
<button onClick={handleSave} disabled={saving}
className="flex items-center gap-1 bg-sky-600 text-white px-4 py-1.5 rounded-lg text-sm hover:bg-sky-700 disabled:opacity-50">
<Check className="w-3.5 h-3.5" /> שמור
</button>
<button onClick={cancelForm}
className="flex items-center gap-1 border border-gray-300 text-gray-600 px-4 py-1.5 rounded-lg text-sm hover:bg-gray-50">
<X className="w-3.5 h-3.5" /> ביטול
</button>
</div>
</div>
) : (
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<span className="font-medium text-gray-800">{item.title}</span>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${item.type === 'income' ? 'bg-green-200 text-green-800' : 'bg-red-200 text-red-800'}`}>
{item.type === 'income' ? 'הכנסה' : 'הוצאה'}
</span>
</div>
{item.description && <div className="text-sm text-gray-500 mt-0.5">{item.description}</div>}
</div>
<div className="flex items-center gap-3">
<span className={`font-bold text-lg ${item.type === 'income' ? 'text-green-700' : 'text-red-600'}`}>
{item.type === 'income' ? '+' : '-'}{Number(item.amount).toLocaleString()}
</span>
<div className="flex gap-1">
<button onClick={() => startEdit(item)}
className="p-1.5 text-gray-400 hover:text-sky-600 hover:bg-white rounded-lg transition-colors">
<Edit className="w-4 h-4" />
</button>
<button onClick={() => handleDelete(item.id)}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-white rounded-lg transition-colors">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
);
}

View File

@@ -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 (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
<div className="bg-white rounded-2xl shadow-xl w-full max-w-lg">
<div className="flex items-center justify-between p-6 border-b border-gray-200">
<h2 className="text-xl font-bold text-gray-800">
{event ? 'עריכת אירוע' : 'אירוע חדש'}
</h2>
<button onClick={onClose} className="p-2 hover:bg-gray-100 rounded-lg transition-colors">
<X className="w-5 h-5 text-gray-500" />
</button>
</div>
<form onSubmit={handleSubmit} className="p-6 space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">שם האירוע *</label>
<input
type="text"
value={form.title}
onChange={e => 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="חתונה, ימי הולדת, כנס..."
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">תיאור</label>
<textarea
value={form.description}
onChange={e => setForm({ ...form, description: e.target.value })}
rows={3}
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-sky-500 resize-none"
placeholder="תיאור האירוע..."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">תאריך ושעה *</label>
<input
type="datetime-local"
value={form.event_date}
onChange={e => setForm({ ...form, event_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-sky-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">מיקום</label>
<input
type="text"
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-sky-500"
placeholder="תל אביב"
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">מספר משתתפים מקסימלי</label>
<input
type="number"
value={form.max_participants}
onChange={e => setForm({ ...form, max_participants: e.target.value })}
min="1"
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-sky-500"
placeholder="100"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">תקציב ()</label>
<input
type="number"
value={form.budget}
onChange={e => setForm({ ...form, budget: e.target.value })}
min="0"
step="0.01"
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-sky-500"
placeholder="5000"
/>
</div>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 rounded-lg px-3 py-2 text-sm">
{error}
</div>
)}
<div className="flex gap-3 pt-2">
<button
type="button"
onClick={onClose}
className="flex-1 border border-gray-300 text-gray-700 rounded-lg py-2.5 font-medium hover:bg-gray-50 transition-colors"
>
ביטול
</button>
<button
type="submit"
disabled={loading}
className="flex-1 bg-sky-600 text-white rounded-lg py-2.5 font-medium hover:bg-sky-700 disabled:opacity-50 transition-colors"
>
{loading ? 'שומר...' : event ? 'שמור שינויים' : 'צור אירוע'}
</button>
</div>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { useNavigate } from 'react-router-dom';
import { removeToken } from '../lib/auth';
import { Calendar, LogOut } from 'lucide-react';
export default function Layout({ children }) {
const navigate = useNavigate();
function handleLogout() {
removeToken();
navigate('/login');
}
return (
<div className="min-h-screen bg-gray-50">
<nav className="bg-white shadow-sm border-b border-gray-200">
<div className="max-w-6xl mx-auto px-4 py-3 flex items-center justify-between">
<button
onClick={() => navigate('/')}
className="flex items-center gap-2 text-xl font-bold text-sky-600 hover:text-sky-700"
>
<Calendar className="w-6 h-6" />
אירועית
</button>
<button
onClick={handleLogout}
className="flex items-center gap-2 text-gray-600 hover:text-gray-800 text-sm"
>
<LogOut className="w-4 h-4" />
יציאה
</button>
</div>
</nav>
<main className="max-w-6xl mx-auto px-4 py-6">
{children}
</main>
</div>
);
}

View File

@@ -0,0 +1,238 @@
import React, { useState, useEffect } from 'react';
import { api } from '../lib/api';
import { Plus, Trash2, Edit, Check, X, User } from 'lucide-react';
const STATUS_LABELS = {
invited: 'הוזמן',
confirmed: 'אישר הגעה',
declined: 'ביטל',
attended: 'השתתף',
};
const STATUS_COLORS = {
invited: 'bg-yellow-100 text-yellow-700',
confirmed: 'bg-green-100 text-green-700',
declined: 'bg-red-100 text-red-700',
attended: 'bg-blue-100 text-blue-700',
};
export default function ParticipantsTab({ eventId }) {
const [participants, setParticipants] = useState([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [editingId, setEditingId] = useState(null);
const [form, setForm] = useState({ name: '', email: '', phone: '', status: 'invited' });
const [error, setError] = useState('');
const [saving, setSaving] = useState(false);
useEffect(() => {
loadParticipants();
}, [eventId]);
async function loadParticipants() {
try {
const data = await api.getParticipants(eventId);
setParticipants(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
function startEdit(p) {
setEditingId(p.id);
setForm({ name: p.name, email: p.email, phone: p.phone || '', status: p.status });
setShowForm(false);
}
function startAdd() {
setEditingId(null);
setForm({ name: '', email: '', phone: '', status: 'invited' });
setShowForm(true);
}
function cancelForm() {
setShowForm(false);
setEditingId(null);
setForm({ name: '', email: '', phone: '', status: 'invited' });
setError('');
}
async function handleSave() {
if (!form.name.trim() || !form.email.trim()) {
setError('שם ואימייל נדרשים');
return;
}
setSaving(true);
setError('');
try {
if (editingId) {
const updated = await api.updateParticipant(eventId, editingId, form);
setParticipants(participants.map(p => p.id === editingId ? updated : p));
} else {
const created = await api.createParticipant(eventId, form);
setParticipants([...participants, created]);
}
cancelForm();
} catch (err) {
setError(err.message);
} finally {
setSaving(false);
}
}
async function handleDelete(id) {
if (!confirm('למחוק את המשתתף?')) return;
try {
await api.deleteParticipant(eventId, id);
setParticipants(participants.filter(p => p.id !== id));
} catch (err) {
alert(err.message);
}
}
const confirmed = participants.filter(p => p.status === 'confirmed').length;
const total = participants.length;
if (loading) return <div className="text-center text-gray-500 py-8">טוען...</div>;
return (
<div>
<div className="flex items-center justify-between mb-4">
<div>
<h2 className="text-lg font-semibold text-gray-800">משתתפים</h2>
<p className="text-sm text-gray-500">{total} סה"כ · {confirmed} אישרו הגעה</p>
</div>
<button
onClick={startAdd}
className="flex items-center gap-2 bg-sky-600 text-white px-4 py-2 rounded-lg hover:bg-sky-700 transition-colors text-sm font-medium"
>
<Plus className="w-4 h-4" />
הוסף משתתף
</button>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 rounded-lg px-3 py-2 text-sm mb-4">
{error}
</div>
)}
{showForm && (
<div className="bg-sky-50 border border-sky-200 rounded-xl p-4 mb-4">
<h3 className="font-medium text-gray-700 mb-3">משתתף חדש</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<input
type="text"
value={form.name}
onChange={e => setForm({ ...form, name: 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"
/>
<input
type="email"
value={form.email}
onChange={e => setForm({ ...form, email: 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"
/>
<input
type="tel"
value={form.phone}
onChange={e => setForm({ ...form, phone: 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"
/>
<select
value={form.status}
onChange={e => setForm({ ...form, status: e.target.value })}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-sky-500"
>
{Object.entries(STATUS_LABELS).map(([val, label]) => (
<option key={val} value={val}>{label}</option>
))}
</select>
</div>
<div className="flex gap-2 mt-3">
<button onClick={handleSave} disabled={saving}
className="flex items-center gap-1 bg-sky-600 text-white px-4 py-1.5 rounded-lg text-sm hover:bg-sky-700 disabled:opacity-50">
<Check className="w-3.5 h-3.5" /> שמור
</button>
<button onClick={cancelForm}
className="flex items-center gap-1 border border-gray-300 text-gray-600 px-4 py-1.5 rounded-lg text-sm hover:bg-gray-50">
<X className="w-3.5 h-3.5" /> ביטול
</button>
</div>
</div>
)}
{participants.length === 0 && !showForm ? (
<div className="text-center py-12 text-gray-400">
<User className="w-10 h-10 mx-auto mb-3 opacity-50" />
<p>אין משתתפים עדיין</p>
</div>
) : (
<div className="space-y-2">
{participants.map(p => (
<div key={p.id} className="border border-gray-200 rounded-xl p-4">
{editingId === p.id ? (
<div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-3">
<input type="text" value={form.name} onChange={e => setForm({ ...form, name: e.target.value })}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-sky-500" placeholder="שם מלא *" />
<input type="email" value={form.email} onChange={e => setForm({ ...form, email: e.target.value })}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-sky-500" placeholder="אימייל *" />
<input type="tel" value={form.phone} onChange={e => setForm({ ...form, phone: e.target.value })}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-sky-500" placeholder="טלפון" />
<select value={form.status} onChange={e => setForm({ ...form, status: e.target.value })}
className="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-sky-500">
{Object.entries(STATUS_LABELS).map(([val, label]) => (
<option key={val} value={val}>{label}</option>
))}
</select>
</div>
{error && <div className="text-red-600 text-sm mb-2">{error}</div>}
<div className="flex gap-2">
<button onClick={handleSave} disabled={saving}
className="flex items-center gap-1 bg-sky-600 text-white px-4 py-1.5 rounded-lg text-sm hover:bg-sky-700 disabled:opacity-50">
<Check className="w-3.5 h-3.5" /> שמור
</button>
<button onClick={cancelForm}
className="flex items-center gap-1 border border-gray-300 text-gray-600 px-4 py-1.5 rounded-lg text-sm hover:bg-gray-50">
<X className="w-3.5 h-3.5" /> ביטול
</button>
</div>
</div>
) : (
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-3">
<span className="font-medium text-gray-800">{p.name}</span>
<span className={`text-xs px-2 py-0.5 rounded-full font-medium ${STATUS_COLORS[p.status]}`}>
{STATUS_LABELS[p.status]}
</span>
</div>
<div className="text-sm text-gray-500 mt-0.5">
{p.email}{p.phone && ` · ${p.phone}`}
</div>
</div>
<div className="flex gap-1">
<button onClick={() => startEdit(p)}
className="p-1.5 text-gray-400 hover:text-sky-600 hover:bg-sky-50 rounded-lg transition-colors">
<Edit className="w-4 h-4" />
</button>
<button onClick={() => handleDelete(p.id)}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors">
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
);
}

13
client/src/index.css Normal file
View File

@@ -0,0 +1,13 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
* {
box-sizing: border-box;
}
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Helvetica Neue', Arial, sans-serif;
direction: rtl;
}

52
client/src/lib/api.js Normal file
View File

@@ -0,0 +1,52 @@
const BASE_URL = '/api';
function getToken() {
return localStorage.getItem('token');
}
async function request(path, options = {}) {
const token = getToken();
const headers = {
'Content-Type': 'application/json',
...(token ? { Authorization: `Bearer ${token}` } : {}),
...options.headers,
};
const res = await fetch(`${BASE_URL}${path}`, {
...options,
headers,
});
if (!res.ok) {
const err = await res.json().catch(() => ({ error: 'Request failed' }));
throw new Error(err.error || 'Request failed');
}
return res.json();
}
export const api = {
// Auth
register: (data) => request('/auth/register', { method: 'POST', body: JSON.stringify(data) }),
login: (data) => request('/auth/login', { method: 'POST', body: JSON.stringify(data) }),
me: () => request('/auth/me'),
// Events
getEvents: () => request('/events'),
getEvent: (id) => request(`/events/${id}`),
createEvent: (data) => request('/events', { method: 'POST', body: JSON.stringify(data) }),
updateEvent: (id, data) => request(`/events/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteEvent: (id) => request(`/events/${id}`, { method: 'DELETE' }),
// Participants
getParticipants: (eventId) => request(`/events/${eventId}/participants`),
createParticipant: (eventId, data) => request(`/events/${eventId}/participants`, { method: 'POST', body: JSON.stringify(data) }),
updateParticipant: (eventId, id, data) => request(`/events/${eventId}/participants/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteParticipant: (eventId, id) => request(`/events/${eventId}/participants/${id}`, { method: 'DELETE' }),
// Budget
getBudget: (eventId) => request(`/events/${eventId}/budget`),
createBudgetItem: (eventId, data) => request(`/events/${eventId}/budget`, { method: 'POST', body: JSON.stringify(data) }),
updateBudgetItem: (eventId, id, data) => request(`/events/${eventId}/budget/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteBudgetItem: (eventId, id) => request(`/events/${eventId}/budget/${id}`, { method: 'DELETE' }),
};

15
client/src/lib/auth.js Normal file
View File

@@ -0,0 +1,15 @@
export function setToken(token) {
localStorage.setItem('token', token);
}
export function getToken() {
return localStorage.getItem('token');
}
export function removeToken() {
localStorage.removeItem('token');
}
export function isLoggedIn() {
return !!getToken();
}

10
client/src/main.jsx Normal file
View File

@@ -0,0 +1,10 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode>
<App />
</React.StrictMode>,
)

View File

@@ -0,0 +1,121 @@
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { api } from '../lib/api';
import { ArrowRight, Calendar, MapPin, Users, Wallet } from 'lucide-react';
import ParticipantsTab from '../components/ParticipantsTab';
import BudgetTab from '../components/BudgetTab';
export default function EventDetailPage() {
const { id } = useParams();
const navigate = useNavigate();
const [event, setEvent] = useState(null);
const [activeTab, setActiveTab] = useState('participants');
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
loadEvent();
}, [id]);
async function loadEvent() {
try {
const data = await api.getEvent(id);
setEvent(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
function formatDate(dateStr) {
return new Date(dateStr).toLocaleDateString('he-IL', {
day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit'
});
}
if (loading) return (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">טוען...</div>
</div>
);
if (error) return (
<div className="bg-red-50 border border-red-200 text-red-700 rounded-lg px-4 py-3">
{error}
</div>
);
if (!event) return null;
return (
<div>
<button
onClick={() => navigate('/')}
className="flex items-center gap-2 text-gray-500 hover:text-gray-800 mb-6 text-sm"
>
<ArrowRight className="w-4 h-4" />
חזרה לאירועים
</button>
<div className="bg-white rounded-2xl border border-gray-200 p-6 mb-6">
<h1 className="text-2xl font-bold text-gray-800 mb-4">{event.title}</h1>
{event.description && (
<p className="text-gray-600 mb-4">{event.description}</p>
)}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4 text-sm text-gray-600">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-sky-500" />
<span>{formatDate(event.event_date)}</span>
</div>
{event.location && (
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-sky-500" />
<span>{event.location}</span>
</div>
)}
{event.max_participants && (
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-sky-500" />
<span>מקסימום {event.max_participants} משתתפים</span>
</div>
)}
</div>
</div>
<div className="bg-white rounded-2xl border border-gray-200 overflow-hidden">
<div className="flex border-b border-gray-200">
<button
onClick={() => setActiveTab('participants')}
className={`flex items-center gap-2 px-6 py-4 text-sm font-medium transition-colors ${
activeTab === 'participants'
? 'border-b-2 border-sky-600 text-sky-600'
: 'text-gray-500 hover:text-gray-700'
}`}
>
<Users className="w-4 h-4" />
משתתפים
</button>
<button
onClick={() => setActiveTab('budget')}
className={`flex items-center gap-2 px-6 py-4 text-sm font-medium transition-colors ${
activeTab === 'budget'
? 'border-b-2 border-sky-600 text-sky-600'
: 'text-gray-500 hover:text-gray-700'
}`}
>
<Wallet className="w-4 h-4" />
תקציב
</button>
</div>
<div className="p-6">
{activeTab === 'participants' && <ParticipantsTab eventId={id} />}
{activeTab === 'budget' && <BudgetTab eventId={id} />}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,182 @@
import React, { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../lib/api';
import { Plus, Calendar, MapPin, Users, Trash2, Edit, ChevronLeft } from 'lucide-react';
import EventFormModal from '../components/EventFormModal';
export default function EventsPage() {
const navigate = useNavigate();
const [events, setEvents] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [showModal, setShowModal] = useState(false);
const [editingEvent, setEditingEvent] = useState(null);
useEffect(() => {
loadEvents();
}, []);
async function loadEvents() {
try {
const data = await api.getEvents();
setEvents(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
async function handleDelete(id, e) {
e.stopPropagation();
if (!confirm('האם למחוק את האירוע?')) return;
try {
await api.deleteEvent(id);
setEvents(events.filter(ev => ev.id !== id));
} catch (err) {
alert(err.message);
}
}
function handleEdit(event, e) {
e.stopPropagation();
setEditingEvent(event);
setShowModal(true);
}
function handleModalClose() {
setShowModal(false);
setEditingEvent(null);
}
async function handleModalSave(data) {
try {
if (editingEvent) {
const updated = await api.updateEvent(editingEvent.id, data);
setEvents(events.map(ev => ev.id === updated.id ? { ...ev, ...updated } : ev));
} else {
const created = await api.createEvent(data);
setEvents([...events, { ...created, participant_count: 0, total_income: 0, total_expenses: 0 }]);
}
handleModalClose();
} catch (err) {
throw err;
}
}
function formatDate(dateStr) {
return new Date(dateStr).toLocaleDateString('he-IL', {
day: 'numeric', month: 'long', year: 'numeric', hour: '2-digit', minute: '2-digit'
});
}
if (loading) return (
<div className="flex items-center justify-center h-64">
<div className="text-gray-500">טוען...</div>
</div>
);
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold text-gray-800">האירועים שלי</h1>
<p className="text-gray-500 text-sm mt-1">{events.length} אירועים</p>
</div>
<button
onClick={() => setShowModal(true)}
className="flex items-center gap-2 bg-sky-600 text-white px-4 py-2 rounded-lg hover:bg-sky-700 transition-colors font-medium"
>
<Plus className="w-4 h-4" />
אירוע חדש
</button>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 rounded-lg px-4 py-3 mb-6">
{error}
</div>
)}
{events.length === 0 ? (
<div className="text-center py-16 bg-white rounded-2xl border border-gray-200">
<Calendar className="w-12 h-12 text-gray-300 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-600 mb-2">אין אירועים עדיין</h3>
<p className="text-gray-400 mb-6">צור את האירוע הראשון שלך</p>
<button
onClick={() => setShowModal(true)}
className="bg-sky-600 text-white px-6 py-2 rounded-lg hover:bg-sky-700 transition-colors"
>
צור אירוע
</button>
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{events.map(event => (
<div
key={event.id}
onClick={() => navigate(`/events/${event.id}`)}
className="bg-white rounded-xl border border-gray-200 p-5 hover:shadow-md hover:border-sky-200 transition-all cursor-pointer"
>
<div className="flex items-start justify-between mb-3">
<h3 className="font-semibold text-gray-800 text-lg leading-tight">{event.title}</h3>
<div className="flex gap-1 mr-2">
<button
onClick={(e) => handleEdit(event, e)}
className="p-1.5 text-gray-400 hover:text-sky-600 hover:bg-sky-50 rounded-lg transition-colors"
>
<Edit className="w-4 h-4" />
</button>
<button
onClick={(e) => handleDelete(event.id, e)}
className="p-1.5 text-gray-400 hover:text-red-600 hover:bg-red-50 rounded-lg transition-colors"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</div>
{event.description && (
<p className="text-gray-500 text-sm mb-3 line-clamp-2">{event.description}</p>
)}
<div className="space-y-1.5 text-sm text-gray-500">
<div className="flex items-center gap-2">
<Calendar className="w-4 h-4 text-sky-500 flex-shrink-0" />
<span>{formatDate(event.event_date)}</span>
</div>
{event.location && (
<div className="flex items-center gap-2">
<MapPin className="w-4 h-4 text-sky-500 flex-shrink-0" />
<span>{event.location}</span>
</div>
)}
<div className="flex items-center gap-2">
<Users className="w-4 h-4 text-sky-500 flex-shrink-0" />
<span>{event.participant_count} משתתפים</span>
</div>
</div>
<div className="mt-4 pt-3 border-t border-gray-100 flex justify-between items-center">
<div className="text-sm">
<span className="text-green-600 font-medium">{Number(event.total_income).toLocaleString()}</span>
<span className="text-gray-400 mx-1">|</span>
<span className="text-red-500 font-medium">{Number(event.total_expenses).toLocaleString()}</span>
</div>
<ChevronLeft className="w-4 h-4 text-gray-400" />
</div>
</div>
))}
</div>
)}
{showModal && (
<EventFormModal
event={editingEvent}
onClose={handleModalClose}
onSave={handleModalSave}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,87 @@
import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { api } from '../lib/api';
import { setToken } from '../lib/auth';
import { Calendar } from 'lucide-react';
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 { token } = await api.login(form);
setToken(token);
navigate('/');
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-sky-50 to-blue-100 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-lg p-8 w-full max-w-md">
<div className="text-center mb-8">
<div className="flex justify-center mb-3">
<Calendar className="w-12 h-12 text-sky-600" />
</div>
<h1 className="text-3xl font-bold text-gray-800">אירועית</h1>
<p className="text-gray-500 mt-1">פלטפורמה לניהול אירועים</p>
</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"
value={form.email}
onChange={e => setForm({ ...form, email: e.target.value })}
required
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-sky-500"
placeholder="your@email.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">סיסמה</label>
<input
type="password"
value={form.password}
onChange={e => setForm({ ...form, password: e.target.value })}
required
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-sky-500"
placeholder="••••••••"
/>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 rounded-lg px-3 py-2 text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-sky-600 text-white rounded-lg py-2.5 font-medium hover:bg-sky-700 disabled:opacity-50 transition-colors"
>
{loading ? 'מתחבר...' : 'כניסה'}
</button>
</form>
<p className="text-center text-sm text-gray-600 mt-6">
אין לך חשבון?{' '}
<Link to="/register" className="text-sky-600 hover:underline font-medium">
הרשמה
</Link>
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,102 @@
import React, { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { api } from '../lib/api';
import { setToken } from '../lib/auth';
import { Calendar } from 'lucide-react';
export default function RegisterPage() {
const navigate = useNavigate();
const [form, setForm] = useState({ name: '', email: '', password: '' });
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
async function handleSubmit(e) {
e.preventDefault();
setError('');
if (form.password.length < 6) {
setError('הסיסמה חייבת להכיל לפחות 6 תווים');
return;
}
setLoading(true);
try {
const { token } = await api.register(form);
setToken(token);
navigate('/');
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
return (
<div className="min-h-screen bg-gradient-to-br from-sky-50 to-blue-100 flex items-center justify-center p-4">
<div className="bg-white rounded-2xl shadow-lg p-8 w-full max-w-md">
<div className="text-center mb-8">
<div className="flex justify-center mb-3">
<Calendar className="w-12 h-12 text-sky-600" />
</div>
<h1 className="text-3xl font-bold text-gray-800">אירועית</h1>
<p className="text-gray-500 mt-1">צור חשבון חדש</p>
</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"
value={form.name}
onChange={e => setForm({ ...form, name: e.target.value })}
required
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-sky-500"
placeholder="ישראל ישראלי"
/>
</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 })}
required
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-sky-500"
placeholder="your@email.com"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">סיסמה</label>
<input
type="password"
value={form.password}
onChange={e => setForm({ ...form, password: e.target.value })}
required
className="w-full border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-sky-500"
placeholder="לפחות 6 תווים"
/>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 rounded-lg px-3 py-2 text-sm">
{error}
</div>
)}
<button
type="submit"
disabled={loading}
className="w-full bg-sky-600 text-white rounded-lg py-2.5 font-medium hover:bg-sky-700 disabled:opacity-50 transition-colors"
>
{loading ? 'נרשם...' : 'הרשמה'}
</button>
</form>
<p className="text-center text-sm text-gray-600 mt-6">
כבר יש לך חשבון?{' '}
<Link to="/login" className="text-sky-600 hover:underline font-medium">
כניסה
</Link>
</p>
</div>
</div>
);
}

21
client/tailwind.config.js Normal file
View File

@@ -0,0 +1,21 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: {
50: '#f0f9ff',
100: '#e0f2fe',
500: '#0ea5e9',
600: '#0284c7',
700: '#0369a1',
}
}
},
},
plugins: [],
}

17
client/vite.config.js Normal file
View 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',
},
},
})