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,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() {
|
||||
<Route path="/settlements" element={<Settlements />} />
|
||||
<Route path="/stats" element={<Stats />} />
|
||||
<Route path="/settings" element={<Settings />} />
|
||||
<Route path="/calendar" element={<Calendar />} />
|
||||
<Route path="/shopping" element={<ShoppingLists />} />
|
||||
<Route path="/shopping/:id" element={<ShoppingListDetail />} />
|
||||
</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() {
|
||||
const qc = useQueryClient();
|
||||
return useMutation({
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
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 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 }) {
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
{
|
||||
"home": "Home",
|
||||
"calendar": "Calendar",
|
||||
"history": "History",
|
||||
"shopping": "Shopping",
|
||||
"settlements": "Settlements",
|
||||
"stats": "Stats",
|
||||
"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_settled": "You're already settled up",
|
||||
"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",
|
||||
"category_name_required": "A category name is required",
|
||||
"category_not_found": "Category not found",
|
||||
"current_password_incorrect": "Current password is incorrect",
|
||||
"email_already_registered": "An account with this email already exists",
|
||||
"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",
|
||||
"guest_name_required": "A guest name is required",
|
||||
"household_has_no_members": "This household has no members to split the expense between",
|
||||
"household_not_found": "Household not found",
|
||||
"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_required": "An invite code is 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_login_fields": "Email and 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",
|
||||
"shares_must_sum_to_amount": "The shares must add up to the expense amount",
|
||||
"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",
|
||||
"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",
|
||||
"calendar": "Kalendarz",
|
||||
"history": "Historia",
|
||||
"shopping": "Zakupy",
|
||||
"settlements": "Rozliczenia",
|
||||
"stats": "Statystyki",
|
||||
"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_settled": "Jesteście już rozliczeni",
|
||||
"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",
|
||||
"category_name_required": "Nazwa kategorii jest wymagana",
|
||||
"category_not_found": "Kategoria nie znaleziona",
|
||||
"current_password_incorrect": "Bieżące hasło jest nieprawidłowe",
|
||||
"email_already_registered": "Konto z tym adresem e-mail już istnieje",
|
||||
"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",
|
||||
"guest_name_required": "Nazwa gościa jest wymagana",
|
||||
"household_has_no_members": "Gospodarstwo domowe nie ma członków do podziału wydatku",
|
||||
"household_not_found": "Gospodarstwo nie znalezione",
|
||||
"household_not_selected": "Nie wybrano gospodarstwa",
|
||||
@@ -19,6 +23,7 @@
|
||||
"invite_code_invalid_or_expired": "Kod zaproszenia jest nieprawidłowy lub wygasł",
|
||||
"invite_code_required": "Kod zaproszenia jest wymagany",
|
||||
"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_login_fields": "E-mail i 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",
|
||||
"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",
|
||||
"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",
|
||||
"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;
|
||||
right: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(5, 1fr);
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
align-items: center;
|
||||
justify-items: center;
|
||||
background: var(--card);
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -100,12 +100,13 @@ input, select, button, textarea { font-family: inherit; font-size: 1rem; color:
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 2px;
|
||||
font-size: 0.7rem;
|
||||
font-size: 0.62rem;
|
||||
color: var(--muted);
|
||||
text-decoration: none;
|
||||
padding: 6px 4px;
|
||||
padding: 6px 2px;
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.nav-item.active {
|
||||
@@ -113,7 +114,7 @@ input, select, button, textarea { font-family: inherit; font-size: 1rem; color:
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.nav-icon { font-size: 1.4rem; }
|
||||
.nav-icon { font-size: 1.25rem; }
|
||||
|
||||
.fab {
|
||||
width: 56px;
|
||||
@@ -648,3 +649,164 @@ input, select, button, textarea { font-family: inherit; font-size: 1rem; color:
|
||||
justify-content: center;
|
||||
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