v1.1.0 Kalendarz i listy zakupów
All checks were successful
Build and Push Docker Images / build-and-push (push) Successful in 3m10s
All checks were successful
Build and Push Docker Images / build-and-push (push) Successful in 3m10s
Dodaje wspólny kalendarz wydarzeń grupy (zakres dat/godzin, cały dzień, notatki, lokalizacja, uczestnicy z gospodarstwa + goście zewnętrzni) oraz listy zakupów (wiele list, dodawanie/edycja/odznaczanie/usuwanie produktów). Nowe tabele w schemacie SQLite są czysto addytywne (CREATE TABLE IF NOT EXISTS), więc nie ruszają istniejących danych. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
14
README.md
14
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.
|
- **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.
|
- **History** — expense list with filters (month/category/payer), edit and delete.
|
||||||
- **Stats** — month-over-month expenses, comparison of household members' spending, category ranking.
|
- **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.
|
- **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.
|
- **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.
|
- **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)
|
│ ├── i18n.js # server-rendered translations (emails, CSV, default categories)
|
||||||
│ ├── db/ # schema.sql + better-sqlite3 connection
|
│ ├── db/ # schema.sql + better-sqlite3 connection
|
||||||
│ ├── middleware/auth.js # JWT verification
|
│ ├── 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
|
│ └── utils/ # balance calculation, mailer (nodemailer), household helpers
|
||||||
└── frontend/
|
└── frontend/
|
||||||
├── Dockerfile # multi-stage: build (node) -> serve (nginx)
|
├── Dockerfile # multi-stage: build (node) -> serve (nginx)
|
||||||
@@ -51,8 +53,8 @@ whowhat/
|
|||||||
├── vite.config.js # PWA config (manifest, service worker)
|
├── vite.config.js # PWA config (manifest, service worker)
|
||||||
└── src/
|
└── src/
|
||||||
├── i18n/ # I18nContext, per-namespace locale JSON files
|
├── i18n/ # I18nContext, per-namespace locale JSON files
|
||||||
├── pages/ # Dashboard, AddExpense, History, Stats, Settings, Login, Register, ...
|
├── pages/ # Dashboard, AddExpense, History, Stats, Settings, Calendar, ShoppingLists, ShoppingListDetail, Login, Register, ...
|
||||||
├── components/ # BottomNav, charts, forms, Icon, Switch, LanguageSwitcher, ...
|
├── components/ # BottomNav, charts, forms, EventFormModal, ShoppingListItemRow, Icon, Switch, LanguageSwitcher, ...
|
||||||
├── api/ # fetch client + React Query hooks
|
├── api/ # fetch client + React Query hooks
|
||||||
├── auth/ # auth context (JWT in localStorage)
|
├── auth/ # auth context (JWT in localStorage)
|
||||||
├── household/ # active household context (list + switching)
|
├── 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)
|
- `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)
|
- `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")
|
- `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).
|
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": "<code>" }`, where `<code>` is a stable snake_ca
|
|||||||
| Expenses | `GET/POST/PUT/DELETE /expenses[/:id]` (filters: `month`, `categoryId`, `payerId`) |
|
| Expenses | `GET/POST/PUT/DELETE /expenses[/:id]` (filters: `month`, `categoryId`, `payerId`) |
|
||||||
| Settlements | `GET/POST /settlements` (POST immediately settles all simplified transfers) |
|
| Settlements | `GET/POST /settlements` (POST immediately settles all simplified transfers) |
|
||||||
| Stats | `GET /stats/balance`, `/summary`, `/monthly`, `/export.csv` |
|
| 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)
|
## Offline mode (PWA)
|
||||||
|
|
||||||
|
|||||||
@@ -78,6 +78,50 @@ CREATE TABLE IF NOT EXISTS settlements (
|
|||||||
settled_at TEXT NOT NULL DEFAULT (datetime('now'))
|
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_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_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_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);
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ const { router: categoriesRouter } = require('./routes/categories');
|
|||||||
const { router: expensesRouter } = require('./routes/expenses');
|
const { router: expensesRouter } = require('./routes/expenses');
|
||||||
const { router: settlementsRouter } = require('./routes/settlements');
|
const { router: settlementsRouter } = require('./routes/settlements');
|
||||||
const { router: statsRouter } = require('./routes/stats');
|
const { router: statsRouter } = require('./routes/stats');
|
||||||
|
const { router: eventsRouter } = require('./routes/events');
|
||||||
|
const { router: shoppingListsRouter } = require('./routes/shoppingLists');
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(cors());
|
app.use(cors());
|
||||||
@@ -21,6 +23,8 @@ app.use('/categories', categoriesRouter);
|
|||||||
app.use('/expenses', expensesRouter);
|
app.use('/expenses', expensesRouter);
|
||||||
app.use('/settlements', settlementsRouter);
|
app.use('/settlements', settlementsRouter);
|
||||||
app.use('/stats', statsRouter);
|
app.use('/stats', statsRouter);
|
||||||
|
app.use('/events', eventsRouter);
|
||||||
|
app.use('/shopping-lists', shoppingListsRouter);
|
||||||
|
|
||||||
app.use((err, req, res, next) => {
|
app.use((err, req, res, next) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
|
|||||||
183
backend/src/routes/events.js
Normal file
183
backend/src/routes/events.js
Normal file
@@ -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 };
|
||||||
151
backend/src/routes/shoppingLists.js
Normal file
151
backend/src/routes/shoppingLists.js
Normal file
@@ -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 };
|
||||||
@@ -14,6 +14,9 @@ import History from './pages/History.jsx';
|
|||||||
import Settlements from './pages/Settlements.jsx';
|
import Settlements from './pages/Settlements.jsx';
|
||||||
import Stats from './pages/Stats.jsx';
|
import Stats from './pages/Stats.jsx';
|
||||||
import Settings from './pages/Settings.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 BottomNav from './components/BottomNav.jsx';
|
||||||
import OfflineBanner from './components/OfflineBanner.jsx';
|
import OfflineBanner from './components/OfflineBanner.jsx';
|
||||||
import InstallBanner from './components/InstallBanner.jsx';
|
import InstallBanner from './components/InstallBanner.jsx';
|
||||||
@@ -65,6 +68,9 @@ export default function App() {
|
|||||||
<Route path="/settlements" element={<Settlements />} />
|
<Route path="/settlements" element={<Settlements />} />
|
||||||
<Route path="/stats" element={<Stats />} />
|
<Route path="/stats" element={<Stats />} />
|
||||||
<Route path="/settings" element={<Settings />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
|
<Route path="/calendar" element={<Calendar />} />
|
||||||
|
<Route path="/shopping" element={<ShoppingLists />} />
|
||||||
|
<Route path="/shopping/:id" element={<ShoppingListDetail />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -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() {
|
export function useSettleUp() {
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
return useMutation({
|
return useMutation({
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ export default function BottomNav() {
|
|||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{ to: '/', label: t('bottomNav.home'), icon: 'home', end: true },
|
{ 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' },
|
{ to: '/history', label: t('bottomNav.history'), icon: 'receipt_long' },
|
||||||
];
|
];
|
||||||
|
|
||||||
const rightItems = [
|
const rightItems = [
|
||||||
|
{ to: '/shopping', label: t('bottomNav.shopping'), icon: 'checklist' },
|
||||||
{ to: '/settlements', label: t('bottomNav.settlements'), icon: 'payments' },
|
{ to: '/settlements', label: t('bottomNav.settlements'), icon: 'payments' },
|
||||||
{ to: '/stats', label: t('bottomNav.stats'), icon: 'bar_chart' },
|
{ to: '/stats', label: t('bottomNav.stats'), icon: 'bar_chart' },
|
||||||
];
|
];
|
||||||
|
|||||||
273
frontend/src/components/EventFormModal.jsx
Normal file
273
frontend/src/components/EventFormModal.jsx
Normal file
@@ -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 (
|
||||||
|
<div className="modal-overlay" onClick={onClose}>
|
||||||
|
<div className="modal-sheet" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="modal-header">
|
||||||
|
<h3>{isEditing ? t('eventFormModal.editTitle') : t('eventFormModal.createTitle')}</h3>
|
||||||
|
<button className="icon-btn" onClick={onClose}><Icon name="close" /></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="expense-form">
|
||||||
|
<div className="field">
|
||||||
|
<label>{t('eventFormModal.titleLabel')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={t('eventFormModal.titlePlaceholder')}
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="switch-row">
|
||||||
|
<div className="switch-label">
|
||||||
|
<span>{t('eventFormModal.allDayLabel')}</span>
|
||||||
|
</div>
|
||||||
|
<Switch checked={allDay} onChange={handleAllDayToggle} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{allDay ? (
|
||||||
|
<div className="split-slider-amounts">
|
||||||
|
<div className="field">
|
||||||
|
<label>{t('eventFormModal.startDateLabel')}</label>
|
||||||
|
<input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>{t('eventFormModal.endDateLabel')}</label>
|
||||||
|
<input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="split-slider-amounts">
|
||||||
|
<div className="field">
|
||||||
|
<label>{t('eventFormModal.startDateLabel')}</label>
|
||||||
|
<input
|
||||||
|
type="datetime-local"
|
||||||
|
value={startDateTime}
|
||||||
|
onChange={(e) => setStartDateTime(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="field">
|
||||||
|
<label>{t('eventFormModal.endDateLabel')}</label>
|
||||||
|
<input type="datetime-local" value={endDateTime} onChange={(e) => setEndDateTime(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>{t('eventFormModal.locationLabel')}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={t('eventFormModal.locationPlaceholder')}
|
||||||
|
value={location}
|
||||||
|
onChange={(e) => setLocation(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>{t('eventFormModal.notesLabel')}</label>
|
||||||
|
<textarea
|
||||||
|
rows={3}
|
||||||
|
placeholder={t('eventFormModal.notesPlaceholder')}
|
||||||
|
value={notes}
|
||||||
|
onChange={(e) => setNotes(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>{t('eventFormModal.attendeesLabel')}</label>
|
||||||
|
<div className="toggle-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="toggle-btn"
|
||||||
|
onClick={() => setAttendeeIds(currentUserId ? [currentUserId] : [])}
|
||||||
|
>
|
||||||
|
{t('eventFormModal.onlyMe')}
|
||||||
|
</button>
|
||||||
|
<button type="button" className="toggle-btn" onClick={() => setAttendeeIds(members.map((m) => m.id))}>
|
||||||
|
{t('eventFormModal.everyone')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="toggle-row" style={{ marginTop: 8 }}>
|
||||||
|
{members.map((m) => (
|
||||||
|
<button
|
||||||
|
key={m.id}
|
||||||
|
type="button"
|
||||||
|
className={`toggle-btn ${attendeeIds.includes(m.id) ? 'selected' : ''}`}
|
||||||
|
onClick={() => toggleAttendee(m.id)}
|
||||||
|
>
|
||||||
|
<Icon name="account_circle" /> {m.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="field">
|
||||||
|
<label>{t('eventFormModal.guestsLabel')}</label>
|
||||||
|
<div className="guest-input-row">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={t('eventFormModal.guestPlaceholder')}
|
||||||
|
value={guestInput}
|
||||||
|
onChange={(e) => setGuestInput(e.target.value)}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
e.preventDefault();
|
||||||
|
addGuest();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button type="button" className="btn-secondary" onClick={addGuest}>
|
||||||
|
<Icon name="add" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{guestNames.length > 0 && (
|
||||||
|
<div className="chip-list">
|
||||||
|
{guestNames.map((name) => (
|
||||||
|
<span key={name} className="guest-chip">
|
||||||
|
{name}
|
||||||
|
<button type="button" onClick={() => removeGuest(name)} aria-label={t('common.delete')}>
|
||||||
|
<Icon name="close" />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && <p className="form-error">{error}</p>}
|
||||||
|
|
||||||
|
<div className="modal-actions">
|
||||||
|
{isEditing && (
|
||||||
|
<button type="button" className="btn-danger" onClick={handleDelete} disabled={deleteEvent.isPending}>
|
||||||
|
{t('common.delete')}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button type="button" className="btn-primary" onClick={handleSave} disabled={saving}>
|
||||||
|
{saving ? t('common.saving') : t('common.save')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
53
frontend/src/components/EventListItem.jsx
Normal file
53
frontend/src/components/EventListItem.jsx
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import Icon from './Icon.jsx';
|
||||||
|
import { useTranslation } from '../i18n/I18nContext.jsx';
|
||||||
|
|
||||||
|
function dateOnly(value) {
|
||||||
|
return value ? value.slice(0, 10) : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeOnly(value) {
|
||||||
|
return value && value.length > 10 ? value.slice(11, 16) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EventListItem({ event, onClick }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const startTime = timeOnly(event.start_at);
|
||||||
|
const endTime = timeOnly(event.end_at);
|
||||||
|
const multiDay = dateOnly(event.start_at) !== dateOnly(event.end_at);
|
||||||
|
|
||||||
|
let timeLabel;
|
||||||
|
if (event.all_day) {
|
||||||
|
timeLabel = multiDay
|
||||||
|
? `${t('calendar.allDay')} · ${dateOnly(event.start_at)} – ${dateOnly(event.end_at)}`
|
||||||
|
: t('calendar.allDay');
|
||||||
|
} else {
|
||||||
|
timeLabel = startTime && endTime ? `${startTime} – ${endTime}` : startTime || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="expense-item event-item" onClick={onClick}>
|
||||||
|
<div className="cat-icon">
|
||||||
|
<Icon name={event.all_day ? 'event' : 'schedule'} />
|
||||||
|
</div>
|
||||||
|
<div className="details">
|
||||||
|
<div className="title">{event.title}</div>
|
||||||
|
<div className="meta">
|
||||||
|
<span>{timeLabel}</span>
|
||||||
|
{event.location && (
|
||||||
|
<>
|
||||||
|
<span>·</span>
|
||||||
|
<Icon name="location_on" />
|
||||||
|
<span>{event.location}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{event.attendees.length > 0 && (
|
||||||
|
<div className="meta">
|
||||||
|
<Icon name="group" />
|
||||||
|
<span>{event.attendees.map((a) => a.name).join(', ')}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
67
frontend/src/components/ShoppingListItemRow.jsx
Normal file
67
frontend/src/components/ShoppingListItemRow.jsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useUpdateShoppingItem, useDeleteShoppingItem } from '../api/queries.js';
|
||||||
|
import { useTranslation } from '../i18n/I18nContext.jsx';
|
||||||
|
import Icon from './Icon.jsx';
|
||||||
|
|
||||||
|
export default function ShoppingListItemRow({ item, listId }) {
|
||||||
|
const updateItem = useUpdateShoppingItem();
|
||||||
|
const deleteItem = useDeleteShoppingItem();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [name, setName] = useState(item.name);
|
||||||
|
const [quantity, setQuantity] = useState(item.quantity || '');
|
||||||
|
|
||||||
|
function toggleChecked() {
|
||||||
|
updateItem.mutate({ listId, itemId: item.id, checked: !item.checked });
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveEdit() {
|
||||||
|
setEditing(false);
|
||||||
|
if (!name.trim()) {
|
||||||
|
setName(item.name);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
updateItem.mutate({ listId, itemId: item.id, name: name.trim(), quantity: quantity.trim() || null });
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`shopping-item-row ${item.checked ? 'checked' : ''}`}>
|
||||||
|
<input type="checkbox" checked={!!item.checked} onChange={toggleChecked} />
|
||||||
|
{editing ? (
|
||||||
|
<div className="shopping-item-edit">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
onBlur={saveEdit}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && saveEdit()}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="shopping-item-qty-input"
|
||||||
|
placeholder={t('shoppingListDetail.quantityPlaceholder')}
|
||||||
|
value={quantity}
|
||||||
|
onChange={(e) => setQuantity(e.target.value)}
|
||||||
|
onBlur={saveEdit}
|
||||||
|
onKeyDown={(e) => e.key === 'Enter' && saveEdit()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<button type="button" className="shopping-item-name" onClick={() => setEditing(true)}>
|
||||||
|
<span>{item.name}</span>
|
||||||
|
{item.quantity && <span className="shopping-item-qty">{item.quantity}</span>}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="icon-btn"
|
||||||
|
onClick={() => deleteItem.mutate({ listId, itemId: item.id })}
|
||||||
|
aria-label={t('common.delete')}
|
||||||
|
>
|
||||||
|
<Icon name="delete" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,7 +5,17 @@ import { useAuth } from '../auth/AuthContext.jsx';
|
|||||||
|
|
||||||
const HouseholdContext = createContext(null);
|
const HouseholdContext = createContext(null);
|
||||||
|
|
||||||
const HOUSEHOLD_SCOPED_KEYS = ['expenses', 'categories', 'balance', 'summary', 'monthly', 'settlements'];
|
const HOUSEHOLD_SCOPED_KEYS = [
|
||||||
|
'expenses',
|
||||||
|
'categories',
|
||||||
|
'balance',
|
||||||
|
'summary',
|
||||||
|
'monthly',
|
||||||
|
'settlements',
|
||||||
|
'events',
|
||||||
|
'shoppingLists',
|
||||||
|
'shoppingList',
|
||||||
|
];
|
||||||
|
|
||||||
export function HouseholdProvider({ children }) {
|
export function HouseholdProvider({ children }) {
|
||||||
const { isAuthenticated } = useAuth();
|
const { isAuthenticated } = useAuth();
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
{
|
{
|
||||||
"home": "Home",
|
"home": "Home",
|
||||||
|
"calendar": "Calendar",
|
||||||
"history": "History",
|
"history": "History",
|
||||||
|
"shopping": "Shopping",
|
||||||
"settlements": "Settlements",
|
"settlements": "Settlements",
|
||||||
"stats": "Stats",
|
"stats": "Stats",
|
||||||
"addExpense": "Add expense"
|
"addExpense": "Add expense"
|
||||||
|
|||||||
8
frontend/src/i18n/locales/en/calendar.json
Normal file
8
frontend/src/i18n/locales/en/calendar.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"pageTitle": "Calendar",
|
||||||
|
"prevMonth": "Previous month",
|
||||||
|
"nextMonth": "Next month",
|
||||||
|
"addEvent": "Add event",
|
||||||
|
"emptyState": "No events on this day",
|
||||||
|
"allDay": "All day"
|
||||||
|
}
|
||||||
@@ -3,13 +3,17 @@
|
|||||||
"already_a_member": "You're already a member of this household",
|
"already_a_member": "You're already a member of this household",
|
||||||
"already_settled": "You're already settled up",
|
"already_settled": "You're already settled up",
|
||||||
"amount_must_be_positive": "The amount must be greater than zero",
|
"amount_must_be_positive": "The amount must be greater than zero",
|
||||||
|
"attendee_must_be_member": "An attendee must be a member of the household",
|
||||||
"both_must_be_members": "Both people must be members of the household",
|
"both_must_be_members": "Both people must be members of the household",
|
||||||
"category_name_required": "A category name is required",
|
"category_name_required": "A category name is required",
|
||||||
"category_not_found": "Category not found",
|
"category_not_found": "Category not found",
|
||||||
"current_password_incorrect": "Current password is incorrect",
|
"current_password_incorrect": "Current password is incorrect",
|
||||||
"email_already_registered": "An account with this email already exists",
|
"email_already_registered": "An account with this email already exists",
|
||||||
"email_required": "Email is required",
|
"email_required": "Email is required",
|
||||||
|
"event_end_before_start": "The end date can't be before the start date",
|
||||||
|
"event_not_found": "Event not found",
|
||||||
"expense_not_found": "Expense not found",
|
"expense_not_found": "Expense not found",
|
||||||
|
"guest_name_required": "A guest name is required",
|
||||||
"household_has_no_members": "This household has no members to split the expense between",
|
"household_has_no_members": "This household has no members to split the expense between",
|
||||||
"household_not_found": "Household not found",
|
"household_not_found": "Household not found",
|
||||||
"household_not_selected": "No household selected",
|
"household_not_selected": "No household selected",
|
||||||
@@ -19,6 +23,7 @@
|
|||||||
"invite_code_invalid_or_expired": "This invite code is invalid or has expired",
|
"invite_code_invalid_or_expired": "This invite code is invalid or has expired",
|
||||||
"invite_code_required": "An invite code is required",
|
"invite_code_required": "An invite code is required",
|
||||||
"missing_bearer_token": "Authentication required",
|
"missing_bearer_token": "Authentication required",
|
||||||
|
"missing_event_fields": "Title, start date and end date are required",
|
||||||
"missing_expense_fields": "Amount, title, date, payer and split type are required",
|
"missing_expense_fields": "Amount, title, date, payer and split type are required",
|
||||||
"missing_login_fields": "Email and password are required",
|
"missing_login_fields": "Email and password are required",
|
||||||
"missing_password_fields": "Current and new password are required",
|
"missing_password_fields": "Current and new password are required",
|
||||||
@@ -35,6 +40,10 @@
|
|||||||
"share_assigned_to_non_member": "A share was assigned to someone outside the household",
|
"share_assigned_to_non_member": "A share was assigned to someone outside the household",
|
||||||
"shares_must_sum_to_amount": "The shares must add up to the expense amount",
|
"shares_must_sum_to_amount": "The shares must add up to the expense amount",
|
||||||
"shares_required": "Shares are required for this split type",
|
"shares_required": "Shares are required for this split type",
|
||||||
|
"shopping_item_name_required": "An item name is required",
|
||||||
|
"shopping_item_not_found": "Item not found",
|
||||||
|
"shopping_list_name_required": "A list name is required",
|
||||||
|
"shopping_list_not_found": "Shopping list not found",
|
||||||
"unknown_split_type": "Unknown split type",
|
"unknown_split_type": "Unknown split type",
|
||||||
"user_not_found": "User not found"
|
"user_not_found": "User not found"
|
||||||
}
|
}
|
||||||
|
|||||||
22
frontend/src/i18n/locales/en/eventFormModal.json
Normal file
22
frontend/src/i18n/locales/en/eventFormModal.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"createTitle": "New event",
|
||||||
|
"editTitle": "Edit event",
|
||||||
|
"titleLabel": "Title",
|
||||||
|
"titlePlaceholder": "e.g. Family dinner",
|
||||||
|
"allDayLabel": "All day",
|
||||||
|
"startDateLabel": "Start",
|
||||||
|
"endDateLabel": "End",
|
||||||
|
"locationLabel": "Location",
|
||||||
|
"locationPlaceholder": "e.g. Home, restaurant...",
|
||||||
|
"notesLabel": "Notes",
|
||||||
|
"notesPlaceholder": "Additional details...",
|
||||||
|
"attendeesLabel": "Who's attending",
|
||||||
|
"onlyMe": "Only me",
|
||||||
|
"everyone": "Everyone",
|
||||||
|
"guestsLabel": "External guests",
|
||||||
|
"guestPlaceholder": "Guest name",
|
||||||
|
"titleRequired": "An event title is required",
|
||||||
|
"dateRequired": "A start and end date are required",
|
||||||
|
"deleteConfirmTitle": "Delete this event?",
|
||||||
|
"deleteConfirmMessage": "This action can't be undone."
|
||||||
|
}
|
||||||
9
frontend/src/i18n/locales/en/shoppingListDetail.json
Normal file
9
frontend/src/i18n/locales/en/shoppingListDetail.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"itemNamePlaceholder": "Item name",
|
||||||
|
"quantityPlaceholder": "Qty",
|
||||||
|
"emptyState": "This list is empty — add the first item",
|
||||||
|
"notFound": "List not found",
|
||||||
|
"deleteListAria": "Delete list",
|
||||||
|
"deleteConfirmTitle": "Delete list \"{{name}}\"?",
|
||||||
|
"deleteConfirmMessage": "All items on this list will be deleted."
|
||||||
|
}
|
||||||
8
frontend/src/i18n/locales/en/shoppingLists.json
Normal file
8
frontend/src/i18n/locales/en/shoppingLists.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"pageTitle": "Shopping lists",
|
||||||
|
"newListPlaceholder": "New list name",
|
||||||
|
"emptyState": "No shopping lists yet — add one above",
|
||||||
|
"progress": "{{checked}}/{{total}} checked",
|
||||||
|
"deleteConfirmTitle": "Delete list \"{{name}}\"?",
|
||||||
|
"deleteConfirmMessage": "All items on this list will be deleted."
|
||||||
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
{
|
{
|
||||||
"home": "Start",
|
"home": "Start",
|
||||||
|
"calendar": "Kalendarz",
|
||||||
"history": "Historia",
|
"history": "Historia",
|
||||||
|
"shopping": "Zakupy",
|
||||||
"settlements": "Rozliczenia",
|
"settlements": "Rozliczenia",
|
||||||
"stats": "Statystyki",
|
"stats": "Statystyki",
|
||||||
"addExpense": "Dodaj wydatek"
|
"addExpense": "Dodaj wydatek"
|
||||||
|
|||||||
8
frontend/src/i18n/locales/pl/calendar.json
Normal file
8
frontend/src/i18n/locales/pl/calendar.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"pageTitle": "Kalendarz",
|
||||||
|
"prevMonth": "Poprzedni miesiąc",
|
||||||
|
"nextMonth": "Następny miesiąc",
|
||||||
|
"addEvent": "Dodaj wydarzenie",
|
||||||
|
"emptyState": "Brak wydarzeń tego dnia",
|
||||||
|
"allDay": "Cały dzień"
|
||||||
|
}
|
||||||
@@ -3,13 +3,17 @@
|
|||||||
"already_a_member": "Jesteś już członkiem tego gospodarstwa",
|
"already_a_member": "Jesteś już członkiem tego gospodarstwa",
|
||||||
"already_settled": "Jesteście już rozliczeni",
|
"already_settled": "Jesteście już rozliczeni",
|
||||||
"amount_must_be_positive": "Kwota musi być większa od zera",
|
"amount_must_be_positive": "Kwota musi być większa od zera",
|
||||||
|
"attendee_must_be_member": "Uczestnik musi być członkiem gospodarstwa domowego",
|
||||||
"both_must_be_members": "Obie osoby muszą być członkami gospodarstwa",
|
"both_must_be_members": "Obie osoby muszą być członkami gospodarstwa",
|
||||||
"category_name_required": "Nazwa kategorii jest wymagana",
|
"category_name_required": "Nazwa kategorii jest wymagana",
|
||||||
"category_not_found": "Kategoria nie znaleziona",
|
"category_not_found": "Kategoria nie znaleziona",
|
||||||
"current_password_incorrect": "Bieżące hasło jest nieprawidłowe",
|
"current_password_incorrect": "Bieżące hasło jest nieprawidłowe",
|
||||||
"email_already_registered": "Konto z tym adresem e-mail już istnieje",
|
"email_already_registered": "Konto z tym adresem e-mail już istnieje",
|
||||||
"email_required": "E-mail jest wymagany",
|
"email_required": "E-mail jest wymagany",
|
||||||
|
"event_end_before_start": "Data końcowa nie może być wcześniejsza niż początkowa",
|
||||||
|
"event_not_found": "Wydarzenie nie znalezione",
|
||||||
"expense_not_found": "Wydatek nie znaleziony",
|
"expense_not_found": "Wydatek nie znaleziony",
|
||||||
|
"guest_name_required": "Nazwa gościa jest wymagana",
|
||||||
"household_has_no_members": "Gospodarstwo domowe nie ma członków do podziału wydatku",
|
"household_has_no_members": "Gospodarstwo domowe nie ma członków do podziału wydatku",
|
||||||
"household_not_found": "Gospodarstwo nie znalezione",
|
"household_not_found": "Gospodarstwo nie znalezione",
|
||||||
"household_not_selected": "Nie wybrano gospodarstwa",
|
"household_not_selected": "Nie wybrano gospodarstwa",
|
||||||
@@ -19,6 +23,7 @@
|
|||||||
"invite_code_invalid_or_expired": "Kod zaproszenia jest nieprawidłowy lub wygasł",
|
"invite_code_invalid_or_expired": "Kod zaproszenia jest nieprawidłowy lub wygasł",
|
||||||
"invite_code_required": "Kod zaproszenia jest wymagany",
|
"invite_code_required": "Kod zaproszenia jest wymagany",
|
||||||
"missing_bearer_token": "Wymagane jest zalogowanie",
|
"missing_bearer_token": "Wymagane jest zalogowanie",
|
||||||
|
"missing_event_fields": "Nazwa, data początku i końca są wymagane",
|
||||||
"missing_expense_fields": "Kwota, tytuł, data, płacący i typ podziału są wymagane",
|
"missing_expense_fields": "Kwota, tytuł, data, płacący i typ podziału są wymagane",
|
||||||
"missing_login_fields": "E-mail i hasło są wymagane",
|
"missing_login_fields": "E-mail i hasło są wymagane",
|
||||||
"missing_password_fields": "Bieżące i nowe hasło są wymagane",
|
"missing_password_fields": "Bieżące i nowe hasło są wymagane",
|
||||||
@@ -35,6 +40,10 @@
|
|||||||
"share_assigned_to_non_member": "Udział przypisano osobie spoza gospodarstwa domowego",
|
"share_assigned_to_non_member": "Udział przypisano osobie spoza gospodarstwa domowego",
|
||||||
"shares_must_sum_to_amount": "Suma udziałów musi być równa kwocie wydatku",
|
"shares_must_sum_to_amount": "Suma udziałów musi być równa kwocie wydatku",
|
||||||
"shares_required": "Udziały są wymagane dla wybranego typu podziału",
|
"shares_required": "Udziały są wymagane dla wybranego typu podziału",
|
||||||
|
"shopping_item_name_required": "Nazwa produktu jest wymagana",
|
||||||
|
"shopping_item_not_found": "Produkt nie znaleziony",
|
||||||
|
"shopping_list_name_required": "Nazwa listy jest wymagana",
|
||||||
|
"shopping_list_not_found": "Lista zakupów nie znaleziona",
|
||||||
"unknown_split_type": "Nieznany typ podziału",
|
"unknown_split_type": "Nieznany typ podziału",
|
||||||
"user_not_found": "Użytkownik nie znaleziony"
|
"user_not_found": "Użytkownik nie znaleziony"
|
||||||
}
|
}
|
||||||
|
|||||||
22
frontend/src/i18n/locales/pl/eventFormModal.json
Normal file
22
frontend/src/i18n/locales/pl/eventFormModal.json
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"createTitle": "Nowe wydarzenie",
|
||||||
|
"editTitle": "Edytuj wydarzenie",
|
||||||
|
"titleLabel": "Nazwa",
|
||||||
|
"titlePlaceholder": "np. Kolacja rodzinna",
|
||||||
|
"allDayLabel": "Cały dzień",
|
||||||
|
"startDateLabel": "Początek",
|
||||||
|
"endDateLabel": "Koniec",
|
||||||
|
"locationLabel": "Lokalizacja",
|
||||||
|
"locationPlaceholder": "np. Dom, restauracja...",
|
||||||
|
"notesLabel": "Notatki",
|
||||||
|
"notesPlaceholder": "Dodatkowe informacje...",
|
||||||
|
"attendeesLabel": "Kto uczestniczy",
|
||||||
|
"onlyMe": "Tylko ja",
|
||||||
|
"everyone": "Wszyscy",
|
||||||
|
"guestsLabel": "Goście zewnętrzni",
|
||||||
|
"guestPlaceholder": "Imię gościa",
|
||||||
|
"titleRequired": "Nazwa wydarzenia jest wymagana",
|
||||||
|
"dateRequired": "Data początku i końca jest wymagana",
|
||||||
|
"deleteConfirmTitle": "Usunąć wydarzenie?",
|
||||||
|
"deleteConfirmMessage": "Tej operacji nie można cofnąć."
|
||||||
|
}
|
||||||
9
frontend/src/i18n/locales/pl/shoppingListDetail.json
Normal file
9
frontend/src/i18n/locales/pl/shoppingListDetail.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"itemNamePlaceholder": "Nazwa produktu",
|
||||||
|
"quantityPlaceholder": "Ilość",
|
||||||
|
"emptyState": "Ta lista jest pusta — dodaj pierwszy produkt",
|
||||||
|
"notFound": "Lista nie znaleziona",
|
||||||
|
"deleteListAria": "Usuń listę",
|
||||||
|
"deleteConfirmTitle": "Usunąć listę „{{name}}”?",
|
||||||
|
"deleteConfirmMessage": "Wszystkie produkty na tej liście zostaną usunięte."
|
||||||
|
}
|
||||||
8
frontend/src/i18n/locales/pl/shoppingLists.json
Normal file
8
frontend/src/i18n/locales/pl/shoppingLists.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"pageTitle": "Listy zakupów",
|
||||||
|
"newListPlaceholder": "Nazwa nowej listy",
|
||||||
|
"emptyState": "Brak list zakupów — dodaj pierwszą powyżej",
|
||||||
|
"progress": "{{checked}}/{{total}} zaznaczonych",
|
||||||
|
"deleteConfirmTitle": "Usunąć listę „{{name}}”?",
|
||||||
|
"deleteConfirmMessage": "Wszystkie produkty na tej liście zostaną usunięte."
|
||||||
|
}
|
||||||
166
frontend/src/pages/Calendar.jsx
Normal file
166
frontend/src/pages/Calendar.jsx
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
import { useMemo, useState } from 'react';
|
||||||
|
import { useEvents } from '../api/queries.js';
|
||||||
|
import { useHouseholdContext } from '../household/HouseholdContext.jsx';
|
||||||
|
import { useAuth } from '../auth/AuthContext.jsx';
|
||||||
|
import { useTranslation } from '../i18n/I18nContext.jsx';
|
||||||
|
import EventListItem from '../components/EventListItem.jsx';
|
||||||
|
import EventFormModal from '../components/EventFormModal.jsx';
|
||||||
|
import Icon from '../components/Icon.jsx';
|
||||||
|
|
||||||
|
const WEEKDAY_LABELS = {
|
||||||
|
pl: ['Pn', 'Wt', 'Śr', 'Cz', 'Pt', 'So', 'Nd'],
|
||||||
|
en: ['Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa', 'Su'],
|
||||||
|
};
|
||||||
|
|
||||||
|
function toDateStr(date) {
|
||||||
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function todayStr() {
|
||||||
|
return toDateStr(new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildMonthGrid(year, month) {
|
||||||
|
const firstOfMonth = new Date(year, month, 1);
|
||||||
|
const startOffset = (firstOfMonth.getDay() + 6) % 7; // Monday-first
|
||||||
|
const gridStart = new Date(year, month, 1 - startOffset);
|
||||||
|
const days = [];
|
||||||
|
for (let i = 0; i < 42; i++) {
|
||||||
|
const d = new Date(gridStart);
|
||||||
|
d.setDate(gridStart.getDate() + i);
|
||||||
|
days.push(d);
|
||||||
|
}
|
||||||
|
return days;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventOccursOn(event, dateStr) {
|
||||||
|
return dateStr >= event.start_at.slice(0, 10) && dateStr <= event.end_at.slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Calendar() {
|
||||||
|
const { user } = useAuth();
|
||||||
|
const { activeHousehold: household } = useHouseholdContext();
|
||||||
|
const { data: events, isLoading } = useEvents();
|
||||||
|
const { t, language } = useTranslation();
|
||||||
|
|
||||||
|
const [viewDate, setViewDate] = useState(() => {
|
||||||
|
const now = new Date();
|
||||||
|
return new Date(now.getFullYear(), now.getMonth(), 1);
|
||||||
|
});
|
||||||
|
const [selectedDate, setSelectedDate] = useState(todayStr());
|
||||||
|
const [editingEvent, setEditingEvent] = useState(null);
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
|
||||||
|
const members = household?.members || [];
|
||||||
|
const monthGrid = useMemo(() => buildMonthGrid(viewDate.getFullYear(), viewDate.getMonth()), [viewDate]);
|
||||||
|
const monthLabel = useMemo(
|
||||||
|
() =>
|
||||||
|
new Intl.DateTimeFormat(language === 'pl' ? 'pl-PL' : 'en-US', { month: 'long', year: 'numeric' }).format(
|
||||||
|
viewDate
|
||||||
|
),
|
||||||
|
[viewDate, language]
|
||||||
|
);
|
||||||
|
const weekdayLabels = WEEKDAY_LABELS[language] || WEEKDAY_LABELS.en;
|
||||||
|
|
||||||
|
const eventsByDay = useMemo(() => {
|
||||||
|
const map = {};
|
||||||
|
for (const day of monthGrid) {
|
||||||
|
const dateStr = toDateStr(day);
|
||||||
|
map[dateStr] = (events || []).filter((e) => eventOccursOn(e, dateStr));
|
||||||
|
}
|
||||||
|
return map;
|
||||||
|
}, [monthGrid, events]);
|
||||||
|
|
||||||
|
const selectedEvents = (eventsByDay[selectedDate] || []).slice().sort((a, b) => (a.start_at < b.start_at ? -1 : 1));
|
||||||
|
|
||||||
|
function changeMonth(delta) {
|
||||||
|
setViewDate((d) => new Date(d.getFullYear(), d.getMonth() + delta, 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToday() {
|
||||||
|
const now = new Date();
|
||||||
|
setViewDate(new Date(now.getFullYear(), now.getMonth(), 1));
|
||||||
|
setSelectedDate(todayStr());
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title">{t('calendar.pageTitle')}</h1>
|
||||||
|
|
||||||
|
<div className="calendar-header">
|
||||||
|
<button className="icon-btn" onClick={() => changeMonth(-1)} aria-label={t('calendar.prevMonth')}>
|
||||||
|
<Icon name="chevron_left" />
|
||||||
|
</button>
|
||||||
|
<button className="calendar-month-label" onClick={goToday}>{monthLabel}</button>
|
||||||
|
<button className="icon-btn" onClick={() => changeMonth(1)} aria-label={t('calendar.nextMonth')}>
|
||||||
|
<Icon name="chevron_right" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="calendar-grid calendar-weekdays">
|
||||||
|
{weekdayLabels.map((w) => (
|
||||||
|
<div key={w} className="calendar-weekday">{w}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="calendar-grid">
|
||||||
|
{monthGrid.map((day) => {
|
||||||
|
const dateStr = toDateStr(day);
|
||||||
|
const isCurrentMonth = day.getMonth() === viewDate.getMonth();
|
||||||
|
const isToday = dateStr === todayStr();
|
||||||
|
const isSelected = dateStr === selectedDate;
|
||||||
|
const hasEvents = (eventsByDay[dateStr] || []).length > 0;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={dateStr}
|
||||||
|
className={[
|
||||||
|
'calendar-day',
|
||||||
|
!isCurrentMonth && 'outside',
|
||||||
|
isToday && 'today',
|
||||||
|
isSelected && 'selected',
|
||||||
|
].filter(Boolean).join(' ')}
|
||||||
|
onClick={() => setSelectedDate(dateStr)}
|
||||||
|
>
|
||||||
|
<span>{day.getDate()}</span>
|
||||||
|
{hasEvents && <span className="calendar-dot" />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="calendar-agenda">
|
||||||
|
<div className="calendar-agenda-header">
|
||||||
|
<h3>{selectedDate}</h3>
|
||||||
|
<button className="btn-secondary" onClick={() => setShowCreate(true)}>
|
||||||
|
<Icon name="add" /> {t('calendar.addEvent')}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && <p>{t('common.loading')}</p>}
|
||||||
|
{!isLoading && selectedEvents.length === 0 && <p className="empty-state">{t('calendar.emptyState')}</p>}
|
||||||
|
{selectedEvents.map((event) => (
|
||||||
|
<EventListItem key={event.id} event={event} onClick={() => setEditingEvent(event)} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showCreate && (
|
||||||
|
<EventFormModal
|
||||||
|
members={members}
|
||||||
|
currentUserId={user?.id}
|
||||||
|
defaultDate={selectedDate}
|
||||||
|
onClose={() => setShowCreate(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{editingEvent && (
|
||||||
|
<EventFormModal
|
||||||
|
event={editingEvent}
|
||||||
|
members={members}
|
||||||
|
currentUserId={user?.id}
|
||||||
|
defaultDate={selectedDate}
|
||||||
|
onClose={() => setEditingEvent(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
frontend/src/pages/ShoppingListDetail.jsx
Normal file
94
frontend/src/pages/ShoppingListDetail.jsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
useShoppingList,
|
||||||
|
useUpdateShoppingList,
|
||||||
|
useDeleteShoppingList,
|
||||||
|
useCreateShoppingItem,
|
||||||
|
} from '../api/queries.js';
|
||||||
|
import { useConfirm } from '../components/ConfirmDialogProvider.jsx';
|
||||||
|
import { useTranslation } from '../i18n/I18nContext.jsx';
|
||||||
|
import ShoppingListItemRow from '../components/ShoppingListItemRow.jsx';
|
||||||
|
import Icon from '../components/Icon.jsx';
|
||||||
|
|
||||||
|
export default function ShoppingListDetail() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: list, isLoading } = useShoppingList(id);
|
||||||
|
const updateList = useUpdateShoppingList();
|
||||||
|
const deleteList = useDeleteShoppingList();
|
||||||
|
const createItem = useCreateShoppingItem();
|
||||||
|
const confirmDialog = useConfirm();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [itemName, setItemName] = useState('');
|
||||||
|
const [itemQuantity, setItemQuantity] = useState('');
|
||||||
|
|
||||||
|
async function handleAddItem(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!itemName.trim()) return;
|
||||||
|
await createItem.mutateAsync({ listId: id, name: itemName.trim(), quantity: itemQuantity.trim() || null });
|
||||||
|
setItemName('');
|
||||||
|
setItemQuantity('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteList() {
|
||||||
|
const ok = await confirmDialog({
|
||||||
|
title: t('shoppingListDetail.deleteConfirmTitle', { name: list.name }),
|
||||||
|
message: t('shoppingListDetail.deleteConfirmMessage'),
|
||||||
|
confirmLabel: t('common.delete'),
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
await deleteList.mutateAsync(id);
|
||||||
|
navigate('/shopping', { replace: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) return <p>{t('common.loading')}</p>;
|
||||||
|
if (!list) return <p className="empty-state">{t('shoppingListDetail.notFound')}</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="calendar-header">
|
||||||
|
<button className="icon-btn" onClick={() => navigate('/shopping')} aria-label={t('common.close')}>
|
||||||
|
<Icon name="arrow_back" />
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="shopping-list-name-input"
|
||||||
|
defaultValue={list.name}
|
||||||
|
onBlur={(e) => {
|
||||||
|
const value = e.target.value.trim();
|
||||||
|
if (value && value !== list.name) updateList.mutate({ id, name: value });
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<button className="icon-btn" onClick={handleDeleteList} aria-label={t('shoppingListDetail.deleteListAria')}>
|
||||||
|
<Icon name="delete_forever" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handleAddItem} className="shopping-add-item-form">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={t('shoppingListDetail.itemNamePlaceholder')}
|
||||||
|
value={itemName}
|
||||||
|
onChange={(e) => setItemName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="shopping-item-qty-input"
|
||||||
|
placeholder={t('shoppingListDetail.quantityPlaceholder')}
|
||||||
|
value={itemQuantity}
|
||||||
|
onChange={(e) => setItemQuantity(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn-primary" disabled={createItem.isPending || !itemName.trim()}>
|
||||||
|
<Icon name="add" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{list.items.length === 0 && <p className="empty-state">{t('shoppingListDetail.emptyState')}</p>}
|
||||||
|
{list.items.map((item) => (
|
||||||
|
<ShoppingListItemRow key={item.id} item={item} listId={id} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
frontend/src/pages/ShoppingLists.jsx
Normal file
73
frontend/src/pages/ShoppingLists.jsx
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useShoppingLists, useCreateShoppingList, useDeleteShoppingList } from '../api/queries.js';
|
||||||
|
import { useConfirm } from '../components/ConfirmDialogProvider.jsx';
|
||||||
|
import { useTranslation } from '../i18n/I18nContext.jsx';
|
||||||
|
import Icon from '../components/Icon.jsx';
|
||||||
|
|
||||||
|
export default function ShoppingLists() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const { data: lists, isLoading } = useShoppingLists();
|
||||||
|
const createList = useCreateShoppingList();
|
||||||
|
const deleteList = useDeleteShoppingList();
|
||||||
|
const confirmDialog = useConfirm();
|
||||||
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
const [name, setName] = useState('');
|
||||||
|
|
||||||
|
async function handleCreate(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!name.trim()) return;
|
||||||
|
await createList.mutateAsync({ name: name.trim() });
|
||||||
|
setName('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete(e, list) {
|
||||||
|
e.stopPropagation();
|
||||||
|
const ok = await confirmDialog({
|
||||||
|
title: t('shoppingLists.deleteConfirmTitle', { name: list.name }),
|
||||||
|
message: t('shoppingLists.deleteConfirmMessage'),
|
||||||
|
confirmLabel: t('common.delete'),
|
||||||
|
});
|
||||||
|
if (!ok) return;
|
||||||
|
deleteList.mutate(list.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<h1 className="page-title">{t('shoppingLists.pageTitle')}</h1>
|
||||||
|
|
||||||
|
<form onSubmit={handleCreate} className="shopping-add-list-form">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder={t('shoppingLists.newListPlaceholder')}
|
||||||
|
value={name}
|
||||||
|
onChange={(e) => setName(e.target.value)}
|
||||||
|
/>
|
||||||
|
<button type="submit" className="btn-primary" disabled={createList.isPending || !name.trim()}>
|
||||||
|
<Icon name="add" />
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{isLoading && <p>{t('common.loading')}</p>}
|
||||||
|
{!isLoading && (lists || []).length === 0 && <p className="empty-state">{t('shoppingLists.emptyState')}</p>}
|
||||||
|
|
||||||
|
{(lists || []).map((list) => (
|
||||||
|
<div key={list.id} className="shopping-list-card" onClick={() => navigate(`/shopping/${list.id}`)}>
|
||||||
|
<div className="shopping-list-card-main">
|
||||||
|
<Icon name="checklist" />
|
||||||
|
<div>
|
||||||
|
<div className="title">{list.name}</div>
|
||||||
|
<div className="switch-desc">
|
||||||
|
{t('shoppingLists.progress', { checked: list.checkedCount, total: list.total })}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button type="button" className="icon-btn" onClick={(e) => handleDelete(e, list)} aria-label={t('common.delete')}>
|
||||||
|
<Icon name="delete" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -85,12 +85,12 @@ input, select, button, textarea { font-family: inherit; font-size: 1rem; color:
|
|||||||
left: 0;
|
left: 0;
|
||||||
right: 0;
|
right: 0;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(5, 1fr);
|
grid-template-columns: repeat(7, 1fr);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-items: center;
|
justify-items: center;
|
||||||
background: var(--card);
|
background: var(--card);
|
||||||
border-top: 1px solid var(--border);
|
border-top: 1px solid var(--border);
|
||||||
padding: 6px 8px calc(6px + env(safe-area-inset-bottom));
|
padding: 6px 4px calc(6px + env(safe-area-inset-bottom));
|
||||||
z-index: 20;
|
z-index: 20;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,12 +100,13 @@ input, select, button, textarea { font-family: inherit; font-size: 1rem; color:
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 2px;
|
gap: 2px;
|
||||||
font-size: 0.7rem;
|
font-size: 0.62rem;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
padding: 6px 4px;
|
padding: 6px 2px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item.active {
|
.nav-item.active {
|
||||||
@@ -113,7 +114,7 @@ input, select, button, textarea { font-family: inherit; font-size: 1rem; color:
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-icon { font-size: 1.4rem; }
|
.nav-icon { font-size: 1.25rem; }
|
||||||
|
|
||||||
.fab {
|
.fab {
|
||||||
width: 56px;
|
width: 56px;
|
||||||
@@ -648,3 +649,164 @@ input, select, button, textarea { font-family: inherit; font-size: 1rem; color:
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Calendar */
|
||||||
|
.calendar-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.calendar-month-label {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
cursor: pointer;
|
||||||
|
text-transform: capitalize;
|
||||||
|
}
|
||||||
|
.calendar-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(7, 1fr);
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.calendar-weekdays { margin-bottom: 4px; }
|
||||||
|
.calendar-weekday {
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.calendar-day {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border: none;
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--card);
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.calendar-day.outside { color: var(--muted); opacity: 0.5; }
|
||||||
|
.calendar-day.today { font-weight: 700; color: var(--primary); }
|
||||||
|
.calendar-day.selected { background: var(--primary); color: white; }
|
||||||
|
.calendar-dot {
|
||||||
|
position: absolute;
|
||||||
|
bottom: 6px;
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--primary);
|
||||||
|
}
|
||||||
|
.calendar-day.selected .calendar-dot { background: white; }
|
||||||
|
.calendar-agenda { margin-top: 20px; }
|
||||||
|
.calendar-agenda-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
.calendar-agenda-header h3 { margin: 0; }
|
||||||
|
.event-item .cat-icon { background: var(--accent-bg); color: var(--accent-text); }
|
||||||
|
|
||||||
|
/* Event form: attendees & guests */
|
||||||
|
.guest-input-row { display: flex; gap: 8px; }
|
||||||
|
.guest-input-row input { flex: 1; }
|
||||||
|
.chip-list { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 10px; }
|
||||||
|
.guest-chip {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
background: var(--accent-bg);
|
||||||
|
color: var(--accent-text);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
.guest-chip button {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
.guest-chip .material-symbols-outlined { font-size: 1rem; }
|
||||||
|
|
||||||
|
/* Shopping lists */
|
||||||
|
.shopping-add-list-form, .shopping-add-item-form {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.shopping-add-list-form input, .shopping-add-item-form input {
|
||||||
|
flex: 1;
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--input-bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.shopping-add-list-form button, .shopping-add-item-form button { flex-shrink: 0; }
|
||||||
|
.shopping-list-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
background: var(--card);
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.shopping-list-card-main { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.shopping-list-name-input {
|
||||||
|
flex: 1;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
font-weight: 700;
|
||||||
|
border: none;
|
||||||
|
background: none;
|
||||||
|
color: var(--text);
|
||||||
|
padding: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.shopping-item-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 10px 4px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.shopping-item-row:last-child { border-bottom: none; }
|
||||||
|
.shopping-item-row input[type='checkbox'] { width: 20px; height: 20px; flex-shrink: 0; }
|
||||||
|
.shopping-item-name {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
text-align: left;
|
||||||
|
color: var(--text);
|
||||||
|
font: inherit;
|
||||||
|
padding: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.shopping-item-qty { font-size: 0.8rem; color: var(--muted); }
|
||||||
|
.shopping-item-row.checked .shopping-item-name { color: var(--muted); text-decoration: line-through; }
|
||||||
|
.shopping-item-edit { flex: 1; display: flex; gap: 8px; }
|
||||||
|
.shopping-item-edit input {
|
||||||
|
box-sizing: border-box;
|
||||||
|
padding: 8px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
background: var(--input-bg);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.shopping-item-edit input:first-child { flex: 1; }
|
||||||
|
.shopping-item-qty-input { width: 90px; flex-shrink: 0; }
|
||||||
|
|||||||
Reference in New Issue
Block a user