mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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.
This commit is contained in:
@@ -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<Template[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [createName, setCreateName] = useState('')
|
||||
|
||||
// Expanded template state
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null)
|
||||
const [categories, setCategories] = useState<TemplateCategory[]>([])
|
||||
const [items, setItems] = useState<TemplateItem[]>([])
|
||||
|
||||
// Editing states
|
||||
const [editingTemplate, setEditingTemplate] = useState<number | null>(null)
|
||||
const [editTemplateName, setEditTemplateName] = useState('')
|
||||
const [editingCatId, setEditingCatId] = useState<number | null>(null)
|
||||
const [editCatName, setEditCatName] = useState('')
|
||||
const [editingItemId, setEditingItemId] = useState<number | null>(null)
|
||||
const [editItemName, setEditItemName] = useState('')
|
||||
|
||||
// Adding states
|
||||
const [addingCategory, setAddingCategory] = useState(false)
|
||||
const [newCatName, setNewCatName] = useState('')
|
||||
const [addingItemToCatId, setAddingItemToCatId] = useState<number | null>(null)
|
||||
const [newItemName, setNewItemName] = useState('')
|
||||
const addItemRef = useRef<HTMLInputElement>(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'}`
|
||||
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
@@ -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<Template[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [createName, setCreateName] = useState('');
|
||||
|
||||
// Expanded template state
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null);
|
||||
const [categories, setCategories] = useState<TemplateCategory[]>([]);
|
||||
const [items, setItems] = useState<TemplateItem[]>([]);
|
||||
|
||||
// Editing states
|
||||
const [editingTemplate, setEditingTemplate] = useState<number | null>(null);
|
||||
const [editTemplateName, setEditTemplateName] = useState('');
|
||||
const [editingCatId, setEditingCatId] = useState<number | null>(null);
|
||||
const [editCatName, setEditCatName] = useState('');
|
||||
const [editingItemId, setEditingItemId] = useState<number | null>(null);
|
||||
const [editItemName, setEditItemName] = useState('');
|
||||
|
||||
// Adding states
|
||||
const [addingCategory, setAddingCategory] = useState(false);
|
||||
const [newCatName, setNewCatName] = useState('');
|
||||
const [addingItemToCatId, setAddingItemToCatId] = useState<number | null>(null);
|
||||
const [newItemName, setNewItemName] = useState('');
|
||||
const addItemRef = useRef<HTMLInputElement>(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,
|
||||
};
|
||||
}
|
||||
@@ -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<Template[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [showCreate, setShowCreate] = useState(false)
|
||||
const [createName, setCreateName] = useState('')
|
||||
|
||||
// Expanded template state
|
||||
const [expandedId, setExpandedId] = useState<number | null>(null)
|
||||
const [categories, setCategories] = useState<TemplateCategory[]>([])
|
||||
const [items, setItems] = useState<TemplateItem[]>([])
|
||||
|
||||
// Editing states
|
||||
const [editingTemplate, setEditingTemplate] = useState<number | null>(null)
|
||||
const [editTemplateName, setEditTemplateName] = useState('')
|
||||
const [editingCatId, setEditingCatId] = useState<number | null>(null)
|
||||
const [editCatName, setEditCatName] = useState('')
|
||||
const [editingItemId, setEditingItemId] = useState<number | null>(null)
|
||||
const [editItemName, setEditItemName] = useState('')
|
||||
|
||||
// Adding states
|
||||
const [addingCategory, setAddingCategory] = useState(false)
|
||||
const [newCatName, setNewCatName] = useState('')
|
||||
const [addingItemToCatId, setAddingItemToCatId] = useState<number | null>(null)
|
||||
const [newItemName, setNewItemName] = useState('')
|
||||
const addItemRef = useRef<HTMLInputElement>(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 (
|
||||
<MAdminCard>
|
||||
{/* Header */}
|
||||
|
||||
Reference in New Issue
Block a user