diff --git a/README.md b/README.md index fc4039d..0a0a379 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,8 @@ A web app (PWA) for splitting expenses within a household — who paid what, who - **Add expense** — large amount field, categories with icons, payer selection, three split modes: equally (among all members), exact amounts, or the full amount to one person. - **History** — expense list with filters (month/category/payer), edit and delete. - **Stats** — month-over-month expenses, comparison of household members' spending, category ranking. +- **Calendar** — a shared household calendar for planning events together: pick a date range or a specific day/time (or mark it all-day), add notes and a location, and choose who's involved (just you, everyone, or specific members) plus any number of external guests (name only, no account needed). +- **Shopping lists** — any number of named shopping lists per household; add/edit/delete items, check them off while shopping, rename or delete a whole list. - **Multiple households** — a user can belong to several households at once and switch between them (Settings → "Your households"); each household can have any number of members, who join with the same invite code. - **Settings** — switch and manage households (rename/change currency, remove a member, delete a household, invites), custom categories (rename/re-icon), light/dark/system theme, **language switcher (Polish/English)**, email notifications, CSV export. - **Account** — email/password registration, login, rename, change password, password reminder/reset by email, account deletion. @@ -43,7 +45,7 @@ whowhat/ │ ├── i18n.js # server-rendered translations (emails, CSV, default categories) │ ├── db/ # schema.sql + better-sqlite3 connection │ ├── middleware/auth.js # JWT verification -│ ├── routes/ # auth, households, categories, expenses, settlements, stats +│ ├── routes/ # auth, households, categories, expenses, settlements, stats, events, shoppingLists │ └── utils/ # balance calculation, mailer (nodemailer), household helpers └── frontend/ ├── Dockerfile # multi-stage: build (node) -> serve (nginx) @@ -51,8 +53,8 @@ whowhat/ ├── vite.config.js # PWA config (manifest, service worker) └── src/ ├── i18n/ # I18nContext, per-namespace locale JSON files - ├── pages/ # Dashboard, AddExpense, History, Stats, Settings, Login, Register, ... - ├── components/ # BottomNav, charts, forms, Icon, Switch, LanguageSwitcher, ... + ├── pages/ # Dashboard, AddExpense, History, Stats, Settings, Calendar, ShoppingLists, ShoppingListDetail, Login, Register, ... + ├── components/ # BottomNav, charts, forms, EventFormModal, ShoppingListItemRow, Icon, Switch, LanguageSwitcher, ... ├── api/ # fetch client + React Query hooks ├── auth/ # auth context (JWT in localStorage) ├── household/ # active household context (list + switching) @@ -116,6 +118,10 @@ The frontend's port `8856` is also published directly on the host — useful for - `expenses` — expenses (amount, payer, category, date, split type) - `expense_shares` — the final split of an expense across household members (always sums to the expense amount regardless of split type) - `settlements` — settlement history ("Settle up") +- `events` — calendar events (title, notes, location, all-day flag, start/end) +- `event_attendees` — who's attending an event: either a `user_id` (household member) or a free-text `guest_name` for external guests +- `shopping_lists` — named shopping lists per household +- `shopping_list_items` — items on a list (name, quantity, checked state) Per-person balance is computed as: `(amount they paid) − (sum of their expense shares) − (net settlements)`. For the "who owes whom" display, balances are simplified with a greedy algorithm (`backend/src/utils/balance.js: simplifyDebts`) that produces the minimum number of transfers to settle everyone (instead of a separate debt between every pair). @@ -141,6 +147,8 @@ Error responses are `{ "error": "" }`, where `` is a stable snake_ca | Expenses | `GET/POST/PUT/DELETE /expenses[/:id]` (filters: `month`, `categoryId`, `payerId`) | | Settlements | `GET/POST /settlements` (POST immediately settles all simplified transfers) | | Stats | `GET /stats/balance`, `/summary`, `/monthly`, `/export.csv` | +| Events | `GET/POST/PUT/DELETE /events[/:id]` | +| Shopping lists | `GET/POST/PUT/DELETE /shopping-lists[/:id]`, `POST/PUT/DELETE /shopping-lists/:id/items[/:itemId]` | ## Offline mode (PWA) diff --git a/backend/src/db/schema.sql b/backend/src/db/schema.sql index b062104..db3a4c8 100644 --- a/backend/src/db/schema.sql +++ b/backend/src/db/schema.sql @@ -78,6 +78,50 @@ CREATE TABLE IF NOT EXISTS settlements ( settled_at TEXT NOT NULL DEFAULT (datetime('now')) ); +CREATE TABLE IF NOT EXISTS events ( + id TEXT PRIMARY KEY, + household_id TEXT NOT NULL REFERENCES households(id) ON DELETE CASCADE, + created_by TEXT NOT NULL REFERENCES users(id), + title TEXT NOT NULL, + notes TEXT, + location TEXT, + all_day INTEGER NOT NULL DEFAULT 0, + start_at TEXT NOT NULL, + end_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS event_attendees ( + id TEXT PRIMARY KEY, + event_id TEXT NOT NULL REFERENCES events(id) ON DELETE CASCADE, + user_id TEXT REFERENCES users(id) ON DELETE CASCADE, + guest_name TEXT +); + +CREATE TABLE IF NOT EXISTS shopping_lists ( + id TEXT PRIMARY KEY, + household_id TEXT NOT NULL REFERENCES households(id) ON DELETE CASCADE, + name TEXT NOT NULL, + created_by TEXT NOT NULL REFERENCES users(id), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS shopping_list_items ( + id TEXT PRIMARY KEY, + list_id TEXT NOT NULL REFERENCES shopping_lists(id) ON DELETE CASCADE, + name TEXT NOT NULL, + quantity TEXT, + checked INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + CREATE INDEX IF NOT EXISTS idx_expenses_household ON expenses(household_id); CREATE INDEX IF NOT EXISTS idx_expense_shares_expense ON expense_shares(expense_id); CREATE INDEX IF NOT EXISTS idx_settlements_household ON settlements(household_id); +CREATE INDEX IF NOT EXISTS idx_events_household ON events(household_id); +CREATE INDEX IF NOT EXISTS idx_event_attendees_event ON event_attendees(event_id); +CREATE INDEX IF NOT EXISTS idx_shopping_lists_household ON shopping_lists(household_id); +CREATE INDEX IF NOT EXISTS idx_shopping_list_items_list ON shopping_list_items(list_id); diff --git a/backend/src/index.js b/backend/src/index.js index 651c907..baef8ab 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -8,6 +8,8 @@ const { router: categoriesRouter } = require('./routes/categories'); const { router: expensesRouter } = require('./routes/expenses'); const { router: settlementsRouter } = require('./routes/settlements'); const { router: statsRouter } = require('./routes/stats'); +const { router: eventsRouter } = require('./routes/events'); +const { router: shoppingListsRouter } = require('./routes/shoppingLists'); const app = express(); app.use(cors()); @@ -21,6 +23,8 @@ app.use('/categories', categoriesRouter); app.use('/expenses', expensesRouter); app.use('/settlements', settlementsRouter); app.use('/stats', statsRouter); +app.use('/events', eventsRouter); +app.use('/shopping-lists', shoppingListsRouter); app.use((err, req, res, next) => { console.error(err); diff --git a/backend/src/routes/events.js b/backend/src/routes/events.js new file mode 100644 index 0000000..f65e823 --- /dev/null +++ b/backend/src/routes/events.js @@ -0,0 +1,183 @@ +const express = require('express'); +const { v4: uuid } = require('uuid'); +const { db } = require('../db/db'); +const { requireAuth } = require('../middleware/auth'); +const { requireHousehold, getMembers } = require('../utils/households'); + +const router = express.Router(); +router.use(requireAuth, requireHousehold); + +function attachAttendees(event) { + const rows = db + .prepare( + `SELECT ea.user_id, ea.guest_name, u.name, u.email FROM event_attendees ea + LEFT JOIN users u ON u.id = ea.user_id + WHERE ea.event_id = ?` + ) + .all(event.id); + const attendees = rows.map((r) => ({ + userId: r.user_id, + name: r.user_id ? r.name : r.guest_name, + email: r.email || null, + isGuest: !r.user_id, + })); + return { ...event, attendees }; +} + +function validateEventBody(body, members) { + const { title, startAt, endAt, attendeeUserIds, guestNames } = body || {}; + if (!title || !startAt || !endAt) { + throw new Error('missing_event_fields'); + } + if (endAt < startAt) { + throw new Error('event_end_before_start'); + } + const memberIds = members.map((m) => m.id); + for (const userId of attendeeUserIds || []) { + if (!memberIds.includes(userId)) { + throw new Error('attendee_must_be_member'); + } + } + for (const name of guestNames || []) { + if (!name || !name.trim()) { + throw new Error('guest_name_required'); + } + } +} + +router.get('/', (req, res) => { + const events = db + .prepare('SELECT * FROM events WHERE household_id = ? ORDER BY start_at') + .all(req.household.id) + .map(attachAttendees); + res.json({ events }); +}); + +router.post('/', (req, res) => { + const { title, notes, location, allDay, startAt, endAt, attendeeUserIds, guestNames } = req.body || {}; + const members = getMembers(req.household.id); + + try { + validateEventBody(req.body, members); + } catch (err) { + return res.status(400).json({ error: err.message }); + } + + const event = { + id: uuid(), + household_id: req.household.id, + created_by: req.userId, + title, + notes: notes || null, + location: location || null, + all_day: allDay ? 1 : 0, + start_at: startAt, + end_at: endAt, + }; + + const insert = db.transaction(() => { + db.prepare( + `INSERT INTO events (id, household_id, created_by, title, notes, location, all_day, start_at, end_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)` + ).run( + event.id, + event.household_id, + event.created_by, + event.title, + event.notes, + event.location, + event.all_day, + event.start_at, + event.end_at + ); + for (const userId of attendeeUserIds || []) { + db.prepare('INSERT INTO event_attendees (id, event_id, user_id, guest_name) VALUES (?, ?, ?, NULL)').run( + uuid(), + event.id, + userId + ); + } + for (const guestName of guestNames || []) { + db.prepare('INSERT INTO event_attendees (id, event_id, user_id, guest_name) VALUES (?, ?, NULL, ?)').run( + uuid(), + event.id, + guestName.trim() + ); + } + }); + insert(); + + const created = db.prepare('SELECT * FROM events WHERE id = ?').get(event.id); + res.status(201).json({ event: attachAttendees(created) }); +}); + +router.put('/:id', (req, res) => { + const existing = db + .prepare('SELECT * FROM events WHERE id = ? AND household_id = ?') + .get(req.params.id, req.household.id); + if (!existing) { + return res.status(404).json({ error: 'event_not_found' }); + } + + const { title, notes, location, allDay, startAt, endAt, attendeeUserIds, guestNames } = req.body || {}; + const members = getMembers(req.household.id); + const merged = { + title: title ?? existing.title, + startAt: startAt ?? existing.start_at, + endAt: endAt ?? existing.end_at, + attendeeUserIds, + guestNames, + }; + + try { + validateEventBody(merged, members); + } catch (err) { + return res.status(400).json({ error: err.message }); + } + + const update = db.transaction(() => { + db.prepare( + `UPDATE events SET title = ?, notes = ?, location = ?, all_day = ?, start_at = ?, end_at = ?, + updated_at = datetime('now') WHERE id = ?` + ).run( + merged.title, + notes !== undefined ? notes : existing.notes, + location !== undefined ? location : existing.location, + allDay !== undefined ? (allDay ? 1 : 0) : existing.all_day, + merged.startAt, + merged.endAt, + existing.id + ); + db.prepare('DELETE FROM event_attendees WHERE event_id = ?').run(existing.id); + for (const userId of attendeeUserIds || []) { + db.prepare('INSERT INTO event_attendees (id, event_id, user_id, guest_name) VALUES (?, ?, ?, NULL)').run( + uuid(), + existing.id, + userId + ); + } + for (const guestName of guestNames || []) { + db.prepare('INSERT INTO event_attendees (id, event_id, user_id, guest_name) VALUES (?, ?, NULL, ?)').run( + uuid(), + existing.id, + guestName.trim() + ); + } + }); + update(); + + const updated = db.prepare('SELECT * FROM events WHERE id = ?').get(existing.id); + res.json({ event: attachAttendees(updated) }); +}); + +router.delete('/:id', (req, res) => { + const result = db + .prepare('DELETE FROM events WHERE id = ? AND household_id = ?') + .run(req.params.id, req.household.id); + if (result.changes === 0) { + return res.status(404).json({ error: 'event_not_found' }); + } + res.status(204).end(); +}); + +module.exports = { router }; diff --git a/backend/src/routes/shoppingLists.js b/backend/src/routes/shoppingLists.js new file mode 100644 index 0000000..2b2792e --- /dev/null +++ b/backend/src/routes/shoppingLists.js @@ -0,0 +1,151 @@ +const express = require('express'); +const { v4: uuid } = require('uuid'); +const { db } = require('../db/db'); +const { requireAuth } = require('../middleware/auth'); +const { requireHousehold } = require('../utils/households'); + +const router = express.Router(); +router.use(requireAuth, requireHousehold); + +function getItems(listId) { + return db + .prepare('SELECT * FROM shopping_list_items WHERE list_id = ? ORDER BY checked, created_at') + .all(listId); +} + +function getListOr404(req, res) { + const list = db + .prepare('SELECT * FROM shopping_lists WHERE id = ? AND household_id = ?') + .get(req.params.id, req.household.id); + if (!list) { + res.status(404).json({ error: 'shopping_list_not_found' }); + return null; + } + return list; +} + +router.get('/', (req, res) => { + const lists = db + .prepare( + `SELECT sl.*, COUNT(i.id) AS total, COALESCE(SUM(i.checked), 0) AS checkedCount + FROM shopping_lists sl + LEFT JOIN shopping_list_items i ON i.list_id = sl.id + WHERE sl.household_id = ? + GROUP BY sl.id + ORDER BY sl.created_at` + ) + .all(req.household.id); + res.json({ lists }); +}); + +router.post('/', (req, res) => { + const { name } = req.body || {}; + if (!name || !name.trim()) { + return res.status(400).json({ error: 'shopping_list_name_required' }); + } + const list = { + id: uuid(), + household_id: req.household.id, + name: name.trim(), + created_by: req.userId, + }; + db.prepare('INSERT INTO shopping_lists (id, household_id, name, created_by) VALUES (?, ?, ?, ?)').run( + list.id, + list.household_id, + list.name, + list.created_by + ); + const created = db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(list.id); + res.status(201).json({ list: { ...created, total: 0, checkedCount: 0 } }); +}); + +router.get('/:id', (req, res) => { + const list = getListOr404(req, res); + if (!list) return; + res.json({ list: { ...list, items: getItems(list.id) } }); +}); + +router.put('/:id', (req, res) => { + const list = getListOr404(req, res); + if (!list) return; + const { name } = req.body || {}; + if (!name || !name.trim()) { + return res.status(400).json({ error: 'shopping_list_name_required' }); + } + db.prepare(`UPDATE shopping_lists SET name = ?, updated_at = datetime('now') WHERE id = ?`).run( + name.trim(), + list.id + ); + const updated = db.prepare('SELECT * FROM shopping_lists WHERE id = ?').get(list.id); + res.json({ list: { ...updated, items: getItems(list.id) } }); +}); + +router.delete('/:id', (req, res) => { + const list = getListOr404(req, res); + if (!list) return; + db.prepare('DELETE FROM shopping_lists WHERE id = ?').run(list.id); + res.status(204).end(); +}); + +router.post('/:id/items', (req, res) => { + const list = getListOr404(req, res); + if (!list) return; + const { name, quantity } = req.body || {}; + if (!name || !name.trim()) { + return res.status(400).json({ error: 'shopping_item_name_required' }); + } + const item = { + id: uuid(), + list_id: list.id, + name: name.trim(), + quantity: quantity || null, + }; + db.prepare('INSERT INTO shopping_list_items (id, list_id, name, quantity) VALUES (?, ?, ?, ?)').run( + item.id, + item.list_id, + item.name, + item.quantity + ); + db.prepare(`UPDATE shopping_lists SET updated_at = datetime('now') WHERE id = ?`).run(list.id); + const created = db.prepare('SELECT * FROM shopping_list_items WHERE id = ?').get(item.id); + res.status(201).json({ item: created }); +}); + +router.put('/:id/items/:itemId', (req, res) => { + const list = getListOr404(req, res); + if (!list) return; + const existing = db + .prepare('SELECT * FROM shopping_list_items WHERE id = ? AND list_id = ?') + .get(req.params.itemId, list.id); + if (!existing) { + return res.status(404).json({ error: 'shopping_item_not_found' }); + } + const { name, quantity, checked } = req.body || {}; + if (name !== undefined && !name.trim()) { + return res.status(400).json({ error: 'shopping_item_name_required' }); + } + db.prepare( + `UPDATE shopping_list_items SET name = ?, quantity = ?, checked = ?, updated_at = datetime('now') WHERE id = ?` + ).run( + name !== undefined ? name.trim() : existing.name, + quantity !== undefined ? quantity : existing.quantity, + checked !== undefined ? (checked ? 1 : 0) : existing.checked, + existing.id + ); + const updated = db.prepare('SELECT * FROM shopping_list_items WHERE id = ?').get(existing.id); + res.json({ item: updated }); +}); + +router.delete('/:id/items/:itemId', (req, res) => { + const list = getListOr404(req, res); + if (!list) return; + const result = db + .prepare('DELETE FROM shopping_list_items WHERE id = ? AND list_id = ?') + .run(req.params.itemId, list.id); + if (result.changes === 0) { + return res.status(404).json({ error: 'shopping_item_not_found' }); + } + res.status(204).end(); +}); + +module.exports = { router }; diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 714a1e8..9f65aa2 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -14,6 +14,9 @@ import History from './pages/History.jsx'; import Settlements from './pages/Settlements.jsx'; import Stats from './pages/Stats.jsx'; import Settings from './pages/Settings.jsx'; +import Calendar from './pages/Calendar.jsx'; +import ShoppingLists from './pages/ShoppingLists.jsx'; +import ShoppingListDetail from './pages/ShoppingListDetail.jsx'; import BottomNav from './components/BottomNav.jsx'; import OfflineBanner from './components/OfflineBanner.jsx'; import InstallBanner from './components/InstallBanner.jsx'; @@ -65,6 +68,9 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> diff --git a/frontend/src/api/queries.js b/frontend/src/api/queries.js index e11a83c..0e9ab66 100644 --- a/frontend/src/api/queries.js +++ b/frontend/src/api/queries.js @@ -225,6 +225,122 @@ export function useResetPassword() { }); } +export function useEvents() { + const { activeHouseholdId } = useHouseholdContext(); + const query = useQuery({ + queryKey: ['events'], + queryFn: () => api.get('/events'), + select: (d) => d.events, + enabled: !!activeHouseholdId, + }); + return { ...query, isLoading: !activeHouseholdId || query.isLoading }; +} + +export function useCreateEvent() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (payload) => api.post('/events', payload), + onSuccess: () => qc.invalidateQueries({ queryKey: ['events'] }), + }); +} + +export function useUpdateEvent() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, ...payload }) => api.put(`/events/${id}`, payload), + onSuccess: () => qc.invalidateQueries({ queryKey: ['events'] }), + }); +} + +export function useDeleteEvent() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id) => api.delete(`/events/${id}`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['events'] }), + }); +} + +export function useShoppingLists() { + const { activeHouseholdId } = useHouseholdContext(); + const query = useQuery({ + queryKey: ['shoppingLists'], + queryFn: () => api.get('/shopping-lists'), + select: (d) => d.lists, + enabled: !!activeHouseholdId, + }); + return { ...query, isLoading: !activeHouseholdId || query.isLoading }; +} + +export function useShoppingList(id) { + const query = useQuery({ + queryKey: ['shoppingList', id], + queryFn: () => api.get(`/shopping-lists/${id}`), + select: (d) => d.list, + enabled: !!id, + }); + return query; +} + +export function useCreateShoppingList() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (payload) => api.post('/shopping-lists', payload), + onSuccess: () => qc.invalidateQueries({ queryKey: ['shoppingLists'] }), + }); +} + +export function useUpdateShoppingList() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ id, ...payload }) => api.put(`/shopping-lists/${id}`, payload), + onSuccess: (_, { id }) => { + qc.invalidateQueries({ queryKey: ['shoppingLists'] }); + qc.invalidateQueries({ queryKey: ['shoppingList', id] }); + }, + }); +} + +export function useDeleteShoppingList() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: (id) => api.delete(`/shopping-lists/${id}`), + onSuccess: () => qc.invalidateQueries({ queryKey: ['shoppingLists'] }), + }); +} + +export function useCreateShoppingItem() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ listId, ...payload }) => api.post(`/shopping-lists/${listId}/items`, payload), + onSuccess: (_, { listId }) => { + qc.invalidateQueries({ queryKey: ['shoppingLists'] }); + qc.invalidateQueries({ queryKey: ['shoppingList', listId] }); + }, + }); +} + +export function useUpdateShoppingItem() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ listId, itemId, ...payload }) => api.put(`/shopping-lists/${listId}/items/${itemId}`, payload), + onSuccess: (_, { listId }) => { + qc.invalidateQueries({ queryKey: ['shoppingLists'] }); + qc.invalidateQueries({ queryKey: ['shoppingList', listId] }); + }, + }); +} + +export function useDeleteShoppingItem() { + const qc = useQueryClient(); + return useMutation({ + mutationFn: ({ listId, itemId }) => api.delete(`/shopping-lists/${listId}/items/${itemId}`), + onSuccess: (_, { listId }) => { + qc.invalidateQueries({ queryKey: ['shoppingLists'] }); + qc.invalidateQueries({ queryKey: ['shoppingList', listId] }); + }, + }); +} + export function useSettleUp() { const qc = useQueryClient(); return useMutation({ diff --git a/frontend/src/components/BottomNav.jsx b/frontend/src/components/BottomNav.jsx index 98bb44d..ed31d50 100644 --- a/frontend/src/components/BottomNav.jsx +++ b/frontend/src/components/BottomNav.jsx @@ -7,10 +7,12 @@ export default function BottomNav() { const items = [ { to: '/', label: t('bottomNav.home'), icon: 'home', end: true }, + { to: '/calendar', label: t('bottomNav.calendar'), icon: 'calendar_month' }, { to: '/history', label: t('bottomNav.history'), icon: 'receipt_long' }, ]; const rightItems = [ + { to: '/shopping', label: t('bottomNav.shopping'), icon: 'checklist' }, { to: '/settlements', label: t('bottomNav.settlements'), icon: 'payments' }, { to: '/stats', label: t('bottomNav.stats'), icon: 'bar_chart' }, ]; diff --git a/frontend/src/components/EventFormModal.jsx b/frontend/src/components/EventFormModal.jsx new file mode 100644 index 0000000..7666a67 --- /dev/null +++ b/frontend/src/components/EventFormModal.jsx @@ -0,0 +1,273 @@ +import { useState } from 'react'; +import { useCreateEvent, useUpdateEvent, useDeleteEvent } from '../api/queries.js'; +import { useConfirm } from './ConfirmDialogProvider.jsx'; +import { useTranslation } from '../i18n/I18nContext.jsx'; +import Switch from './Switch.jsx'; +import Icon from './Icon.jsx'; + +function dateOnly(value) { + return value ? value.slice(0, 10) : ''; +} + +export default function EventFormModal({ event, members, currentUserId, defaultDate, onClose }) { + const createEvent = useCreateEvent(); + const updateEvent = useUpdateEvent(); + const deleteEvent = useDeleteEvent(); + const confirmDialog = useConfirm(); + const { t, tError } = useTranslation(); + + const isEditing = !!event; + const initialAllDay = event ? !!event.all_day : true; + + const [title, setTitle] = useState(event?.title || ''); + const [allDay, setAllDay] = useState(initialAllDay); + const [startDate, setStartDate] = useState( + initialAllDay ? dateOnly(event?.start_at) || defaultDate : defaultDate + ); + const [endDate, setEndDate] = useState(initialAllDay ? dateOnly(event?.end_at) || defaultDate : defaultDate); + const [startDateTime, setStartDateTime] = useState(!initialAllDay ? event?.start_at || '' : ''); + const [endDateTime, setEndDateTime] = useState(!initialAllDay ? event?.end_at || '' : ''); + const [location, setLocation] = useState(event?.location || ''); + const [notes, setNotes] = useState(event?.notes || ''); + const [attendeeIds, setAttendeeIds] = useState( + event ? event.attendees.filter((a) => !a.isGuest).map((a) => a.userId) : currentUserId ? [currentUserId] : [] + ); + const [guestNames, setGuestNames] = useState( + event ? event.attendees.filter((a) => a.isGuest).map((a) => a.name) : [] + ); + const [guestInput, setGuestInput] = useState(''); + const [error, setError] = useState(''); + + function handleAllDayToggle(checked) { + setAllDay(checked); + if (checked) { + setStartDate(dateOnly(startDateTime) || startDate); + setEndDate(dateOnly(endDateTime) || endDate); + } else { + setStartDateTime(startDate ? `${startDate}T09:00` : ''); + setEndDateTime(endDate ? `${endDate}T10:00` : ''); + } + } + + function toggleAttendee(id) { + setAttendeeIds((prev) => (prev.includes(id) ? prev.filter((x) => x !== id) : [...prev, id])); + } + + function addGuest() { + const name = guestInput.trim(); + if (!name || guestNames.includes(name)) return; + setGuestNames((prev) => [...prev, name]); + setGuestInput(''); + } + + function removeGuest(name) { + setGuestNames((prev) => prev.filter((g) => g !== name)); + } + + async function handleSave() { + setError(''); + const startAt = allDay ? startDate : startDateTime; + const endAt = allDay ? endDate : endDateTime; + + if (!title.trim()) { + setError(t('eventFormModal.titleRequired')); + return; + } + if (!startAt || !endAt) { + setError(t('eventFormModal.dateRequired')); + return; + } + if (endAt < startAt) { + setError(t('errors.event_end_before_start')); + return; + } + + const payload = { + title: title.trim(), + notes: notes.trim() || null, + location: location.trim() || null, + allDay, + startAt, + endAt, + attendeeUserIds: attendeeIds, + guestNames, + }; + + try { + if (isEditing) { + await updateEvent.mutateAsync({ id: event.id, ...payload }); + } else { + await createEvent.mutateAsync(payload); + } + onClose(); + } catch (err) { + setError(tError(err)); + } + } + + async function handleDelete() { + const ok = await confirmDialog({ + title: t('eventFormModal.deleteConfirmTitle'), + message: t('eventFormModal.deleteConfirmMessage'), + confirmLabel: t('common.delete'), + }); + if (!ok) return; + await deleteEvent.mutateAsync(event.id); + onClose(); + } + + const saving = createEvent.isPending || updateEvent.isPending; + + return ( +
+
e.stopPropagation()}> +
+

{isEditing ? t('eventFormModal.editTitle') : t('eventFormModal.createTitle')}

+ +
+ +
+
+ + setTitle(e.target.value)} + autoFocus + /> +
+ +
+
+ {t('eventFormModal.allDayLabel')} +
+ +
+ + {allDay ? ( +
+
+ + setStartDate(e.target.value)} /> +
+
+ + setEndDate(e.target.value)} /> +
+
+ ) : ( +
+
+ + setStartDateTime(e.target.value)} + /> +
+
+ + setEndDateTime(e.target.value)} /> +
+
+ )} + +
+ + setLocation(e.target.value)} + /> +
+ +
+ +