diff --git a/client/src/components/Admin/PackingTemplateManager.tsx b/client/src/components/Admin/PackingTemplateManager.tsx index 5bf4d720a..846ca76ac 100644 --- a/client/src/components/Admin/PackingTemplateManager.tsx +++ b/client/src/components/Admin/PackingTemplateManager.tsx @@ -1,12 +1,10 @@ -import { useState, useEffect, useRef } from 'react' -import { adminApi } from '../../api/client' -import { useToast } from '../shared/Toast' import { useTranslation } from '../../i18n' import { Plus, Trash2, Pencil, Package, X, Check, ChevronRight, FolderPlus, Loader2 } from 'lucide-react' import { fs } from '../shared/DialogShell' import { Tooltip } from '../shared/Tooltip' import { COMPOSER } from '../Packing/packingPopoverStyles' import { SettingRows, SettingsCard, SettingsHint, StatusPill, SETTINGS_BUTTON_PRIMARY, SETTINGS_ICON_BUTTON } from '../Settings/settingsKit' +import { type TemplateItem, usePackingTemplateAdmin } from './usePackingTemplateAdmin' /** The planner's composer (#2541): a framed field on the card with its confirm and cancel beside it. */ const COMPOSER_ROW = 'flex items-center gap-2 rounded-[10px] border border-edge bg-surface-card py-1 ps-3 pe-1' @@ -19,9 +17,6 @@ const SMALL_ICON_BUTTON = 'grid h-7 w-7 flex-none place-items-center rounded-[8p /** An item's actions show while the pointer or the focus is on its row. */ const REVEAL = 'opacity-0 focus-visible:opacity-100 group-hover:opacity-100 group-focus-within:opacity-100' -interface TemplateCategory { id: number; template_id: number; name: string; sort_order: number } -interface TemplateItem { id: number; category_id: number; name: string; sort_order: number; weight_grams?: number | null; quantity?: number | null; bag_name?: string | null } - /** "2× · 300 g · Backpack": what a template item brings along besides its name (#1131). */ function templateItemMeta(item: TemplateItem): string { const parts: string[] = [] @@ -30,147 +25,18 @@ function templateItemMeta(item: TemplateItem): string { if (item.bag_name) parts.push(item.bag_name) return parts.join(' · ') } -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('') - - // Expanded template state - 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('') - - // 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 toast = useToast() + const { + templates, isLoading, showCreate, setShowCreate, createName, setCreateName, expandedId, categories, items, + editingTemplate, setEditingTemplate, editTemplateName, setEditTemplateName, editingCatId, setEditingCatId, + editCatName, setEditCatName, editingItemId, setEditingItemId, editItemName, setEditItemName, addingCategory, + setAddingCategory, newCatName, setNewCatName, addingItemToCatId, setAddingItemToCatId, newItemName, + setNewItemName, addItemRef, toggleExpand, handleCreateTemplate, handleDeleteTemplate, handleRenameTemplate, + handleAddCategory, handleRenameCategory, handleDeleteCategory, handleAddItem, handleRenameItem, handleDeleteItem, + } = usePackingTemplateAdmin() const { t } = useTranslation() - useEffect(() => { void loadTemplates() }, []) - - const loadTemplates = async () => { - setIsLoading(true) - try { - 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) - try { - 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 - 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 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')) } - } - - const handleRenameTemplate = async (id: number) => { - 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')) } - } - - // Category CRUD - const handleAddCategory = async () => { - 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 handleRenameCategory = async (catId: number) => { - 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')) } - } - - const handleDeleteCategory = async (catId: number) => { - 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.deleteCategoryError')) } - } - - // Item CRUD - const handleAddItem = async (catId: number) => { - // The name is already guaranteed non-empty by the button and the Enter handler. - if (!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 handleRenameItem = async (itemId: number) => { - 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')) } - } - - const handleDeleteItem = async (itemId: number) => { - if (!expandedId) return - try { - await adminApi.deleteTemplateItem(expandedId, itemId) - setItems(prev => prev.filter(i => i.id !== itemId)) - } catch { toast.error(t('admin.packingTemplates.deleteItemError')) } - } - const composerConfirmClass = (enabled: boolean) => `grid h-7 w-7 flex-none place-items-center rounded-[8px] bg-accent text-accent-text transition-opacity disabled:cursor-default ${enabled ? 'hover:opacity-90' : 'opacity-40'}` diff --git a/client/src/components/Admin/usePackingTemplateAdmin.test.ts b/client/src/components/Admin/usePackingTemplateAdmin.test.ts new file mode 100644 index 000000000..25b7b1ec7 --- /dev/null +++ b/client/src/components/Admin/usePackingTemplateAdmin.test.ts @@ -0,0 +1,242 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { adminApi } from '../../api/client'; +import { usePackingTemplateAdmin } from './usePackingTemplateAdmin'; + +// FE-HOOK-PACKTPL-001 to FE-HOOK-PACKTPL-014 + +const toast = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() })); +vi.mock('../shared/Toast', () => ({ useToast: () => toast })); +vi.mock('../../i18n', () => ({ useTranslation: () => ({ t: (k: string) => k }) })); + +const TPL = { id: 1, name: 'Beach', item_count: 2, category_count: 1, created_by_name: 'admin' }; +const CAT = { id: 10, template_id: 1, name: 'Clothes', sort_order: 0 }; +const ITEM = { id: 100, category_id: 10, name: 'Shirt', sort_order: 0 }; + +beforeEach(() => { + toast.success.mockReset(); + toast.error.mockReset(); + vi.spyOn(adminApi, 'packingTemplates').mockResolvedValue({ templates: [TPL] }); + vi.spyOn(adminApi, 'getPackingTemplate').mockResolvedValue({ categories: [CAT], items: [ITEM] }); + vi.spyOn(adminApi, 'createPackingTemplate').mockResolvedValue({ template: { ...TPL, id: 2, name: 'Ski' } }); + vi.spyOn(adminApi, 'updatePackingTemplate').mockResolvedValue({}); + vi.spyOn(adminApi, 'deletePackingTemplate').mockResolvedValue({}); + vi.spyOn(adminApi, 'addTemplateCategory').mockResolvedValue({ category: { ...CAT, id: 11, name: 'Toys' } }); + vi.spyOn(adminApi, 'updateTemplateCategory').mockResolvedValue({}); + vi.spyOn(adminApi, 'deleteTemplateCategory').mockResolvedValue({}); + vi.spyOn(adminApi, 'addTemplateItem').mockResolvedValue({ item: { ...ITEM, id: 101, name: 'Hat' } }); + vi.spyOn(adminApi, 'updateTemplateItem').mockResolvedValue({}); + vi.spyOn(adminApi, 'deleteTemplateItem').mockResolvedValue({}); +}); +afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); +}); + +async function expanded(genericDeleteErrors?: boolean) { + const hook = renderHook(() => + usePackingTemplateAdmin(genericDeleteErrors === undefined ? undefined : { genericDeleteErrors }) + ); + await waitFor(() => expect(hook.result.current.isLoading).toBe(false)); + await act(() => hook.result.current.toggleExpand(1)); + return hook; +} + +describe('usePackingTemplateAdmin', () => { + it('FE-HOOK-PACKTPL-001: loads the templates once on mount; a failure toasts the load error', async () => { + const { result, rerender } = renderHook(() => usePackingTemplateAdmin()); + await waitFor(() => expect(result.current.isLoading).toBe(false)); + rerender(); + expect(adminApi.packingTemplates).toHaveBeenCalledTimes(1); + expect(result.current.templates).toEqual([TPL]); + + vi.mocked(adminApi.packingTemplates).mockRejectedValue(new Error('down')); + const failed = renderHook(() => usePackingTemplateAdmin()); + await waitFor(() => expect(failed.result.current.isLoading).toBe(false)); + expect(toast.error).toHaveBeenCalledWith('admin.packingTemplates.loadError'); + }); + + it('FE-HOOK-PACKTPL-002: expanding loads the template and closes the add composers; again collapses', async () => { + const { result } = await expanded(); + expect(adminApi.getPackingTemplate).toHaveBeenCalledWith(1); + expect(result.current.expandedId).toBe(1); + expect(result.current.categories).toEqual([CAT]); + expect(result.current.items).toEqual([ITEM]); + act(() => { + result.current.setAddingCategory(true); + result.current.setAddingItemToCatId(10); + }); + await act(() => result.current.toggleExpand(1)); + expect(result.current.expandedId).toBeNull(); + expect(adminApi.getPackingTemplate).toHaveBeenCalledTimes(1); + await act(() => result.current.toggleExpand(1)); + expect(result.current.addingCategory).toBe(false); + expect(result.current.addingItemToCatId).toBeNull(); + }); + + it('FE-HOOK-PACKTPL-003: creating a template prepends it, expands it empty and closes the composer', async () => { + const { result } = await expanded(); + act(() => { + result.current.setShowCreate(true); + result.current.setCreateName(' Ski '); + }); + await act(() => result.current.handleCreateTemplate()); + expect(adminApi.createPackingTemplate).toHaveBeenCalledWith({ name: 'Ski' }); + expect(result.current.templates.map((x) => [x.id, x.item_count, x.category_count])).toEqual([ + [2, 0, 0], + [1, 2, 1], + ]); + expect(result.current.expandedId).toBe(2); + expect(result.current.categories).toEqual([]); + expect(result.current.items).toEqual([]); + expect(result.current.showCreate).toBe(false); + expect(result.current.createName).toBe(''); + expect(toast.success).toHaveBeenCalledWith('admin.packingTemplates.created'); + }); + + it('FE-HOOK-PACKTPL-004: a blank template name creates nothing; a failed create toasts', async () => { + const { result } = await expanded(); + act(() => result.current.setCreateName(' ')); + await act(() => result.current.handleCreateTemplate()); + expect(adminApi.createPackingTemplate).not.toHaveBeenCalled(); + vi.mocked(adminApi.createPackingTemplate).mockRejectedValue(new Error('x')); + act(() => result.current.setCreateName('Ski')); + await act(() => result.current.handleCreateTemplate()); + expect(toast.error).toHaveBeenCalledWith('admin.packingTemplates.createError'); + }); + + it('FE-HOOK-PACKTPL-005: deleting the expanded template drops it and collapses', async () => { + const { result } = await expanded(); + await act(() => result.current.handleDeleteTemplate(1)); + expect(result.current.templates).toEqual([]); + expect(result.current.expandedId).toBeNull(); + expect(toast.success).toHaveBeenCalledWith('admin.packingTemplates.deleted'); + vi.mocked(adminApi.deletePackingTemplate).mockRejectedValue(new Error('x')); + await act(() => result.current.handleDeleteTemplate(1)); + expect(toast.error).toHaveBeenCalledWith('admin.packingTemplates.deleteError'); + }); + + it('FE-HOOK-PACKTPL-006: renaming a template trims the name; a blank name only leaves edit mode', async () => { + const { result } = await expanded(); + act(() => { + result.current.setEditingTemplate(1); + result.current.setEditTemplateName(' '); + }); + await act(() => result.current.handleRenameTemplate(1)); + expect(adminApi.updatePackingTemplate).not.toHaveBeenCalled(); + expect(result.current.editingTemplate).toBeNull(); + act(() => { + result.current.setEditingTemplate(1); + result.current.setEditTemplateName(' Sea '); + }); + await act(() => result.current.handleRenameTemplate(1)); + expect(adminApi.updatePackingTemplate).toHaveBeenCalledWith(1, { name: 'Sea' }); + expect(result.current.templates[0].name).toBe('Sea'); + expect(result.current.editingTemplate).toBeNull(); + }); + + it('FE-HOOK-PACKTPL-007: a failed template rename toasts and stays in edit mode', async () => { + vi.mocked(adminApi.updatePackingTemplate).mockRejectedValue(new Error('x')); + const { result } = await expanded(); + act(() => { + result.current.setEditingTemplate(1); + result.current.setEditTemplateName('Sea'); + }); + await act(() => result.current.handleRenameTemplate(1)); + expect(toast.error).toHaveBeenCalledWith('admin.packingTemplates.saveError'); + expect(result.current.editingTemplate).toBe(1); + }); + + it('FE-HOOK-PACKTPL-008: adding a category appends it and closes the composer', async () => { + const { result } = await expanded(); + act(() => { + result.current.setAddingCategory(true); + result.current.setNewCatName(' Toys '); + }); + await act(() => result.current.handleAddCategory()); + expect(adminApi.addTemplateCategory).toHaveBeenCalledWith(1, { name: 'Toys' }); + expect(result.current.categories.map((c) => c.id)).toEqual([10, 11]); + expect(result.current.addingCategory).toBe(false); + expect(result.current.newCatName).toBe(''); + }); + + it('FE-HOOK-PACKTPL-009: renaming a category trims it; a blank name only leaves edit mode', async () => { + const { result } = await expanded(); + act(() => { + result.current.setEditingCatId(10); + result.current.setEditCatName(''); + }); + await act(() => result.current.handleRenameCategory(10)); + expect(adminApi.updateTemplateCategory).not.toHaveBeenCalled(); + expect(result.current.editingCatId).toBeNull(); + act(() => result.current.setEditCatName(' Wear ')); + await act(() => result.current.handleRenameCategory(10)); + expect(adminApi.updateTemplateCategory).toHaveBeenCalledWith(1, 10, { name: 'Wear' }); + expect(result.current.categories[0].name).toBe('Wear'); + }); + + it('FE-HOOK-PACKTPL-010: deleting a category drops its items too', async () => { + const { result } = await expanded(); + await act(() => result.current.handleDeleteCategory(10)); + expect(adminApi.deleteTemplateCategory).toHaveBeenCalledWith(1, 10); + expect(result.current.categories).toEqual([]); + expect(result.current.items).toEqual([]); + }); + + it('FE-HOOK-PACKTPL-011: delete errors are specific on desktop and generic on the phone', async () => { + vi.mocked(adminApi.deleteTemplateCategory).mockRejectedValue(new Error('x')); + vi.mocked(adminApi.deleteTemplateItem).mockRejectedValue(new Error('x')); + const desktop = await expanded(); + await act(() => desktop.result.current.handleDeleteCategory(10)); + await act(() => desktop.result.current.handleDeleteItem(100)); + expect(toast.error.mock.calls.map((c) => c[0])).toEqual([ + 'admin.packingTemplates.deleteCategoryError', + 'admin.packingTemplates.deleteItemError', + ]); + toast.error.mockReset(); + const phone = await expanded(true); + await act(() => phone.result.current.handleDeleteCategory(10)); + await act(() => phone.result.current.handleDeleteItem(100)); + expect(toast.error.mock.calls.map((c) => c[0])).toEqual(['admin.toast.deleteError', 'admin.toast.deleteError']); + expect(phone.result.current.items).toEqual([ITEM]); + }); + + it('FE-HOOK-PACKTPL-012: adding an item appends it, clears the field and refocuses it', async () => { + const { result } = await expanded(); + const input = document.createElement('input'); + document.body.appendChild(input); + result.current.addItemRef.current = input; + vi.useFakeTimers(); + act(() => result.current.setNewItemName(' Hat ')); + await act(() => result.current.handleAddItem(10)); + expect(adminApi.addTemplateItem).toHaveBeenCalledWith(1, 10, { name: 'Hat' }); + expect(result.current.items.map((i) => i.id)).toEqual([100, 101]); + expect(result.current.newItemName).toBe(''); + act(() => vi.advanceTimersByTime(30)); + expect(document.activeElement).toBe(input); + input.remove(); + }); + + it('FE-HOOK-PACKTPL-013: a blank item name adds nothing', async () => { + const { result } = await expanded(); + act(() => result.current.setNewItemName(' ')); + await act(() => result.current.handleAddItem(10)); + expect(adminApi.addTemplateItem).not.toHaveBeenCalled(); + }); + + it('FE-HOOK-PACKTPL-014: renaming and deleting an item update the list', async () => { + const { result } = await expanded(); + act(() => { + result.current.setEditingItemId(100); + result.current.setEditItemName(' Tee '); + }); + await act(() => result.current.handleRenameItem(100)); + expect(adminApi.updateTemplateItem).toHaveBeenCalledWith(1, 100, { name: 'Tee' }); + expect(result.current.items[0].name).toBe('Tee'); + expect(result.current.editingItemId).toBeNull(); + await act(() => result.current.handleDeleteItem(100)); + expect(adminApi.deleteTemplateItem).toHaveBeenCalledWith(1, 100); + expect(result.current.items).toEqual([]); + }); +}); diff --git a/client/src/components/Admin/usePackingTemplateAdmin.ts b/client/src/components/Admin/usePackingTemplateAdmin.ts new file mode 100644 index 000000000..4762cb3fb --- /dev/null +++ b/client/src/components/Admin/usePackingTemplateAdmin.ts @@ -0,0 +1,266 @@ +import { useEffect, useEffectEvent, 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; +} +export interface TemplateItem { + id: number; + category_id: number; + name: string; + sort_order: number; + weight_grams?: number | null; + quantity?: number | null; + bag_name?: string | null; +} +export interface Template { + id: number; + name: string; + item_count: number; + category_count: number; + created_by_name: string; +} + +interface Options { + /** The phone shell toasts the generic admin delete error for categories and items. */ + genericDeleteErrors?: boolean; +} + +/** + * The packing template editor behind both admin shells: the template list loaded on + * mount, one expanded template with its categories and items, and the create, rename and + * delete writes for all three levels. The desktop and phone shells render their own + * markup over it. + */ +export function usePackingTemplateAdmin({ genericDeleteErrors = false }: Options = {}) { + 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([]); + + // 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(''); + + // 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 toast = useToast(); + const { t } = useTranslation(); + + const loadTemplates = async () => { + setIsLoading(true); + try { + const data = await adminApi.packingTemplates(); + setTemplates(data.templates || []); + } catch { + toast.error(t('admin.packingTemplates.loadError')); + } finally { + setIsLoading(false); + } + }; + + const loadOnMount = useEffectEvent(() => { + void loadTemplates(); + }); + useEffect(() => { + loadOnMount(); + }, []); + + const toggleExpand = async (id: number) => { + 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')); + } + }; + + // Template CRUD + const handleCreateTemplate = async () => { + 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 handleDeleteTemplate = async (id: number) => { + try { + await adminApi.deletePackingTemplate(id); + setTemplates((prev) => prev.filter((tpl) => tpl.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; + } + try { + await adminApi.updatePackingTemplate(id, { name: editTemplateName.trim() }); + setTemplates((prev) => prev.map((tpl) => (tpl.id === id ? { ...tpl, name: editTemplateName.trim() } : tpl))); + setEditingTemplate(null); + } catch { + toast.error(t('admin.packingTemplates.saveError')); + } + }; + + // Category CRUD + const handleAddCategory = async () => { + 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 handleRenameCategory = async (catId: number) => { + 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')); + } + }; + + const handleDeleteCategory = async (catId: number) => { + 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(genericDeleteErrors ? 'admin.toast.deleteError' : 'admin.packingTemplates.deleteCategoryError')); + } + }; + + // Item CRUD + const handleAddItem = async (catId: number) => { + // Both shells only call this with a name (the button and the Enter handler check it). + 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 handleRenameItem = async (itemId: number) => { + 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')); + } + }; + + const handleDeleteItem = async (itemId: number) => { + if (!expandedId) return; + try { + await adminApi.deleteTemplateItem(expandedId, itemId); + setItems((prev) => prev.filter((i) => i.id !== itemId)); + } catch { + toast.error(t(genericDeleteErrors ? 'admin.toast.deleteError' : 'admin.packingTemplates.deleteItemError')); + } + }; + + return { + templates, + isLoading, + showCreate, + setShowCreate, + createName, + setCreateName, + expandedId, + categories, + items, + editingTemplate, + setEditingTemplate, + editTemplateName, + setEditTemplateName, + editingCatId, + setEditingCatId, + editCatName, + setEditCatName, + editingItemId, + setEditingItemId, + editItemName, + setEditItemName, + addingCategory, + setAddingCategory, + newCatName, + setNewCatName, + addingItemToCatId, + setAddingItemToCatId, + newItemName, + setNewItemName, + addItemRef, + toggleExpand, + handleCreateTemplate, + handleDeleteTemplate, + handleRenameTemplate, + handleAddCategory, + handleRenameCategory, + handleDeleteCategory, + handleAddItem, + handleRenameItem, + handleDeleteItem, + }; +} diff --git a/client/src/mobile/screens/admin/MAdminPackingTemplateManager.tsx b/client/src/mobile/screens/admin/MAdminPackingTemplateManager.tsx index 6e2af87dd..7be7dc08a 100644 --- a/client/src/mobile/screens/admin/MAdminPackingTemplateManager.tsx +++ b/client/src/mobile/screens/admin/MAdminPackingTemplateManager.tsx @@ -1,14 +1,9 @@ -import { useState, useEffect, useRef, ReactNode } from 'react' -import { adminApi } from '../../../api/client' -import { useToast } from '../../../components/shared/Toast' +import { ReactNode } from 'react' +import { usePackingTemplateAdmin } from '../../../components/Admin/usePackingTemplateAdmin' import { useTranslation } from '../../../i18n' import { Plus, Trash2, Edit2, Package, X, Check, ChevronDown, ChevronRight, FolderPlus } from 'lucide-react' import { MAdminButton, MAdminCard, MAdminCardHead, MAdminInput } from './MAdminUi' -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 } - // Small round icon action button in the mobile admin idiom (flat --m-ic circle). function PkIconBtn({ onClick, @@ -52,147 +47,20 @@ const inlineFieldCls = /** * Mobile-native re-skin of the admin Packing Template Manager: create/rename/ - * delete templates, expand a template to manage its categories and items. All - * state, effects and adminApi mutations are preserved from the desktop version. + * delete templates, expand a template to manage its categories and items. The + * state, effects and adminApi writes live in the shared usePackingTemplateAdmin hook. */ export default function MAdminPackingTemplateManager() { - 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([]) - - // 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('') - - // 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 toast = useToast() + const { + templates, isLoading, showCreate, setShowCreate, createName, setCreateName, expandedId, categories, items, + editingTemplate, setEditingTemplate, editTemplateName, setEditTemplateName, editingCatId, setEditingCatId, + editCatName, setEditCatName, editingItemId, setEditingItemId, editItemName, setEditItemName, addingCategory, + setAddingCategory, newCatName, setNewCatName, addingItemToCatId, setAddingItemToCatId, newItemName, + setNewItemName, addItemRef, toggleExpand, handleCreateTemplate, handleDeleteTemplate, handleRenameTemplate, + handleAddCategory, handleRenameCategory, handleDeleteCategory, handleAddItem, handleRenameItem, handleDeleteItem, + } = usePackingTemplateAdmin({ genericDeleteErrors: true }) const { t } = useTranslation() - useEffect(() => { void loadTemplates() }, []) - - const loadTemplates = async () => { - setIsLoading(true) - try { - 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) - try { - 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 - 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 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')) } - } - - const handleRenameTemplate = async (id: number) => { - 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')) } - } - - // Category CRUD - const handleAddCategory = async () => { - 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 handleRenameCategory = async (catId: number) => { - 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')) } - } - - const handleDeleteCategory = async (catId: number) => { - 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.toast.deleteError')) } - } - - // Item CRUD - const handleAddItem = async (catId: number) => { - 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 handleRenameItem = async (itemId: number) => { - 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')) } - } - - const handleDeleteItem = async (itemId: number) => { - if (!expandedId) return - try { - await adminApi.deleteTemplateItem(expandedId, itemId) - setItems(prev => prev.filter(i => i.id !== itemId)) - } catch { toast.error(t('admin.toast.deleteError')) } - } - return ( {/* Header */}