Files

19 KiB

phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, must_haves
phase plan type wave depends_on files_modified autonomous requirements must_haves
23-manual-entry-fallback 01 execute 1
src/client/components/ManualEntryForm.tsx
src/client/components/CatalogSearchOverlay.tsx
true
CATFLOW-07
CATFLOW-08
truths artifacts key_links
User can click 'Add Manually' from catalog search empty state to enter manual entry mode
User can click a persistent 'Add Manually' link below search results to enter manual entry mode
User sees a compact form with name (required), category, weight, price, purchase price, notes, product link, and image upload fields
User can submit the form to create a standalone collection item (no globalItemId)
After saving, user sees a success card with 'Submit to Catalog?' button, 'Add Another', and 'Done'
'Submit to Catalog?' button shows a 'Coming soon' toast and takes no backend action
Back arrow returns from manual form to search results without closing the overlay
Search query auto-populates the item name field when entering manual mode
path provides exports
src/client/components/ManualEntryForm.tsx Compact manual item entry form
ManualEntryForm
path provides contains
src/client/components/CatalogSearchOverlay.tsx Entry points, mode switching, success card rendering manualEntryMode
from to via pattern
src/client/components/CatalogSearchOverlay.tsx src/client/components/ManualEntryForm.tsx conditional render when manualEntryMode && !savedItemName manualEntryMode.*ManualEntryForm
from to via pattern
src/client/components/ManualEntryForm.tsx src/client/hooks/useItems.ts useCreateItem() mutation useCreateItem
from to via pattern
src/client/components/CatalogSearchOverlay.tsx sonner toast Submit to Catalog button toast.*Coming soon
Add manual item entry fallback to the catalog search flow so users can add gear not found in the catalog.

Purpose: Complete the catalog-driven gear flow by ensuring users are never stuck when a catalog item doesn't exist. The "Add Manually" path creates standalone collection items and plants the seed for future catalog submissions.

Output: ManualEntryForm component + CatalogSearchOverlay wired with entry points, inline form rendering, and post-save success card.

<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>

@.planning/PROJECT.md @.planning/ROADMAP.md @.planning/STATE.md @.planning/phases/23-manual-entry-fallback/23-CONTEXT.md @.planning/phases/23-manual-entry-fallback/23-RESEARCH.md

@src/client/components/CatalogSearchOverlay.tsx @src/client/components/AddToCollectionModal.tsx @src/client/components/ItemForm.tsx @src/client/hooks/useItems.ts @src/client/hooks/useCategories.ts @src/shared/schemas.ts

From src/client/hooks/useItems.ts:

export function useCreateItem(): UseMutationResult<ItemWithCategory, Error, CreateItem>;
// CreateItem comes from src/shared/types.ts, inferred from createItemSchema
// globalItemId is optional — omitting it creates a standalone item

From src/shared/schemas.ts:

export const createItemSchema = z.object({
  name: z.string().min(1).max(200),
  categoryId: z.number().int().positive(),
  weightGrams: z.number().int().nonnegative().optional(),
  priceCents: z.number().int().nonnegative().optional(),
  notes: z.string().max(2000).optional(),
  productUrl: z.string().url().max(500).optional(),
  imageFilename: z.string().optional(),
  globalItemId: z.number().int().positive().optional(),
  purchasePriceCents: z.number().int().nonnegative().optional(),
  quantity: z.number().int().positive().optional(),
});

From src/client/components/CatalogSearchOverlay.tsx:

// Local state pattern — all view state managed via useState
const [searchInput, setSearchInput] = useState("");
const catalogSearchOpen = useUIStore((s) => s.catalogSearchOpen);
const catalogSearchMode = useUIStore((s) => s.catalogSearchMode);
const closeCatalogSearch = useUIStore((s) => s.closeCatalogSearch);

// EmptyState at line 667 — currently no onAddManually prop
function EmptyState({ hasQuery }: { hasQuery: boolean }) { ... }

// Reset effect at line 76 — must add new state resets here
useEffect(() => { if (!catalogSearchOpen) { /* resets */ } }, [catalogSearchOpen]);

From src/client/components/CategoryPicker.tsx:

interface CategoryPickerProps {
  value: number;
  onChange: (categoryId: number) => void;
}

From src/client/components/ImageUpload.tsx:

