-
- {/* Timeline line */}
-
+ {/* Timeline */}
+
+
+ {/* Timeline line */}
+
-
- {(isPrerelease ? releases : releases.filter(r => !r.prerelease)).map((release, idx) => {
- const isLatest = idx === 0
- const isExpanded = expanded[release.id]
+
+ {(isPrerelease ? releases : releases.filter((r) => !r.prerelease)).map((release, idx) => {
+ const isLatest = idx === 0;
+ const isExpanded = expanded[release.id];
- return (
-
- {/* Timeline dot */}
-
-
-
-
- {/* Release content */}
-
-
-
- {release.tag_name}
-
- {isLatest && (
-
- {t('admin.github.latest')}
-
- )}
- {release.prerelease && (
-
- {t('admin.github.prerelease')}
-
- )}
+ return (
+
+ {/* Timeline dot */}
+
+
- {release.name && release.name !== release.tag_name && (
-
- {release.name}
-
- )}
-
-
-
-
- {formatDate(release.published_at || release.created_at)}
-
- {release.author && (
-
- {t('admin.github.by')} {release.author.login}
+ {/* Release content */}
+
+
+
+ {release.tag_name}
- )}
-
-
- {/* Expandable body */}
- {release.body && (
-
-
-
- {isExpanded && (
-
- {renderBody(release.body)}
-
+ {isLatest && (
+
+ {t('admin.github.latest')}
+
+ )}
+ {release.prerelease && (
+
+ {t('admin.github.prerelease')}
+
)}
- )}
-
-
- )
- })}
-
-
- {/* Load more */}
- {hasMore && (
-
-
+ {release.name && release.name !== release.tag_name && (
+
+ {release.name}
+
+ )}
+
+
+
+
+ {formatDate(release.published_at || release.created_at)}
+
+ {release.author && (
+
+ {t('admin.github.by')} {release.author.login}
+
+ )}
+
+
+ {/* Expandable body */}
+ {release.body && (
+
+
+
+ {isExpanded && (
+
+ {renderBody(release.body)}
+
+ )}
+
+ )}
+
+
+ );
+ })}
+
- )}
+
+ {/* Load more */}
+ {hasMore && (
+
+
+
+ )}
+
-
)}
- )
+ );
}
diff --git a/client/src/components/Admin/PackingTemplateManager.test.tsx b/client/src/components/Admin/PackingTemplateManager.test.tsx
index 74b2986e..784dd695 100644
--- a/client/src/components/Admin/PackingTemplateManager.test.tsx
+++ b/client/src/components/Admin/PackingTemplateManager.test.tsx
@@ -1,18 +1,18 @@
// FE-ADMIN-PKG-001 to FE-ADMIN-PKG-020
-import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
+import { render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
-import PackingTemplateManager from './PackingTemplateManager';
import { ToastContainer } from '../shared/Toast';
+import PackingTemplateManager from './PackingTemplateManager';
-const tmpl1 = { id: 1, name: 'Beach Trip', item_count: 5, category_count: 2, created_by_name: 'admin' }
-const tmpl2 = { id: 2, name: 'City Break', item_count: 3, category_count: 1, created_by_name: 'admin' }
+const tmpl1 = { id: 1, name: 'Beach Trip', item_count: 5, category_count: 2, created_by_name: 'admin' };
+const tmpl2 = { id: 2, name: 'City Break', item_count: 3, category_count: 1, created_by_name: 'admin' };
-const cat1 = { id: 10, template_id: 1, name: 'Clothing', sort_order: 0 }
-const item1 = { id: 100, category_id: 10, name: 'T-shirt', sort_order: 0 }
-const item2 = { id: 101, category_id: 10, name: 'Shorts', sort_order: 1 }
+const cat1 = { id: 10, template_id: 1, name: 'Clothing', sort_order: 0 };
+const item1 = { id: 100, category_id: 10, name: 'T-shirt', sort_order: 0 };
+const item2 = { id: 101, category_id: 10, name: 'Shorts', sort_order: 1 };
beforeEach(() => {
resetAllStores();
@@ -22,7 +22,7 @@ describe('PackingTemplateManager', () => {
it('FE-ADMIN-PKG-001: shows loading spinner on mount', async () => {
server.use(
http.get('/api/admin/packing-templates', async () => {
- await new Promise(r => setTimeout(r, 100));
+ await new Promise((r) => setTimeout(r, 100));
return HttpResponse.json({ templates: [] });
})
);
@@ -37,11 +37,7 @@ describe('PackingTemplateManager', () => {
});
it('FE-ADMIN-PKG-003: template list renders names and counts', async () => {
- server.use(
- http.get('/api/admin/packing-templates', () =>
- HttpResponse.json({ templates: [tmpl1, tmpl2] })
- )
- );
+ server.use(http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1, tmpl2] })));
render(
);
await screen.findByText('Beach Trip');
expect(screen.getByText('City Break')).toBeInTheDocument();
@@ -67,7 +63,12 @@ describe('PackingTemplateManager', () => {
return HttpResponse.json({ template: { id: 99, name: 'New Template' } });
})
);
- render(<>
>);
+ render(
+ <>
+
+
+ >
+ );
await screen.findByText('No templates created yet');
await user.click(screen.getByRole('button', { name: /new template/i }));
const input = screen.getByPlaceholderText('Template name (e.g. Beach Holiday)');
@@ -101,12 +102,8 @@ describe('PackingTemplateManager', () => {
it('FE-ADMIN-PKG-007: expanding a template loads and displays its categories and items', async () => {
const user = userEvent.setup();
server.use(
- http.get('/api/admin/packing-templates', () =>
- HttpResponse.json({ templates: [tmpl1] })
- ),
- http.get('/api/admin/packing-templates/1', () =>
- HttpResponse.json({ categories: [cat1], items: [item1, item2] })
- )
+ http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
+ http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [item1, item2] }))
);
render(
);
await screen.findByText('Beach Trip');
@@ -119,12 +116,8 @@ describe('PackingTemplateManager', () => {
it('FE-ADMIN-PKG-008: collapsing an expanded template hides its content', async () => {
const user = userEvent.setup();
server.use(
- http.get('/api/admin/packing-templates', () =>
- HttpResponse.json({ templates: [tmpl1] })
- ),
- http.get('/api/admin/packing-templates/1', () =>
- HttpResponse.json({ categories: [cat1], items: [item1, item2] })
- )
+ http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
+ http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [item1, item2] }))
);
render(
);
await screen.findByText('Beach Trip');
@@ -142,22 +135,25 @@ describe('PackingTemplateManager', () => {
const user = userEvent.setup();
let deleteCalled = false;
server.use(
- http.get('/api/admin/packing-templates', () =>
- HttpResponse.json({ templates: [tmpl1, tmpl2] })
- ),
+ http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1, tmpl2] })),
http.delete('/api/admin/packing-templates/1', () => {
deleteCalled = true;
return HttpResponse.json({ success: true });
})
);
- render(<>
>);
+ render(
+ <>
+
+
+ >
+ );
await screen.findByText('Beach Trip');
expect(screen.getByText('City Break')).toBeInTheDocument();
// Find all Trash2 (delete) buttons — there are 2 (one per template)
- const deleteButtons = screen.getAllByRole('button').filter(b =>
- b.className.includes('hover:bg-red-50') || b.querySelector('svg')
- );
+ const deleteButtons = screen
+ .getAllByRole('button')
+ .filter((b) => b.className.includes('hover:bg-red-50') || b.querySelector('svg'));
// Click the delete button for "Beach Trip" (first template row's trash button)
// The buttons layout in each row: [chevron, edit, delete]
// We find rows first
@@ -168,7 +164,7 @@ describe('PackingTemplateManager', () => {
} else {
// Fallback: find all red-hover buttons and click first
const allBtns = screen.getAllByRole('button');
- const redBtns = allBtns.filter(b => b.className.includes('hover:bg-red-50'));
+ const redBtns = allBtns.filter((b) => b.className.includes('hover:bg-red-50'));
await user.click(redBtns[0]);
}
await waitFor(() => expect(deleteCalled).toBe(true));
@@ -181,9 +177,7 @@ describe('PackingTemplateManager', () => {
const user = userEvent.setup();
let putCalled = false;
server.use(
- http.get('/api/admin/packing-templates', () =>
- HttpResponse.json({ templates: [tmpl1] })
- ),
+ http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.put('/api/admin/packing-templates/1', async () => {
putCalled = true;
return HttpResponse.json({ success: true });
@@ -201,7 +195,7 @@ describe('PackingTemplateManager', () => {
} else {
// Fallback: find all slate-100-hover buttons
const allBtns = screen.getAllByRole('button');
- const editBtns = allBtns.filter(b => b.className.includes('hover:bg-slate-100'));
+ const editBtns = allBtns.filter((b) => b.className.includes('hover:bg-slate-100'));
await user.click(editBtns[0]);
}
@@ -215,12 +209,8 @@ describe('PackingTemplateManager', () => {
it('FE-ADMIN-PKG-011: adding a category to an expanded template', async () => {
const user = userEvent.setup();
server.use(
- http.get('/api/admin/packing-templates', () =>
- HttpResponse.json({ templates: [tmpl1] })
- ),
- http.get('/api/admin/packing-templates/1', () =>
- HttpResponse.json({ categories: [], items: [] })
- ),
+ http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
+ http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [], items: [] })),
http.post('/api/admin/packing-templates/1/categories', async () =>
HttpResponse.json({ category: { id: 20, template_id: 1, name: 'Electronics', sort_order: 1 } })
)
@@ -239,12 +229,8 @@ describe('PackingTemplateManager', () => {
it('FE-ADMIN-PKG-012: adding an item to a category', async () => {
const user = userEvent.setup();
server.use(
- http.get('/api/admin/packing-templates', () =>
- HttpResponse.json({ templates: [tmpl1] })
- ),
- http.get('/api/admin/packing-templates/1', () =>
- HttpResponse.json({ categories: [cat1], items: [] })
- ),
+ http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
+ http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [] })),
http.post('/api/admin/packing-templates/1/categories/10/items', async () =>
HttpResponse.json({ item: { id: 102, category_id: 10, name: 'Sandals', sort_order: 2 } })
)
@@ -269,15 +255,9 @@ describe('PackingTemplateManager', () => {
it('FE-ADMIN-PKG-013: renaming a category inline updates its name', async () => {
const user = userEvent.setup();
server.use(
- http.get('/api/admin/packing-templates', () =>
- HttpResponse.json({ templates: [tmpl1] })
- ),
- http.get('/api/admin/packing-templates/1', () =>
- HttpResponse.json({ categories: [cat1], items: [] })
- ),
- http.put('/api/admin/packing-templates/1/categories/10', async () =>
- HttpResponse.json({ success: true })
- )
+ http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
+ http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [] })),
+ http.put('/api/admin/packing-templates/1/categories/10', async () => HttpResponse.json({ success: true }))
);
render(
);
await screen.findByText('Beach Trip');
@@ -286,8 +266,8 @@ describe('PackingTemplateManager', () => {
// Find the Edit2 button in the Clothing category header
const clothingHeader = screen.getByText('Clothing').closest('div')!;
- const editBtns = Array.from(clothingHeader.querySelectorAll('button')).filter(
- b => b.className.includes('hover:text-slate-700')
+ const editBtns = Array.from(clothingHeader.querySelectorAll('button')).filter((b) =>
+ b.className.includes('hover:text-slate-700')
);
// Second button (after Plus) is Edit2
await user.click(editBtns[1]);
@@ -301,15 +281,11 @@ describe('PackingTemplateManager', () => {
it('FE-ADMIN-PKG-014: deleting a category removes it and its items', async () => {
const user = userEvent.setup();
server.use(
- http.get('/api/admin/packing-templates', () =>
- HttpResponse.json({ templates: [tmpl1] })
- ),
+ http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () =>
HttpResponse.json({ categories: [cat1], items: [item1, item2] })
),
- http.delete('/api/admin/packing-templates/1/categories/10', () =>
- HttpResponse.json({ success: true })
- )
+ http.delete('/api/admin/packing-templates/1/categories/10', () => HttpResponse.json({ success: true }))
);
render(
);
await screen.findByText('Beach Trip');
@@ -331,15 +307,9 @@ describe('PackingTemplateManager', () => {
it('FE-ADMIN-PKG-015: renaming an item inline updates its name', async () => {
const user = userEvent.setup();
server.use(
- http.get('/api/admin/packing-templates', () =>
- HttpResponse.json({ templates: [tmpl1] })
- ),
- http.get('/api/admin/packing-templates/1', () =>
- HttpResponse.json({ categories: [cat1], items: [item1] })
- ),
- http.put('/api/admin/packing-templates/1/items/100', async () =>
- HttpResponse.json({ success: true })
- )
+ http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
+ http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [item1] })),
+ http.put('/api/admin/packing-templates/1/items/100', async () => HttpResponse.json({ success: true }))
);
render(
);
await screen.findByText('Beach Trip');
@@ -348,9 +318,9 @@ describe('PackingTemplateManager', () => {
// Find the Edit2 button in the T-shirt item row (opacity-0 group-hover buttons)
const itemRow = screen.getByText('T-shirt').closest('div')!;
- const editBtn = Array.from(itemRow.querySelectorAll('button')).find(
- b => b.className.includes('opacity-0')
- ) as HTMLElement | undefined;
+ const editBtn = Array.from(itemRow.querySelectorAll('button')).find((b) => b.className.includes('opacity-0')) as
+ | HTMLElement
+ | undefined;
if (editBtn) {
await user.click(editBtn);
} else {
@@ -368,15 +338,11 @@ describe('PackingTemplateManager', () => {
it('FE-ADMIN-PKG-016: deleting an item removes it from the list', async () => {
const user = userEvent.setup();
server.use(
- http.get('/api/admin/packing-templates', () =>
- HttpResponse.json({ templates: [tmpl1] })
- ),
+ http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.get('/api/admin/packing-templates/1', () =>
HttpResponse.json({ categories: [cat1], items: [item1, item2] })
),
- http.delete('/api/admin/packing-templates/1/items/100', () =>
- HttpResponse.json({ success: true })
- )
+ http.delete('/api/admin/packing-templates/1/items/100', () => HttpResponse.json({ success: true }))
);
render(
);
await screen.findByText('Beach Trip');
@@ -386,9 +352,7 @@ describe('PackingTemplateManager', () => {
// Find the Trash2 button in the T-shirt row
const itemRow = screen.getByText('T-shirt').closest('div')!;
- const trashBtns = Array.from(itemRow.querySelectorAll('button')).filter(
- b => b.className.includes('opacity-0')
- );
+ const trashBtns = Array.from(itemRow.querySelectorAll('button')).filter((b) => b.className.includes('opacity-0'));
// Second opacity-0 button is the delete (trash) button
const trashBtn = trashBtns[1] || trashBtns[0];
await user.click(trashBtn as HTMLElement);
@@ -401,12 +365,8 @@ describe('PackingTemplateManager', () => {
const user = userEvent.setup();
let postCalled = false;
server.use(
- http.get('/api/admin/packing-templates', () =>
- HttpResponse.json({ templates: [tmpl1] })
- ),
- http.get('/api/admin/packing-templates/1', () =>
- HttpResponse.json({ categories: [], items: [] })
- ),
+ http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
+ http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [], items: [] })),
http.post('/api/admin/packing-templates/1/categories', async () => {
postCalled = true;
return HttpResponse.json({ category: { id: 20, template_id: 1, name: 'Ignored', sort_order: 1 } });
@@ -419,9 +379,7 @@ describe('PackingTemplateManager', () => {
await user.click(screen.getByText('Add category'));
const catInput = screen.getByPlaceholderText('Category name (e.g. Clothing)');
await user.type(catInput, 'Test{Escape}');
- await waitFor(() =>
- expect(screen.queryByPlaceholderText('Category name (e.g. Clothing)')).not.toBeInTheDocument()
- );
+ await waitFor(() => expect(screen.queryByPlaceholderText('Category name (e.g. Clothing)')).not.toBeInTheDocument());
expect(postCalled).toBe(false);
});
@@ -429,12 +387,8 @@ describe('PackingTemplateManager', () => {
const user = userEvent.setup();
let postCalled = false;
server.use(
- http.get('/api/admin/packing-templates', () =>
- HttpResponse.json({ templates: [tmpl1] })
- ),
- http.get('/api/admin/packing-templates/1', () =>
- HttpResponse.json({ categories: [cat1], items: [] })
- ),
+ http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
+ http.get('/api/admin/packing-templates/1', () => HttpResponse.json({ categories: [cat1], items: [] })),
http.post('/api/admin/packing-templates/1/categories/10/items', async () => {
postCalled = true;
return HttpResponse.json({ item: { id: 102, category_id: 10, name: 'Ignored', sort_order: 2 } });
@@ -451,9 +405,7 @@ describe('PackingTemplateManager', () => {
const itemInput = screen.getByPlaceholderText('Item name');
await user.type(itemInput, 'Test{Escape}');
- await waitFor(() =>
- expect(screen.queryByPlaceholderText('Item name')).not.toBeInTheDocument()
- );
+ await waitFor(() => expect(screen.queryByPlaceholderText('Item name')).not.toBeInTheDocument());
expect(postCalled).toBe(false);
});
@@ -461,9 +413,7 @@ describe('PackingTemplateManager', () => {
const user = userEvent.setup();
let putCalled = false;
server.use(
- http.get('/api/admin/packing-templates', () =>
- HttpResponse.json({ templates: [tmpl1] })
- ),
+ http.get('/api/admin/packing-templates', () => HttpResponse.json({ templates: [tmpl1] })),
http.put('/api/admin/packing-templates/1', async () => {
putCalled = true;
return HttpResponse.json({ success: true });
@@ -479,7 +429,7 @@ describe('PackingTemplateManager', () => {
await user.click(editBtn);
} else {
const allBtns = screen.getAllByRole('button');
- const editBtns = allBtns.filter(b => b.className.includes('hover:bg-slate-100'));
+ const editBtns = allBtns.filter((b) => b.className.includes('hover:bg-slate-100'));
await user.click(editBtns[0]);
}
diff --git a/client/src/components/Admin/PackingTemplateManager.tsx b/client/src/components/Admin/PackingTemplateManager.tsx
index adcbe9a0..1a44173e 100644
--- a/client/src/components/Admin/PackingTemplateManager.tsx
+++ b/client/src/components/Admin/PackingTemplateManager.tsx
@@ -1,260 +1,414 @@
-import { useState, useEffect, useRef } from 'react'
-import { adminApi } from '../../api/client'
-import { useToast } from '../shared/Toast'
-import { useTranslation } from '../../i18n'
-import { Plus, Trash2, Edit2, Package, X, Check, ChevronDown, ChevronRight, FolderPlus } from 'lucide-react'
+import { Check, ChevronDown, ChevronRight, Edit2, FolderPlus, Package, Plus, Trash2, X } from 'lucide-react';
+import { useEffect, useRef, useState } from 'react';
+import { adminApi } from '../../api/client';
+import { useTranslation } from '../../i18n';
+import { useToast } from '../shared/Toast';
-interface TemplateCategory { id: number; template_id: number; name: string; sort_order: number }
-interface TemplateItem { id: number; category_id: number; name: string; sort_order: number }
-interface Template { id: number; name: string; item_count: number; category_count: number; created_by_name: string }
+interface TemplateCategory {
+ id: number;
+ template_id: number;
+ name: string;
+ sort_order: number;
+}
+interface TemplateItem {
+ id: number;
+ category_id: number;
+ name: string;
+ sort_order: number;
+}
+interface Template {
+ id: number;
+ name: string;
+ item_count: number;
+ category_count: number;
+ created_by_name: string;
+}
export default function PackingTemplateManager() {
- const [templates, setTemplates] = useState
([])
- const [isLoading, setIsLoading] = useState(true)
- const [showCreate, setShowCreate] = useState(false)
- const [createName, setCreateName] = useState('')
+ const [templates, setTemplates] = useState([]);
+ const [isLoading, setIsLoading] = useState(true);
+ const [showCreate, setShowCreate] = useState(false);
+ const [createName, setCreateName] = useState('');
// Expanded template state
- const [expandedId, setExpandedId] = useState(null)
- const [categories, setCategories] = useState([])
- const [items, setItems] = useState([])
+ const [expandedId, setExpandedId] = useState(null);
+ const [categories, setCategories] = useState([]);
+ const [items, setItems] = useState([]);
// Editing states
- const [editingTemplate, setEditingTemplate] = useState(null)
- const [editTemplateName, setEditTemplateName] = useState('')
- const [editingCatId, setEditingCatId] = useState(null)
- const [editCatName, setEditCatName] = useState('')
- const [editingItemId, setEditingItemId] = useState(null)
- const [editItemName, setEditItemName] = useState('')
+ const [editingTemplate, setEditingTemplate] = useState(null);
+ const [editTemplateName, setEditTemplateName] = useState('');
+ const [editingCatId, setEditingCatId] = useState(null);
+ const [editCatName, setEditCatName] = useState('');
+ const [editingItemId, setEditingItemId] = useState(null);
+ const [editItemName, setEditItemName] = useState('');
// Adding states
- const [addingCategory, setAddingCategory] = useState(false)
- const [newCatName, setNewCatName] = useState('')
- const [addingItemToCatId, setAddingItemToCatId] = useState(null)
- const [newItemName, setNewItemName] = useState('')
- const addItemRef = useRef(null)
+ const [addingCategory, setAddingCategory] = useState(false);
+ const [newCatName, setNewCatName] = useState('');
+ const [addingItemToCatId, setAddingItemToCatId] = useState(null);
+ const [newItemName, setNewItemName] = useState('');
+ const addItemRef = useRef(null);
- const toast = useToast()
- const { t } = useTranslation()
+ const toast = useToast();
+ const { t } = useTranslation();
- useEffect(() => { loadTemplates() }, [])
+ useEffect(() => {
+ loadTemplates();
+ }, []);
const loadTemplates = async () => {
- setIsLoading(true)
+ setIsLoading(true);
try {
- const data = await adminApi.packingTemplates()
- setTemplates(data.templates || [])
- } catch { toast.error(t('admin.packingTemplates.loadError')) }
- finally { setIsLoading(false) }
- }
+ const data = await adminApi.packingTemplates();
+ setTemplates(data.templates || []);
+ } catch {
+ toast.error(t('admin.packingTemplates.loadError'));
+ } finally {
+ setIsLoading(false);
+ }
+ };
const toggleExpand = async (id: number) => {
- if (expandedId === id) { setExpandedId(null); return }
- setExpandedId(id)
- setAddingCategory(false)
- setAddingItemToCatId(null)
+ if (expandedId === id) {
+ setExpandedId(null);
+ return;
+ }
+ setExpandedId(id);
+ setAddingCategory(false);
+ setAddingItemToCatId(null);
try {
- const data = await adminApi.getPackingTemplate(id)
- setCategories(data.categories || [])
- setItems(data.items || [])
- } catch { toast.error(t('admin.packingTemplates.loadError')) }
- }
+ const data = await adminApi.getPackingTemplate(id);
+ setCategories(data.categories || []);
+ setItems(data.items || []);
+ } catch {
+ toast.error(t('admin.packingTemplates.loadError'));
+ }
+ };
// Template CRUD
const handleCreateTemplate = async () => {
- if (!createName.trim()) return
+ if (!createName.trim()) return;
try {
- const data = await adminApi.createPackingTemplate({ name: createName.trim() })
- setTemplates(prev => [{ ...data.template, item_count: 0, category_count: 0 }, ...prev])
- setCreateName(''); setShowCreate(false)
- setExpandedId(data.template.id); setCategories([]); setItems([])
- toast.success(t('admin.packingTemplates.created'))
- } catch { toast.error(t('admin.packingTemplates.createError')) }
- }
+ const data = await adminApi.createPackingTemplate({ name: createName.trim() });
+ setTemplates((prev) => [{ ...data.template, item_count: 0, category_count: 0 }, ...prev]);
+ setCreateName('');
+ setShowCreate(false);
+ setExpandedId(data.template.id);
+ setCategories([]);
+ setItems([]);
+ toast.success(t('admin.packingTemplates.created'));
+ } catch {
+ toast.error(t('admin.packingTemplates.createError'));
+ }
+ };
const handleDeleteTemplate = async (id: number) => {
try {
- await adminApi.deletePackingTemplate(id)
- setTemplates(prev => prev.filter(t => t.id !== id))
- if (expandedId === id) setExpandedId(null)
- toast.success(t('admin.packingTemplates.deleted'))
- } catch { toast.error(t('admin.packingTemplates.deleteError')) }
- }
+ await adminApi.deletePackingTemplate(id);
+ setTemplates((prev) => prev.filter((t) => t.id !== id));
+ if (expandedId === id) setExpandedId(null);
+ toast.success(t('admin.packingTemplates.deleted'));
+ } catch {
+ toast.error(t('admin.packingTemplates.deleteError'));
+ }
+ };
const handleRenameTemplate = async (id: number) => {
- if (!editTemplateName.trim()) { setEditingTemplate(null); return }
+ if (!editTemplateName.trim()) {
+ setEditingTemplate(null);
+ return;
+ }
try {
- await adminApi.updatePackingTemplate(id, { name: editTemplateName.trim() })
- setTemplates(prev => prev.map(t => t.id === id ? { ...t, name: editTemplateName.trim() } : t))
- setEditingTemplate(null)
- } catch { toast.error(t('admin.packingTemplates.saveError')) }
- }
+ await adminApi.updatePackingTemplate(id, { name: editTemplateName.trim() });
+ setTemplates((prev) => prev.map((t) => (t.id === id ? { ...t, name: editTemplateName.trim() } : t)));
+ setEditingTemplate(null);
+ } catch {
+ toast.error(t('admin.packingTemplates.saveError'));
+ }
+ };
// Category CRUD
const handleAddCategory = async () => {
- if (!newCatName.trim() || !expandedId) return
+ if (!newCatName.trim() || !expandedId) return;
try {
- const data = await adminApi.addTemplateCategory(expandedId, { name: newCatName.trim() })
- setCategories(prev => [...prev, data.category])
- setNewCatName(''); setAddingCategory(false)
- } catch { toast.error(t('admin.packingTemplates.saveError')) }
- }
+ const data = await adminApi.addTemplateCategory(expandedId, { name: newCatName.trim() });
+ setCategories((prev) => [...prev, data.category]);
+ setNewCatName('');
+ setAddingCategory(false);
+ } catch {
+ toast.error(t('admin.packingTemplates.saveError'));
+ }
+ };
const handleRenameCategory = async (catId: number) => {
- if (!editCatName.trim() || !expandedId) { setEditingCatId(null); return }
+ if (!editCatName.trim() || !expandedId) {
+ setEditingCatId(null);
+ return;
+ }
try {
- await adminApi.updateTemplateCategory(expandedId, catId, { name: editCatName.trim() })
- setCategories(prev => prev.map(c => c.id === catId ? { ...c, name: editCatName.trim() } : c))
- setEditingCatId(null)
- } catch { toast.error(t('admin.packingTemplates.saveError')) }
- }
+ await adminApi.updateTemplateCategory(expandedId, catId, { name: editCatName.trim() });
+ setCategories((prev) => prev.map((c) => (c.id === catId ? { ...c, name: editCatName.trim() } : c)));
+ setEditingCatId(null);
+ } catch {
+ toast.error(t('admin.packingTemplates.saveError'));
+ }
+ };
const handleDeleteCategory = async (catId: number) => {
- if (!expandedId) return
+ if (!expandedId) return;
try {
- await adminApi.deleteTemplateCategory(expandedId, catId)
- setCategories(prev => prev.filter(c => c.id !== catId))
- setItems(prev => prev.filter(i => i.category_id !== catId))
- } catch { toast.error(t('admin.packingTemplates.deleteError')) }
- }
+ await adminApi.deleteTemplateCategory(expandedId, catId);
+ setCategories((prev) => prev.filter((c) => c.id !== catId));
+ setItems((prev) => prev.filter((i) => i.category_id !== catId));
+ } catch {
+ toast.error(t('admin.packingTemplates.deleteError'));
+ }
+ };
// Item CRUD
const handleAddItem = async (catId: number) => {
- if (!newItemName.trim() || !expandedId) return
+ if (!newItemName.trim() || !expandedId) return;
try {
- const data = await adminApi.addTemplateItem(expandedId, catId, { name: newItemName.trim() })
- setItems(prev => [...prev, data.item])
- setNewItemName('')
- setTimeout(() => addItemRef.current?.focus(), 30)
- } catch { toast.error(t('admin.packingTemplates.saveError')) }
- }
+ const data = await adminApi.addTemplateItem(expandedId, catId, { name: newItemName.trim() });
+ setItems((prev) => [...prev, data.item]);
+ setNewItemName('');
+ setTimeout(() => addItemRef.current?.focus(), 30);
+ } catch {
+ toast.error(t('admin.packingTemplates.saveError'));
+ }
+ };
const handleRenameItem = async (itemId: number) => {
- if (!editItemName.trim() || !expandedId) { setEditingItemId(null); return }
+ if (!editItemName.trim() || !expandedId) {
+ setEditingItemId(null);
+ return;
+ }
try {
- await adminApi.updateTemplateItem(expandedId, itemId, { name: editItemName.trim() })
- setItems(prev => prev.map(i => i.id === itemId ? { ...i, name: editItemName.trim() } : i))
- setEditingItemId(null)
- } catch { toast.error(t('admin.packingTemplates.saveError')) }
- }
+ await adminApi.updateTemplateItem(expandedId, itemId, { name: editItemName.trim() });
+ setItems((prev) => prev.map((i) => (i.id === itemId ? { ...i, name: editItemName.trim() } : i)));
+ setEditingItemId(null);
+ } catch {
+ toast.error(t('admin.packingTemplates.saveError'));
+ }
+ };
const handleDeleteItem = async (itemId: number) => {
- if (!expandedId) return
+ if (!expandedId) return;
try {
- await adminApi.deleteTemplateItem(expandedId, itemId)
- setItems(prev => prev.filter(i => i.id !== itemId))
- } catch { toast.error(t('admin.packingTemplates.deleteError')) }
- }
+ await adminApi.deleteTemplateItem(expandedId, itemId);
+ setItems((prev) => prev.filter((i) => i.id !== itemId));
+ } catch {
+ toast.error(t('admin.packingTemplates.deleteError'));
+ }
+ };
- const inputStyle = 'w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent outline-none'
- const btnIcon = 'p-1.5 rounded-lg transition-colors'
+ const inputStyle =
+ 'w-full px-3 py-2 border border-slate-200 rounded-lg text-sm focus:ring-2 focus:ring-slate-400 focus:border-transparent outline-none';
+ const btnIcon = 'p-1.5 rounded-lg transition-colors';
return (
-
+
{/* Header */}
-
+
{t('admin.packingTemplates.title')}
-
{t('admin.packingTemplates.subtitle')}
+
{t('admin.packingTemplates.subtitle')}
-
{/* Create template */}
{showCreate && (
-
-
-
setCreateName(e.target.value)}
- onKeyDown={e => { if (e.key === 'Enter') handleCreateTemplate(); if (e.key === 'Escape') setShowCreate(false) }}
- placeholder={t('admin.packingTemplates.namePlaceholder')} className={inputStyle} />
-
-
setShowCreate(false)} className={`${btnIcon} text-slate-400 hover:text-slate-600`}>
+
+
+
setCreateName(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') handleCreateTemplate();
+ if (e.key === 'Escape') setShowCreate(false);
+ }}
+ placeholder={t('admin.packingTemplates.namePlaceholder')}
+ className={inputStyle}
+ />
+
+
+
+
setShowCreate(false)} className={`${btnIcon} text-slate-400 hover:text-slate-600`}>
+
+
)}
{/* Template list */}
{isLoading ? (
-
+
) : templates.length === 0 ? (
{t('admin.packingTemplates.empty')}
) : (
- {templates.map(tmpl => (
+ {templates.map((tmpl) => (
{/* Template row */}
-
-
toggleExpand(tmpl.id)} className="text-slate-400 flex-shrink-0 p-0 bg-transparent border-none cursor-pointer">
+
+
toggleExpand(tmpl.id)}
+ className="flex-shrink-0 cursor-pointer border-none bg-transparent p-0 text-slate-400"
+ >
{expandedId === tmpl.id ? : }
-
+
{editingTemplate === tmpl.id ? (
-
setEditTemplateName(e.target.value)}
+
setEditTemplateName(e.target.value)}
onBlur={() => handleRenameTemplate(tmpl.id)}
- onKeyDown={e => { if (e.key === 'Enter') handleRenameTemplate(tmpl.id); if (e.key === 'Escape') setEditingTemplate(null) }}
- className="flex-1 px-2 py-0.5 border border-slate-300 rounded text-sm" />
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') handleRenameTemplate(tmpl.id);
+ if (e.key === 'Escape') setEditingTemplate(null);
+ }}
+ className="flex-1 rounded border border-slate-300 px-2 py-0.5 text-sm"
+ />
) : (
-
toggleExpand(tmpl.id)} className="flex-1 text-sm font-medium text-slate-700 cursor-pointer">{tmpl.name}
+
toggleExpand(tmpl.id)}
+ className="flex-1 cursor-pointer text-sm font-medium text-slate-700"
+ >
+ {tmpl.name}
+
)}
-
- {tmpl.category_count} {t('admin.packingTemplates.categories')} · {tmpl.item_count} {t('admin.packingTemplates.items')}
+
+ {tmpl.category_count} {t('admin.packingTemplates.categories')} · {tmpl.item_count}{' '}
+ {t('admin.packingTemplates.items')}
- { setEditingTemplate(tmpl.id); setEditTemplateName(tmpl.name) }}
- className={`${btnIcon} hover:bg-slate-100 text-slate-400 hover:text-slate-700`}>
- handleDeleteTemplate(tmpl.id)}
- className={`${btnIcon} hover:bg-red-50 text-slate-400 hover:text-red-500`}>
+ {
+ setEditingTemplate(tmpl.id);
+ setEditTemplateName(tmpl.name);
+ }}
+ className={`${btnIcon} text-slate-400 hover:bg-slate-100 hover:text-slate-700`}
+ >
+
+
+ handleDeleteTemplate(tmpl.id)}
+ className={`${btnIcon} text-slate-400 hover:bg-red-50 hover:text-red-500`}
+ >
+
+
{/* Expanded content */}
{expandedId === tmpl.id && (
-
- {categories.map(cat => {
- const catItems = items.filter(i => i.category_id === cat.id)
+
+ {categories.map((cat) => {
+ const catItems = items.filter((i) => i.category_id === cat.id);
return (
-
+
{/* Category header */}
-
+
{editingCatId === cat.id ? (
<>
-
setEditCatName(e.target.value)}
+
setEditCatName(e.target.value)}
onBlur={() => handleRenameCategory(cat.id)}
- onKeyDown={e => { if (e.key === 'Enter') handleRenameCategory(cat.id); if (e.key === 'Escape') setEditingCatId(null) }}
- className="flex-1 px-2 py-0.5 border border-slate-300 rounded text-sm font-semibold" />
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') handleRenameCategory(cat.id);
+ if (e.key === 'Escape') setEditingCatId(null);
+ }}
+ className="flex-1 rounded border border-slate-300 px-2 py-0.5 text-sm font-semibold"
+ />
>
) : (
-
{cat.name}
+
+ {cat.name}
+
)}
{catItems.length}
-
{ setAddingItemToCatId(addingItemToCatId === cat.id ? null : cat.id); setNewItemName(''); setTimeout(() => addItemRef.current?.focus(), 30) }}
- className={`${btnIcon} text-slate-400 hover:text-slate-700`}>
-
{ setEditingCatId(cat.id); setEditCatName(cat.name) }}
- className={`${btnIcon} text-slate-400 hover:text-slate-700`}>
-
handleDeleteCategory(cat.id)}
- className={`${btnIcon} text-slate-400 hover:text-red-500`}>
+
{
+ setAddingItemToCatId(addingItemToCatId === cat.id ? null : cat.id);
+ setNewItemName('');
+ setTimeout(() => addItemRef.current?.focus(), 30);
+ }}
+ className={`${btnIcon} text-slate-400 hover:text-slate-700`}
+ >
+
+
+
{
+ setEditingCatId(cat.id);
+ setEditCatName(cat.name);
+ }}
+ className={`${btnIcon} text-slate-400 hover:text-slate-700`}
+ >
+
+
+
handleDeleteCategory(cat.id)}
+ className={`${btnIcon} text-slate-400 hover:text-red-500`}
+ >
+
+
{/* Items */}
{(catItems.length > 0 || addingItemToCatId === cat.id) && (
- {catItems.map(item => (
-
)}
- )
+ );
})}
{/* Add category button */}
{addingCategory ? (
- setNewCatName(e.target.value)}
- onKeyDown={e => { if (e.key === 'Enter') handleAddCategory(); if (e.key === 'Escape') { setAddingCategory(false); setNewCatName('') } }}
+ setNewCatName(e.target.value)}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') handleAddCategory();
+ if (e.key === 'Escape') {
+ setAddingCategory(false);
+ setNewCatName('');
+ }
+ }}
placeholder={t('admin.packingTemplates.categoryName')}
- className="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm" />
-
- { setAddingCategory(false); setNewCatName('') }} className={`${btnIcon} text-slate-400`}>
+ className="flex-1 rounded-lg border border-slate-200 px-3 py-2 text-sm"
+ />
+
+
+
+ {
+ setAddingCategory(false);
+ setNewCatName('');
+ }}
+ className={`${btnIcon} text-slate-400`}
+ >
+
+
) : (
-
setAddingCategory(true)}
- className="flex items-center gap-2 px-3 py-2.5 w-full text-sm text-slate-400 hover:text-slate-600 border border-dashed border-slate-200 rounded-lg hover:border-slate-400 transition-colors">
+ setAddingCategory(true)}
+ className="flex w-full items-center gap-2 rounded-lg border border-dashed border-slate-200 px-3 py-2.5 text-sm text-slate-400 transition-colors hover:border-slate-400 hover:text-slate-600"
+ >
{t('admin.packingTemplates.addCategory')}
)}
@@ -302,5 +500,5 @@ export default function PackingTemplateManager() {
)}
- )
+ );
}
diff --git a/client/src/components/Admin/PermissionsPanel.test.tsx b/client/src/components/Admin/PermissionsPanel.test.tsx
index fb7323ec..07044951 100644
--- a/client/src/components/Admin/PermissionsPanel.test.tsx
+++ b/client/src/components/Admin/PermissionsPanel.test.tsx
@@ -1,8 +1,8 @@
// FE-ADMIN-PERM-001 to FE-ADMIN-PERM-010
-import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
import { server } from '../../../tests/helpers/msw/server';
+import { render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores } from '../../../tests/helpers/store';
import { ToastContainer } from '../shared/Toast';
import PermissionsPanel from './PermissionsPanel';
@@ -41,7 +41,7 @@ function renderPanel() {
<>
- >,
+ >
);
}
@@ -50,11 +50,7 @@ function renderPanel() {
beforeEach(() => {
resetAllStores();
// Override the default handler (returns object) with correct array shape
- server.use(
- http.get('/api/admin/permissions', () =>
- HttpResponse.json({ permissions: SAMPLE_PERMISSIONS }),
- ),
- );
+ server.use(http.get('/api/admin/permissions', () => HttpResponse.json({ permissions: SAMPLE_PERMISSIONS })));
});
afterEach(() => {
@@ -69,7 +65,7 @@ describe('PermissionsPanel', () => {
http.get('/api/admin/permissions', async () => {
await new Promise(() => {}); // never resolves
return HttpResponse.json({ permissions: [] });
- }),
+ })
);
renderPanel();
const spinner = document.querySelector('.animate-spin');
@@ -95,11 +91,7 @@ describe('PermissionsPanel', () => {
buildPermission('trip_create', 'admin', 'trip_member'), // level ≠ default → badge
buildPermission('trip_edit', 'trip_member', 'trip_member'), // level === default → no badge
];
- server.use(
- http.get('/api/admin/permissions', () =>
- HttpResponse.json({ permissions: perms }),
- ),
- );
+ server.use(http.get('/api/admin/permissions', () => HttpResponse.json({ permissions: perms })));
renderPanel();
await screen.findByText('Trip Management');
// Badge should appear once (for trip_create)
@@ -150,13 +142,9 @@ describe('PermissionsPanel', () => {
it('FE-ADMIN-PERM-006: Reset button restores values to defaultLevel and enables Save', async () => {
const perms = [
buildPermission('trip_create', 'admin', 'trip_member'), // customized
- ...SAMPLE_PERMISSIONS.filter(p => p.key !== 'trip_create'),
+ ...SAMPLE_PERMISSIONS.filter((p) => p.key !== 'trip_create'),
];
- server.use(
- http.get('/api/admin/permissions', () =>
- HttpResponse.json({ permissions: perms }),
- ),
- );
+ server.use(http.get('/api/admin/permissions', () => HttpResponse.json({ permissions: perms })));
const user = userEvent.setup();
renderPanel();
await screen.findByText('Trip Management');
@@ -179,11 +167,7 @@ describe('PermissionsPanel', () => {
});
it('FE-ADMIN-PERM-007: successful save calls PUT and shows success toast', async () => {
- server.use(
- http.put('/api/admin/permissions', () =>
- HttpResponse.json({ permissions: SAMPLE_PERMISSIONS }),
- ),
- );
+ server.use(http.put('/api/admin/permissions', () => HttpResponse.json({ permissions: SAMPLE_PERMISSIONS })));
const user = userEvent.setup();
renderPanel();
await screen.findByText('Trip Management');
@@ -204,11 +188,7 @@ describe('PermissionsPanel', () => {
});
it('FE-ADMIN-PERM-008: failed save shows error toast and keeps Save enabled', async () => {
- server.use(
- http.put('/api/admin/permissions', () =>
- HttpResponse.json({ error: 'server error' }, { status: 500 }),
- ),
- );
+ server.use(http.put('/api/admin/permissions', () => HttpResponse.json({ error: 'server error' }, { status: 500 })));
const user = userEvent.setup();
renderPanel();
await screen.findByText('Trip Management');
@@ -231,12 +211,13 @@ describe('PermissionsPanel', () => {
it('FE-ADMIN-PERM-009: Save button is disabled while save is in-flight', async () => {
let resolvePut!: () => void;
server.use(
- http.put('/api/admin/permissions', () =>
- new Promise
(resolve => {
- resolvePut = () =>
- resolve(HttpResponse.json({ permissions: SAMPLE_PERMISSIONS }) as unknown as Response);
- }),
- ),
+ http.put(
+ '/api/admin/permissions',
+ () =>
+ new Promise((resolve) => {
+ resolvePut = () => resolve(HttpResponse.json({ permissions: SAMPLE_PERMISSIONS }) as unknown as Response);
+ })
+ )
);
const user = userEvent.setup();
renderPanel();
@@ -263,11 +244,7 @@ describe('PermissionsPanel', () => {
});
it('FE-ADMIN-PERM-010: load failure shows error toast', async () => {
- server.use(
- http.get('/api/admin/permissions', () =>
- HttpResponse.json({ error: 'server error' }, { status: 500 }),
- ),
- );
+ server.use(http.get('/api/admin/permissions', () => HttpResponse.json({ error: 'server error' }, { status: 500 })));
renderPanel();
await screen.findByText('Error');
});
diff --git a/client/src/components/Admin/PermissionsPanel.tsx b/client/src/components/Admin/PermissionsPanel.tsx
index acab4f0c..14f3262e 100644
--- a/client/src/components/Admin/PermissionsPanel.tsx
+++ b/client/src/components/Admin/PermissionsPanel.tsx
@@ -1,16 +1,16 @@
-import React, { useEffect, useState, useMemo } from 'react'
-import { adminApi } from '../../api/client'
-import { useTranslation } from '../../i18n'
-import { usePermissionsStore, PermissionLevel } from '../../store/permissionsStore'
-import { useToast } from '../shared/Toast'
-import { Save, Loader2, RotateCcw } from 'lucide-react'
-import CustomSelect from '../shared/CustomSelect'
+import { Loader2, RotateCcw, Save } from 'lucide-react';
+import React, { useEffect, useMemo, useState } from 'react';
+import { adminApi } from '../../api/client';
+import { useTranslation } from '../../i18n';
+import { PermissionLevel, usePermissionsStore } from '../../store/permissionsStore';
+import CustomSelect from '../shared/CustomSelect';
+import { useToast } from '../shared/Toast';
interface PermissionEntry {
- key: string
- level: PermissionLevel
- defaultLevel: PermissionLevel
- allowedLevels: PermissionLevel[]
+ key: string;
+ level: PermissionLevel;
+ defaultLevel: PermissionLevel;
+ allowedLevels: PermissionLevel[];
}
const LEVEL_LABELS: Record = {
@@ -18,7 +18,7 @@ const LEVEL_LABELS: Record = {
trip_owner: 'perm.level.tripOwner',
trip_member: 'perm.level.tripMember',
everybody: 'perm.level.everybody',
-}
+};
const CATEGORIES = [
{ id: 'trip', keys: ['trip_create', 'trip_edit', 'trip_delete', 'trip_archive', 'trip_cover_upload'] },
@@ -26,82 +26,82 @@ const CATEGORIES = [
{ id: 'files', keys: ['file_upload', 'file_edit', 'file_delete'] },
{ id: 'content', keys: ['place_edit', 'day_edit', 'reservation_edit'] },
{ id: 'extras', keys: ['budget_edit', 'packing_edit', 'collab_edit', 'share_manage'] },
-]
+];
export default function PermissionsPanel(): React.ReactElement {
- const { t } = useTranslation()
- const toast = useToast()
- const [entries, setEntries] = useState([])
- const [values, setValues] = useState>({})
- const [loading, setLoading] = useState(true)
- const [saving, setSaving] = useState(false)
- const [dirty, setDirty] = useState(false)
+ const { t } = useTranslation();
+ const toast = useToast();
+ const [entries, setEntries] = useState([]);
+ const [values, setValues] = useState>({});
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [dirty, setDirty] = useState(false);
useEffect(() => {
- loadPermissions()
- }, [])
+ loadPermissions();
+ }, []);
const loadPermissions = async () => {
- setLoading(true)
+ setLoading(true);
try {
- const data = await adminApi.getPermissions()
- setEntries(data.permissions)
- const vals: Record = {}
- for (const p of data.permissions) vals[p.key] = p.level
- setValues(vals)
- setDirty(false)
+ const data = await adminApi.getPermissions();
+ setEntries(data.permissions);
+ const vals: Record = {};
+ for (const p of data.permissions) vals[p.key] = p.level;
+ setValues(vals);
+ setDirty(false);
} catch {
- toast.error(t('common.error'))
+ toast.error(t('common.error'));
} finally {
- setLoading(false)
+ setLoading(false);
}
- }
+ };
const handleChange = (key: string, level: PermissionLevel) => {
- setValues(prev => ({ ...prev, [key]: level }))
- setDirty(true)
- }
+ setValues((prev) => ({ ...prev, [key]: level }));
+ setDirty(true);
+ };
const handleSave = async () => {
- setSaving(true)
+ setSaving(true);
try {
- const data = await adminApi.updatePermissions(values)
+ const data = await adminApi.updatePermissions(values);
if (data.permissions) {
- usePermissionsStore.getState().setPermissions(data.permissions)
+ usePermissionsStore.getState().setPermissions(data.permissions);
}
- setDirty(false)
- toast.success(t('perm.saved'))
+ setDirty(false);
+ toast.success(t('perm.saved'));
} catch {
- toast.error(t('common.error'))
+ toast.error(t('common.error'));
} finally {
- setSaving(false)
+ setSaving(false);
}
- }
+ };
const handleReset = () => {
- const defaults: Record = {}
- for (const p of entries) defaults[p.key] = p.defaultLevel
- setValues(defaults)
- setDirty(true)
- }
+ const defaults: Record = {};
+ for (const p of entries) defaults[p.key] = p.defaultLevel;
+ setValues(defaults);
+ setDirty(true);
+ };
- const entryMap = useMemo(() => new Map(entries.map(e => [e.key, e])), [entries])
+ const entryMap = useMemo(() => new Map(entries.map((e) => [e.key, e])), [entries]);
if (loading) {
return (
- )
+ );
}
return (
-
-
+
+
{t('perm.title')}
-
{t('perm.subtitle')}
+
{t('perm.subtitle')}
-
+
{t('perm.resetDefaults')}
- {saving ? : }
+ {saving ? : }
{t('common.save')}
- {CATEGORIES.map(cat => (
+ {CATEGORIES.map((cat) => (
-
+
{t(`perm.cat.${cat.id}`)}
- {cat.keys.map(key => {
- const entry = entryMap.get(key)
- if (!entry) return null
- const currentLevel = values[key] || entry.defaultLevel
- const isDefault = currentLevel === entry.defaultLevel
+ {cat.keys.map((key) => {
+ const entry = entryMap.get(key);
+ if (!entry) return null;
+ const currentLevel = values[key] || entry.defaultLevel;
+ const isDefault = currentLevel === entry.defaultLevel;
return (
-
+
{t(`perm.action.${key}`)}
-
{t(`perm.actionHint.${key}`)}
+
{t(`perm.actionHint.${key}`)}
{!isDefault && (
-
+
{t('perm.customized')}
)}
handleChange(key, val as PermissionLevel)}
- options={entry.allowedLevels.map(l => ({
+ options={entry.allowedLevels.map((l) => ({
value: l,
label: t(LEVEL_LABELS[l] || l),
}))}
@@ -160,7 +160,7 @@ export default function PermissionsPanel(): React.ReactElement {
/>
- )
+ );
})}
@@ -168,5 +168,5 @@ export default function PermissionsPanel(): React.ReactElement {
- )
+ );
}
diff --git a/client/src/components/Budget/BudgetPanel.test.tsx b/client/src/components/Budget/BudgetPanel.test.tsx
index 244cbc96..934fb128 100644
--- a/client/src/components/Budget/BudgetPanel.test.tsx
+++ b/client/src/components/Budget/BudgetPanel.test.tsx
@@ -1,26 +1,22 @@
// FE-COMP-BUDGET-001 to FE-COMP-BUDGET-040
-import { render, screen, waitFor } from '../../../tests/helpers/render';
import userEvent from '@testing-library/user-event';
import { http, HttpResponse } from 'msw';
+import { buildBudgetItem, buildSettings, buildTrip, buildUser } from '../../../tests/helpers/factories';
import { server } from '../../../tests/helpers/msw/server';
-import { useAuthStore } from '../../store/authStore';
-import { useTripStore } from '../../store/tripStore';
-import { useSettingsStore } from '../../store/settingsStore';
-import { usePermissionsStore } from '../../store/permissionsStore';
+import { render, screen, waitFor } from '../../../tests/helpers/render';
import { resetAllStores, seedStore } from '../../../tests/helpers/store';
-import { buildUser, buildTrip, buildBudgetItem, buildSettings } from '../../../tests/helpers/factories';
+import { useAuthStore } from '../../store/authStore';
+import { usePermissionsStore } from '../../store/permissionsStore';
+import { useSettingsStore } from '../../store/settingsStore';
+import { useTripStore } from '../../store/tripStore';
import BudgetPanel from './BudgetPanel';
beforeEach(() => {
resetAllStores();
// Settlement and per-person APIs needed by BudgetPanel
server.use(
- http.get('/api/trips/:id/budget/settlement', () =>
- HttpResponse.json({ balances: [], flows: [] })
- ),
- http.get('/api/trips/:id/budget/per-person', () =>
- HttpResponse.json({ summary: [] })
- ),
+ http.get('/api/trips/:id/budget/settlement', () => HttpResponse.json({ balances: [], flows: [] })),
+ http.get('/api/trips/:id/budget/per-person', () => HttpResponse.json({ summary: [] }))
);
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) });
@@ -28,52 +24,40 @@ beforeEach(() => {
describe('BudgetPanel', () => {
it('FE-COMP-BUDGET-001: renders empty state when no budget items', async () => {
- server.use(
- http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
- );
+ server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })));
render(
);
await screen.findByText('No budget created yet');
});
it('FE-COMP-BUDGET-002: shows empty state text body', async () => {
- server.use(
- http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
- );
+ server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })));
render(
);
await screen.findByText(/Create categories and entries/i);
});
it('FE-COMP-BUDGET-003: shows category input in empty state when user can edit', async () => {
- server.use(
- http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
- );
+ server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })));
render(
);
await screen.findByPlaceholderText('Enter category name...');
});
it('FE-COMP-BUDGET-004: renders budget items from store after load', async () => {
const item = buildBudgetItem({ trip_id: 1, name: 'Hotel Paris', category: 'Accommodation' });
- server.use(
- http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
- );
+ server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render(
);
await screen.findByText('Hotel Paris');
});
it('FE-COMP-BUDGET-005: renders category section header', async () => {
const item = buildBudgetItem({ trip_id: 1, name: 'Flight to Rome', category: 'Transport' });
- server.use(
- http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
- );
+ server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render(
);
await screen.findByText('Transport');
});
it('FE-COMP-BUDGET-006: renders budget table headers', async () => {
const item = buildBudgetItem({ trip_id: 1, category: 'Food' });
- server.use(
- http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
- );
+ server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render(
);
await screen.findByText('Name');
await screen.findByText('Total');
@@ -81,27 +65,21 @@ describe('BudgetPanel', () => {
it('FE-COMP-BUDGET-007: shows Budget title heading', async () => {
const item = buildBudgetItem({ trip_id: 1, category: 'Other' });
- server.use(
- http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
- );
+ server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render(
);
await screen.findByText('Budget');
});
it('FE-COMP-BUDGET-008: shows CSV export button', async () => {
const item = buildBudgetItem({ trip_id: 1, category: 'Other' });
- server.use(
- http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
- );
+ server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render(
);
await screen.findByText('CSV');
});
it('FE-COMP-BUDGET-009: add item row visible in table', async () => {
const item = buildBudgetItem({ trip_id: 1, category: 'Food' });
- server.use(
- http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
- );
+ server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render(
);
await screen.findByPlaceholderText('New Entry');
});
@@ -112,7 +90,7 @@ describe('BudgetPanel', () => {
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [initialItem] })),
http.post('/api/trips/1/budget', async ({ request }) => {
- const body = await request.json() as Record
;
+ const body = (await request.json()) as Record;
const item = buildBudgetItem({ trip_id: 1, name: String(body.name || 'New Item'), category: 'Food' });
return HttpResponse.json({ item });
})
@@ -127,9 +105,7 @@ describe('BudgetPanel', () => {
it('FE-COMP-BUDGET-011: delete button present for items when user can edit', async () => {
const item = buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Test Item' });
- server.use(
- http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
- );
+ server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render();
await screen.findByText('Test Item');
// Delete button has title="Delete"
@@ -154,9 +130,7 @@ describe('BudgetPanel', () => {
it('FE-COMP-BUDGET-013: multiple items in same category all render', async () => {
const item1 = buildBudgetItem({ trip_id: 1, category: 'Hotels', name: 'Hotel A' });
const item2 = buildBudgetItem({ trip_id: 1, category: 'Hotels', name: 'Hotel B' });
- server.use(
- http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] }))
- );
+ server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] })));
render();
await screen.findByText('Hotel A');
await screen.findByText('Hotel B');
@@ -165,9 +139,7 @@ describe('BudgetPanel', () => {
it('FE-COMP-BUDGET-014: items from different categories render separate sections', async () => {
const item1 = buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Flight' });
const item2 = buildBudgetItem({ trip_id: 1, category: 'Hotels', name: 'Hotel' });
- server.use(
- http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] }))
- );
+ server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] })));
render();
await screen.findByText('Transport');
await screen.findByText('Hotels');
@@ -175,9 +147,7 @@ describe('BudgetPanel', () => {
it('FE-COMP-BUDGET-015: currency from settings store is used for default_currency display', async () => {
seedStore(useSettingsStore, { settings: buildSettings({ default_currency: 'USD' }) });
- server.use(
- http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
- );
+ server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })));
render();
// Component renders even in empty state
await screen.findByText('No budget created yet');
@@ -186,9 +156,7 @@ describe('BudgetPanel', () => {
it('FE-COMP-BUDGET-016: trip currency EUR is shown in header for item rows', async () => {
seedStore(useTripStore, { trip: buildTrip({ id: 1, currency: 'EUR' }) });
const item = buildBudgetItem({ trip_id: 1, category: 'Other', name: 'Misc', total_price: 50 });
- server.use(
- http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
- );
+ server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render();
await screen.findByText('Misc');
// Row exists - EUR formatting would appear in values
@@ -196,9 +164,7 @@ describe('BudgetPanel', () => {
it('FE-COMP-BUDGET-017: Delete Category button shown in category header', async () => {
const item = buildBudgetItem({ trip_id: 1, category: 'ToDelete', name: 'Item' });
- server.use(
- http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
- );
+ server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render();
await screen.findByText('ToDelete');
expect(screen.getByTitle('Delete Category')).toBeInTheDocument();
@@ -206,9 +172,7 @@ describe('BudgetPanel', () => {
it('FE-COMP-BUDGET-018: renders add item button (+ icon) in add row', async () => {
const item = buildBudgetItem({ trip_id: 1, category: 'Other' });
- server.use(
- http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
- );
+ server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render();
await screen.findByPlaceholderText('New Entry');
// The add button is present
@@ -221,7 +185,7 @@ describe('BudgetPanel', () => {
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [initialItem] })),
http.post('/api/trips/1/budget', async ({ request }) => {
- const body = await request.json() as Record;
+ const body = (await request.json()) as Record;
const item = buildBudgetItem({ trip_id: 1, name: String(body.name), category: 'Food' });
return HttpResponse.json({ item });
})
@@ -233,9 +197,7 @@ describe('BudgetPanel', () => {
});
it('FE-COMP-BUDGET-020: component renders without crashing with empty tripMembers', async () => {
- server.use(
- http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
- );
+ server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })));
render();
await screen.findByText('No budget created yet');
});
@@ -243,9 +205,7 @@ describe('BudgetPanel', () => {
it('FE-COMP-BUDGET-021: inline edit name cell — clicking a name cell makes it editable', async () => {
const user = userEvent.setup();
const item = { ...buildBudgetItem({ id: 21, trip_id: 1, category: 'Food', name: 'Old Name' }), total_price: 10 };
- server.use(
- http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
- );
+ server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render();
await screen.findByText('Old Name');
await user.click(screen.getByText('Old Name'));
@@ -261,7 +221,7 @@ describe('BudgetPanel', () => {
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
http.put('/api/trips/1/budget/10', async ({ request }) => {
- const b = await request.json() as Record;
+ const b = (await request.json()) as Record;
putCalled = true;
return HttpResponse.json({ item: { ...item, name: b.name } });
})
@@ -277,10 +237,11 @@ describe('BudgetPanel', () => {
});
it('FE-COMP-BUDGET-023: total price is shown formatted with currency symbol', async () => {
- const item = { ...buildBudgetItem({ id: 23, trip_id: 1, category: 'Restaurants', name: 'Dinner' }), total_price: 45.5 };
- server.use(
- http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
- );
+ const item = {
+ ...buildBudgetItem({ id: 23, trip_id: 1, category: 'Restaurants', name: 'Dinner' }),
+ total_price: 45.5,
+ };
+ server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render();
await screen.findByText('Dinner');
// The formatted number appears in the InlineEditCell for total price (and grand total card)
@@ -291,7 +252,10 @@ describe('BudgetPanel', () => {
it('FE-COMP-BUDGET-024: delete category button removes all items in that category', async () => {
const user = userEvent.setup();
- const item = { ...buildBudgetItem({ id: 24, trip_id: 1, category: 'Flights', name: 'Flight to Paris' }), total_price: 200 };
+ const item = {
+ ...buildBudgetItem({ id: 24, trip_id: 1, category: 'Flights', name: 'Flight to Paris' }),
+ total_price: 200,
+ };
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })),
http.delete('/api/trips/1/budget/24', () => HttpResponse.json({ success: true }))
@@ -311,9 +275,7 @@ describe('BudgetPanel', () => {
vi.spyOn(URL, 'createObjectURL').mockImplementation(createObjectURL);
const user = userEvent.setup();
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Other', name: 'Misc' }), total_price: 10 };
- server.use(
- http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
- );
+ server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render();
await screen.findByText('CSV');
await user.click(screen.getByText('CSV'));
@@ -324,9 +286,7 @@ describe('BudgetPanel', () => {
it('FE-COMP-BUDGET-026: category total row shows sum of items in category', async () => {
const item1 = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Lunch' }), total_price: 20 };
const item2 = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Dinner' }), total_price: 30 };
- server.use(
- http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] }))
- );
+ server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] })));
render();
await screen.findByText('Lunch');
// The category header shows subtotal formatted as "50.00 €" (also appears in pie legend)
@@ -334,9 +294,7 @@ describe('BudgetPanel', () => {
});
it('FE-COMP-BUDGET-027: add new category input is visible in empty state', async () => {
- server.use(
- http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] }))
- );
+ server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })));
render();
await screen.findByPlaceholderText('Enter category name...');
});
@@ -346,7 +304,9 @@ describe('BudgetPanel', () => {
server.use(
http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [] })),
http.post('/api/trips/1/budget', () =>
- HttpResponse.json({ item: { ...buildBudgetItem({ category: 'Souvenirs', name: 'New Entry' }), total_price: 0 } })
+ HttpResponse.json({
+ item: { ...buildBudgetItem({ category: 'Souvenirs', name: 'New Entry' }), total_price: 0 },
+ })
)
);
render();
@@ -410,9 +370,7 @@ describe('BudgetPanel', () => {
it('FE-COMP-BUDGET-032: grand total row shows sum across all categories', async () => {
const item1 = { ...buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Flight' }), total_price: 100 };
const item2 = { ...buildBudgetItem({ trip_id: 1, category: 'Hotels', name: 'Hotel' }), total_price: 200 };
- server.use(
- http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] }))
- );
+ server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item1, item2] })));
render();
await screen.findByText('Flight');
await screen.findByText('Hotel');
@@ -427,9 +385,7 @@ describe('BudgetPanel', () => {
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Read Only Item' }), total_price: 50 };
- server.use(
- http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
- );
+ server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render();
await screen.findByText('Read Only Item');
// In read-only mode the Delete button should not be visible
@@ -440,10 +396,12 @@ describe('BudgetPanel', () => {
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
- const item = { ...buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Train' }), total_price: 30, expense_date: '2025-06-15' };
- server.use(
- http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
- );
+ const item = {
+ ...buildBudgetItem({ trip_id: 1, category: 'Transport', name: 'Train' }),
+ total_price: 30,
+ expense_date: '2025-06-15',
+ };
+ server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render();
await screen.findByText('Train');
// expense_date is rendered as plain text in read-only mode
@@ -461,10 +419,16 @@ describe('BudgetPanel', () => {
{ user_id: 1, username: 'alice', avatar_url: '/uploads/avatars/alice.jpg', balance: -30 },
{ user_id: 2, username: 'bob', avatar_url: null, balance: 30 },
],
- flows: [{ from: { username: 'alice', avatar_url: '/uploads/avatars/alice.jpg' }, to: { username: 'bob', avatar_url: null }, amount: 30 }]
+ flows: [
+ {
+ from: { username: 'alice', avatar_url: '/uploads/avatars/alice.jpg' },
+ to: { username: 'bob', avatar_url: null },
+ amount: 30,
+ },
+ ],
})
),
- http.get('/api/trips/1/budget/per-person', () => HttpResponse.json({ summary: [] })),
+ http.get('/api/trips/1/budget/per-person', () => HttpResponse.json({ summary: [] }))
);
const tripMembers = [
{ id: 1, username: 'alice', avatar_url: '/uploads/avatars/alice.jpg' },
@@ -485,10 +449,12 @@ describe('BudgetPanel', () => {
seedStore(usePermissionsStore, { permissions: { budget_edit: 'trip_owner' } });
seedStore(useAuthStore, { user: buildUser(), isAuthenticated: true });
seedStore(useTripStore, { trip: buildTrip({ id: 1, owner_id: 9999 }) });
- const item = { ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Snack' }), total_price: 5, expense_date: null };
- server.use(
- http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] }))
- );
+ const item = {
+ ...buildBudgetItem({ trip_id: 1, category: 'Food', name: 'Snack' }),
+ total_price: 5,
+ expense_date: null,
+ };
+ server.use(http.get('/api/trips/1/budget', () => HttpResponse.json({ items: [item] })));
render();
await screen.findByText('Snack');
// When expense_date is null, the fallback '—' is shown
diff --git a/client/src/components/Budget/BudgetPanel.tsx b/client/src/components/Budget/BudgetPanel.tsx
index 1b2cab37..9274b904 100644
--- a/client/src/components/Budget/BudgetPanel.tsx
+++ b/client/src/components/Budget/BudgetPanel.tsx
@@ -1,43 +1,66 @@
-import ReactDOM from 'react-dom'
-import { useState, useEffect, useRef, useMemo, useCallback } from 'react'
-import DOM from 'react-dom'
-import { useTripStore } from '../../store/tripStore'
-import { useCanDo } from '../../store/permissionsStore'
-import { useTranslation } from '../../i18n'
-import { Plus, Trash2, Calculator, Wallet, Pencil, Users, Check, Info, ChevronDown, ChevronRight, Download, GripVertical, TrendingUp, TrendingDown, PieChart as PieChartIcon } from 'lucide-react'
+import {
+ Calculator,
+ Check,
+ ChevronDown,
+ ChevronRight,
+ Download,
+ GripVertical,
+ Info,
+ Pencil,
+ PieChart as PieChartIcon,
+ Plus,
+ Trash2,
+ TrendingDown,
+ TrendingUp,
+ Users,
+ Wallet,
+} from 'lucide-react';
+import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
+import ReactDOM from 'react-dom';
+import { budgetApi } from '../../api/client';
+import { useTranslation } from '../../i18n';
+import { useCanDo } from '../../store/permissionsStore';
+import { useTripStore } from '../../store/tripStore';
+import type { BudgetItem, BudgetMember } from '../../types';
+import { currencyDecimals } from '../../utils/formatters';
+import { CustomDatePicker } from '../shared/CustomDateTimePicker';
+import CustomSelect from '../shared/CustomSelect';
function useIsDark(): boolean {
- const [dark, setDark] = useState(() => typeof document !== 'undefined' && document.documentElement.classList.contains('dark'))
+ const [dark, setDark] = useState(
+ () => typeof document !== 'undefined' && document.documentElement.classList.contains('dark')
+ );
useEffect(() => {
- if (typeof document === 'undefined') return
- const mo = new MutationObserver(() => setDark(document.documentElement.classList.contains('dark')))
- mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] })
- return () => mo.disconnect()
- }, [])
- return dark
+ if (typeof document === 'undefined') return;
+ const mo = new MutationObserver(() => setDark(document.documentElement.classList.contains('dark')));
+ mo.observe(document.documentElement, { attributes: true, attributeFilter: ['class'] });
+ return () => mo.disconnect();
+ }, []);
+ return dark;
}
function widgetTheme(dark: boolean) {
- if (dark) return {
- bg: 'linear-gradient(180deg, #17171d 0%, #0d0d12 100%)',
- border: 'rgba(255,255,255,0.07)',
- text: '#ffffff',
- sub: 'rgba(255,255,255,0.6)',
- faint: 'rgba(255,255,255,0.4)',
- track: 'rgba(255,255,255,0.04)',
- divider: 'rgba(255,255,255,0.07)',
- iconBg: 'rgba(255,255,255,0.08)',
- iconBorder: 'rgba(255,255,255,0.12)',
- iconColor: 'rgba(255,255,255,0.9)',
- centerBg: '#17171d',
- flowBg: 'rgba(255,255,255,0.05)',
- flowBorder: 'rgba(255,255,255,0.07)',
- flowHoverBg: 'rgba(255,255,255,0.08)',
- flowHoverBorder: 'rgba(255,255,255,0.12)',
- rowHover: 'rgba(255,255,255,0.03)',
- shadow: '0 20px 50px rgba(0,0,0,0.35), inset 0 1px 0 rgba(255,255,255,0.04)',
- donutShadow: 'drop-shadow(0 0 20px rgba(0,0,0,0.3))',
- }
+ if (dark)
+ return {
+ bg: 'linear-gradient(180deg, #17171d 0%, #0d0d12 100%)',
+ border: 'rgba(255,255,255,0.07)',
+ text: '#ffffff',
+ sub: 'rgba(255,255,255,0.6)',
+ faint: 'rgba(255,255,255,0.4)',
+ track: 'rgba(255,255,255,0.04)',
+ divider: 'rgba(255,255,255,0.07)',
+ iconBg: 'rgba(255,255,255,0.08)',
+ iconBorder: 'rgba(255,255,255,0.12)',
+ iconColor: 'rgba(255,255,255,0.9)',
+ centerBg: '#17171d',
+ flowBg: 'rgba(255,255,255,0.05)',
+ flowBorder: 'rgba(255,255,255,0.07)',
+ flowHoverBg: 'rgba(255,255,255,0.08)',
+ flowHoverBorder: 'rgba(255,255,255,0.12)',
+ rowHover: 'rgba(255,255,255,0.03)',
+ shadow: '0 20px 50px rgba(0,0,0,0.35), inset 0 1px 0 rgba(255,255,255,0.04)',
+ donutShadow: 'drop-shadow(0 0 20px rgba(0,0,0,0.3))',
+ };
return {
bg: 'linear-gradient(180deg, #ffffff 0%, #f9fafb 100%)',
border: 'rgba(15,23,42,0.08)',
@@ -57,370 +80,729 @@ function widgetTheme(dark: boolean) {
rowHover: 'rgba(15,23,42,0.04)',
shadow: '0 12px 32px rgba(15,23,42,0.08), 0 2px 6px rgba(0,0,0,0.04)',
donutShadow: 'drop-shadow(0 4px 18px rgba(15,23,42,0.12))',
- }
+ };
}
function hexLighten(hex: string, amount: number): string {
- const m = hex.replace('#', '').match(/.{2}/g)
- if (!m || m.length !== 3) return hex
- const mix = (c: number) => Math.min(255, Math.round(c + (255 - c) * amount))
- const [r, g, b] = m.map(x => parseInt(x, 16))
- return `#${[mix(r), mix(g), mix(b)].map(v => v.toString(16).padStart(2, '0')).join('')}`
+ const m = hex.replace('#', '').match(/.{2}/g);
+ if (!m || m.length !== 3) return hex;
+ const mix = (c: number) => Math.min(255, Math.round(c + (255 - c) * amount));
+ const [r, g, b] = m.map((x) => parseInt(x, 16));
+ return `#${[mix(r), mix(g), mix(b)].map((v) => v.toString(16).padStart(2, '0')).join('')}`;
}
-import CustomSelect from '../shared/CustomSelect'
-import { budgetApi } from '../../api/client'
-import { CustomDatePicker } from '../shared/CustomDateTimePicker'
-import type { BudgetItem, BudgetMember } from '../../types'
-import { currencyDecimals } from '../../utils/formatters'
interface TripMember {
- id: number
- username: string
- avatar_url?: string | null
+ id: number;
+ username: string;
+ avatar_url?: string | null;
}
interface PieSegment {
- label: string
- value: number
- color: string
+ label: string;
+ value: number;
+ color: string;
}
interface PerPersonSummaryEntry {
- user_id: number
- username: string
- avatar_url: string | null
- total_assigned: number
+ user_id: number;
+ username: string;
+ avatar_url: string | null;
+ total_assigned: number;
}
// ── Helpers ──────────────────────────────────────────────────────────────────
const CURRENCIES = [
- 'EUR', 'USD', 'GBP', 'JPY', 'CHF', 'CZK', 'PLN', 'SEK', 'NOK', 'DKK',
- 'TRY', 'THB', 'AUD', 'CAD', 'NZD', 'BRL', 'MXN', 'INR', 'IDR', 'MYR',
- 'PHP', 'SGD', 'KRW', 'CNY', 'HKD', 'TWD', 'ZAR', 'AED', 'SAR', 'ILS',
- 'EGP', 'MAD', 'HUF', 'RON', 'BGN', 'HRK', 'ISK', 'RUB', 'UAH', 'BDT',
- 'LKR', 'VND', 'CLP', 'COP', 'PEN', 'ARS',
-]
+ 'EUR',
+ 'USD',
+ 'GBP',
+ 'JPY',
+ 'CHF',
+ 'CZK',
+ 'PLN',
+ 'SEK',
+ 'NOK',
+ 'DKK',
+ 'TRY',
+ 'THB',
+ 'AUD',
+ 'CAD',
+ 'NZD',
+ 'BRL',
+ 'MXN',
+ 'INR',
+ 'IDR',
+ 'MYR',
+ 'PHP',
+ 'SGD',
+ 'KRW',
+ 'CNY',
+ 'HKD',
+ 'TWD',
+ 'ZAR',
+ 'AED',
+ 'SAR',
+ 'ILS',
+ 'EGP',
+ 'MAD',
+ 'HUF',
+ 'RON',
+ 'BGN',
+ 'HRK',
+ 'ISK',
+ 'RUB',
+ 'UAH',
+ 'BDT',
+ 'LKR',
+ 'VND',
+ 'CLP',
+ 'COP',
+ 'PEN',
+ 'ARS',
+];
const SYMBOLS = {
- EUR: '€', USD: '$', GBP: '£', JPY: '¥', CHF: 'CHF', CZK: 'Kč', PLN: 'zł',
- SEK: 'kr', NOK: 'kr', DKK: 'kr', TRY: '₺', THB: '฿', AUD: 'A$', CAD: 'C$',
- NZD: 'NZ$', BRL: 'R$', MXN: 'MX$', INR: '₹', IDR: 'Rp', MYR: 'RM',
- PHP: '₱', SGD: 'S$', KRW: '₩', CNY: '¥', HKD: 'HK$', TWD: 'NT$',
- ZAR: 'R', AED: 'د.إ', SAR: '﷼', ILS: '₪', EGP: 'E£', MAD: 'MAD',
- HUF: 'Ft', RON: 'lei', BGN: 'лв', HRK: 'kn', ISK: 'kr', RUB: '₽',
- UAH: '₴', BDT: '৳', LKR: 'Rs', VND: '₫', CLP: 'CL$', COP: 'CO$',
- PEN: 'S/.', ARS: 'AR$',
-}
-const PIE_COLORS = ['#6366f1', '#ec4899', '#f59e0b', '#10b981', '#3b82f6', '#8b5cf6', '#ef4444', '#14b8a6', '#f97316', '#06b6d4', '#84cc16', '#a855f7']
+ EUR: '€',
+ USD: '$',
+ GBP: '£',
+ JPY: '¥',
+ CHF: 'CHF',
+ CZK: 'Kč',
+ PLN: 'zł',
+ SEK: 'kr',
+ NOK: 'kr',
+ DKK: 'kr',
+ TRY: '₺',
+ THB: '฿',
+ AUD: 'A$',
+ CAD: 'C$',
+ NZD: 'NZ$',
+ BRL: 'R$',
+ MXN: 'MX$',
+ INR: '₹',
+ IDR: 'Rp',
+ MYR: 'RM',
+ PHP: '₱',
+ SGD: 'S$',
+ KRW: '₩',
+ CNY: '¥',
+ HKD: 'HK$',
+ TWD: 'NT$',
+ ZAR: 'R',
+ AED: 'د.إ',
+ SAR: '﷼',
+ ILS: '₪',
+ EGP: 'E£',
+ MAD: 'MAD',
+ HUF: 'Ft',
+ RON: 'lei',
+ BGN: 'лв',
+ HRK: 'kn',
+ ISK: 'kr',
+ RUB: '₽',
+ UAH: '₴',
+ BDT: '৳',
+ LKR: 'Rs',
+ VND: '₫',
+ CLP: 'CL$',
+ COP: 'CO$',
+ PEN: 'S/.',
+ ARS: 'AR$',
+};
+const PIE_COLORS = [
+ '#6366f1',
+ '#ec4899',
+ '#f59e0b',
+ '#10b981',
+ '#3b82f6',
+ '#8b5cf6',
+ '#ef4444',
+ '#14b8a6',
+ '#f97316',
+ '#06b6d4',
+ '#84cc16',
+ '#a855f7',
+];
const fmtNum = (v, locale, cur) => {
- if (v == null || isNaN(v)) return '-'
- const d = currencyDecimals(cur)
- return Number(v).toLocaleString(locale, { minimumFractionDigits: d, maximumFractionDigits: d }) + ' ' + (SYMBOLS[cur] || cur)
-}
+ if (v == null || isNaN(v)) return '-';
+ const d = currencyDecimals(cur);
+ return (
+ Number(v).toLocaleString(locale, { minimumFractionDigits: d, maximumFractionDigits: d }) +
+ ' ' +
+ (SYMBOLS[cur] || cur)
+ );
+};
-const calcPP = (p, n) => (n > 0 ? p / n : null)
-const calcPD = (p, d) => (d > 0 ? p / d : null)
-const calcPPD = (p, n, d) => (n > 0 && d > 0 ? p / (n * d) : null)
+const calcPP = (p, n) => (n > 0 ? p / n : null);
+const calcPD = (p, d) => (d > 0 ? p / d : null);
+const calcPPD = (p, n, d) => (n > 0 && d > 0 ? p / (n * d) : null);
// ── Inline Edit Cell ─────────────────────────────────────────────────────────
-function InlineEditCell({ value, onSave, type = 'text', style = {}, placeholder = '', decimals = 2, locale, editTooltip, readOnly = false }) {
- const [editing, setEditing] = useState(false)
- const [editValue, setEditValue] = useState(value ?? '')
- const inputRef = useRef(null)
+function InlineEditCell({
+ value,
+ onSave,
+ type = 'text',
+ style = {},
+ placeholder = '',
+ decimals = 2,
+ locale,
+ editTooltip,
+ readOnly = false,
+}) {
+ const [editing, setEditing] = useState(false);
+ const [editValue, setEditValue] = useState(value ?? '');
+ const inputRef = useRef(null);
- useEffect(() => { if (editing && inputRef.current) { inputRef.current.focus(); inputRef.current.select() } }, [editing])
+ useEffect(() => {
+ if (editing && inputRef.current) {
+ inputRef.current.focus();
+ inputRef.current.select();
+ }
+ }, [editing]);
const save = () => {
- setEditing(false)
- let v = editValue
- if (type === 'number') { const p = parseFloat(String(editValue).replace(',', '.')); v = isNaN(p) ? null : p }
- if (v !== value) onSave(v)
- }
+ setEditing(false);
+ let v = editValue;
+ if (type === 'number') {
+ const p = parseFloat(String(editValue).replace(',', '.'));
+ v = isNaN(p) ? null : p;
+ }
+ if (v !== value) onSave(v);
+ };
const handlePaste = (e) => {
- if (type !== 'number') return
- e.preventDefault()
- let text = e.clipboardData.getData('text').trim()
+ if (type !== 'number') return;
+ e.preventDefault();
+ let text = e.clipboardData.getData('text').trim();
// Strip everything except digits, dots, commas, minus
- text = text.replace(/[^\d.,-]/g, '')
+ text = text.replace(/[^\d.,-]/g, '');
// Remove all thousand separators (dots or commas before 3-digit groups), keep last separator as decimal
- const lastComma = text.lastIndexOf(',')
- const lastDot = text.lastIndexOf('.')
- const decimalPos = Math.max(lastComma, lastDot)
+ const lastComma = text.lastIndexOf(',');
+ const lastDot = text.lastIndexOf('.');
+ const decimalPos = Math.max(lastComma, lastDot);
if (decimalPos > -1) {
- const intPart = text.substring(0, decimalPos).replace(/[.,]/g, '')
- const decPart = text.substring(decimalPos + 1)
- text = intPart + '.' + decPart
+ const intPart = text.substring(0, decimalPos).replace(/[.,]/g, '');
+ const decPart = text.substring(decimalPos + 1);
+ text = intPart + '.' + decPart;
} else {
- text = text.replace(/[.,]/g, '')
+ text = text.replace(/[.,]/g, '');
}
- setEditValue(text)
- }
+ setEditValue(text);
+ };
if (editing) {
- return setEditValue(e.target.value)} onBlur={save} onPaste={handlePaste}
- onKeyDown={e => { if (e.key === 'Enter') save(); if (e.key === 'Escape') { setEditValue(value ?? ''); setEditing(false) } }}
- style={{ width: '100%', border: '1px solid var(--accent)', borderRadius: 4, padding: '4px 6px', fontSize: 13, outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', fontFamily: 'inherit', ...style }}
- placeholder={placeholder} />
+ return (
+ setEditValue(e.target.value)}
+ onBlur={save}
+ onPaste={handlePaste}
+ onKeyDown={(e) => {
+ if (e.key === 'Enter') save();
+ if (e.key === 'Escape') {
+ setEditValue(value ?? '');
+ setEditing(false);
+ }
+ }}
+ style={{
+ width: '100%',
+ border: '1px solid var(--accent)',
+ borderRadius: 4,
+ padding: '4px 6px',
+ fontSize: 13,
+ outline: 'none',
+ background: 'var(--bg-input)',
+ color: 'var(--text-primary)',
+ fontFamily: 'inherit',
+ ...style,
+ }}
+ placeholder={placeholder}
+ />
+ );
}
- const display = type === 'number' && value != null
- ? Number(value).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
- : (value || '')
+ const display =
+ type === 'number' && value != null
+ ? Number(value).toLocaleString(locale, { minimumFractionDigits: decimals, maximumFractionDigits: decimals })
+ : value || '';
return (
- { if (readOnly) return; setEditValue(value ?? ''); setEditing(true) }} title={readOnly ? undefined : editTooltip}
- style={{ cursor: readOnly ? 'default' : 'pointer', padding: '2px 4px', borderRadius: 4, minHeight: 22, display: 'flex', alignItems: 'center',
- justifyContent: style?.textAlign === 'center' ? 'center' : 'flex-start', transition: 'background 0.15s',
- color: display ? 'var(--text-primary)' : 'var(--text-faint)', fontSize: 13, ...style }}
- onMouseEnter={e => { if (!readOnly) e.currentTarget.style.background = 'var(--bg-hover)' }}
- onMouseLeave={e => { if (!readOnly) e.currentTarget.style.background = 'transparent' }}>
+
{
+ if (readOnly) return;
+ setEditValue(value ?? '');
+ setEditing(true);
+ }}
+ title={readOnly ? undefined : editTooltip}
+ style={{
+ cursor: readOnly ? 'default' : 'pointer',
+ padding: '2px 4px',
+ borderRadius: 4,
+ minHeight: 22,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: style?.textAlign === 'center' ? 'center' : 'flex-start',
+ transition: 'background 0.15s',
+ color: display ? 'var(--text-primary)' : 'var(--text-faint)',
+ fontSize: 13,
+ ...style,
+ }}
+ onMouseEnter={(e) => {
+ if (!readOnly) e.currentTarget.style.background = 'var(--bg-hover)';
+ }}
+ onMouseLeave={(e) => {
+ if (!readOnly) e.currentTarget.style.background = 'transparent';
+ }}
+ >
{display || placeholder || '-'}
- )
+ );
}
// ── Add Item Row ─────────────────────────────────────────────────────────────
interface AddItemRowProps {
- onAdd: (data: { name: string; total_price: number; persons: number | null; days: number | null; note: string | null; expense_date: string | null }) => void
- t: (key: string) => string
+ onAdd: (data: {
+ name: string;
+ total_price: number;
+ persons: number | null;
+ days: number | null;
+ note: string | null;
+ expense_date: string | null;
+ }) => void;
+ t: (key: string) => string;
}
function AddItemRow({ onAdd, t }: AddItemRowProps) {
- const [name, setName] = useState('')
- const [price, setPrice] = useState('')
- const [persons, setPersons] = useState('')
- const [days, setDays] = useState('')
- const [note, setNote] = useState('')
- const [expenseDate, setExpenseDate] = useState('')
- const nameRef = useRef(null)
+ const [name, setName] = useState('');
+ const [price, setPrice] = useState('');
+ const [persons, setPersons] = useState('');
+ const [days, setDays] = useState('');
+ const [note, setNote] = useState('');
+ const [expenseDate, setExpenseDate] = useState('');
+ const nameRef = useRef(null);
const handleAdd = () => {
- if (!name.trim()) return
- onAdd({ name: name.trim(), total_price: parseFloat(String(price).replace(',', '.')) || 0, persons: parseInt(persons) || null, days: parseInt(days) || null, note: note.trim() || null, expense_date: expenseDate || null })
- setName(''); setPrice(''); setPersons(''); setDays(''); setNote(''); setExpenseDate('')
- setTimeout(() => nameRef.current?.focus(), 50)
- }
+ if (!name.trim()) return;
+ onAdd({
+ name: name.trim(),
+ total_price: parseFloat(String(price).replace(',', '.')) || 0,
+ persons: parseInt(persons) || null,
+ days: parseInt(days) || null,
+ note: note.trim() || null,
+ expense_date: expenseDate || null,
+ });
+ setName('');
+ setPrice('');
+ setPersons('');
+ setDays('');
+ setNote('');
+ setExpenseDate('');
+ setTimeout(() => nameRef.current?.focus(), 50);
+ };
- const inp = { border: '1px solid var(--border-primary)', borderRadius: 4, padding: '4px 6px', fontSize: 13, outline: 'none', fontFamily: 'inherit', width: '100%', background: 'var(--bg-input)', color: 'var(--text-primary)' }
+ const inp = {
+ border: '1px solid var(--border-primary)',
+ borderRadius: 4,
+ padding: '4px 6px',
+ fontSize: 13,
+ outline: 'none',
+ fontFamily: 'inherit',
+ width: '100%',
+ background: 'var(--bg-input)',
+ color: 'var(--text-primary)',
+ };
return (
|
- setName(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
- placeholder={t('budget.newEntry')} style={inp} />
+ setName(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
+ placeholder={t('budget.newEntry')}
+ style={inp}
+ />
|
- setPrice(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
- onPaste={e => { e.preventDefault(); let t = e.clipboardData.getData('text').trim().replace(/[^\d.,-]/g, ''); const lc = t.lastIndexOf(','), ld = t.lastIndexOf('.'), dp = Math.max(lc, ld); if (dp > -1) { t = t.substring(0, dp).replace(/[.,]/g, '') + '.' + t.substring(dp + 1) } else { t = t.replace(/[.,]/g, '') } setPrice(t) }}
- placeholder="0,00" inputMode="decimal" style={{ ...inp, textAlign: 'center' }} />
+ setPrice(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
+ onPaste={(e) => {
+ e.preventDefault();
+ let t = e.clipboardData
+ .getData('text')
+ .trim()
+ .replace(/[^\d.,-]/g, '');
+ const lc = t.lastIndexOf(','),
+ ld = t.lastIndexOf('.'),
+ dp = Math.max(lc, ld);
+ if (dp > -1) {
+ t = t.substring(0, dp).replace(/[.,]/g, '') + '.' + t.substring(dp + 1);
+ } else {
+ t = t.replace(/[.,]/g, '');
+ }
+ setPrice(t);
+ }}
+ placeholder="0,00"
+ inputMode="decimal"
+ style={{ ...inp, textAlign: 'center' }}
+ />
|
- setPersons(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
- placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} />
+ setPersons(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
+ placeholder="-"
+ inputMode="numeric"
+ style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }}
+ />
|
- setDays(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()}
- placeholder="-" inputMode="numeric" style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }} />
+ setDays(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
+ placeholder="-"
+ inputMode="numeric"
+ style={{ ...inp, textAlign: 'center', maxWidth: 60, margin: '0 auto' }}
+ />
+ |
+
+ -
+ |
+
+ -
+ |
+
+ -
|
- - |
- - |
- - |
|
- setNote(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleAdd()} placeholder={t('budget.table.note')} style={inp} />
+ setNote(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && handleAdd()}
+ placeholder={t('budget.table.note')}
+ style={inp}
+ />
|
-
+
|
- )
+ );
}
// ── Chip with custom tooltip ─────────────────────────────────────────────────
interface ChipWithTooltipProps {
- label: string
- avatarUrl: string | null
- size?: number
- paid?: boolean
- onClick?: () => void
+ label: string;
+ avatarUrl: string | null;
+ size?: number;
+ paid?: boolean;
+ onClick?: () => void;
}
function ChipWithTooltip({ label, avatarUrl, size = 20, paid, onClick }: ChipWithTooltipProps) {
- const [hover, setHover] = useState(false)
- const [pos, setPos] = useState({ top: 0, left: 0 })
- const ref = useRef(null)
+ const [hover, setHover] = useState(false);
+ const [pos, setPos] = useState({ top: 0, left: 0 });
+ const ref = useRef(null);
const onEnter = () => {
if (ref.current) {
- const rect = ref.current.getBoundingClientRect()
- setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 })
+ const rect = ref.current.getBoundingClientRect();
+ setPos({ top: rect.top - 6, left: rect.left + rect.width / 2 });
}
- setHover(true)
- }
+ setHover(true);
+ };
- const borderColor = paid ? '#22c55e' : 'var(--border-primary)'
- const bg = paid ? 'rgba(34,197,94,0.15)' : 'var(--bg-tertiary)'
+ const borderColor = paid ? '#22c55e' : 'var(--border-primary)';
+ const bg = paid ? 'rgba(34,197,94,0.15)' : 'var(--bg-tertiary)';
return (
<>
-
setHover(false)}
+
setHover(false)}
onClick={onClick}
style={{
- width: size, height: size, borderRadius: '50%', border: `2px solid ${borderColor}`,
- background: bg, display: 'flex', alignItems: 'center', justifyContent: 'center',
- fontSize: size * 0.4, fontWeight: 700, color: paid ? '#16a34a' : 'var(--text-muted)',
- overflow: 'hidden', flexShrink: 0, cursor: onClick ? 'pointer' : 'default',
+ width: size,
+ height: size,
+ borderRadius: '50%',
+ border: `2px solid ${borderColor}`,
+ background: bg,
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ fontSize: size * 0.4,
+ fontWeight: 700,
+ color: paid ? '#16a34a' : 'var(--text-muted)',
+ overflow: 'hidden',
+ flexShrink: 0,
+ cursor: onClick ? 'pointer' : 'default',
transition: 'border-color 0.15s, background 0.15s',
- }}>
- {avatarUrl
- ?

- : label?.[0]?.toUpperCase()
- }
+ }}
+ >
+ {avatarUrl ? (
+

+ ) : (
+ label?.[0]?.toUpperCase()
+ )}
- {hover && ReactDOM.createPortal(
-
- {label}
- {paid && (
- Paid
- )}
-
,
- document.body
- )}
+ {hover &&
+ ReactDOM.createPortal(
+
+ {label}
+ {paid && (
+
+ Paid
+
+ )}
+
,
+ document.body
+ )}
>
- )
+ );
}
// ── Budget Member Chips (for Persons column) ────────────────────────────────
interface BudgetMemberChipsProps {
- members?: BudgetMember[]
- tripMembers?: TripMember[]
- onSetMembers: (memberIds: number[]) => void
- onTogglePaid?: (userId: number, paid: boolean) => void
- compact?: boolean
- readOnly?: boolean
+ members?: BudgetMember[];
+ tripMembers?: TripMember[];
+ onSetMembers: (memberIds: number[]) => void;
+ onTogglePaid?: (userId: number, paid: boolean) => void;
+ compact?: boolean;
+ readOnly?: boolean;
}
-function BudgetMemberChips({ members = [], tripMembers = [], onSetMembers, onTogglePaid, compact = true, readOnly = false }: BudgetMemberChipsProps) {
- const chipSize = compact ? 20 : 30
- const btnSize = compact ? 18 : 28
- const iconSize = compact ? (members.length > 0 ? 8 : 9) : (members.length > 0 ? 12 : 14)
- const [showDropdown, setShowDropdown] = useState(false)
- const [dropPos, setDropPos] = useState({ top: 0, left: 0 })
- const btnRef = useRef(null)
- const dropRef = useRef(null)
+function BudgetMemberChips({
+ members = [],
+ tripMembers = [],
+ onSetMembers,
+ onTogglePaid,
+ compact = true,
+ readOnly = false,
+}: BudgetMemberChipsProps) {
+ const chipSize = compact ? 20 : 30;
+ const btnSize = compact ? 18 : 28;
+ const iconSize = compact ? (members.length > 0 ? 8 : 9) : members.length > 0 ? 12 : 14;
+ const [showDropdown, setShowDropdown] = useState(false);
+ const [dropPos, setDropPos] = useState({ top: 0, left: 0 });
+ const btnRef = useRef(null);
+ const dropRef = useRef(null);
const openDropdown = useCallback(() => {
if (btnRef.current) {
- const rect = btnRef.current.getBoundingClientRect()
- setDropPos({ top: rect.bottom + 4, left: rect.left + rect.width / 2 })
+ const rect = btnRef.current.getBoundingClientRect();
+ setDropPos({ top: rect.bottom + 4, left: rect.left + rect.width / 2 });
}
- setShowDropdown(v => !v)
- }, [])
+ setShowDropdown((v) => !v);
+ }, []);
useEffect(() => {
- if (!showDropdown) return
+ if (!showDropdown) return;
const close = (e) => {
- if (dropRef.current && dropRef.current.contains(e.target)) return
- if (btnRef.current && btnRef.current.contains(e.target)) return
- setShowDropdown(false)
- }
- document.addEventListener('mousedown', close)
- return () => document.removeEventListener('mousedown', close)
- }, [showDropdown])
+ if (dropRef.current && dropRef.current.contains(e.target)) return;
+ if (btnRef.current && btnRef.current.contains(e.target)) return;
+ setShowDropdown(false);
+ };
+ document.addEventListener('mousedown', close);
+ return () => document.removeEventListener('mousedown', close);
+ }, [showDropdown]);
- const memberIds = members.map(m => m.user_id)
+ const memberIds = members.map((m) => m.user_id);
const toggleMember = (userId) => {
- const newIds = memberIds.includes(userId)
- ? memberIds.filter(id => id !== userId)
- : [...memberIds, userId]
- onSetMembers(newIds)
- }
+ const newIds = memberIds.includes(userId) ? memberIds.filter((id) => id !== userId) : [...memberIds, userId];
+ onSetMembers(newIds);
+ };
return (
- {members.map(m => (
-
(
+ onTogglePaid(m.user_id, !m.paid) : undefined}
/>
))}
{!readOnly && (
-
+ width: btnSize,
+ height: btnSize,
+ borderRadius: '50%',
+ border: '1.5px dashed var(--border-primary)',
+ background: 'none',
+ cursor: 'pointer',
+ display: 'flex',
+ alignItems: 'center',
+ justifyContent: 'center',
+ color: 'var(--text-faint)',
+ padding: 0,
+ flexShrink: 0,
+ }}
+ >
{members.length > 0 ? : }
)}
- {showDropdown && ReactDOM.createPortal(
-
- {tripMembers.map(tm => {
- const isActive = memberIds.includes(tm.id)
- return (
-
toggleMember(tm.id)} style={{
- display: 'flex', alignItems: 'center', gap: 6, width: '100%', padding: '5px 8px',
- borderRadius: 6, border: 'none', background: isActive ? 'var(--bg-hover)' : 'none', cursor: 'pointer',
- fontFamily: 'inherit', fontSize: 11, color: 'var(--text-primary)', textAlign: 'left',
- }}
- onMouseEnter={e => { if (!isActive) e.currentTarget.style.background = 'var(--bg-hover)' }}
- onMouseLeave={e => { if (!isActive) e.currentTarget.style.background = 'none' }}
- >
-
- {tm.avatar_url
- ?

- : tm.username?.[0]?.toUpperCase()
- }
-
- {tm.username}
- {isActive && }
-
- )
- })}
-
,
- document.body
- )}
+ {showDropdown &&
+ ReactDOM.createPortal(
+
+ {tripMembers.map((tm) => {
+ const isActive = memberIds.includes(tm.id);
+ return (
+
toggleMember(tm.id)}
+ style={{
+ display: 'flex',
+ alignItems: 'center',
+ gap: 6,
+ width: '100%',
+ padding: '5px 8px',
+ borderRadius: 6,
+ border: 'none',
+ background: isActive ? 'var(--bg-hover)' : 'none',
+ cursor: 'pointer',
+ fontFamily: 'inherit',
+ fontSize: 11,
+ color: 'var(--text-primary)',
+ textAlign: 'left',
+ }}
+ onMouseEnter={(e) => {
+ if (!isActive) e.currentTarget.style.background = 'var(--bg-hover)';
+ }}
+ onMouseLeave={(e) => {
+ if (!isActive) e.currentTarget.style.background = 'none';
+ }}
+ >
+
+ {tm.avatar_url ? (
+

+ ) : (
+ tm.username?.[0]?.toUpperCase()
+ )}
+
+ {tm.username}
+ {isActive && }
+
+ );
+ })}
+
,
+ document.body
+ )}
- )
+ );
}
// ── Per-Person Inline (inside total card) ────────────────────────────────────
interface PerPersonInlineProps {
- tripId: number
- budgetItems: BudgetItem[]
- currency: string
- locale: string
+ tripId: number;
+ budgetItems: BudgetItem[];
+ currency: string;
+ locale: string;
}
const SPLIT_COLORS = [
@@ -430,302 +812,540 @@ const SPLIT_COLORS = [
{ solid: '#f59e0b', gradient: 'linear-gradient(135deg, #f59e0b, #f97316)' },
{ solid: '#06b6d4', gradient: 'linear-gradient(135deg, #06b6d4, #3b82f6)' },
{ solid: '#a855f7', gradient: 'linear-gradient(135deg, #a855f7, #d946ef)' },
-]
+];
export function splitColorFor(userId: number, order: number) {
- return SPLIT_COLORS[order % SPLIT_COLORS.length]
+ return SPLIT_COLORS[order % SPLIT_COLORS.length];
}
function colorForUserId(userId: number) {
- return SPLIT_COLORS[((userId | 0) - 1 + SPLIT_COLORS.length * 1000) % SPLIT_COLORS.length]
+ return SPLIT_COLORS[((userId | 0) - 1 + SPLIT_COLORS.length * 1000) % SPLIT_COLORS.length];
}
-function RingAvatar({ userId, username, avatarUrl, size = 34, innerBg = '#17171d', textColor = '#fff' }: { userId: number; username?: string; avatarUrl?: string | null; size?: number; innerBg?: string; textColor?: string }) {
- const color = colorForUserId(userId)
+function RingAvatar({
+ userId,
+ username,
+ avatarUrl,
+ size = 34,
+ innerBg = '#17171d',
+ textColor = '#fff',
+}: {
+ userId: number;
+ username?: string;
+ avatarUrl?: string | null;
+ size?: number;
+ innerBg?: string;
+ textColor?: string;
+}) {
+ const color = colorForUserId(userId);
return (
-
-
- {avatarUrl ?

: username?.[0]?.toUpperCase()}
+
+
+ {avatarUrl ? (
+

+ ) : (
+ username?.[0]?.toUpperCase()
+ )}
- )
+ );
}
-function PerPersonInline({ tripId, budgetItems, currency, locale, grandTotal, theme }: PerPersonInlineProps & { grandTotal: number; theme: ReturnType
}) {
- const [data, setData] = useState(null)
- const fmt = (v: number) => fmtNum(v, locale, currency)
+function PerPersonInline({
+ tripId,
+ budgetItems,
+ currency,
+ locale,
+ grandTotal,
+ theme,
+}: PerPersonInlineProps & { grandTotal: number; theme: ReturnType }) {
+ const [data, setData] = useState(null);
+ const fmt = (v: number) => fmtNum(v, locale, currency);
useEffect(() => {
- budgetApi.perPersonSummary(tripId).then(d => setData(d.summary)).catch(() => {})
- }, [tripId, budgetItems])
+ budgetApi
+ .perPersonSummary(tripId)
+ .then((d) => setData(d.summary))
+ .catch(() => {});
+ }, [tripId, budgetItems]);
- if (!data || data.length === 0) return null
+ if (!data || data.length === 0) return null;
- const people = data.map((p: any) => ({ ...p, color: colorForUserId(p.user_id) }))
+ const people = data.map((p: any) => ({ ...p, color: colorForUserId(p.user_id) }));
return (
<>
{grandTotal > 0 && (
-
- {people.map(p => (
-
+
+ {people.map((p) => (
+
))}
)}
-
- {people.map(p => {
- const percent = grandTotal > 0 ? Math.round((p.total_assigned / grandTotal) * 100) : 0
+
+ {people.map((p) => {
+ const percent = grandTotal > 0 ? Math.round((p.total_assigned / grandTotal) * 100) : 0;
return (
-
+
-
{p.username}
+
+ {p.username}
+
{percent}%
-
{fmt(p.total_assigned)}
+
+ {fmt(p.total_assigned)}
+
- )
+ );
})}
>
- )
+ );
}
// ── Pie Chart (pure CSS conic-gradient) ──────────────────────────────────────
interface PieChartProps {
- segments: PieSegment[]
- size?: number
- totalLabel: string
+ segments: PieSegment[];
+ size?: number;
+ totalLabel: string;
}
function PieChart({ segments, size = 200, totalLabel }: PieChartProps) {
- if (!segments.length) return null
+ if (!segments.length) return null;
- const total = segments.reduce((s, x) => s + x.value, 0)
- if (total === 0) return null
+ const total = segments.reduce((s, x) => s + x.value, 0);
+ if (total === 0) return null;
- let cumDeg = 0
- const stops = segments.map(seg => {
- const start = cumDeg
- const deg = (seg.value / total) * 360
- cumDeg += deg
- return `${seg.color} ${start}deg ${start + deg}deg`
- }).join(', ')
+ let cumDeg = 0;
+ const stops = segments
+ .map((seg) => {
+ const start = cumDeg;
+ const deg = (seg.value / total) * 360;
+ cumDeg += deg;
+ return `${seg.color} ${start}deg ${start + deg}deg`;
+ })
+ .join(', ');
return (
-
- )
+ );
}
// ── Main Component ───────────────────────────────────────────────────────────
interface BudgetPanelProps {
- tripId: number
- tripMembers?: TripMember[]
+ tripId: number;
+ tripMembers?: TripMember[];
}
export default function BudgetPanel({ tripId, tripMembers = [] }: BudgetPanelProps) {
- const { trip, budgetItems, addBudgetItem, updateBudgetItem, deleteBudgetItem, loadBudgetItems, updateTrip, setBudgetItemMembers, toggleBudgetMemberPaid, reorderBudgetItems, reorderBudgetCategories } = useTripStore()
- const can = useCanDo()
- const { t, locale } = useTranslation()
- const isDark = useIsDark()
- const theme = useMemo(() => widgetTheme(isDark), [isDark])
- const [newCategoryName, setNewCategoryName] = useState('')
- const [editingCat, setEditingCat] = useState(null) // { name, value }
- const [settlement, setSettlement] = useState<{ balances: any[]; flows: any[] } | null>(null)
- const [settlementOpen, setSettlementOpen] = useState(false)
- const currency = trip?.currency || 'EUR'
- const canEdit = can('budget_edit', trip)
+ const {
+ trip,
+ budgetItems,
+ addBudgetItem,
+ updateBudgetItem,
+ deleteBudgetItem,
+ loadBudgetItems,
+ updateTrip,
+ setBudgetItemMembers,
+ toggleBudgetMemberPaid,
+ reorderBudgetItems,
+ reorderBudgetCategories,
+ } = useTripStore();
+ const can = useCanDo();
+ const { t, locale } = useTranslation();
+ const isDark = useIsDark();
+ const theme = useMemo(() => widgetTheme(isDark), [isDark]);
+ const [newCategoryName, setNewCategoryName] = useState('');
+ const [editingCat, setEditingCat] = useState(null); // { name, value }
+ const [settlement, setSettlement] = useState<{ balances: any[]; flows: any[] } | null>(null);
+ const [settlementOpen, setSettlementOpen] = useState(false);
+ const currency = trip?.currency || 'EUR';
+ const canEdit = can('budget_edit', trip);
- const fmt = (v, cur) => fmtNum(v, locale, cur)
- const hasMultipleMembers = tripMembers.length > 1
+ const fmt = (v, cur) => fmtNum(v, locale, cur);
+ const hasMultipleMembers = tripMembers.length > 1;
// Drag state for categories
- const [dragCat, setDragCat] = useState
(null)
- const [dragOverCat, setDragOverCat] = useState(null)
+ const [dragCat, setDragCat] = useState(null);
+ const [dragOverCat, setDragOverCat] = useState(null);
// Drag state for items within a category
- const [dragItem, setDragItem] = useState(null)
- const [dragOverItem, setDragOverItem] = useState(null)
- const [dragItemCat, setDragItemCat] = useState(null)
+ const [dragItem, setDragItem] = useState(null);
+ const [dragOverItem, setDragOverItem] = useState(null);
+ const [dragItemCat, setDragItemCat] = useState(null);
// Load settlement data whenever budget items change
useEffect(() => {
- if (!hasMultipleMembers) return
- budgetApi.settlement(tripId).then(setSettlement).catch(() => {})
- }, [tripId, budgetItems, hasMultipleMembers])
+ if (!hasMultipleMembers) return;
+ budgetApi
+ .settlement(tripId)
+ .then(setSettlement)
+ .catch(() => {});
+ }, [tripId, budgetItems, hasMultipleMembers]);
const setCurrency = (cur) => {
- if (tripId) updateTrip(tripId, { currency: cur })
- }
+ if (tripId) updateTrip(tripId, { currency: cur });
+ };
- useEffect(() => { if (tripId) loadBudgetItems(tripId) }, [tripId])
+ useEffect(() => {
+ if (tripId) loadBudgetItems(tripId);
+ }, [tripId]);
const grouped = useMemo(() => {
- const map = new Map()
- for (const item of (budgetItems || [])) {
- const cat = item.category || 'Other'
- if (!map.has(cat)) map.set(cat, [])
- map.get(cat)!.push(item)
+ const map = new Map();
+ for (const item of budgetItems || []) {
+ const cat = item.category || 'Other';
+ if (!map.has(cat)) map.set(cat, []);
+ map.get(cat)!.push(item);
}
- return map
- }, [budgetItems])
+ return map;
+ }, [budgetItems]);
- const categoryNames = Array.from(grouped.keys())
+ const categoryNames = Array.from(grouped.keys());
// Stable color mapping: assign index-based colors once, never reassign on reorder
- const colorMapRef = useRef(new Map())
+ const colorMapRef = useRef(new Map());
const categoryColor = useCallback((cat: string) => {
- const map = colorMapRef.current
+ const map = colorMapRef.current;
if (!map.has(cat)) {
- map.set(cat, PIE_COLORS[map.size % PIE_COLORS.length])
+ map.set(cat, PIE_COLORS[map.size % PIE_COLORS.length]);
}
- return map.get(cat)!
- }, [])
- const grandTotal = (budgetItems || []).reduce((s, i) => s + (i.total_price || 0), 0)
+ return map.get(cat)!;
+ }, []);
+ const grandTotal = (budgetItems || []).reduce((s, i) => s + (i.total_price || 0), 0);
- const pieSegments = useMemo(() =>
- categoryNames.map((cat, i) => ({
- name: cat,
- value: (grouped.get(cat) || []).reduce((s, x) => s + (x.total_price || 0), 0),
- color: categoryColor(cat),
- })).filter(s => s.value > 0)
- , [grouped, categoryNames])
+ const pieSegments = useMemo(
+ () =>
+ categoryNames
+ .map((cat, i) => ({
+ name: cat,
+ value: (grouped.get(cat) || []).reduce((s, x) => s + (x.total_price || 0), 0),
+ color: categoryColor(cat),
+ }))
+ .filter((s) => s.value > 0),
+ [grouped, categoryNames]
+ );
- const handleAddItem = async (category, data) => { try { await addBudgetItem(tripId, { ...data, category }) } catch {} }
- const handleUpdateField = async (id, field, value) => { try { await updateBudgetItem(tripId, id, { [field]: value }) } catch {} }
- const handleDeleteItem = async (id) => { try { await deleteBudgetItem(tripId, id) } catch {} }
+ const handleAddItem = async (category, data) => {
+ try {
+ await addBudgetItem(tripId, { ...data, category });
+ } catch {}
+ };
+ const handleUpdateField = async (id, field, value) => {
+ try {
+ await updateBudgetItem(tripId, id, { [field]: value });
+ } catch {}
+ };
+ const handleDeleteItem = async (id) => {
+ try {
+ await deleteBudgetItem(tripId, id);
+ } catch {}
+ };
const handleDeleteCategory = async (cat) => {
- const items = grouped.get(cat) || []
- for (const item of Array.from(items)) await deleteBudgetItem(tripId, item.id)
- }
+ const items = grouped.get(cat) || [];
+ for (const item of Array.from(items)) await deleteBudgetItem(tripId, item.id);
+ };
const handleRenameCategory = async (oldName, newName) => {
- if (!newName.trim() || newName.trim() === oldName) return
- const items = grouped.get(oldName) || []
- for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() })
- }
+ if (!newName.trim() || newName.trim() === oldName) return;
+ const items = grouped.get(oldName) || [];
+ for (const item of Array.from(items)) await updateBudgetItem(tripId, item.id, { category: newName.trim() });
+ };
const handleAddCategory = () => {
- if (!newCategoryName.trim()) return
- addBudgetItem(tripId, { name: t('budget.defaultEntry'), category: newCategoryName.trim(), total_price: 0 })
- setNewCategoryName('')
- }
+ if (!newCategoryName.trim()) return;
+ addBudgetItem(tripId, { name: t('budget.defaultEntry'), category: newCategoryName.trim(), total_price: 0 });
+ setNewCategoryName('');
+ };
const handleExportCsv = () => {
- const sep = ';'
- const esc = (v: any) => { const s = String(v ?? ''); return s.includes(sep) || s.includes('"') || s.includes('\n') ? '"' + s.replace(/"/g, '""') + '"' : s }
- const d = currencyDecimals(currency)
- const fmtPrice = (v: number | null | undefined) => v != null ? v.toFixed(d) : ''
+ const sep = ';';
+ const esc = (v: any) => {
+ const s = String(v ?? '');
+ return s.includes(sep) || s.includes('"') || s.includes('\n') ? '"' + s.replace(/"/g, '""') + '"' : s;
+ };
+ const d = currencyDecimals(currency);
+ const fmtPrice = (v: number | null | undefined) => (v != null ? v.toFixed(d) : '');
- const fmtDate = (iso: string) => { if (!iso) return ''; const d = new Date(iso + 'T00:00:00Z'); return d.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' }) }
- const header = ['Category', 'Name', 'Date', 'Total (' + currency + ')', 'Persons', 'Days', 'Per Person', 'Per Day', 'Per Person/Day', 'Note']
- const rows = [header.join(sep)]
+ const fmtDate = (iso: string) => {
+ if (!iso) return '';
+ const d = new Date(iso + 'T00:00:00Z');
+ return d.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric', timeZone: 'UTC' });
+ };
+ const header = [
+ 'Category',
+ 'Name',
+ 'Date',
+ 'Total (' + currency + ')',
+ 'Persons',
+ 'Days',
+ 'Per Person',
+ 'Per Day',
+ 'Per Person/Day',
+ 'Note',
+ ];
+ const rows = [header.join(sep)];
for (const cat of categoryNames) {
- for (const item of (grouped.get(cat) || [])) {
- const pp = calcPP(item.total_price, item.persons)
- const pd = calcPD(item.total_price, item.days)
- const ppd = calcPPD(item.total_price, item.persons, item.days)
- rows.push([
- esc(item.category), esc(item.name), esc(fmtDate(item.expense_date || '')),
- fmtPrice(item.total_price), item.persons ?? '', item.days ?? '',
- fmtPrice(pp), fmtPrice(pd), fmtPrice(ppd),
- esc(item.note || ''),
- ].join(sep))
+ for (const item of grouped.get(cat) || []) {
+ const pp = calcPP(item.total_price, item.persons);
+ const pd = calcPD(item.total_price, item.days);
+ const ppd = calcPPD(item.total_price, item.persons, item.days);
+ rows.push(
+ [
+ esc(item.category),
+ esc(item.name),
+ esc(fmtDate(item.expense_date || '')),
+ fmtPrice(item.total_price),
+ item.persons ?? '',
+ item.days ?? '',
+ fmtPrice(pp),
+ fmtPrice(pd),
+ fmtPrice(ppd),
+ esc(item.note || ''),
+ ].join(sep)
+ );
}
}
- const bom = '\uFEFF'
- const blob = new Blob([bom + rows.join('\r\n')], { type: 'text/csv;charset=utf-8;' })
- const url = URL.createObjectURL(blob)
- const a = document.createElement('a')
- a.href = url
- const safeName = (trip?.title || 'trip').replace(/[^a-zA-Z0-9\u00C0-\u024F _-]/g, '').trim()
- a.download = `budget-${safeName}.csv`
- a.click()
- URL.revokeObjectURL(url)
- }
+ const bom = '\uFEFF';
+ const blob = new Blob([bom + rows.join('\r\n')], { type: 'text/csv;charset=utf-8;' });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement('a');
+ a.href = url;
+ const safeName = (trip?.title || 'trip').replace(/[^a-zA-Z0-9\u00C0-\u024F _-]/g, '').trim();
+ a.download = `budget-${safeName}.csv`;
+ a.click();
+ URL.revokeObjectURL(url);
+ };
- const th = { padding: '6px 8px', textAlign: 'center', fontSize: 11, fontWeight: 600, color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em', borderBottom: '2px solid var(--border-primary)', whiteSpace: 'nowrap', background: 'var(--bg-secondary)' }
- const td = { padding: '2px 6px', borderBottom: '1px solid var(--border-secondary)', fontSize: 13, verticalAlign: 'middle', color: 'var(--text-primary)' }
+ const th = {
+ padding: '6px 8px',
+ textAlign: 'center',
+ fontSize: 11,
+ fontWeight: 600,
+ color: 'var(--text-muted)',
+ textTransform: 'uppercase',
+ letterSpacing: '0.05em',
+ borderBottom: '2px solid var(--border-primary)',
+ whiteSpace: 'nowrap',
+ background: 'var(--bg-secondary)',
+ };
+ const td = {
+ padding: '2px 6px',
+ borderBottom: '1px solid var(--border-secondary)',
+ fontSize: 13,
+ verticalAlign: 'middle',
+ color: 'var(--text-primary)',
+ };
// ── Empty State ──────────────────────────────────────────────────────────
if (!budgetItems || budgetItems.length === 0) {
return (
-
+
-
{t('budget.emptyTitle')}
-
{t('budget.emptyText')}
+
+ {t('budget.emptyTitle')}
+
+
+ {t('budget.emptyText')}
+
{canEdit && (
-
-
setNewCategoryName(e.target.value)}
- onKeyDown={e => e.key === 'Enter' && handleAddCategory()}
+
+
setNewCategoryName(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && handleAddCategory()}
placeholder={t('budget.emptyPlaceholder')}
- style={{ flex: 1, padding: '9px 14px', borderRadius: 10, border: '1px solid var(--border-primary)', fontSize: 13, fontFamily: 'inherit', outline: 'none', background: 'var(--bg-input)', color: 'var(--text-primary)', minWidth: 0 }} />
-
+ style={{
+ flex: 1,
+ padding: '9px 14px',
+ borderRadius: 10,
+ border: '1px solid var(--border-primary)',
+ fontSize: 13,
+ fontFamily: 'inherit',
+ outline: 'none',
+ background: 'var(--bg-input)',
+ color: 'var(--text-primary)',
+ minWidth: 0,
+ }}
+ />
+
)}
- )
+ );
}
// ── Main Layout ──────────────────────────────────────────────────────────
- const totalBudget = budgetItems.reduce((s, x) => s + (x.total_price || 0), 0)
+ const totalBudget = budgetItems.reduce((s, x) => s + (x.total_price || 0), 0);
return (