Compare commits
1 Commits
airewit
...
d2d7ee27d5
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d2d7ee27d5 |
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
client/node_modules/
|
||||||
|
client/dist/
|
||||||
|
.env
|
||||||
|
.saac/
|
||||||
24
Dockerfile
Normal file
24
Dockerfile
Normal file
@@ -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"]
|
||||||
13
client/index.html
Normal file
13
client/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="he" dir="rtl">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>אירועית - ניהול אירועים</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.jsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
28
client/package.json
Normal file
28
client/package.json
Normal 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
6
client/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
39
client/src/App.jsx
Normal file
39
client/src/App.jsx
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
226
client/src/components/BudgetTab.jsx
Normal file
226
client/src/components/BudgetTab.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
145
client/src/components/EventFormModal.jsx
Normal file
145
client/src/components/EventFormModal.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
39
client/src/components/Layout.jsx
Normal file
39
client/src/components/Layout.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
238
client/src/components/ParticipantsTab.jsx
Normal file
238
client/src/components/ParticipantsTab.jsx
Normal 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
13
client/src/index.css
Normal 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
52
client/src/lib/api.js
Normal 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
15
client/src/lib/auth.js
Normal 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
10
client/src/main.jsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import React from 'react'
|
||||||
|
import ReactDOM from 'react-dom/client'
|
||||||
|
import App from './App.jsx'
|
||||||
|
import './index.css'
|
||||||
|
|
||||||
|
ReactDOM.createRoot(document.getElementById('root')).render(
|
||||||
|
<React.StrictMode>
|
||||||
|
<App />
|
||||||
|
</React.StrictMode>,
|
||||||
|
)
|
||||||
121
client/src/pages/EventDetailPage.jsx
Normal file
121
client/src/pages/EventDetailPage.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
182
client/src/pages/EventsPage.jsx
Normal file
182
client/src/pages/EventsPage.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
87
client/src/pages/LoginPage.jsx
Normal file
87
client/src/pages/LoginPage.jsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
102
client/src/pages/RegisterPage.jsx
Normal file
102
client/src/pages/RegisterPage.jsx
Normal 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
21
client/tailwind.config.js
Normal 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
17
client/vite.config.js
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:3000',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
36
docker-compose.yml
Normal file
36
docker-compose.yml
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build: .
|
||||||
|
expose:
|
||||||
|
- "3000"
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://postgres:postgres@postgres:5432/airewit
|
||||||
|
- JWT_SECRET=${JWT_SECRET:-airewit-jwt-secret-change-in-production}
|
||||||
|
- NODE_ENV=production
|
||||||
|
- PORT=3000
|
||||||
|
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:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U postgres"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
18
package.json
Normal file
18
package.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "airewit",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"description": "Event management platform",
|
||||||
|
"main": "server.js",
|
||||||
|
"scripts": {
|
||||||
|
"start": "node server.js",
|
||||||
|
"dev": "node server.js"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"bcryptjs": "^2.4.3",
|
||||||
|
"cors": "^2.8.5",
|
||||||
|
"express": "^4.18.2",
|
||||||
|
"jsonwebtoken": "^9.0.2",
|
||||||
|
"pg": "^8.11.3",
|
||||||
|
"uuid": "^9.0.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
378
server.js
Normal file
378
server.js
Normal file
@@ -0,0 +1,378 @@
|
|||||||
|
const express = require('express');
|
||||||
|
const cors = require('cors');
|
||||||
|
const { Pool } = require('pg');
|
||||||
|
const bcrypt = require('bcryptjs');
|
||||||
|
const jwt = require('jsonwebtoken');
|
||||||
|
const { v4: uuidv4 } = require('uuid');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const app = express();
|
||||||
|
const PORT = process.env.PORT || 3000;
|
||||||
|
const JWT_SECRET = process.env.JWT_SECRET || 'airewit-secret-key-change-in-prod';
|
||||||
|
|
||||||
|
const pool = new Pool({
|
||||||
|
connectionString: process.env.DATABASE_URL || 'postgresql://postgres:postgres@postgres:5432/airewit',
|
||||||
|
});
|
||||||
|
|
||||||
|
app.use(cors());
|
||||||
|
app.use(express.json());
|
||||||
|
app.use(express.static(path.join(__dirname, 'client/dist')));
|
||||||
|
|
||||||
|
// Initialize DB tables
|
||||||
|
async function initDB() {
|
||||||
|
const client = await pool.connect();
|
||||||
|
try {
|
||||||
|
await client.query(`
|
||||||
|
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
email VARCHAR(255) UNIQUE NOT NULL,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
password_hash VARCHAR(255) NOT NULL,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS events (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id UUID REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
event_date TIMESTAMP NOT NULL,
|
||||||
|
location VARCHAR(255),
|
||||||
|
max_participants INTEGER,
|
||||||
|
budget DECIMAL(10,2) DEFAULT 0,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS participants (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
event_id UUID REFERENCES events(id) ON DELETE CASCADE,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
email VARCHAR(255) NOT NULL,
|
||||||
|
phone VARCHAR(50),
|
||||||
|
status VARCHAR(50) DEFAULT 'invited',
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS budget_items (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
event_id UUID REFERENCES events(id) ON DELETE CASCADE,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
type VARCHAR(20) NOT NULL CHECK (type IN ('income', 'expense')),
|
||||||
|
amount DECIMAL(10,2) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_events_user_id ON events(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_participants_event_id ON participants(event_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_budget_items_event_id ON budget_items(event_id);
|
||||||
|
`);
|
||||||
|
console.log('Database initialized');
|
||||||
|
} catch (err) {
|
||||||
|
console.error('DB init error:', err.message);
|
||||||
|
} finally {
|
||||||
|
client.release();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auth middleware
|
||||||
|
function authMiddleware(req, res, next) {
|
||||||
|
const token = req.headers.authorization?.replace('Bearer ', '');
|
||||||
|
if (!token) return res.status(401).json({ error: 'Unauthorized' });
|
||||||
|
try {
|
||||||
|
req.user = jwt.verify(token, JWT_SECRET);
|
||||||
|
next();
|
||||||
|
} catch {
|
||||||
|
res.status(401).json({ error: 'Invalid token' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Health check
|
||||||
|
app.get('/health', async (req, res) => {
|
||||||
|
let dbOk = false;
|
||||||
|
try {
|
||||||
|
await pool.query('SELECT 1');
|
||||||
|
dbOk = true;
|
||||||
|
} catch {}
|
||||||
|
res.json({
|
||||||
|
status: 'ok',
|
||||||
|
commit: process.env.GIT_COMMIT || 'unknown',
|
||||||
|
db: dbOk ? 'connected' : 'error',
|
||||||
|
timestamp: new Date().toISOString()
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== AUTH ==========
|
||||||
|
|
||||||
|
app.post('/api/auth/register', async (req, res) => {
|
||||||
|
const { email, name, password } = req.body;
|
||||||
|
if (!email || !name || !password) return res.status(400).json({ error: 'Missing fields' });
|
||||||
|
try {
|
||||||
|
const hash = await bcrypt.hash(password, 10);
|
||||||
|
const result = await pool.query(
|
||||||
|
'INSERT INTO users (email, name, password_hash) VALUES ($1, $2, $3) RETURNING id, email, name, created_at',
|
||||||
|
[email, name, hash]
|
||||||
|
);
|
||||||
|
const user = result.rows[0];
|
||||||
|
const token = jwt.sign({ userId: user.id, email: user.email }, JWT_SECRET, { expiresIn: '7d' });
|
||||||
|
res.status(201).json({ user, token });
|
||||||
|
} catch (err) {
|
||||||
|
if (err.code === '23505') return res.status(409).json({ error: 'Email already exists' });
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/auth/login', async (req, res) => {
|
||||||
|
const { email, password } = req.body;
|
||||||
|
if (!email || !password) return res.status(400).json({ error: 'Missing fields' });
|
||||||
|
try {
|
||||||
|
const result = await pool.query('SELECT * FROM users WHERE email = $1', [email]);
|
||||||
|
const user = result.rows[0];
|
||||||
|
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
const valid = await bcrypt.compare(password, user.password_hash);
|
||||||
|
if (!valid) return res.status(401).json({ error: 'Invalid credentials' });
|
||||||
|
const token = jwt.sign({ userId: user.id, email: user.email }, JWT_SECRET, { expiresIn: '7d' });
|
||||||
|
res.json({ user: { id: user.id, email: user.email, name: user.name }, token });
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/auth/me', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query('SELECT id, email, name, created_at FROM users WHERE id = $1', [req.user.userId]);
|
||||||
|
if (!result.rows[0]) return res.status(404).json({ error: 'User not found' });
|
||||||
|
res.json(result.rows[0]);
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== EVENTS ==========
|
||||||
|
|
||||||
|
app.get('/api/events', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`SELECT e.*,
|
||||||
|
COUNT(DISTINCT p.id) AS participant_count,
|
||||||
|
COALESCE(SUM(CASE WHEN bi.type='income' THEN bi.amount ELSE 0 END), 0) AS total_income,
|
||||||
|
COALESCE(SUM(CASE WHEN bi.type='expense' THEN bi.amount ELSE 0 END), 0) AS total_expenses
|
||||||
|
FROM events e
|
||||||
|
LEFT JOIN participants p ON p.event_id = e.id
|
||||||
|
LEFT JOIN budget_items bi ON bi.event_id = e.id
|
||||||
|
WHERE e.user_id = $1
|
||||||
|
GROUP BY e.id
|
||||||
|
ORDER BY e.event_date ASC`,
|
||||||
|
[req.user.userId]
|
||||||
|
);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch (err) {
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/events/: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.user.userId]
|
||||||
|
);
|
||||||
|
if (!result.rows[0]) return res.status(404).json({ error: 'Event not found' });
|
||||||
|
res.json(result.rows[0]);
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/events', authMiddleware, async (req, res) => {
|
||||||
|
const { title, description, event_date, location, max_participants, budget } = req.body;
|
||||||
|
if (!title || !event_date) return res.status(400).json({ error: 'Title and event_date are required' });
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`INSERT INTO events (user_id, title, description, event_date, location, max_participants, budget)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7) RETURNING *`,
|
||||||
|
[req.user.userId, title, description, event_date, location, max_participants || null, budget || 0]
|
||||||
|
);
|
||||||
|
res.status(201).json(result.rows[0]);
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/events/:id', authMiddleware, async (req, res) => {
|
||||||
|
const { title, description, event_date, location, max_participants, budget } = req.body;
|
||||||
|
if (!title || !event_date) return res.status(400).json({ error: 'Title and event_date are required' });
|
||||||
|
try {
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE events SET title=$1, description=$2, event_date=$3, location=$4,
|
||||||
|
max_participants=$5, budget=$6, updated_at=NOW()
|
||||||
|
WHERE id=$7 AND user_id=$8 RETURNING *`,
|
||||||
|
[title, description, event_date, location, max_participants || null, budget || 0, req.params.id, req.user.userId]
|
||||||
|
);
|
||||||
|
if (!result.rows[0]) return res.status(404).json({ error: 'Event not found' });
|
||||||
|
res.json(result.rows[0]);
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/events/: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.user.userId]
|
||||||
|
);
|
||||||
|
if (!result.rows[0]) return res.status(404).json({ error: 'Event not found' });
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== PARTICIPANTS ==========
|
||||||
|
|
||||||
|
app.get('/api/events/:eventId/participants', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
// Verify event belongs to user
|
||||||
|
const ev = await pool.query('SELECT id FROM events WHERE id=$1 AND user_id=$2', [req.params.eventId, req.user.userId]);
|
||||||
|
if (!ev.rows[0]) return res.status(404).json({ error: 'Event not found' });
|
||||||
|
const result = await pool.query(
|
||||||
|
'SELECT * FROM participants WHERE event_id=$1 ORDER BY created_at ASC',
|
||||||
|
[req.params.eventId]
|
||||||
|
);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/events/:eventId/participants', authMiddleware, async (req, res) => {
|
||||||
|
const { name, email, phone, status } = req.body;
|
||||||
|
if (!name || !email) return res.status(400).json({ error: 'Name and email are required' });
|
||||||
|
try {
|
||||||
|
const ev = await pool.query('SELECT id FROM events WHERE id=$1 AND user_id=$2', [req.params.eventId, req.user.userId]);
|
||||||
|
if (!ev.rows[0]) return res.status(404).json({ error: 'Event not found' });
|
||||||
|
const result = await pool.query(
|
||||||
|
'INSERT INTO participants (event_id, name, email, phone, status) VALUES ($1,$2,$3,$4,$5) RETURNING *',
|
||||||
|
[req.params.eventId, name, email, phone || null, status || 'invited']
|
||||||
|
);
|
||||||
|
res.status(201).json(result.rows[0]);
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/events/:eventId/participants/:id', authMiddleware, async (req, res) => {
|
||||||
|
const { name, email, phone, status } = req.body;
|
||||||
|
if (!name || !email) return res.status(400).json({ error: 'Name and email are required' });
|
||||||
|
try {
|
||||||
|
const ev = await pool.query('SELECT id FROM events WHERE id=$1 AND user_id=$2', [req.params.eventId, req.user.userId]);
|
||||||
|
if (!ev.rows[0]) return res.status(404).json({ error: 'Event not found' });
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE participants SET name=$1, email=$2, phone=$3, status=$4, updated_at=NOW()
|
||||||
|
WHERE id=$5 AND event_id=$6 RETURNING *`,
|
||||||
|
[name, email, phone || null, status || 'invited', req.params.id, req.params.eventId]
|
||||||
|
);
|
||||||
|
if (!result.rows[0]) return res.status(404).json({ error: 'Participant not found' });
|
||||||
|
res.json(result.rows[0]);
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/events/:eventId/participants/:id', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const ev = await pool.query('SELECT id FROM events WHERE id=$1 AND user_id=$2', [req.params.eventId, req.user.userId]);
|
||||||
|
if (!ev.rows[0]) return res.status(404).json({ error: 'Event not found' });
|
||||||
|
const result = await pool.query(
|
||||||
|
'DELETE FROM participants WHERE id=$1 AND event_id=$2 RETURNING id',
|
||||||
|
[req.params.id, req.params.eventId]
|
||||||
|
);
|
||||||
|
if (!result.rows[0]) return res.status(404).json({ error: 'Participant not found' });
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ========== BUDGET ITEMS ==========
|
||||||
|
|
||||||
|
app.get('/api/events/:eventId/budget', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const ev = await pool.query('SELECT id FROM events WHERE id=$1 AND user_id=$2', [req.params.eventId, req.user.userId]);
|
||||||
|
if (!ev.rows[0]) return res.status(404).json({ error: 'Event not found' });
|
||||||
|
const result = await pool.query(
|
||||||
|
'SELECT * FROM budget_items WHERE event_id=$1 ORDER BY created_at ASC',
|
||||||
|
[req.params.eventId]
|
||||||
|
);
|
||||||
|
res.json(result.rows);
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/api/events/:eventId/budget', authMiddleware, async (req, res) => {
|
||||||
|
const { title, type, amount, description } = req.body;
|
||||||
|
if (!title || !type || !amount) return res.status(400).json({ error: 'Title, type, and amount are required' });
|
||||||
|
if (!['income', 'expense'].includes(type)) return res.status(400).json({ error: 'Type must be income or expense' });
|
||||||
|
try {
|
||||||
|
const ev = await pool.query('SELECT id FROM events WHERE id=$1 AND user_id=$2', [req.params.eventId, req.user.userId]);
|
||||||
|
if (!ev.rows[0]) return res.status(404).json({ error: 'Event not found' });
|
||||||
|
const result = await pool.query(
|
||||||
|
'INSERT INTO budget_items (event_id, title, type, amount, description) VALUES ($1,$2,$3,$4,$5) RETURNING *',
|
||||||
|
[req.params.eventId, title, type, amount, description || null]
|
||||||
|
);
|
||||||
|
res.status(201).json(result.rows[0]);
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.put('/api/events/:eventId/budget/:id', authMiddleware, async (req, res) => {
|
||||||
|
const { title, type, amount, description } = req.body;
|
||||||
|
if (!title || !type || !amount) return res.status(400).json({ error: 'Title, type, and amount are required' });
|
||||||
|
if (!['income', 'expense'].includes(type)) return res.status(400).json({ error: 'Type must be income or expense' });
|
||||||
|
try {
|
||||||
|
const ev = await pool.query('SELECT id FROM events WHERE id=$1 AND user_id=$2', [req.params.eventId, req.user.userId]);
|
||||||
|
if (!ev.rows[0]) return res.status(404).json({ error: 'Event not found' });
|
||||||
|
const result = await pool.query(
|
||||||
|
`UPDATE budget_items SET title=$1, type=$2, amount=$3, description=$4, updated_at=NOW()
|
||||||
|
WHERE id=$5 AND event_id=$6 RETURNING *`,
|
||||||
|
[title, type, amount, description || null, req.params.id, req.params.eventId]
|
||||||
|
);
|
||||||
|
if (!result.rows[0]) return res.status(404).json({ error: 'Budget item not found' });
|
||||||
|
res.json(result.rows[0]);
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete('/api/events/:eventId/budget/:id', authMiddleware, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const ev = await pool.query('SELECT id FROM events WHERE id=$1 AND user_id=$2', [req.params.eventId, req.user.userId]);
|
||||||
|
if (!ev.rows[0]) return res.status(404).json({ error: 'Event not found' });
|
||||||
|
const result = await pool.query(
|
||||||
|
'DELETE FROM budget_items WHERE id=$1 AND event_id=$2 RETURNING id',
|
||||||
|
[req.params.id, req.params.eventId]
|
||||||
|
);
|
||||||
|
if (!result.rows[0]) return res.status(404).json({ error: 'Budget item not found' });
|
||||||
|
res.json({ success: true });
|
||||||
|
} catch {
|
||||||
|
res.status(500).json({ error: 'Server error' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// SPA fallback
|
||||||
|
app.get('*', (req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, 'client/dist/index.html'));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.listen(PORT, async () => {
|
||||||
|
console.log(`Server running on port ${PORT}`);
|
||||||
|
await initDB();
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user