interface ImageUploadProps {
  value: string | null;
  onChange: (filename: string | null) => void;
}
Task 1: Create ManualEntryForm component src/client/components/ManualEntryForm.tsx

<read_first> src/client/components/AddToCollectionModal.tsx src/client/components/ItemForm.tsx src/client/hooks/useItems.ts src/client/hooks/useCategories.ts src/shared/schemas.ts src/client/components/CategoryPicker.tsx src/client/components/ImageUpload.tsx </read_first>

Create `src/client/components/ManualEntryForm.tsx` — a compact, focused form for adding items manually (per D-05, D-06, D-07).

Component interface (per D-05):

interface ManualEntryFormProps {
  initialName?: string;   // Auto-populated from search query
  onSuccess: (itemName: string) => void;  // Called after successful save
  onBack: () => void;     // Returns to search results (per D-04)
}

Form state fields (per D-06):

  • name (string, required) — initialized from initialName prop
  • categoryId (number | null) — pre-select first category once loaded (follow AddToCollectionModal pattern to avoid categoryId=0 Zod error)
  • weightGrams (string, for input) — plain number input, converted to integer on submit
  • priceDollars (string, for input) — plain number input, converted to cents via Math.round(Number(val) * 100)
  • purchasePrice (string, for input) — same cents conversion
  • notes (string)
  • productUrl (string)
  • imageFilename (string | null) — managed via ImageUpload component
  • error (string | null) — validation/API error display

Hooks to use:

  • useCategories() from @/client/hooks/useCategories for CategoryPicker data
  • useCreateItem() from @/client/hooks/useItems for the mutation

Form layout (top to bottom):

  1. Back arrow row: <button> with ArrowLeft icon (from lucide-react) + "Add Manually" title text — clicking calls onBack (per D-04)
  2. Name input (text, required, full width)
  3. Category picker via <CategoryPicker value={categoryId} onChange={setCategoryId} />
  4. Two-column row: Weight (grams) input | Price input (dollars, label says "MSRP")
  5. Purchase price input (dollars, label says "Purchase Price")
  6. Product URL input (text)
  7. Notes textarea (3 rows)
  8. ImageUpload component
  9. Error message display (red text, conditionally shown)
  10. Submit button: "Add to Collection" — disabled when createItem.isPending or !name.trim() or categoryId === null

Category initialization pattern (from AddToCollectionModal):

const { data: categories } = useCategories();
useEffect(() => {
  if (categories && categories.length > 0 && categoryId === null) {
    setCategoryId(categories[0].id);
  }
}, [categories, categoryId]);

Submit handler (per D-08):

  • Validate name is non-empty, categoryId is not null
  • Call createItem.mutate() with:
    • name: name.trim()
    • categoryId
    • weightGrams: weightGrams ? Number(weightGrams) : undefined
    • priceCents: priceDollars ? Math.round(Number(priceDollars) * 100) : undefined
    • purchasePriceCents: purchasePrice ? Math.round(Number(purchasePrice) * 100) : undefined
    • notes: notes || undefined
    • productUrl: productUrl || undefined
    • imageFilename: imageFilename ?? undefined
    • NO globalItemId — deliberately omitted for standalone item
  • onSuccess callback: call onSuccess(item.name) where item is the mutation result
  • onError callback: setError(err instanceof Error ? err.message : "Failed to save")

Styling: Tailwind CSS v4 classes. Use @/ path alias for imports. Match the visual density of AddToCollectionModal (compact, not full-page).

