From f1719c3e358788119895038094031cda7fb4642e Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 9 Oct 2026 10:17:10 +0200 Subject: [PATCH] refactor(admin): share the packing template manager logic between desktop and phone usePackingTemplates now owns the template list, the expanded template with its categories and items, and every create, rename and delete. The phone keeps its generic delete error toasts through the genericDeleteErrors option. --- .../Admin/PackingTemplateManager.tsx | 152 +--------- .../Admin/usePackingTemplateAdmin.test.ts | 242 ++++++++++++++++ .../Admin/usePackingTemplateAdmin.ts | 266 ++++++++++++++++++ .../admin/MAdminPackingTemplateManager.tsx | 156 +--------- 4 files changed, 529 insertions(+), 287 deletions(-) create mode 100644 client/src/components/Admin/usePackingTemplateAdmin.test.ts create mode 100644 client/src/components/Admin/usePackingTemplateAdmin.ts 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 */}