Do NOT:

  • Import or reuse ItemForm directly (it's 315 lines with edit/delete logic — per D-07)

  • Add manualEntryMode to Zustand UIStore (local state only — per research anti-patterns)

  • Show a toast.success() on save — the success card in CatalogSearchOverlay handles that (per research anti-patterns)

    bun run lint

    <acceptance_criteria>

    • File src/client/components/ManualEntryForm.tsx exists
    • File contains export function ManualEntryForm(
    • File contains interface ManualEntryFormProps with initialName, onSuccess, onBack
    • File contains useCreateItem() import from @/client/hooks/useItems
    • File contains useCategories() import from @/client/hooks/useCategories
    • File contains <CategoryPicker usage (not a plain <select>)
    • File contains <ImageUpload usage (not a raw file input)
    • File contains createItem.mutate( call
    • File does NOT contain globalItemId in the mutate call payload
    • File does NOT contain toast.success or toast(" (no self-toasting)
    • File contains ArrowLeft import from lucide-react
    • File contains onBack being called (back arrow handler)
    • File contains Math.round(Number( for price conversion to cents
    • bun run lint exits 0 </acceptance_criteria>

    ManualEntryForm component exists with all form fields per D-06, uses CategoryPicker and ImageUpload, calls useCreateItem without globalItemId, has back arrow calling onBack, and passes lint.

Task 2: Wire ManualEntryForm into CatalogSearchOverlay with entry points and success card src/client/components/CatalogSearchOverlay.tsx

<read_first> src/client/components/CatalogSearchOverlay.tsx src/client/components/ManualEntryForm.tsx </read_first>

Modify `src/client/components/CatalogSearchOverlay.tsx` to add manual entry mode, entry points, and post-save success card.

1. Add imports at top of file:

import { ManualEntryForm } from "./ManualEntryForm";
import { toast } from "sonner";

2. Add local state (after existing useState declarations, around line 21):

const [manualEntryMode, setManualEntryMode] = useState(false);
const [savedItemName, setSavedItemName] = useState<string | null>(null);

3. Add resets to existing reset effect (line 76-87): Inside the if (!catalogSearchOpen) block, add:

setManualEntryMode(false);
setSavedItemName(null);

4. Add handler functions (after existing toggleTag/removeTag, around line 99):

function handleEnterManualMode() {
  setManualEntryMode(true);
}

function handleManualSuccess(itemName: string) {
  setSavedItemName(itemName);
}

function handleAddAnother() {
  setManualEntryMode(false);
  setSavedItemName(null);
}

5. Modify the header back arrow (line 136-141) to be context-sensitive (per D-04): Change the back arrow onClick from closeCatalogSearch to:

onClick={manualEntryMode ? () => { setManualEntryMode(false); setSavedItemName(null); } : closeCatalogSearch}

Also update the context text (line 143-145) — when manualEntryMode is true and savedItemName is null, show "Manual Entry" instead of the mode text. When savedItemName is not null, show "Item Added".

6. Replace the Results area content (lines 410-452) with conditional rendering (per D-03): The {/* Results */} div at line 409 should contain:

if manualEntryMode && savedItemName:
  → render SuccessCard (inline, see below)
else if manualEntryMode && !savedItemName:
  → render <ManualEntryForm initialName={searchInput} onSuccess={handleManualSuccess} onBack={() => { setManualEntryMode(false); setSavedItemName(null); }} />
else:
  → existing results/loading/empty rendering (unchanged)

When NOT in manualEntryMode, also hide the search input row and filter bar (the form doesn't need them). Actually, keep the header visible for the back arrow — just conditionally render the search input/filters. When manualEntryMode is true, hide the search input row and filter toggle and view toggle, but keep the back arrow + context text row.

7. Modify EmptyState component (line 667) to accept onAddManually (per D-01, D-02):

function EmptyState({ hasQuery, onAddManually }: { hasQuery: boolean; onAddManually: () => void }) {
  return (
    <div className="flex flex-col items-center justify-center py-20 px-4">
      {/* existing SVG icon unchanged */}
      <p className="text-sm text-gray-500 text-center mb-3">
        {hasQuery ? "No items found matching your search" : "Search the catalog to find gear"}
      </p>
      <button
        type="button"
        onClick={onAddManually}
        className="text-sm text-gray-500 hover:text-gray-700 underline underline-offset-2"
      >
        {hasQuery ? "Can't find it? Add manually" : "Add Manually"}
      </button>
    </div>
  );
}

Update the <EmptyState call site (line 447) to pass onAddManually={handleEnterManualMode}.

8. Add persistent "Add Manually" link below results (per D-01): After the results grid/list (after the closing </div> of the grid at line 431 or list at line 444), but still inside the results conditional block (items && items.length > 0), add:

<div className="flex justify-center py-6">
  <button
    type="button"
    onClick={handleEnterManualMode}
    className="text-sm text-gray-400 hover:text-gray-600 underline underline-offset-2"
  >
    Can't find it? Add manually
  </button>
</div>

9. Create inline SuccessCard (per D-09, D-10, D-11): Add a local component or inline JSX for the success card, rendered when manualEntryMode && savedItemName:

<div className="flex flex-col items-center justify-center py-20 px-4">
  <div className="w-12 h-12 bg-green-100 rounded-full flex items-center justify-center mb-4">
    <svg className="w-6 h-6 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
      <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
    </svg>
  </div>
  <p className="text-sm font-medium text-gray-900 mb-1">Added {savedItemName} to collection</p>
  <button
    type="button"
    onClick={() => toast("Coming soon — catalog submissions are on the roadmap!")}
    className="mt-3 text-sm text-blue-600 hover:text-blue-800 underline underline-offset-2"
  >
    Submit to Catalog?
  </button>
  <div className="flex gap-4 mt-6">
    <button
      type="button"
      onClick={handleAddAnother}
      className="px-4 py-2 text-sm text-gray-600 hover:text-gray-800 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
    >
      Add Another
    </button>
    <button
      type="button"
      onClick={closeCatalogSearch}
      className="px-4 py-2 text-sm text-white bg-gray-900 rounded-lg hover:bg-gray-800 transition-colors"
    >
      Done
    </button>
  </div>
</div>

"Submit to Catalog?" button (per D-10): Calls toast("Coming soon — catalog submissions are on the roadmap!") only. No loading state, no disabled, no API call.

Do NOT:

  • Add any state to Zustand UIStore

  • Show a separate toast.success() when the item is saved (the success card IS the feedback)

  • Make "Add Manually" only visible in collection mode — show it in both modes (per research open question 2, D-08 locks to POST /api/items regardless)

    bun run lint

    <acceptance_criteria>

    • CatalogSearchOverlay.tsx contains import { ManualEntryForm } from ./ManualEntryForm
    • CatalogSearchOverlay.tsx contains import { toast } from "sonner"
    • CatalogSearchOverlay.tsx contains useState(false) for manualEntryMode
    • CatalogSearchOverlay.tsx contains useState<string | null>(null) for savedItemName
    • CatalogSearchOverlay.tsx contains setManualEntryMode(false) inside the reset effect (the if (!catalogSearchOpen) block)
    • CatalogSearchOverlay.tsx contains setSavedItemName(null) inside the reset effect
    • EmptyState function signature contains onAddManually
    • EmptyState renders text "Can't find it? Add manually" (per D-02)
    • EmptyState renders text "Add Manually" (per D-02)
    • CatalogSearchOverlay.tsx contains <ManualEntryForm with initialName={searchInput} prop
    • CatalogSearchOverlay.tsx contains onSuccess={handleManualSuccess} on ManualEntryForm
    • There is a persistent "Add manually" button/link rendered after search results (outside EmptyState)
    • Success card contains text "Added" and "to collection"
    • Success card contains "Submit to Catalog?" button text
    • toast("Coming soon appears in the file (for Submit to Catalog button)
    • Success card contains "Add Another" button text
    • Success card contains "Done" button text
    • "Done" button calls closeCatalogSearch
    • "Add Another" button calls handleAddAnother
    • Back arrow onClick is context-sensitive (checks manualEntryMode)
    • bun run lint exits 0 </acceptance_criteria>

    CatalogSearchOverlay has "Add Manually" links in both empty state and below results, renders ManualEntryForm inline when manualEntryMode is active, shows success card with non-functional "Submit to Catalog?" button after save, and properly resets state on overlay close.

1. `bun run lint` passes with no errors 2. `bun test` passes (no backend changes, existing tests unaffected) 3. Manual verification: open catalog search, search for non-existent item, see "Can't find it? Add manually" link, click it, fill form, submit, see success card, click "Submit to Catalog?" and see toast, click "Add Another" to return to search, click "Done" to close overlay

<success_criteria>

  • ManualEntryForm.tsx exists as a focused, compact form using CategoryPicker and ImageUpload
  • CatalogSearchOverlay renders "Add Manually" links in empty state and below results
  • Clicking "Add Manually" shows the form inline, pre-populated with search query as item name
  • Submitting the form creates a standalone item (no globalItemId) via useCreateItem
  • Success card shows with "Submit to Catalog?" (toast only), "Add Another", and "Done"
  • Back arrow returns to search results, not closes overlay
  • All state resets when overlay closes
  • No new Zustand store state added
  • Lint and existing tests pass </success_criteria>
After completion, create `.planning/phases/23-manual-entry-fallback/23-01-SUMMARY.md`